Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <prompt>`), 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.
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/dev-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
149 changes: 149 additions & 0 deletions apps/desktop/src-tauri/src/commands/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,155 @@ pub(crate) fn opencode_list_models() -> Result<Vec<String>, 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 "<prompt>" [--model <m>]` — 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<String> {
// 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<PathBuf> = 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<CopilotCliInfo, String> {
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<String>,
cwd: Option<String>,
model: Option<String>,
) -> Result<String, String> {
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
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*`.

Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/__tests__/useAIProvider-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -48,6 +50,7 @@ vi.mock("../utils/backend", () => ({
claudeCliPrompt,
codexCliPrompt,
opencodeCliPrompt,
copilotCliPrompt,
listOpencodeModels,
detectClaudeCli,
}));
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading