diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea8c93..f9c42f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **GitHub Copilot CLI as an AI provider** — `copilot-cli` joins `claude-code-cli`, `codex-cli`, and `opencode-cli` in the `AIProvider` union. It piggybacks on the user's locally-installed `copilot` binary and their GitHub Copilot subscription — no API key required. Detection mirrors the existing CLI providers (binary discovery across PATH + common install locations) and it appears in Settings → AI with the same status/re-detect pattern. Prompts run one-shot via `copilot -p` (model selectable via the per-provider model picker, free-text since Copilot has no enumeration command). Tool permissions are deliberately restricted (`--deny-tool=shell`, `--deny-tool=write`, `--no-ask-user`, and `COPILOT_ALLOW_ALL` stripped from the child env) so Copilot only produces text and cannot edit files, run shell commands, or block on interactive prompts. + +### Technical + +- New Rust commands `detect_copilot_cli` and `copilot_cli_prompt` (`copilot --no-color --deny-tool=shell --deny-tool=write --no-ask-user [--model …] -p `), plus a `CopilotCliInfo` type; registered in `lib.rs`. Mirrored across all three layers: `commands/ai.rs`, `dev-server.mjs`, and the `backend-ai.ts` wrapper. +- `useAIProvider` adds `copilot-cli` to `CLI_AGENT_PROVIDERS`, dispatches it in `suggest()` / `rawPrompt()`, and forwards the per-provider model. `SettingsPanel` gains the provider option, detection state, and status block. +- i18n: `aiProviderCopilotCli`, `aiProviderCopilotCliNotFound`, `aiCopilotCliDetectedHint`, `aiCopilotCliInfoBox` across all five locales (en, fr, es, pt-BR, zh-CN). +- Unit tests extended in `useAIProvider-opencode.test.ts` for the Copilot dispatch and model fallback. + + ## [2.17.0] - 2026-06-04 v2.17 rounds out the agent-CLI lineup with **opencode** as a first-class AI provider, and gives every CLI agent its own model picker — a second select under the provider dropdown, scoped per provider so switching back restores the previous choice. diff --git a/apps/desktop/dev-server.mjs b/apps/desktop/dev-server.mjs index 913efd3..e1eb423 100644 --- a/apps/desktop/dev-server.mjs +++ b/apps/desktop/dev-server.mjs @@ -3267,6 +3267,85 @@ async function handleRequest(req, res) { } } + // GET /api/copilot-cli-detect + if (url.pathname === "/api/copilot-cli-detect" && req.method === "GET") { + try { + const COPILOT = resolveBin("copilot"); + const exists = (() => { + try { return existsSync(COPILOT); } catch { return false; } + })(); + let resolved = exists ? COPILOT : ""; + if (!resolved) { + try { + const r = spawnSync(process.platform === "win32" ? "where" : "which", ["copilot"], { encoding: "utf-8" }); + if (r.status === 0 && r.stdout.trim()) { + resolved = r.stdout.split(/\r?\n/)[0].trim(); + } + } catch { /* ignore */ } + } + + if (!resolved) { + return jsonResponse(req, res, { + found: false, + path: "", + version: "", + logged_in: false, + status: "not_found", + detail: "Binaire `copilot` introuvable. Installez-le avec `npm install -g @github/copilot`.", + }); + } + + let version = ""; + try { + const r = spawnSync(resolved, ["--version"], { encoding: "utf-8" }); + if (r.status === 0) version = r.stdout.trim(); + } catch { /* ignore */ } + + return jsonResponse(req, res, { + found: true, + path: resolved, + version, + logged_in: false, + status: "detected", + detail: "", + }); + } catch (err) { + return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); + } + } + + // POST /api/copilot-cli-prompt { prompt, systemPrompt?, cwd?, model? } + if (url.pathname === "/api/copilot-cli-prompt" && req.method === "POST") { + try { + const body = await readBody(req); + const COPILOT = resolveBin("copilot"); + const fullPrompt = body.systemPrompt && body.systemPrompt.trim() + ? `# System\n${body.systemPrompt.trim()}\n\n# User\n${(body.prompt || "").trim()}` + : (body.prompt || ""); + const cpArgs = ["--no-color", "--deny-tool=shell", "--deny-tool=write", "--no-ask-user"]; + if (body.model && String(body.model).trim()) { + cpArgs.push("--model", String(body.model).trim()); + } + cpArgs.push("-p", fullPrompt); + const cpEnv = { ...process.env }; + delete cpEnv.COPILOT_ALLOW_ALL; + const r = spawnSync(COPILOT, cpArgs, { + cwd: body.cwd || undefined, + env: cpEnv, + encoding: "utf-8", + timeout: 5 * 60 * 1000, + maxBuffer: 20 * 1024 * 1024, + }); + if (r.status !== 0) { + const detail = (r.stderr || r.stdout || "").trim() || "Copilot CLI a échoué sans message"; + return jsonResponse(req, res, { error: detail }, 500); + } + return res.writeHead(200, { ...corsHeaders(req), "Content-Type": "text/plain" }).end(r.stdout); + } catch (err) { + return jsonResponse(req, res, { error: err.stderr?.toString() || err.message }, 500); + } + } + // POST /api/claude-cli-login (opens a terminal with `claude login`) if (url.pathname === "/api/claude-cli-login" && req.method === "POST") { try { diff --git a/apps/desktop/src-tauri/src/commands/ai.rs b/apps/desktop/src-tauri/src/commands/ai.rs index 7e215f4..b9b820e 100644 --- a/apps/desktop/src-tauri/src/commands/ai.rs +++ b/apps/desktop/src-tauri/src/commands/ai.rs @@ -495,6 +495,155 @@ pub(crate) fn opencode_list_models() -> Result, String> { Ok(models) } +// ─── GitHub Copilot CLI provider ───────────────────────────────────────── +// +// GitHub Copilot CLI (`copilot`) is an AI coding agent. Like the other CLIs +// it exposes a non-interactive entry point: +// - `copilot -p "" [--model ]` — run one prompt, print the +// response to stdout and exit. The trailing stats footer (credits / +// tokens) is written to stderr, so stdout stays clean. +// +// We deliberately run Copilot text-only: `--deny-tool=shell`, +// `--deny-tool=write` and `--no-ask-user` block file edits, shell exec and +// interactive prompts, and `COPILOT_ALLOW_ALL` is stripped from the child +// env. The prompt we send is self-contained (it carries the full conflict +// hunk), so the model only needs to produce text — never tools. +// +// Auth is handled by Copilot itself (`copilot` login / GitHub subscription), +// stored on the user's machine — GitWand just shells out, same trick as the +// other three CLIs. + +fn resolve_copilot_binary() -> Option { + // 1) PATH first + let which_cmd = if cfg!(windows) { "where" } else { "which" }; + if let Ok(out) = hidden_cmd(which_cmd).arg("copilot").output() { + if out.status.success() { + let raw = String::from_utf8_lossy(&out.stdout); + let first = raw.lines().next().unwrap_or("").trim(); + if !first.is_empty() && std::path::Path::new(first).exists() { + return Some(first.to_string()); + } + } + } + + // 2) Common install locations (npm global, homebrew, local bin) + let home = dirs::home_dir(); + let mut candidates: Vec = Vec::new(); + if let Some(h) = home.as_ref() { + candidates.push(h.join(".copilot/bin/copilot")); + candidates.push(h.join(".local/bin/copilot")); + candidates.push(h.join(".npm-global/bin/copilot")); + candidates.push(h.join("AppData/Roaming/npm/copilot.cmd")); + candidates.push(h.join("AppData/Roaming/npm/copilot")); + } + candidates.push(PathBuf::from("/opt/homebrew/bin/copilot")); + candidates.push(PathBuf::from("/usr/local/bin/copilot")); + candidates.push(PathBuf::from("/usr/bin/copilot")); + + for c in candidates { + if c.exists() { + return Some(c.to_string_lossy().to_string()); + } + } + None +} + +/// Detect GitHub Copilot CLI presence and version. Same privacy stance as the +/// Claude / Codex / opencode detectors: no prompt is sent to verify auth — +/// that is confirmed implicitly on the first real `copilot_cli_prompt`. +#[tauri::command] +pub(crate) fn detect_copilot_cli() -> Result { + let binary = match resolve_copilot_binary() { + Some(b) => b, + None => { + return Ok(CopilotCliInfo { + found: false, + path: String::new(), + version: String::new(), + logged_in: false, + status: "not_found".to_string(), + detail: "Binaire `copilot` introuvable. Installez-le avec `npm install -g @github/copilot`." + .to_string(), + }); + } + }; + + let version = hidden_cmd(&binary) + .arg("--version") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + + Ok(CopilotCliInfo { + found: true, + path: binary, + version, + logged_in: false, + status: "detected".to_string(), + detail: String::new(), + }) +} + +#[tauri::command] +pub(crate) fn copilot_cli_prompt( + prompt: String, + system_prompt: Option, + cwd: Option, + model: Option, +) -> Result { + let binary = resolve_copilot_binary() + .ok_or_else(|| "Binaire `copilot` introuvable".to_string())?; + + // Copilot CLI doesn't expose a separate system channel — prepend the + // system prompt as a Markdown section, same shape as the other flows. + let full_prompt = match system_prompt { + Some(sys) if !sys.trim().is_empty() => { + format!("# System\n{}\n\n# User\n{}", sys.trim(), prompt.trim()) + } + _ => prompt, + }; + + let mut cmd = hidden_cmd(&binary); + // `--no-color` keeps stdout free of ANSI escapes. Flags precede the + // positional prompt passed via `-p`. + cmd.arg("--no-color"); + // Safety: GitWand only wants a text answer back. Deny the tools that + // could mutate the user's machine (shell exec, file writes) and disable + // the interactive `ask_user` tool so a one-shot run can never block + // waiting for input. `COPILOT_ALLOW_ALL` is stripped from the inherited + // env so a stray variable can't silently re-enable every tool. + cmd.env_remove("COPILOT_ALLOW_ALL"); + cmd.args(["--deny-tool=shell", "--deny-tool=write", "--no-ask-user"]); + if let Some(m) = model.as_ref() { + if !m.trim().is_empty() { + cmd.args(["--model", m.trim()]); + } + } + cmd.args(["-p", full_prompt.as_str()]); + if let Some(dir) = cwd { + if !dir.trim().is_empty() { + cmd.current_dir(dir); + } + } + + let output = cmd + .output() + .map_err(|e| format!("Failed to run copilot CLI: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if stderr.is_empty() { stdout } else { stderr }; + return Err(if detail.is_empty() { + "Copilot CLI a échoué sans message".to_string() + } else { + detail + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + // ─── Claude OAuth login (opens a native terminal) ──────────────────────── /// Launch `claude login` in the user's native terminal emulator. We don't diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ae9af77..1d134b7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -94,9 +94,10 @@ use tauri_plugin_global_shortcut::GlobalShortcutExt; // `pub(crate) use crate::types::*;`. // `strip_claude_auth_env` + `resolve_claude_binary` + `resolve_codex_binary` -// + `resolve_opencode_binary` + 8 AI CLI commands (detect_claude_cli, -// claude_cli_prompt, detect_codex_cli, codex_cli_prompt, claude_cli_login, -// detect_opencode_cli, opencode_cli_prompt, opencode_list_models — v2.17) +// + `resolve_opencode_binary` + `resolve_copilot_binary` + 10 AI CLI commands +// (detect_claude_cli, claude_cli_prompt, detect_codex_cli, codex_cli_prompt, +// claude_cli_login, detect_opencode_cli, opencode_cli_prompt, +// opencode_list_models, detect_copilot_cli, copilot_cli_prompt) // migrated to `src/commands/ai.rs` (§3.4f). // Handler entries below route to `commands::ai::*`. @@ -384,6 +385,8 @@ pub fn run() { commands::ai::detect_opencode_cli, commands::ai::opencode_cli_prompt, commands::ai::opencode_list_models, + commands::ai::detect_copilot_cli, + commands::ai::copilot_cli_prompt, commands::network::check_remote_reachable, commands::gitlab::detect_glab, commands::gitlab::gl_list_mrs, diff --git a/apps/desktop/src-tauri/src/types.rs b/apps/desktop/src-tauri/src/types.rs index 10a1637..72e98e4 100644 --- a/apps/desktop/src-tauri/src/types.rs +++ b/apps/desktop/src-tauri/src/types.rs @@ -550,6 +550,18 @@ pub struct OpencodeCliInfo { pub detail: String, } +// ─── GitHub Copilot CLI ──────────────────────────────────────────── + +#[derive(Serialize)] +pub struct CopilotCliInfo { + pub found: bool, + pub path: String, + pub version: String, + pub logged_in: bool, + pub status: String, + pub detail: String, +} + // ─── Git hooks ───────────────────────────────────────────────────── #[derive(Serialize)] diff --git a/apps/desktop/src/__tests__/useAIProvider-opencode.test.ts b/apps/desktop/src/__tests__/useAIProvider-opencode.test.ts index a7db2f4..2745300 100644 --- a/apps/desktop/src/__tests__/useAIProvider-opencode.test.ts +++ b/apps/desktop/src/__tests__/useAIProvider-opencode.test.ts @@ -26,12 +26,14 @@ const { claudeCliPrompt, codexCliPrompt, opencodeCliPrompt, + copilotCliPrompt, listOpencodeModels, detectClaudeCli, } = vi.hoisted(() => ({ claudeCliPrompt: vi.fn(async () => "ok-claude"), codexCliPrompt: vi.fn(async () => "ok-codex"), opencodeCliPrompt: vi.fn(async () => "ok-opencode"), + copilotCliPrompt: vi.fn(async () => "ok-copilot"), listOpencodeModels: vi.fn(async () => ["anthropic/claude-x", "openai/gpt-y"]), // Keep the auto-fallback disabled so it never hijacks the explicit provider. detectClaudeCli: vi.fn(async () => ({ @@ -48,6 +50,7 @@ vi.mock("../utils/backend", () => ({ claudeCliPrompt, codexCliPrompt, opencodeCliPrompt, + copilotCliPrompt, listOpencodeModels, detectClaudeCli, })); @@ -109,6 +112,10 @@ describe("listModelsForProvider", () => { expect(await listModelsForProvider("codex-cli")).toEqual([]); }); + it("returns an empty list (free-text fallback) for Copilot", async () => { + expect(await listModelsForProvider("copilot-cli")).toEqual([]); + }); + it("enumerates opencode models dynamically", async () => { const models = await listModelsForProvider("opencode-cli"); expect(models).toEqual(["anthropic/claude-x", "openai/gpt-y"]); @@ -146,6 +153,16 @@ describe("rawPrompt provider dispatch", () => { expect(claudeCliPrompt).toHaveBeenCalledWith("user", "sys", undefined, "text", "opus"); }); + it("routes copilot-cli to copilotCliPrompt with the selected model", async () => { + setSettings({ + aiProvider: "copilot-cli", + aiModelByProvider: { "copilot-cli": "gpt-5" }, + }); + const out = await useAIProvider().rawPrompt("sys", "user"); + expect(out).toBe("ok-copilot"); + expect(copilotCliPrompt).toHaveBeenCalledWith("user", "sys", undefined, "gpt-5"); + }); + it("passes undefined when no model is configured (CLI default)", async () => { setSettings({ aiProvider: "opencode-cli", aiModelByProvider: {} }); await useAIProvider().rawPrompt("s", "u"); diff --git a/apps/desktop/src/components/SettingsPanel.vue b/apps/desktop/src/components/SettingsPanel.vue index 1a8ed0c..a9fe5c0 100644 --- a/apps/desktop/src/components/SettingsPanel.vue +++ b/apps/desktop/src/components/SettingsPanel.vue @@ -20,6 +20,8 @@ import { type CodexCliInfo, detectOpencodeCli, type OpencodeCliInfo, + detectCopilotCli, + type CopilotCliInfo, readGitwandrc, writeGitwandrc, checkForUpdates, @@ -415,6 +417,9 @@ function onAIProviderChange(val: AIProvider) { } else if (val === "opencode-cli") { runOpencodeCliDetect(); loadCliModels(val); + } else if (val === "copilot-cli") { + runCopilotCliDetect(); + loadCliModels(val); } else if (val === "claude") { if (!settings.value.aiApiEndpoint || settings.value.aiApiEndpoint === "https://api.openai.com/v1") { updateSetting("aiApiEndpoint", "https://api.anthropic.com"); @@ -581,6 +586,28 @@ async function runOpencodeCliDetect() { } } +// ─── GitHub Copilot CLI detection ─────────────────────── +const copilotCliInfo = ref(null); +const copilotCliDetecting = ref(false); + +async function runCopilotCliDetect() { + copilotCliDetecting.value = true; + try { + copilotCliInfo.value = await detectCopilotCli(); + } catch (e) { + copilotCliInfo.value = { + found: false, + path: "", + version: "", + logged_in: false, + status: "error", + detail: (e as Error).message, + }; + } finally { + copilotCliDetecting.value = false; + } +} + // ─── Per-provider model picker for CLI agents (v2.17) ─── const cliModelOptions = ref([]); const cliModelsLoading = ref(false); @@ -655,6 +682,7 @@ type LlmFallbackProvider = | "claude-code-cli" | "codex-cli" | "opencode-cli" + | "copilot-cli" | "openai-compat" | "ollama" | "mcp"; @@ -821,6 +849,7 @@ onMounted(() => { runClaudeCliDetect(); runCodexCliDetect(); runOpencodeCliDetect(); + runCopilotCliDetect(); // Preload the model list when a CLI agent is already the active provider. loadCliModels(); }); @@ -1481,6 +1510,10 @@ function savePresetForm() { {{ t('settings.aiProviderOpencodeCli') }}{{ opencodeCliInfo && !opencodeCliInfo.found ? t('settings.aiProviderOpencodeCliNotFound') : '' }} +