Преглед изворни кода

feat(llm): 新增 Cursor CLI 本地 proxy 预置

通过 Tauri 托管 cursor-api-proxy,注入本机凭证并动态分配端口;
设置页支持状态检测,测试模型走 ensure 路径避免直连失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi пре 1 месец
родитељ
комит
1c22ce0b9c

+ 608 - 0
src-tauri/src/commands/cursor_cli.rs

@@ -0,0 +1,608 @@
+//! Cursor CLI + cursor-api-proxy management.
+//!
+//! Detects the local `agent` binary and can start `cursor-api-proxy` so the
+//! frontend can talk OpenAI-compatible HTTP. Port is chosen dynamically
+//! (prefer 8765, else an ephemeral free port) via `CURSOR_BRIDGE_PORT`.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use serde::Serialize;
+use tauri::State;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tokio::process::{Child, Command};
+use tokio::sync::Mutex;
+
+use super::cli_resolver::{child_path_env, find_cli_command};
+use super::local_cli_config::{apply_local_cli_environment, resolve_home_dir};
+
+const PREFERRED_PROXY_PORT: u16 = 8765;
+const DEFAULT_PROXY_BASE: &str = "http://127.0.0.1:8765";
+const PROXY_START_TIMEOUT_MS: u64 = 15_000;
+const PROXY_POLL_MS: u64 = 200;
+
+#[derive(Default)]
+struct ManagedProxy {
+    child: Option<Child>,
+    /// e.g. http://127.0.0.1:8765 — the port this managed child actually bound.
+    base_url: Option<String>,
+}
+
+#[derive(Default)]
+pub struct CursorProxyState {
+    managed: Arc<Mutex<ManagedProxy>>,
+}
+
+#[derive(Serialize)]
+pub struct DetectResult {
+    installed: bool,
+    version: Option<String>,
+    path: Option<String>,
+    model: Option<String>,
+    error: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct ProxyStatus {
+    healthy: bool,
+    base_url: String,
+    managed: bool,
+    error: Option<String>,
+}
+
+fn suppress_windows_console(_cmd: &mut Command) {
+    #[cfg(windows)]
+    {
+        #[allow(unused_imports)]
+        use std::os::windows::process::CommandExt;
+
+        const CREATE_NO_WINDOW: u32 = 0x08000000;
+        _cmd.creation_flags(CREATE_NO_WINDOW);
+    }
+}
+
+async fn find_agent_command() -> Result<std::path::PathBuf, String> {
+    find_cli_command("agent", &["agent.cmd", "agent.exe"]).await
+}
+
+async fn find_proxy_launcher() -> Result<(std::path::PathBuf, Vec<String>), String> {
+    if let Ok(bin) = find_cli_command(
+        "cursor-api-proxy",
+        &["cursor-api-proxy.cmd", "cursor-api-proxy.exe"],
+    )
+    .await
+    {
+        return Ok((bin, vec![]));
+    }
+
+    let npx = find_cli_command("npx", &["npx.cmd", "npx.exe"])
+        .await
+        .map_err(|_| {
+            "`cursor-api-proxy` and `npx` not found on PATH. Install Node.js 18+ and `npm i -g cursor-api-proxy`, or ensure `npx` works."
+                .to_string()
+        })?;
+    Ok((
+        npx,
+        vec![
+            "--yes".to_string(),
+            "cursor-api-proxy".to_string(),
+        ],
+    ))
+}
+
+fn normalize_proxy_base(base_url: Option<String>) -> String {
+    let raw = base_url
+        .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string())
+        .trim()
+        .to_string();
+    let trimmed = raw.trim_end_matches('/').to_string();
+    if trimmed.to_lowercase().ends_with("/v1") {
+        trimmed[..trimmed.len() - 3].trim_end_matches('/').to_string()
+    } else {
+        trimmed
+    }
+}
+
+fn parse_http_url(base: &str) -> Result<(String, u16, String), String> {
+    let url = base.trim();
+    let without_scheme = if let Some(rest) = url.strip_prefix("http://") {
+        rest
+    } else if url.starts_with("https://") {
+        return Err("cursor-api-proxy health check only supports http:// localhost URLs".to_string());
+    } else {
+        return Err(format!("Invalid proxy base URL: {base}"));
+    };
+
+    let (host_port, path) = match without_scheme.split_once('/') {
+        Some((hp, p)) => (hp, format!("/{p}")),
+        None => (without_scheme, "/".to_string()),
+    };
+
+    let (host, port) = if let Some((h, p)) = host_port.rsplit_once(':') {
+        let port: u16 = p
+            .parse()
+            .map_err(|_| format!("Invalid port in proxy URL: {base}"))?;
+        (h.to_string(), port)
+    } else {
+        (host_port.to_string(), 80)
+    };
+
+    Ok((host, port, path))
+}
+
+fn port_available(port: u16) -> bool {
+    std::net::TcpListener::bind(("127.0.0.1", port)).is_ok()
+}
+
+/// Prefer 8765; if taken, bind `:0` once to learn a free ephemeral port.
+fn allocate_proxy_port() -> Result<u16, String> {
+    if port_available(PREFERRED_PROXY_PORT) {
+        return Ok(PREFERRED_PROXY_PORT);
+    }
+    let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
+        .map_err(|e| format!("Failed to allocate free localhost port: {e}"))?;
+    let port = listener
+        .local_addr()
+        .map_err(|e| format!("Failed to read allocated port: {e}"))?
+        .port();
+    drop(listener);
+    Ok(port)
+}
+
+fn base_url_for_port(port: u16) -> String {
+    format!("http://127.0.0.1:{port}")
+}
+
+async fn http_get_status(base: &str, path: &str) -> Result<u16, String> {
+    let (host, port, _) = parse_http_url(base)?;
+    let request_path = if path.starts_with('/') {
+        path.to_string()
+    } else {
+        format!("/{path}")
+    };
+
+    let mut stream = tokio::time::timeout(
+        Duration::from_secs(2),
+        TcpStream::connect((host.as_str(), port)),
+    )
+    .await
+    .map_err(|_| "health check timed out connecting".to_string())?
+    .map_err(|e| format!("health check connect failed: {e}"))?;
+
+    let req = format!(
+        "GET {request_path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
+    );
+    stream
+        .write_all(req.as_bytes())
+        .await
+        .map_err(|e| format!("health check write failed: {e}"))?;
+
+    let mut buf = vec![0u8; 1024];
+    let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
+        .await
+        .map_err(|_| "health check timed out reading".to_string())?
+        .map_err(|e| format!("health check read failed: {e}"))?;
+
+    let text = String::from_utf8_lossy(&buf[..n]);
+    let status_line = text.lines().next().unwrap_or("");
+    let code = status_line
+        .split_whitespace()
+        .nth(1)
+        .and_then(|s| s.parse::<u16>().ok())
+        .ok_or_else(|| format!("unexpected health response: {status_line}"))?;
+    Ok(code)
+}
+
+async fn ping_health(base: &str) -> bool {
+    matches!(http_get_status(base, "/health").await, Ok(200))
+}
+
+/// Detect whether Cursor `agent` CLI is installed on PATH.
+pub async fn do_cursor_cli_detect() -> Result<DetectResult, String> {
+    let path = match find_agent_command().await {
+        Ok(p) => p,
+        Err(error) => {
+            return Ok(DetectResult {
+                installed: false,
+                version: None,
+                path: None,
+                model: None,
+                error: Some(error),
+            });
+        }
+    };
+
+    let path_str = path.to_string_lossy().to_string();
+    let mut cmd = Command::new(&path);
+    suppress_windows_console(&mut cmd);
+    apply_local_cli_environment(&mut cmd);
+    if let Some(path_env) = child_path_env().await {
+        cmd.env("PATH", path_env);
+    }
+
+    let output = tokio::time::timeout(Duration::from_secs(5), cmd.arg("--version").output()).await;
+
+    match output {
+        Ok(Ok(out)) if out.status.success() => {
+            let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
+            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
+            let version = if !stdout.is_empty() {
+                stdout
+            } else if !stderr.is_empty() {
+                stderr
+            } else {
+                "agent".to_string()
+            };
+            Ok(DetectResult {
+                installed: true,
+                version: Some(version),
+                path: Some(path_str),
+                model: None,
+                error: None,
+            })
+        }
+        Ok(Ok(out)) => {
+            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
+            Ok(DetectResult {
+                installed: true,
+                version: None,
+                path: Some(path_str),
+                model: None,
+                error: Some(if stderr.is_empty() {
+                    format!("`agent --version` exited with {}", out.status)
+                } else {
+                    stderr
+                }),
+            })
+        }
+        Ok(Err(e)) => Ok(DetectResult {
+            installed: false,
+            version: None,
+            path: Some(path_str),
+            model: None,
+            error: Some(format!("Failed to spawn `agent`: {e}")),
+        }),
+        Err(_) => Ok(DetectResult {
+            installed: true,
+            version: None,
+            path: Some(path_str),
+            model: None,
+            error: Some("`agent --version` timed out after 5s".to_string()),
+        }),
+    }
+}
+
+#[tauri::command]
+pub async fn cursor_cli_detect() -> Result<DetectResult, String> {
+    do_cursor_cli_detect().await
+}
+
+#[tauri::command]
+pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<ProxyStatus, String> {
+    let (base, managed) = {
+        let guard = state.managed.lock().await;
+        (
+            guard
+                .base_url
+                .clone()
+                .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string()),
+            guard.child.is_some(),
+        )
+    };
+    let healthy = ping_health(&base).await;
+    Ok(ProxyStatus {
+        healthy,
+        base_url: base,
+        managed,
+        error: if healthy {
+            None
+        } else {
+            Some("cursor-api-proxy is not reachable".to_string())
+        },
+    })
+}
+
+fn read_nonempty_env(keys: &[&str]) -> Option<String> {
+    keys.iter().find_map(|key| {
+        std::env::var(key)
+            .ok()
+            .map(|v| v.trim().to_string())
+            .filter(|v| !v.is_empty())
+    })
+}
+
+/// Parse `export NAME=value` / `NAME=value` from a shell rc file. No secret logging.
+fn read_export_from_rc(rc_path: &std::path::Path, name: &str) -> Option<String> {
+    let content = std::fs::read_to_string(rc_path).ok()?;
+    let prefix = format!("{name}=");
+    for raw in content.lines() {
+        let line = raw.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+        let line = line.strip_prefix("export ").unwrap_or(line).trim();
+        if let Some(rest) = line.strip_prefix(&prefix) {
+            let value = rest
+                .trim()
+                .trim_matches(|c| c == '\'' || c == '"')
+                .trim()
+                .to_string();
+            if !value.is_empty() {
+                return Some(value);
+            }
+        }
+    }
+    None
+}
+
+fn read_cursor_api_key_from_user_files() -> Option<String> {
+    let home = resolve_home_dir()?;
+    for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
+        if let Some(v) = read_export_from_rc(&home.join(rel), "CURSOR_API_KEY") {
+            return Some(v);
+        }
+    }
+    let auth_path = home.join(".cursor").join("auth.json");
+    let content = std::fs::read_to_string(auth_path).ok()?;
+    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
+    json.get("apiKey")
+        .and_then(|v| v.as_str())
+        .map(str::trim)
+        .filter(|v| !v.is_empty())
+        .map(ToOwned::to_owned)
+}
+
+fn read_agent_credential_store_from_user_files() -> Option<String> {
+    let home = resolve_home_dir()?;
+    for rel in [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"] {
+        if let Some(v) = read_export_from_rc(&home.join(rel), "AGENT_CLI_CREDENTIAL_STORE") {
+            return Some(v);
+        }
+    }
+    None
+}
+
+/// GUI apps do not load ~/.zshrc. Inject the same Cursor CLI auth the user
+/// exports in shell: CURSOR_API_KEY + AGENT_CLI_CREDENTIAL_STORE.
+fn apply_cursor_auth_env(cmd: &mut Command) {
+    let api_key = read_nonempty_env(&["CURSOR_API_KEY"]).or_else(read_cursor_api_key_from_user_files);
+    if let Some(api_key) = api_key {
+        cmd.env("CURSOR_API_KEY", api_key);
+    }
+
+    let store = read_nonempty_env(&["AGENT_CLI_CREDENTIAL_STORE"])
+        .or_else(read_agent_credential_store_from_user_files)
+        .unwrap_or_else(|| "file".to_string());
+    cmd.env("AGENT_CLI_CREDENTIAL_STORE", store);
+
+    if let Some(token) = read_nonempty_env(&["CURSOR_AUTH_TOKEN"]) {
+        cmd.env("CURSOR_AUTH_TOKEN", token);
+    }
+
+    for key in [
+        "HTTP_PROXY",
+        "HTTPS_PROXY",
+        "ALL_PROXY",
+        "http_proxy",
+        "https_proxy",
+        "all_proxy",
+    ] {
+        cmd.env_remove(key);
+    }
+}
+
+fn shell_quote(value: &str) -> String {
+    format!("'{}'", value.replace('\'', "'\"'\"'"))
+}
+
+async fn spawn_proxy_process(port: u16) -> Result<Child, String> {
+    let (launcher, extra_args) = find_proxy_launcher().await?;
+    let path_env = child_path_env().await;
+
+    #[cfg(unix)]
+    {
+        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
+        let mut parts = Vec::with_capacity(1 + extra_args.len());
+        parts.push(shell_quote(&launcher.to_string_lossy()));
+        for arg in &extra_args {
+            parts.push(shell_quote(arg));
+        }
+        let cmdline = format!("exec {}", parts.join(" "));
+
+        let mut cmd = Command::new(&shell);
+        suppress_windows_console(&mut cmd);
+        apply_local_cli_environment(&mut cmd);
+        if let Some(path_env) = path_env {
+            cmd.env("PATH", path_env);
+        }
+        apply_cursor_auth_env(&mut cmd);
+        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
+        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+        cmd.args(["-l", "-c", &cmdline]);
+        cmd.stdin(std::process::Stdio::null())
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .kill_on_drop(true);
+
+        return cmd
+            .spawn()
+            .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"));
+    }
+
+    #[cfg(windows)]
+    {
+        let mut cmd = Command::new(&launcher);
+        suppress_windows_console(&mut cmd);
+        apply_local_cli_environment(&mut cmd);
+        if let Some(path_env) = path_env {
+            cmd.env("PATH", path_env);
+        }
+        apply_cursor_auth_env(&mut cmd);
+        cmd.env("CURSOR_BRIDGE_HOST", "127.0.0.1");
+        cmd.env("CURSOR_BRIDGE_PORT", port.to_string());
+        cmd.args(&extra_args);
+        cmd.stdin(std::process::Stdio::null())
+            .stdout(std::process::Stdio::null())
+            .stderr(std::process::Stdio::null())
+            .kill_on_drop(true);
+
+        cmd.spawn()
+            .map_err(|e| format!("Failed to start cursor-api-proxy: {e}"))
+    }
+}
+
+async fn stop_managed_child(state: &CursorProxyState) {
+    let mut guard = state.managed.lock().await;
+    if let Some(mut child) = guard.child.take() {
+        let _ = child.start_kill();
+        let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
+    }
+    guard.base_url = None;
+}
+
+async fn wait_until_healthy(base: &str) -> bool {
+    let deadline = tokio::time::Instant::now() + Duration::from_millis(PROXY_START_TIMEOUT_MS);
+    while tokio::time::Instant::now() < deadline {
+        if ping_health(base).await {
+            return true;
+        }
+        tokio::time::sleep(Duration::from_millis(PROXY_POLL_MS)).await;
+    }
+    false
+}
+
+/// Ensure cursor-api-proxy is healthy. Starts (or force-restarts) on a free port.
+#[tauri::command]
+pub async fn cursor_proxy_ensure(
+    state: State<'_, CursorProxyState>,
+    force_restart: Option<bool>,
+) -> Result<ProxyStatus, String> {
+    let force = force_restart.unwrap_or(false);
+
+    {
+        let mut guard = state.managed.lock().await;
+        if let Some(child) = guard.child.as_mut() {
+            match child.try_wait() {
+                Ok(None) => {
+                    if !force {
+                        if let Some(base) = guard.base_url.clone() {
+                            drop(guard);
+                            if ping_health(&base).await {
+                                return Ok(ProxyStatus {
+                                    healthy: true,
+                                    base_url: base,
+                                    managed: true,
+                                    error: None,
+                                });
+                            }
+                        }
+                    }
+                }
+                _ => {
+                    guard.child = None;
+                    guard.base_url = None;
+                }
+            }
+        }
+    }
+
+    stop_managed_child(&state).await;
+
+    let port = allocate_proxy_port()?;
+    let base = base_url_for_port(port);
+    let child = spawn_proxy_process(port).await?;
+    {
+        let mut guard = state.managed.lock().await;
+        guard.child = Some(child);
+        guard.base_url = Some(base.clone());
+    }
+
+    if wait_until_healthy(&base).await {
+        return Ok(ProxyStatus {
+            healthy: true,
+            base_url: base,
+            managed: true,
+            error: None,
+        });
+    }
+
+    stop_managed_child(&state).await;
+    Err(format!(
+        "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)."
+    ))
+}
+
+/// Stop the proxy process if this app started it.
+#[tauri::command]
+pub async fn cursor_proxy_stop(state: State<'_, CursorProxyState>) -> Result<(), String> {
+    stop_managed_child(&state).await;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn normalize_strips_v1_suffix() {
+        assert_eq!(
+            normalize_proxy_base(Some("http://127.0.0.1:8765/v1".into())),
+            "http://127.0.0.1:8765"
+        );
+        assert_eq!(
+            normalize_proxy_base(Some("http://127.0.0.1:8765/".into())),
+            "http://127.0.0.1:8765"
+        );
+        assert_eq!(normalize_proxy_base(None), DEFAULT_PROXY_BASE);
+    }
+
+    #[test]
+    fn parse_localhost_url() {
+        let (host, port, path) = parse_http_url("http://127.0.0.1:8765").unwrap();
+        assert_eq!(host, "127.0.0.1");
+        assert_eq!(port, 8765);
+        assert_eq!(path, "/");
+    }
+
+    #[test]
+    fn allocate_prefers_8765_when_free() {
+        if port_available(PREFERRED_PROXY_PORT) {
+            assert_eq!(allocate_proxy_port().unwrap(), PREFERRED_PROXY_PORT);
+        }
+    }
+
+    #[test]
+    fn allocate_returns_nonzero_when_preferred_taken() {
+        let _hold = std::net::TcpListener::bind(("127.0.0.1", PREFERRED_PROXY_PORT));
+        if _hold.is_err() {
+            let port = allocate_proxy_port().unwrap();
+            assert!(port > 0);
+            return;
+        }
+        let port = allocate_proxy_port().unwrap();
+        assert_ne!(port, PREFERRED_PROXY_PORT);
+        assert!(port > 0);
+    }
+
+    #[test]
+    fn reads_export_lines_from_rc() {
+        let dir = std::env::temp_dir().join(format!("qmai-cursor-rc-{}", std::process::id()));
+        let _ = std::fs::create_dir_all(&dir);
+        let rc = dir.join(".zshrc");
+        std::fs::write(
+            &rc,
+            "# comment\nexport CURSOR_API_KEY=crsr_test_key\nexport AGENT_CLI_CREDENTIAL_STORE=file\n",
+        )
+        .unwrap();
+        assert_eq!(
+            read_export_from_rc(&rc, "CURSOR_API_KEY").as_deref(),
+            Some("crsr_test_key")
+        );
+        assert_eq!(
+            read_export_from_rc(&rc, "AGENT_CLI_CREDENTIAL_STORE").as_deref(),
+            Some("file")
+        );
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}

+ 1 - 0
src-tauri/src/commands/mod.rs

@@ -2,6 +2,7 @@ pub mod backup;
 pub mod claude_cli;
 mod cli_resolver;
 pub mod codex_cli;
+pub mod cursor_cli;
 pub mod extract_images;
 pub mod file_sync;
 pub mod fs;

+ 5 - 0
src-tauri/src/lib.rs

@@ -40,6 +40,7 @@ pub fn run() {
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
             Ok(())
@@ -84,6 +85,10 @@ pub fn run() {
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_kill,
+            commands::cursor_cli::cursor_cli_detect,
+            commands::cursor_cli::cursor_proxy_status,
+            commands::cursor_cli::cursor_proxy_ensure,
+            commands::cursor_cli::cursor_proxy_stop,
             commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,

+ 5 - 0
src-tauri/src/main.rs

@@ -53,6 +53,7 @@ fn main() {
             }
             app.manage(commands::claude_cli::ClaudeCliState::default());
             app.manage(commands::codex_cli::CodexCliState::default());
+            app.manage(commands::cursor_cli::CursorProxyState::default());
             app.manage(commands::file_sync::FileSyncState::default());
             app.manage(commands::mcp_stdio::McpStdioState::default());
             Ok(())
@@ -96,6 +97,10 @@ fn main() {
             commands::codex_cli::codex_cli_detect,
             commands::codex_cli::codex_cli_spawn,
             commands::codex_cli::codex_cli_kill,
+            commands::cursor_cli::cursor_cli_detect,
+            commands::cursor_cli::cursor_proxy_status,
+            commands::cursor_cli::cursor_proxy_ensure,
+            commands::cursor_cli::cursor_proxy_stop,
             commands::extract_images::extract_pdf_images_cmd,
             commands::extract_images::extract_office_images_cmd,
             commands::extract_images::extract_and_save_pdf_images_cmd,

+ 22 - 0
src/components/settings/llm-presets.ts

@@ -20,6 +20,7 @@ export type Provider =
   | "minimax"
   | "claude-code"
   | "codex-cli"
+  | "cursor-cli"
 
 export interface LlmPreset {
   /** Stable id used as the dropdown value. */
@@ -121,6 +122,27 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     ],
     suggestedContextSize: 200000,
   },
+  {
+    id: "cursor-cli",
+    label: "Cursor CLI (local)",
+    hint: "Local `agent` via cursor-api-proxy — no official API key needed",
+    provider: "cursor-cli",
+    baseUrl: "http://127.0.0.1:8765/v1",
+    defaultModel: "composer-2-fast",
+    apiMode: "chat_completions",
+    // Picks aligned with cursor-api-proxy docs/cursor-models-picks.md.
+    suggestedModels: [
+      "auto",
+      "composer-2-fast",
+      "claude-opus-4-7-medium-fast",
+      "claude-opus-4-7-high",
+      "gpt-5.3-codex-high",
+      "gpt-5.3-codex-xhigh",
+      "gpt-5.5-medium",
+      "claude-opus-4-7-thinking-max",
+    ],
+    suggestedContextSize: 200000,
+  },
   {
     id: "openai",
     label: "OpenAI (GPT)",

+ 15 - 0
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -31,6 +31,7 @@ describe("QMAI model settings", () => {
       "anthropic",
       "claude-code-cli",
       "codex-cli",
+      "cursor-cli",
       "openai",
       "google",
       "azure",
@@ -106,6 +107,20 @@ describe("QMAI model settings", () => {
     expect(codex.localCliIsolation).toBe(true)
     expect(codex.model).toBe("")
     expect(codex.codexCliTimeoutMinutes).toBe(45)
+
+    const cursor = resolveConfig(
+      preset("cursor-cli"),
+      { baseUrl: "http://127.0.0.1:8765/v1" },
+      fallback,
+    )
+    expect(cursor.provider).toBe("cursor-cli")
+    expect(cursor.customEndpoint).toBe("http://127.0.0.1:8765/v1")
+    expect(cursor.model).toBe("composer-2-fast")
+    expect(cursor.apiKey).toBe("")
+
+    const cursorProvider = getProviderConfig(cursor)
+    expect(cursorProvider.url).toBe("http://127.0.0.1:8765/v1/chat/completions")
+    expect(cursorProvider.headers.Authorization).toBe("Bearer unused")
   })
 
   it("has Chinese labels for the built-in model settings instead of placeholder question marks", () => {

+ 16 - 0
src/components/settings/preset-resolver.ts

@@ -85,6 +85,22 @@ export function resolveConfig(
     }
   }
 
+  if (preset.provider === "cursor-cli") {
+    // HTTP bridge via cursor-api-proxy. Optional apiKey only if the
+    // proxy was started with CURSOR_BRIDGE_API_KEY.
+    return {
+      provider: "cursor-cli",
+      apiKey,
+      model: ov.model?.trim() || preset.defaultModel || "",
+      ollamaUrl: fallback.ollamaUrl,
+      customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "http://127.0.0.1:8765/v1",
+      maxContextSize,
+      apiMode: "chat_completions",
+      reasoning,
+      localCliIsolation: false,
+    }
+  }
+
   // openai / anthropic / google / minimax — use fixed endpoint baked into the
   // provider dispatch. We still let users override baseUrl via apiKey env if
   // needed by editing manually, but presets for these don't expose it.

+ 179 - 4
src/components/settings/sections/llm-provider-section.tsx

@@ -163,6 +163,7 @@ function PresetRow({
   const localCliIsolation = ov.localCliIsolation === true
   const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
   const isLocalCliProvider = preset.provider === "claude-code" || preset.provider === "codex-cli"
+  const isCursorCliProvider = preset.provider === "cursor-cli"
   const [testState, setTestState] = useState<ProviderTestState>({ kind: "idle" })
   const [modelOptions, setModelOptions] = useState<string[]>([])
   const [modelListState, setModelListState] = useState<ModelActionState>(null)
@@ -172,11 +173,13 @@ function PresetRow({
   const hasConfig = !!apiKey || !!ov.baseUrl || !!ov.model || !!ov.azureApiVersion || !!ov.azureModelFamily
   // Local CLI providers authenticate via their own existing login state
   // (inherited by the spawned subprocess), so no API key field is shown.
-  // Ollama ditto for its local-only model.
+  // Ollama ditto for its local-only model. Cursor CLI uses cursor-api-proxy;
+  // bridge auth key is optional (only if CURSOR_BRIDGE_API_KEY is set).
   const needsApiKey =
     preset.provider !== "ollama" &&
     preset.provider !== "claude-code" &&
-    preset.provider !== "codex-cli"
+    preset.provider !== "codex-cli" &&
+    preset.provider !== "cursor-cli"
 
   const resolvedConfig = useMemo(
     () => resolveConfig(preset, ov, useWikiStore.getState().llmConfig),
@@ -244,8 +247,9 @@ function PresetRow({
         id: `model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
         name: modelId,
         model: modelId,
-        apiKey: apiKey,
-        customEndpoint: baseUrl,
+        apiKey: apiKey || undefined,
+        // Cursor CLI endpoint is managed dynamically — never bake 8765 into saved models.
+        customEndpoint: isCursorCliProvider ? undefined : (baseUrl || undefined),
         createdAt: Date.now(),
       }
       updatedModels = [...currentSaved, newModel]
@@ -416,6 +420,22 @@ function PresetRow({
 
           {preset.provider === "claude-code" && <ClaudeCliStatusPill />}
           {preset.provider === "codex-cli" && <CodexCliStatusPill />}
+          {isCursorCliProvider && <CursorCliStatusPill />}
+
+          {isCursorCliProvider && (
+            <div className="space-y-2">
+              <Label>{t("settings.sections.llm.cursorBridgeApiKey")}</Label>
+              <Input
+                type="password"
+                value={apiKey}
+                onChange={(e) => onChange({ apiKey: e.target.value })}
+                placeholder={t("settings.sections.llm.cursorBridgeApiKeyPlaceholder")}
+              />
+              <p className="text-xs text-muted-foreground">
+                {t("settings.sections.llm.cursorBridgeApiKeyHint")}
+              </p>
+            </div>
+          )}
 
           {isLocalCliProvider && (
             <div className="space-y-2 rounded-md border p-3">
@@ -582,6 +602,16 @@ function PresetRow({
           <SavedModelsManager
             savedModels={ov.savedModels ?? []}
             onChange={(models) => onChange({ savedModels: models })}
+            hideEndpoint={isCursorCliProvider}
+            buildTestConfig={(saved) => ({
+              ...resolvedConfig,
+              model: saved.model,
+              apiKey: saved.apiKey?.trim() || resolvedConfig.apiKey,
+              // Cursor CLI ignores saved endpoints; other providers may override.
+              customEndpoint: isCursorCliProvider
+                ? resolvedConfig.customEndpoint
+                : (saved.customEndpoint?.trim() || resolvedConfig.customEndpoint),
+            })}
           />
 
           <div className="space-y-2">
@@ -1141,3 +1171,148 @@ function CodexCliStatusPill() {
     </div>
   )
 }
+
+function CursorCliStatusPill() {
+  const { t } = useTranslation()
+  const [state, setState] = useState<"loading" | "ok" | "err">("loading")
+  const [agent, setAgent] = useState<DetectResult | null>(null)
+  const [proxyError, setProxyError] = useState<string | null>(null)
+  const [proxyHealthy, setProxyHealthy] = useState(false)
+  const [proxyBase, setProxyBase] = useState<string | null>(null)
+
+  async function detect() {
+    setState("loading")
+    setProxyError(null)
+    if (!isTauri()) {
+      setAgent({
+        installed: false,
+        version: null,
+        path: null,
+        error: t("settings.sections.llm.cliStatus.desktopOnly"),
+      })
+      setProxyHealthy(false)
+      setState("err")
+      return
+    }
+    try {
+      const { detectCursorCli, getCursorProxyStatus, ensureCursorProxyRunning } =
+        await import("@/lib/cursor-cli-proxy")
+      const agentResult = await detectCursorCli()
+      setAgent(agentResult)
+
+      let status = await getCursorProxyStatus()
+      let ensureError: string | null = null
+      try {
+        const endpoint = await ensureCursorProxyRunning(
+          { provider: "cursor-cli" },
+          { forceRestart: true },
+        )
+        status = await getCursorProxyStatus()
+        setProxyBase(status.base_url || endpoint.replace(/\/v1$/i, ""))
+      } catch (e) {
+        ensureError = e instanceof Error ? e.message : String(e)
+      }
+      setProxyHealthy(status.healthy)
+      setProxyError(ensureError ?? (status.healthy ? null : status.error))
+      if (status.base_url) setProxyBase(status.base_url)
+
+      const ok = agentResult.installed && status.healthy
+      setState(ok ? "ok" : "err")
+    } catch (e) {
+      setAgent({
+        installed: false,
+        version: null,
+        path: null,
+        error: e instanceof Error ? e.message : String(e),
+      })
+      setProxyHealthy(false)
+      setState("err")
+    }
+  }
+
+  useEffect(() => {
+    void detect()
+  }, [])
+
+  return (
+    <div className="space-y-1.5">
+      <div className="flex items-center gap-2">
+        <Label className="m-0">{t("settings.sections.llm.cliStatus.title")}</Label>
+        <button
+          type="button"
+          onClick={() => void detect()}
+          className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+          disabled={state === "loading"}
+        >
+          {state === "loading"
+            ? t("settings.sections.llm.cliStatus.checking")
+            : t("settings.sections.llm.cliStatus.recheck")}
+        </button>
+      </div>
+      <div
+        className={`flex items-start gap-1.5 rounded-md border px-2 py-1.5 text-xs ${
+          state === "ok"
+            ? "border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400"
+            : state === "err"
+              ? "border-rose-500/40 bg-rose-500/5 text-rose-700 dark:text-rose-400"
+              : "border-border bg-background/50 text-muted-foreground"
+        }`}
+      >
+        {state === "loading" && <Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />}
+        {state === "ok" && <CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
+        {state === "err" && <XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />}
+        <div className="min-w-0 flex-1 space-y-0.5">
+          {state === "loading" && <div>{t("settings.sections.llm.cliStatus.cursorDetecting")}</div>}
+          {state !== "loading" && (
+            <>
+              <div>
+                {agent?.installed
+                  ? t("settings.sections.llm.cliStatus.cursorAgentReady", {
+                      versionSuffix: agent.version ? ` ${agent.version}` : "",
+                    })
+                  : (agent?.error ?? t("settings.sections.llm.cliStatus.cursorAgentUnavailable"))}
+              </div>
+              {agent?.path && (
+                <div className="truncate font-mono text-[10px] text-muted-foreground">
+                  {agent.path}
+                </div>
+              )}
+              <div>
+                {proxyHealthy
+                  ? t("settings.sections.llm.cliStatus.cursorProxyReady", {
+                      baseUrl: proxyBase ?? "",
+                    })
+                  : (proxyError ?? t("settings.sections.llm.cliStatus.cursorProxyUnavailable"))}
+              </div>
+              <div className="text-muted-foreground">
+                {t("settings.sections.llm.cliStatus.authErrorPrefix")}{" "}
+                <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                  agent login
+                </code>{" "}
+                {t("settings.sections.llm.cliStatus.cursorAuthErrorSuffix")}
+              </div>
+              {!agent?.installed && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
+                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                    curl https://cursor.com/install -fsS | bash
+                  </code>{" "}
+                  {t("settings.sections.llm.cliStatus.installSuffix")}
+                </div>
+              )}
+              {agent?.installed && !proxyHealthy && (
+                <div className="text-muted-foreground">
+                  {t("settings.sections.llm.cliStatus.installPrefix")}{" "}
+                  <code className="rounded bg-background/60 px-1 py-0.5 font-mono text-[10px]">
+                    npx cursor-api-proxy
+                  </code>{" "}
+                  {t("settings.sections.llm.cliStatus.installSuffix")}
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 3 - 1
src/components/settings/sections/rerank-section.tsx

@@ -42,6 +42,7 @@ const PROVIDER_OPTIONS: Array<{ value: LlmConfig["provider"]; label: string }> =
   { value: "minimax", label: "MiniMax" },
   { value: "claude-code", label: "Claude Code CLI" },
   { value: "codex-cli", label: "Codex CLI" },
+  { value: "cursor-cli", label: "Cursor CLI" },
 ]
 
 export function RerankSection({ draft, setDraft }: Props) {
@@ -68,7 +69,8 @@ export function RerankSection({ draft, setDraft }: Props) {
   const needsApiKey =
     config.provider !== "ollama" &&
     config.provider !== "claude-code" &&
-    config.provider !== "codex-cli"
+    config.provider !== "codex-cli" &&
+    config.provider !== "cursor-cli"
   const hasConfig = config.useMainLlm || Boolean(config.model || config.customEndpoint || config.ollamaUrl)
 
   function handleOpenPanel() {

+ 56 - 62
src/components/settings/sections/saved-models-manager.tsx

@@ -1,16 +1,21 @@
 import { useState } from "react"
 import { useTranslation } from "react-i18next"
-import { Plus, Edit, Trash2, Check, X, Download, TestTube } from "lucide-react"
+import { Plus, Edit, Trash2, Download, TestTube, Check, X } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
 import { toast } from "@/lib/toast"
-import type { SavedModel } from "@/stores/wiki-store"
+import { testSettingsLlmModel, normalizeModelTestError } from "@/lib/settings-model-test"
+import type { LlmConfig, SavedModel } from "@/stores/wiki-store"
 
 interface SavedModelsManagerProps {
   savedModels: SavedModel[]
   onChange: (models: SavedModel[]) => void
+  /** Build a full LlmConfig for connectivity tests (provider + live endpoint). */
+  buildTestConfig: (model: SavedModel) => LlmConfig
+  /** Hide per-model endpoint UI (e.g. Cursor CLI uses a managed local proxy). */
+  hideEndpoint?: boolean
 }
 
 interface ModelFormData {
@@ -21,7 +26,12 @@ interface ModelFormData {
   description: string
 }
 
-export function SavedModelsManager({ savedModels, onChange }: SavedModelsManagerProps) {
+export function SavedModelsManager({
+  savedModels,
+  onChange,
+  buildTestConfig,
+  hideEndpoint = false,
+}: SavedModelsManagerProps) {
   const { t } = useTranslation()
   const [dialogOpen, setDialogOpen] = useState(false)
   const [editingId, setEditingId] = useState<string | null>(null)
@@ -69,7 +79,9 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
       name: formData.name.trim(),
       model: formData.model.trim(),
       apiKey: formData.apiKey.trim() || undefined,
-      customEndpoint: formData.customEndpoint.trim() || undefined,
+      customEndpoint: hideEndpoint
+        ? undefined
+        : (formData.customEndpoint.trim() || undefined),
       description: formData.description.trim() || undefined,
       createdAt: editingId
         ? savedModels.find((m) => m.id === editingId)?.createdAt || Date.now()
@@ -128,37 +140,13 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
   async function handleTestModel(model: SavedModel) {
     setTestingModel(model.id)
     try {
-      const endpoint = model.customEndpoint || ""
-      const apiKey = model.apiKey || ""
-
-      if (!endpoint) {
-        toast.error("该模型未配置接口地址")
-        return
-      }
-
-      const response = await fetch(`${endpoint}/chat/completions`, {
-        method: "POST",
-        headers: {
-          "Authorization": `Bearer ${apiKey}`,
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({
-          model: model.model,
-          messages: [{ role: "user", content: "Hi" }],
-          max_tokens: 10,
-        }),
-      })
-
-      if (!response.ok) {
-        throw new Error(`HTTP ${response.status}`)
-      }
-
-      const data = await response.json()
-
-      toast.success(`模型 ${model.name} 可正常使用`)
-      console.log("Test response:", data)
+      const result = await testSettingsLlmModel(buildTestConfig(model))
+      toast.success(`模型 ${model.name} 可正常使用:${result.content.slice(0, 40)}`)
     } catch (error) {
-      toast.error(error instanceof Error ? error.message : "未知错误")
+      const normalized = error instanceof Error
+        ? normalizeModelTestError(error)
+        : new Error(String(error))
+      toast.error(normalized.message || "未知错误")
     } finally {
       setTestingModel(null)
     }
@@ -226,7 +214,7 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
                 <p className="text-xs text-muted-foreground line-clamp-2">{model.description}</p>
               )}
 
-              {model.customEndpoint && (
+              {!hideEndpoint && model.customEndpoint && (
                 <p className="truncate text-xs text-muted-foreground">
                   <span className="font-medium">接口:</span>
                   {model.customEndpoint}
@@ -307,20 +295,22 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
               </p>
             </div>
 
-            <div className="space-y-2">
-              <Label htmlFor="model-endpoint">
-                {t("settings.sections.llm.savedModels.customEndpoint")}
-              </Label>
-              <Input
-                id="model-endpoint"
-                value={formData.customEndpoint}
-                onChange={(e) => setFormData({ ...formData, customEndpoint: e.target.value })}
-                placeholder={t("settings.sections.llm.savedModels.customEndpointPlaceholder")}
-              />
-              <p className="text-xs text-muted-foreground">
-                {t("settings.sections.llm.savedModels.customEndpointHint")}
-              </p>
-            </div>
+            {!hideEndpoint && (
+              <div className="space-y-2">
+                <Label htmlFor="model-endpoint">
+                  {t("settings.sections.llm.savedModels.customEndpoint")}
+                </Label>
+                <Input
+                  id="model-endpoint"
+                  value={formData.customEndpoint}
+                  onChange={(e) => setFormData({ ...formData, customEndpoint: e.target.value })}
+                  placeholder={t("settings.sections.llm.savedModels.customEndpointPlaceholder")}
+                />
+                <p className="text-xs text-muted-foreground">
+                  {t("settings.sections.llm.savedModels.customEndpointHint")}
+                </p>
+              </div>
+            )}
 
             <div className="space-y-2">
               <Label htmlFor="model-description">
@@ -335,27 +325,31 @@ export function SavedModelsManager({ savedModels, onChange }: SavedModelsManager
             </div>
 
             <div className="flex gap-2">
-              <Button
-                type="button"
-                variant="outline"
-                onClick={handleFetchModels}
-                disabled={fetchingModels || !formData.customEndpoint.trim()}
-                className="flex-1"
-              >
-                <Download className="mr-2 h-4 w-4" />
-                {fetchingModels ? "拉取中..." : "拉取模型"}
-              </Button>
+              {!hideEndpoint && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  onClick={handleFetchModels}
+                  disabled={fetchingModels || !formData.customEndpoint.trim()}
+                  className="flex-1"
+                >
+                  <Download className="mr-2 h-4 w-4" />
+                  {fetchingModels ? "拉取中..." : "拉取模型"}
+                </Button>
+              )}
               <Button
                 type="button"
                 variant="outline"
                 onClick={() => {
                   if (formData.model.trim()) {
-                    handleTestModel({
+                    void handleTestModel({
                       id: "temp",
-                      name: formData.name,
+                      name: formData.name || formData.model,
                       model: formData.model,
                       apiKey: formData.apiKey || undefined,
-                      customEndpoint: formData.customEndpoint || undefined,
+                      customEndpoint: hideEndpoint
+                        ? undefined
+                        : (formData.customEndpoint || undefined),
                       createdAt: Date.now(),
                     })
                   }

+ 2 - 2
src/components/settings/settings-types.ts

@@ -12,7 +12,7 @@ import type { VisualStyle } from "@/lib/visual-style-settings"
  */
 export interface SettingsDraft {
   // LLM provider
-  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   apiKey: string
   model: string
   ollamaUrl: string
@@ -39,7 +39,7 @@ export interface SettingsDraft {
   // Multimodal (image captioning at ingest time)
   multimodalEnabled: boolean
   multimodalUseMainLlm: boolean
-  multimodalProvider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  multimodalProvider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   multimodalApiKey: string
   multimodalModel: string
   multimodalOllamaUrl: string

+ 2 - 2
src/components/sources/outline-chat-panel.tsx

@@ -1774,7 +1774,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         });
         return { started: false, sent: false };
       }
-      if (!modelSupportsTools(effectiveModelId)) {
+      if (!modelSupportsTools(effectiveModelId, effectiveLlmConfig.provider)) {
         addMessage(convId, {
           id: crypto.randomUUID(),
           role: "assistant",
@@ -3084,7 +3084,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         });
         return;
       }
-      if (!modelSupportsTools(effectiveModelId)) {
+      if (!modelSupportsTools(effectiveModelId, effectiveLlmConfig.provider)) {
         addMessage(activeConversationId, {
           id: crypto.randomUUID(),
           role: "assistant",

+ 1 - 1
src/hooks/use-agent-config.ts

@@ -111,7 +111,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
 
   return useMemo(() => {
     const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
-    const supportsTools = modelSupportsTools(agentLlmConfig.model)
+    const supportsTools = modelSupportsTools(agentLlmConfig.model, agentLlmConfig.provider)
 
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
       return {

+ 9 - 0
src/i18n/en.json

@@ -804,13 +804,19 @@
           "recheck": "Re-check",
           "claudeDetecting": "Detecting local Claude binary…",
           "codexDetecting": "Detecting local Codex binary…",
+          "cursorDetecting": "Detecting agent and cursor-api-proxy…",
           "claudeReady": "Detected{{versionSuffix}}. Ready to use your local subscription — no API key needed.",
           "codexReady": "Detected{{versionSuffix}}. Ready to use your local login — no API key needed.",
+          "cursorAgentReady": "Cursor agent detected{{versionSuffix}}.",
+          "cursorProxyReady": "cursor-api-proxy is ready ({{baseUrl}}).",
           "authErrorPrefix": "If chat fails with an authentication error, run",
           "claudeAuthErrorSuffix": "in a terminal to refresh the OAuth login.",
           "codexAuthErrorSuffix": "in a terminal to refresh the login.",
+          "cursorAuthErrorSuffix": "in a terminal to refresh the Cursor login.",
           "claudeUnavailable": "Claude CLI not available.",
           "codexUnavailable": "Codex CLI not available.",
+          "cursorAgentUnavailable": "Cursor agent CLI not available.",
+          "cursorProxyUnavailable": "cursor-api-proxy is not reachable.",
           "desktopOnly": "Desktop only",
           "installPrefix": "Install with",
           "installSuffix": "then re-check."
@@ -843,6 +849,9 @@
         "codexCliTimeout": "Codex CLI timeout",
         "codexCliTimeoutUnit": "minutes",
         "codexCliTimeoutHint": "Overall subprocess timeout for longer generations, from 1 to 240 minutes.",
+        "cursorBridgeApiKey": "Proxy API key (optional)",
+        "cursorBridgeApiKeyPlaceholder": "Only if the proxy was started with CURSOR_BRIDGE_API_KEY",
+        "cursorBridgeApiKeyHint": "Leave blank by default. Fill in the same value if you started the proxy with CURSOR_BRIDGE_API_KEY.",
         "providerTests": "Model tests",
         "providerTestsHint": "Connection test checks reachability. Functional test asks the model to return a specific token, confirming it can generate usable output.",
         "testConnection": "Test connection",

+ 9 - 0
src/i18n/zh.json

@@ -511,13 +511,19 @@
           "recheck": "重新检查",
           "claudeDetecting": "正在检查 claude 命令...",
           "codexDetecting": "正在检查 codex 命令...",
+          "cursorDetecting": "正在检查 agent 与 cursor-api-proxy...",
           "claudeReady": "Claude CLI 可用{{versionSuffix}},无需填写 API Key。",
           "codexReady": "Codex CLI 可用{{versionSuffix}},无需填写 API Key。",
+          "cursorAgentReady": "Cursor agent 可用{{versionSuffix}}。",
+          "cursorProxyReady": "cursor-api-proxy 已就绪({{baseUrl}})。",
           "authErrorPrefix": "如果运行时报认证错误,请先在终端执行",
           "claudeAuthErrorSuffix": "完成 Claude OAuth 登录。",
           "codexAuthErrorSuffix": "完成 Codex 登录。",
+          "cursorAuthErrorSuffix": "完成 Cursor 登录。",
           "claudeUnavailable": "未检测到 Claude CLI。",
           "codexUnavailable": "未检测到 Codex CLI。",
+          "cursorAgentUnavailable": "未检测到 Cursor agent CLI。",
+          "cursorProxyUnavailable": "cursor-api-proxy 未就绪。",
           "desktopOnly": "仅桌面应用可检测 CLI 状态。",
           "installPrefix": "可执行",
           "installSuffix": "安装后再重新检查。"
@@ -550,6 +556,9 @@
         "codexCliTimeout": "Codex CLI 超时",
         "codexCliTimeoutUnit": "分钟",
         "codexCliTimeoutHint": "长文本生成的子进程总超时时间,可设置 1-240 分钟。",
+        "cursorBridgeApiKey": "Proxy API Key(可选)",
+        "cursorBridgeApiKeyPlaceholder": "仅当 proxy 设置了 CURSOR_BRIDGE_API_KEY 时填写",
+        "cursorBridgeApiKeyHint": "默认无需填写。若手动启动 proxy 时设置了 CURSOR_BRIDGE_API_KEY,在此填入相同值。",
         "providerTests": "模型测试",
         "providerTestsHint": "连接测试用于检查是否可访问;功能测试会要求模型返回指定标记,确认模型能正常生成可用内容。",
         "testConnection": "测试连接",

+ 13 - 1
src/lib/agent/config.ts

@@ -11,14 +11,26 @@ export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
   "deepseek-reasoner",
   "claude-code",
   "codex-cli",
+  "cursor-cli",
 ]
 
+const TOOL_UNSUPPORTED_PROVIDERS = new Set<LlmConfig["provider"]>([
+  "claude-code",
+  "codex-cli",
+  "cursor-cli",
+])
+
 export interface BuildAgentConfigOptions extends ToolFactoryOptions {
   llmConfig: LlmConfig
   requestOverrides?: AgentConfig["requestOverrides"]
 }
 
-export function modelSupportsTools(modelId: string): boolean {
+export function modelSupportsTools(
+  modelId: string,
+  provider?: LlmConfig["provider"],
+): boolean {
+  if (provider && TOOL_UNSUPPORTED_PROVIDERS.has(provider)) return false
+
   const id = modelId.trim().toLowerCase()
   if (!id) return false
 

+ 45 - 0
src/lib/cursor-cli-provider.spec.ts

@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { modelSupportsTools } from "@/lib/agent/config"
+import { getProviderConfig } from "@/lib/llm-providers"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { toCursorProxyV1Endpoint } from "@/lib/cursor-cli-proxy"
+
+const base: LlmConfig = {
+  provider: "cursor-cli",
+  apiKey: "",
+  model: "composer-2-fast",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "http://127.0.0.1:8765/v1",
+  maxContextSize: 200000,
+  apiMode: "chat_completions",
+  reasoning: { mode: "auto" },
+}
+
+describe("cursor-cli provider", () => {
+  it("is usable when the preset is enabled without an API key", () => {
+    expect(hasUsableLlm(base, {})).toBe(false)
+    expect(hasUsableLlm(base, { "cursor-cli": { enabled: true } })).toBe(true)
+  })
+
+  it("does not support agent tools", () => {
+    expect(modelSupportsTools("composer-2-fast", "cursor-cli")).toBe(false)
+    expect(modelSupportsTools("gpt-4o", "openai")).toBe(true)
+  })
+
+  it("builds an OpenAI-compatible chat completions URL", () => {
+    const cfg = getProviderConfig(base)
+    expect(cfg.url).toBe("http://127.0.0.1:8765/v1/chat/completions")
+    expect(cfg.headers.Authorization).toBe("Bearer unused")
+  })
+
+  it("uses an optional bridge API key when provided", () => {
+    const cfg = getProviderConfig({ ...base, apiKey: "secret" })
+    expect(cfg.headers.Authorization).toBe("Bearer secret")
+  })
+
+  it("normalizes dynamic proxy bases to /v1", () => {
+    expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123")).toBe("http://127.0.0.1:9123/v1")
+    expect(toCursorProxyV1Endpoint("http://127.0.0.1:9123/v1")).toBe("http://127.0.0.1:9123/v1")
+  })
+})

+ 86 - 0
src/lib/cursor-cli-proxy.ts

@@ -0,0 +1,86 @@
+/**
+ * Cursor CLI local provider helpers.
+ *
+ * Ensures cursor-api-proxy is reachable before HTTP chat / model-list calls.
+ * The listening port is chosen by Tauri (prefer 8765, else a free port) and
+ * returned so callers can point OpenAI-compatible requests at the live URL.
+ */
+
+import { invoke } from "@tauri-apps/api/core"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { isTauri } from "@/lib/platform"
+import type { LocalCliDetectResult } from "./local-cli-config"
+
+export const DEFAULT_CURSOR_PROXY_BASE = "http://127.0.0.1:8765"
+export const DEFAULT_CURSOR_PROXY_V1 = "http://127.0.0.1:8765/v1"
+
+export interface CursorProxyStatus {
+  healthy: boolean
+  base_url: string
+  managed: boolean
+  error: string | null
+}
+
+export function toCursorProxyV1Endpoint(baseUrl: string): string {
+  const trimmed = baseUrl.replace(/\/+$/, "")
+  if (/\/v1$/i.test(trimmed)) return trimmed
+  return `${trimmed}/v1`
+}
+
+export async function detectCursorCli(): Promise<LocalCliDetectResult> {
+  if (!isTauri()) {
+    return {
+      installed: false,
+      version: null,
+      path: null,
+      error: "仅桌面端支持本地 CLI 检测",
+    }
+  }
+  return invoke<LocalCliDetectResult>("cursor_cli_detect")
+}
+
+export async function getCursorProxyStatus(): Promise<CursorProxyStatus> {
+  if (!isTauri()) {
+    return {
+      healthy: false,
+      base_url: DEFAULT_CURSOR_PROXY_BASE,
+      managed: false,
+      error: "仅桌面端可托管 cursor-api-proxy",
+    }
+  }
+  return invoke<CursorProxyStatus>("cursor_proxy_status")
+}
+
+/**
+ * Ensure proxy is up; returns the live OpenAI-compatible base (`…/v1`).
+ */
+export async function ensureCursorProxyRunning(
+  config: Pick<LlmConfig, "provider">,
+  options?: { forceRestart?: boolean },
+): Promise<string> {
+  if (config.provider !== "cursor-cli") {
+    return DEFAULT_CURSOR_PROXY_V1
+  }
+  if (!isTauri()) {
+    throw new Error("Cursor CLI 仅桌面端可用。请在 Tauri 应用中使用,或手动启动 cursor-api-proxy。")
+  }
+  const status = await invoke<CursorProxyStatus>("cursor_proxy_ensure", {
+    forceRestart: options?.forceRestart ?? false,
+  })
+  if (!status.healthy) {
+    throw new Error(status.error ?? `cursor-api-proxy 未就绪:${status.base_url}`)
+  }
+  return toCursorProxyV1Endpoint(status.base_url)
+}
+
+/** After Authentication required, kill managed proxy and respawn with zshrc credentials. */
+export async function restartCursorProxyWithAuth(
+  config: Pick<LlmConfig, "provider">,
+): Promise<string> {
+  return ensureCursorProxyRunning(config, { forceRestart: true })
+}
+
+/** Apply the live proxy `/v1` endpoint onto an LlmConfig for HTTP dispatch. */
+export function withCursorProxyEndpoint(config: LlmConfig, v1Endpoint: string): LlmConfig {
+  return { ...config, customEndpoint: v1Endpoint, apiMode: "chat_completions" }
+}

+ 26 - 0
src/lib/has-usable-llm.test.ts

@@ -71,6 +71,32 @@ describe("hasUsableLlm", () => {
     expect(hasUsableLlm(cfg, providers)).toBe(true)
   })
 
+  it("accepts cursor-cli when the preset is enabled without an API key", () => {
+    const providers: ProviderConfigs = {
+      "cursor-cli": { enabled: true },
+    }
+    const cfg: LlmConfig = {
+      ...baseCfg,
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "composer-2-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }
+    expect(hasUsableLlm(cfg, providers)).toBe(true)
+  })
+
+  it("rejects cursor-cli when the preset is not enabled", () => {
+    const providers: ProviderConfigs = {}
+    const cfg: LlmConfig = {
+      ...baseCfg,
+      provider: "cursor-cli",
+      apiKey: "",
+      model: "composer-2-fast",
+      customEndpoint: "http://127.0.0.1:8765/v1",
+    }
+    expect(hasUsableLlm(cfg, providers)).toBe(false)
+  })
+
   it("accepts ollama without apiKey when enabled", () => {
     const providers: ProviderConfigs = {
       "ollama-local": { enabled: true, model: "qwen2.5" },

+ 2 - 1
src/lib/has-usable-llm.ts

@@ -9,6 +9,7 @@ export type LlmProvider = LlmConfig["provider"]
 const PRESET_ID_BY_PROVIDER: Partial<Record<LlmProvider, string>> = {
   "claude-code": "claude-code-cli",
   "codex-cli": "codex-cli",
+  "cursor-cli": "cursor-cli",
   "ollama": "ollama-local",
 }
 
@@ -46,7 +47,7 @@ export function hasUsableLlm(
   const hasKey = cfg.apiKey.trim().length > 0
   const hasModel = cfg.model.trim().length > 0
 
-  if (cfg.provider === "claude-code" || cfg.provider === "codex-cli") {
+  if (cfg.provider === "claude-code" || cfg.provider === "codex-cli" || cfg.provider === "cursor-cli") {
     const presetId = PRESET_ID_BY_PROVIDER[cfg.provider]!
     return isPresetEnabled(providerConfigs, presetId)
   }

+ 7 - 1
src/lib/llm-client.ts

@@ -4,6 +4,7 @@ import { getProviderConfig, type RequestOverrides } from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
+import { ensureCursorProxyRunning, withCursorProxyEndpoint } from "./cursor-cli-proxy"
 import { trimChatMessagesToBudget } from "./chat-request-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
@@ -134,7 +135,7 @@ export async function streamChat(
    */
   requestOverrides?: RequestOverrides,
 ): Promise<void> {
-  const runtimeConfig = await resolveRuntimeLocalCliConfig(config)
+  let runtimeConfig = await resolveRuntimeLocalCliConfig(config)
   const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
   const configuredWindow = Number.isFinite(runtimeConfig.maxContextSize) && runtimeConfig.maxContextSize > 0
     ? runtimeConfig.maxContextSize
@@ -161,6 +162,11 @@ export async function streamChat(
     return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
   }
 
+  if (runtimeConfig.provider === "cursor-cli") {
+    const endpoint = await ensureCursorProxyRunning(runtimeConfig)
+    runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
+  }
+
   const providerConfig = getProviderConfig(runtimeConfig)
 
   // Combined abort: (a) user cancel, (b) our long-horizon timeout.

+ 23 - 0
src/lib/llm-providers.ts

@@ -1007,6 +1007,29 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         `${provider} provider uses subprocess transport; getProviderConfig should not be called for it`,
       )
 
+    case "cursor-cli": {
+      // OpenAI-compatible HTTP via local cursor-api-proxy.
+      const endpoint = (customEndpoint || "http://127.0.0.1:8765/v1").replace(/\/+$/, "")
+      const base = normalizeEndpoint(endpoint, "chat_completions").normalized.replace(/\/+$/, "")
+      const url = /\/chat\/completions$/i.test(base)
+        ? base
+        : `${base}/chat/completions`
+      const key = apiKey.trim() || "unused"
+      return {
+        url,
+        headers: withCustomOriginHeader({
+          "Content-Type": JSON_CONTENT_TYPE,
+          Authorization: `Bearer ${key}`,
+        }, url),
+        buildBody: (messages, overrides) => ({
+          ...buildOpenAiCompatibleBody(config, messages, overrides),
+          model,
+        }),
+        parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
+      }
+    }
+
     case "custom": {
       // Custom endpoints can speak either OpenAI's /chat/completions
       // wire or Anthropic's /v1/messages wire. The field `apiMode` on

+ 30 - 5
src/lib/settings-model-list.ts

@@ -1,5 +1,6 @@
 import { getProviderConfig, withCustomOriginHeader } from "@/lib/llm-providers"
 import { detectLocalCliConfig } from "@/lib/local-cli-config"
+import { ensureCursorProxyRunning, restartCursorProxyWithAuth, withCursorProxyEndpoint } from "@/lib/cursor-cli-proxy"
 import { isDirectRerankEndpoint } from "@/lib/rerank-api"
 import { getHttpFetch } from "@/lib/tauri-fetch"
 import type { EmbeddingConfig, LlmConfig, RerankConfig } from "@/stores/wiki-store"
@@ -182,12 +183,36 @@ export async function fetchLlmModelList(config: LlmConfig): Promise<LlmModelList
     return fetchLocalCliModel(config)
   }
 
-  const { url, headers } = buildModelsUrl(config)
-  const result = await fetchModelList(url, headers, config.model)
-  if (config.provider === "google") {
-    return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
+  let runtimeConfig = config
+  if (config.provider === "cursor-cli") {
+    const endpoint = await ensureCursorProxyRunning(config)
+    runtimeConfig = withCursorProxyEndpoint(config, endpoint)
+  }
+
+  const { url, headers } = buildModelsUrl(runtimeConfig)
+
+  const load = async () => {
+    const result = await fetchModelList(url, headers, runtimeConfig.model)
+    if (runtimeConfig.provider === "google") {
+      return toModelListResult(result.models.map((model) => model.replace(/^models\//, "")))
+    }
+    return result
+  }
+
+  try {
+    return await load()
+  } catch (error) {
+    if (runtimeConfig.provider !== "cursor-cli") throw error
+    const message = error instanceof Error ? error.message : String(error)
+    if (!/authentication required|CURSOR_API_KEY|CURSOR_AUTH_TOKEN/i.test(message)) {
+      throw error
+    }
+    const endpoint = await restartCursorProxyWithAuth(runtimeConfig)
+    runtimeConfig = withCursorProxyEndpoint(runtimeConfig, endpoint)
+    const retry = buildModelsUrl(runtimeConfig)
+    const result = await fetchModelList(retry.url, retry.headers, runtimeConfig.model)
+    return result
   }
-  return result
 }
 
 export async function fetchEmbeddingModelList(config: EmbeddingConfig): Promise<LlmModelListResult> {

+ 6 - 0
src/lib/settings-model-test.ts

@@ -33,6 +33,12 @@ function ensureModel(model: string, emptyMessage: string): string {
 export function normalizeModelTestError(error: Error): Error {
   const message = error.message
 
+  if (message === "Load failed" || /failed to fetch|networkerror|load failed/i.test(message)) {
+    return new Error(
+      "无法连接模型接口(Load failed)。若使用 Cursor CLI,请先在设置中重新检查 CLI 状态,确认 proxy 已拉起后再测。",
+    )
+  }
+
   if (/insufficient account balance/i.test(message)) {
     return new Error("当前中转站账户余额不足,或该模型没有可用额度,请先充值或切换可用模型。")
   }

+ 1 - 1
src/stores/wiki-store.ts

@@ -133,7 +133,7 @@ export interface ReasoningConfig {
 }
 
 interface LlmConfig {
-  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli"
+  provider: "openai" | "anthropic" | "google" | "azure" | "ollama" | "custom" | "minimax" | "claude-code" | "codex-cli" | "cursor-cli"
   apiKey: string
   model: string
   ollamaUrl: string