diff --git a/docs/copilot-vscode/README.md b/docs/copilot-vscode/README.md new file mode 100644 index 0000000..0fc1ee1 --- /dev/null +++ b/docs/copilot-vscode/README.md @@ -0,0 +1,132 @@ +# GitHub Copilot in VS Code → Opper + +The CLI now ships an adapter that does the Stable-channel setup for you: + +```bash +opper editors github-copilot-vscode +``` + +It detects the OAI Compatible community extension, prompts before +installing it (with a marketplace link and a clean cancel path), then +writes the Opper provider block into your VS Code user `settings.json`. +Remove with `opper editors github-copilot-vscode --remove`. + +The artifacts in this directory are the manual recipe — useful as a +reference, for users who don't have the CLI installed, or for the +Insiders channel where the native BYOK path isn't currently usable. + +## What this gets you + +- **In scope:** Copilot **Chat** and **Agent mode** in VS Code, answered by + Opper-routed models from the curated picker (`src/config/models.ts`). +- **Out of scope:** Inline ghost-text completions still go to GitHub's own + Copilot service. BYOK does not redirect them. Embeddings, repository + indexing, intent detection and a few other side queries also keep + hitting GitHub's service. +- **Auth:** API key entered once via VS Code's "Manage Models" UI; stored + in the OS keychain (Insiders) or by the community extension (Stable), + not in `settings.json`. +- **Subscription gates:** Copilot Free / Pro have BYOK on by default. + Copilot Business / Enterprise users need their org admin to enable the + "Bring Your Own Language Model Key in VS Code" policy. + +## Files + +- `generate.ts` — derives both JSON snippets from `PICKER_MODELS`. Run + `npx tsx docs/copilot-vscode/generate.ts` after any model-list change. +- `insiders-settings.json` — paste-ready block for **VS Code Insiders** + using the native `github.copilot.chat.customOAIModels` setting. +- `stable-settings.json` — paste-ready block for **VS Code Stable**, used + with the community extension `johnny-zhao.oai-compatible-copilot`. + +## VS Code Insiders setup (UI path) + +> **Status (May 2026):** The native settings-driven path +> (`github.copilot.chat.customOAIModels`) is **deprecated** in the Copilot +> Chat extension shipped with Insiders 1.120 — it lives under +> `ConfigKey.Deprecated.CustomOAIModels` in `vscode-copilot-chat` source +> with a "remove after 6 months" TODO. It runs a one-shot migration into +> a new BYOK storage system, then ignores subsequent edits. +> +> So Insiders has no working declarative configuration today. Use the +> UI flow below; we'll revisit declarative setup once Microsoft ships +> the array-based replacement for `customOAIModels`. + +The `insiders-settings.json` snippet in this directory is preserved as a +reference but should **not** be merged into Insiders user settings — +it'll be ignored. + +1. Open Copilot Chat → click the model picker → **Manage Models**. +2. Click **+ Add Models…** → **OpenAI Compatible**. +3. When prompted: + - Base URL: `https://api.opper.ai/v3/compat` + - API key: your `OPPER_API_KEY` + - Model id: `claude-opus-4-7` (start here; add more once one works) +4. Repeat step 2 for any additional model id from `PICKER_MODELS` you + want surfaced. +5. Pick the model in the chat picker → send a message → confirm the + trace lands on `https://platform.opper.ai/traces`. + +## VS Code Stable setup (community extension) + +Until the native custom-OAI provider lands in Stable, the community +extension is the most painless path. + +1. Install the extension: + `code --install-extension johnny-zhao.oai-compatible-copilot` + (or search for "OAI Compatible Provider for Copilot" in the + Extensions sidebar). +2. Open user `settings.json` and merge the contents of + `stable-settings.json` into it (top-level keys: `oaicopilot.baseUrl` + and `oaicopilot.models`). +3. Reload the window. +4. Open Copilot Chat → model picker → **Manage Models** → choose **OAI + Compatible** → enter your `OPPER_API_KEY` when prompted → select the + Opper models to surface in the picker. +5. Send a chat message using one of the new models and verify the trace + appears on Opper. + +## Testing checklist + +For each model worth validating (start with `claude-opus-4-7` and one +non-Anthropic model — `gpt-5.5` or `gemini-3.1-pro-preview`): + +- [ ] Plain chat reply renders end-to-end. +- [ ] Streaming chunks arrive incrementally (not just at the end). +- [ ] **Agent mode** runs a multi-step task that uses tools (e.g. + `read_file`, `apply_patch`). +- [ ] Long-context request (paste a 20k-token prompt) doesn't truncate. +- [ ] Vision: drag in an image — for models flagged `vision: true` it + should be accepted; for `vision: false` it should be rejected + cleanly rather than silently ignored. +- [ ] Thinking / reasoning content surfaces (or is suppressed cleanly) + for models flagged `thinking: true`. +- [ ] Trace lands on `https://platform.opper.ai/traces` with the right + model id. + +## What to flag back + +If anything in the table below is wrong, ping me and I'll regenerate: + +- A capability flag (`toolCalling`, `vision`, `thinking`) that doesn't + match what Opper actually reports for a model. +- A `maxInputTokens` / `maxOutputTokens` that VS Code rejects or that the + upstream model can't honour. +- A model id that `/v3/compat` returns "unknown model" for. +- Any model that needs a different `apiMode` than `"openai"` (Stable + extension only — Insiders pins to OpenAI shape). + +## Promotion to phase 2 + +Once the JSON is stable, the adapter lives in +`src/agents/github-copilot-vscode.ts` as a configure-only adapter (mirror +of `claude-desktop.ts`): + +- `configure()` writes a sentinel-bracketed `customOAIModels` entry into + the user `settings.json` for whichever channel is detected. +- `unconfigure()` strips it. +- No `spawn()` — VS Code is launched by the user, not by us. + +The `generate.ts` logic here becomes the body of `configure()`; the +`VISION` set either graduates onto `PickerModel` in `src/config/models.ts` +or sits beside the new adapter. diff --git a/docs/copilot-vscode/generate.ts b/docs/copilot-vscode/generate.ts new file mode 100644 index 0000000..8810e76 --- /dev/null +++ b/docs/copilot-vscode/generate.ts @@ -0,0 +1,86 @@ +/** + * Phase-1 scratch generator for the GitHub Copilot in VS Code BYOK + * integration. Reads PICKER_MODELS and emits the two settings.json snippets + * sitting next to this file: + * + * - insiders-settings.json: native `github.copilot.chat.customOAIModels` + * block (works in VS Code Insiders 1.104+). + * - stable-settings.json: `oaicopilot.*` block consumed by the + * "OAI Compatible Provider for Copilot" community extension on stable. + * + * Run from the repo root: + * npx tsx docs/copilot-vscode/generate.ts + * + * When phase 2 promotes this to a real adapter, the logic here moves into + * `src/agents/github-copilot-vscode.ts` and the capability table either + * graduates onto `PickerModel` or sits beside the adapter. + */ +import { writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PICKER_MODELS } from "../../src/config/models.js"; + +const PROVIDER_URL = "https://api.opper.ai/v3/compat"; +const PROVIDER_NAME = "Opper"; +const DEFAULT_MAX_OUTPUT = 32_768; + +// Conservative vision allowlist — flip during testing as we confirm each +// model's compat behaviour. Keeping non-vision off by default avoids the +// picker advertising image support that the upstream model can't honour. +const VISION = new Set([ + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "gpt-5.5", + "gemini-3.1-pro-preview", +]); + +const here = dirname(fileURLToPath(import.meta.url)); + +// Insiders' native customOAIModels uses an OBJECT keyed by model id, with +// each entry repeating the provider URL. The grouped-array shape (one +// provider, nested `models`) is a still-open feature request +// (microsoft/vscode#277102), not the implemented schema — using it +// registers the provider name but silently drops the model list. +const insidersBlock = { + "github.copilot.chat.customOAIModels": Object.fromEntries( + PICKER_MODELS.map((m) => [ + m.id, + { + name: `${PROVIDER_NAME} · ${m.id}`, + url: PROVIDER_URL, + requiresAPIKey: true, + toolCalling: true, + vision: VISION.has(m.id), + thinking: m.reasoning, + maxInputTokens: m.contextWindow, + maxOutputTokens: DEFAULT_MAX_OUTPUT, + }, + ]), + ), +}; + +const stableBlock = { + "oaicopilot.baseUrl": PROVIDER_URL, + "oaicopilot.models": PICKER_MODELS.map((m) => ({ + id: m.id, + owned_by: "opper", + displayName: m.id, + apiMode: "openai", + context_length: m.contextWindow, + max_tokens: DEFAULT_MAX_OUTPUT, + vision: VISION.has(m.id), + enable_thinking: m.reasoning, + })), +}; + +await writeFile( + join(here, "insiders-settings.json"), + `${JSON.stringify(insidersBlock, null, 2)}\n`, +); +await writeFile( + join(here, "stable-settings.json"), + `${JSON.stringify(stableBlock, null, 2)}\n`, +); + +console.log("wrote insiders-settings.json and stable-settings.json"); diff --git a/docs/copilot-vscode/insiders-settings.json b/docs/copilot-vscode/insiders-settings.json new file mode 100644 index 0000000..143d093 --- /dev/null +++ b/docs/copilot-vscode/insiders-settings.json @@ -0,0 +1,104 @@ +{ + "github.copilot.chat.customOAIModels": { + "claude-opus-4-7": { + "name": "Opper · claude-opus-4-7", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": true, + "thinking": true, + "maxInputTokens": 1000000, + "maxOutputTokens": 32768 + }, + "claude-sonnet-4-6": { + "name": "Opper · claude-sonnet-4-6", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": true, + "thinking": true, + "maxInputTokens": 1000000, + "maxOutputTokens": 32768 + }, + "claude-haiku-4-5": { + "name": "Opper · claude-haiku-4-5", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": true, + "thinking": false, + "maxInputTokens": 200000, + "maxOutputTokens": 32768 + }, + "gpt-5.5": { + "name": "Opper · gpt-5.5", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": true, + "thinking": true, + "maxInputTokens": 1050000, + "maxOutputTokens": 32768 + }, + "gemini-3.1-pro-preview": { + "name": "Opper · gemini-3.1-pro-preview", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": true, + "thinking": true, + "maxInputTokens": 1048576, + "maxOutputTokens": 32768 + }, + "deepinfra/kimi-k2.6": { + "name": "Opper · deepinfra/kimi-k2.6", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": false, + "thinking": true, + "maxInputTokens": 262144, + "maxOutputTokens": 32768 + }, + "deepinfra/glm-5.1": { + "name": "Opper · deepinfra/glm-5.1", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": false, + "thinking": true, + "maxInputTokens": 202752, + "maxOutputTokens": 32768 + }, + "fireworks/minimax-m2p7": { + "name": "Opper · fireworks/minimax-m2p7", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": false, + "thinking": false, + "maxInputTokens": 196608, + "maxOutputTokens": 32768 + }, + "deepinfra/deepseek-v4-pro": { + "name": "Opper · deepinfra/deepseek-v4-pro", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": false, + "thinking": true, + "maxInputTokens": 1048576, + "maxOutputTokens": 32768 + }, + "deepinfra/deepseek-v4-flash": { + "name": "Opper · deepinfra/deepseek-v4-flash", + "url": "https://api.opper.ai/v3/compat", + "requiresAPIKey": true, + "toolCalling": true, + "vision": false, + "thinking": true, + "maxInputTokens": 1048576, + "maxOutputTokens": 32768 + } + } +} diff --git a/docs/copilot-vscode/stable-settings.json b/docs/copilot-vscode/stable-settings.json new file mode 100644 index 0000000..b658c65 --- /dev/null +++ b/docs/copilot-vscode/stable-settings.json @@ -0,0 +1,105 @@ +{ + "oaicopilot.baseUrl": "https://api.opper.ai/v3/compat", + "oaicopilot.models": [ + { + "id": "claude-opus-4-7", + "owned_by": "opper", + "displayName": "claude-opus-4-7", + "apiMode": "openai", + "context_length": 1000000, + "max_tokens": 32768, + "vision": true, + "enable_thinking": true + }, + { + "id": "claude-sonnet-4-6", + "owned_by": "opper", + "displayName": "claude-sonnet-4-6", + "apiMode": "openai", + "context_length": 1000000, + "max_tokens": 32768, + "vision": true, + "enable_thinking": true + }, + { + "id": "claude-haiku-4-5", + "owned_by": "opper", + "displayName": "claude-haiku-4-5", + "apiMode": "openai", + "context_length": 200000, + "max_tokens": 32768, + "vision": true, + "enable_thinking": false + }, + { + "id": "gpt-5.5", + "owned_by": "opper", + "displayName": "gpt-5.5", + "apiMode": "openai", + "context_length": 1050000, + "max_tokens": 32768, + "vision": true, + "enable_thinking": true + }, + { + "id": "gemini-3.1-pro-preview", + "owned_by": "opper", + "displayName": "gemini-3.1-pro-preview", + "apiMode": "openai", + "context_length": 1048576, + "max_tokens": 32768, + "vision": true, + "enable_thinking": true + }, + { + "id": "deepinfra/kimi-k2.6", + "owned_by": "opper", + "displayName": "deepinfra/kimi-k2.6", + "apiMode": "openai", + "context_length": 262144, + "max_tokens": 32768, + "vision": false, + "enable_thinking": true + }, + { + "id": "deepinfra/glm-5.1", + "owned_by": "opper", + "displayName": "deepinfra/glm-5.1", + "apiMode": "openai", + "context_length": 202752, + "max_tokens": 32768, + "vision": false, + "enable_thinking": true + }, + { + "id": "fireworks/minimax-m2p7", + "owned_by": "opper", + "displayName": "fireworks/minimax-m2p7", + "apiMode": "openai", + "context_length": 196608, + "max_tokens": 32768, + "vision": false, + "enable_thinking": false + }, + { + "id": "deepinfra/deepseek-v4-pro", + "owned_by": "opper", + "displayName": "deepinfra/deepseek-v4-pro", + "apiMode": "openai", + "context_length": 1048576, + "max_tokens": 32768, + "vision": false, + "enable_thinking": true + }, + { + "id": "deepinfra/deepseek-v4-flash", + "owned_by": "opper", + "displayName": "deepinfra/deepseek-v4-flash", + "apiMode": "openai", + "context_length": 1048576, + "max_tokens": 32768, + "vision": false, + "enable_thinking": true + } + ] +} diff --git a/src/agents/github-copilot-vscode.ts b/src/agents/github-copilot-vscode.ts new file mode 100644 index 0000000..fb04b56 --- /dev/null +++ b/src/agents/github-copilot-vscode.ts @@ -0,0 +1,65 @@ +import { which } from "../util/which.js"; +import { vscodeUserSettingsPath } from "../util/editor-paths.js"; +import { + configureGitHubCopilotVSCode, + unconfigureGitHubCopilotVSCode, + isGitHubCopilotVSCodeConfigured, + installCommunityExtension, + COMMUNITY_EXTENSION_ID, +} from "../setup/github-copilot-vscode.js"; +import type { AgentAdapter, DetectResult } from "./types.js"; + +/** + * Routes GitHub Copilot Chat in VS Code through Opper via the + * "OAI Compatible Provider for Copilot" community extension. Configure-only: + * the user opens VS Code themselves; we just write the Opper provider block + * into user `settings.json`. + * + * `configure()` prompts the user before installing the third-party + * extension if it's missing — see `confirmAndInstallExtension` in + * `setup/github-copilot-vscode.ts`. + * + * Stable-channel only for now. Insiders' native BYOK flow + * (`github.copilot.chat.customOAIModels`) is deprecated upstream and lives + * behind a one-shot migration that no longer reads on subsequent edits. + */ + +export { COMMUNITY_EXTENSION_ID }; + +async function detect(): Promise { + const codeBin = await which("code"); + if (!codeBin) return { installed: false }; + return { + installed: true, + configPath: vscodeUserSettingsPath("stable"), + }; +} + +async function install(): Promise { + await installCommunityExtension(); +} + +async function isConfigured(): Promise { + return isGitHubCopilotVSCodeConfigured("stable"); +} + +async function configure(): Promise { + await configureGitHubCopilotVSCode({ channel: "stable" }); +} + +async function unconfigure(): Promise { + await unconfigureGitHubCopilotVSCode({ channel: "stable" }); +} + +export const githubCopilotVSCode: AgentAdapter = { + name: "github-copilot-vscode", + displayName: "GitHub Copilot (VS Code)", + docsUrl: "https://github.com/features/copilot", + detect, + isConfigured, + configure, + unconfigure, + install, + // No spawn — user opens VS Code themselves; Opper models appear in the + // Copilot Chat picker on next session. +}; diff --git a/src/agents/registry.ts b/src/agents/registry.ts index e7ad3d8..4016d03 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -6,6 +6,7 @@ import { codex } from "./codex.js"; import { hermes } from "./hermes.js"; import { pi } from "./pi.js"; import { openclaw } from "./openclaw.js"; +import { githubCopilotVSCode } from "./github-copilot-vscode.js"; const ADAPTERS: ReadonlyArray = [ opencode, @@ -15,6 +16,7 @@ const ADAPTERS: ReadonlyArray = [ hermes, pi, openclaw, + githubCopilotVSCode, ]; export function listAdapters(): ReadonlyArray { diff --git a/src/cli/editors.ts b/src/cli/editors.ts index 680b9d8..0f4b3d8 100644 --- a/src/cli/editors.ts +++ b/src/cli/editors.ts @@ -1,6 +1,8 @@ import { editorsListCommand, editorsOpenCodeCommand, + editorsGitHubCopilotVSCodeCommand, + editorsGitHubCopilotVSCodeRemoveCommand, } from "../commands/editors.js"; import type { RegisterFn } from "./types.js"; @@ -26,6 +28,20 @@ const register: RegisterFn = (program) => { overwrite: cmdOpts.overwrite ?? false, }); }); + + editors + .command("github-copilot-vscode") + .description( + "Route VS Code Copilot Chat through Opper via the OAI Compatible community extension", + ) + .option("--remove", "remove the Opper provider from VS Code settings") + .action(async (cmdOpts: { remove?: boolean }) => { + if (cmdOpts.remove) { + await editorsGitHubCopilotVSCodeRemoveCommand(); + return; + } + await editorsGitHubCopilotVSCodeCommand(); + }); }; export default register; diff --git a/src/commands/editors.ts b/src/commands/editors.ts index 563c706..b98e881 100644 --- a/src/commands/editors.ts +++ b/src/commands/editors.ts @@ -1,7 +1,13 @@ import { configureOpenCode } from "../setup/opencode.js"; +import { + configureGitHubCopilotVSCode, + unconfigureGitHubCopilotVSCode, +} from "../setup/github-copilot-vscode.js"; +import { githubCopilotVSCode } from "../agents/github-copilot-vscode.js"; import { listAdapters } from "../agents/registry.js"; import { isLaunchable } from "../agents/types.js"; import { brand } from "../ui/colors.js"; +import { OpperError } from "../errors.js"; import type { Location } from "../util/editor-paths.js"; export interface EditorsOpenCodeOptions { @@ -9,6 +15,7 @@ export interface EditorsOpenCodeOptions { overwrite: boolean; } + /** * Lists configure-only integrations from the agents registry. Anything in * the registry without a `spawn` method is "an editor" for this command's @@ -46,3 +53,48 @@ export async function editorsOpenCodeCommand( } console.log(brand.accent(`✓ Wrote OpenCode config to ${result.path}.`)); } + +export async function editorsGitHubCopilotVSCodeCommand(): Promise { + const detect = await githubCopilotVSCode.detect(); + if (!detect.installed) { + throw new OpperError( + "AGENT_NOT_FOUND", + "VS Code's `code` CLI was not found on PATH", + "Open VS Code → Cmd+Shift+P → 'Shell Command: Install code in PATH', then re-run.", + ); + } + + // The setup function detects the missing extension and prompts the user + // before installing — no extra orchestration needed here. + const result = await configureGitHubCopilotVSCode({ channel: "stable" }); + console.log( + brand.accent(`✓ Wrote Opper provider block to ${result.path}.`), + ); + console.log(""); + console.log("Next steps in VS Code:"); + console.log(" 1. Reload the window (Cmd+Shift+P → 'Developer: Reload Window')"); + console.log( + " 2. Open Copilot Chat → click the model picker → 'Manage Models' → 'OAI Compatible'", + ); + console.log(" 3. Paste your OPPER_API_KEY when prompted"); + console.log(" 4. Pick an Opper model and start chatting"); + console.log(""); + console.log( + brand.dim( + "Inline completions stay on GitHub's own service — BYOK only covers Chat and Agent mode.", + ), + ); +} + +export async function editorsGitHubCopilotVSCodeRemoveCommand(): Promise { + const result = await unconfigureGitHubCopilotVSCode({ channel: "stable" }); + if (result.removed) { + console.log( + brand.accent(`✓ Removed Opper provider from ${result.path}.`), + ); + } else { + console.log( + brand.dim(`No Opper provider found in ${result.path}; nothing to remove.`), + ); + } +} diff --git a/src/setup/github-copilot-vscode.ts b/src/setup/github-copilot-vscode.ts new file mode 100644 index 0000000..9e99d79 --- /dev/null +++ b/src/setup/github-copilot-vscode.ts @@ -0,0 +1,243 @@ +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { confirm, isCancel, log } from "@clack/prompts"; +import { + vscodeUserSettingsPath, + type VSCodeChannel, +} from "../util/editor-paths.js"; +import { which } from "../util/which.js"; +import { run } from "../util/run.js"; +import { OpperError } from "../errors.js"; +import { OPPER_COMPAT_URL } from "../config/endpoints.js"; +import { PICKER_MODELS } from "../config/models.js"; + +export const COMMUNITY_EXTENSION_ID = "johnny-zhao.oai-compatible-copilot"; +const COMMUNITY_EXTENSION_MARKETPLACE_URL = + "https://marketplace.visualstudio.com/items?itemName=johnny-zhao.oai-compatible-copilot"; + +const DEFAULT_MAX_OUTPUT = 32_768; + +// Conservative vision allowlist — only flip true for models we know report +// image input over Opper's compat endpoint. Users tweak by hand if needed. +const VISION = new Set([ + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "gpt-5.5", + "gemini-3.1-pro-preview", +]); + +const OWNED_BY = "opper"; + +/** Settings keys this adapter owns. Other `oaicopilot.*` keys are the user's + * extension preferences and are left alone. */ +export const OWNED_SETTING_KEYS = [ + "oaicopilot.baseUrl", + "oaicopilot.models", +] as const; + +interface OAIModelEntry { + id: string; + owned_by: string; + displayName: string; + apiMode: "openai"; + context_length: number; + max_tokens: number; + vision: boolean; + enable_thinking: boolean; +} + +function buildModelList(): OAIModelEntry[] { + return PICKER_MODELS.map((m) => ({ + id: m.id, + owned_by: OWNED_BY, + displayName: m.id, + apiMode: "openai", + context_length: m.contextWindow, + max_tokens: DEFAULT_MAX_OUTPUT, + vision: VISION.has(m.id), + enable_thinking: m.reasoning, + })); +} + +/** + * Reports whether the OAI Compatible community extension is present in the + * stable VS Code installation by querying `code --list-extensions`. + */ +export async function isCommunityExtensionInstalled(): Promise { + const codeBin = await which("code"); + if (!codeBin) return false; + const result = run("code", ["--list-extensions"], {}); + if (result.code !== 0) return false; + return result.stdout + .split("\n") + .map((line) => line.trim().toLowerCase()) + .includes(COMMUNITY_EXTENSION_ID.toLowerCase()); +} + +/** + * Run `code --install-extension `. Throws `AGENT_NOT_FOUND` when `code` + * is missing or the install exits non-zero. No prompt — caller decides + * whether to confirm with the user first. + */ +export async function installCommunityExtension(): Promise { + const codeBin = await which("code"); + if (!codeBin) { + throw new OpperError( + "AGENT_NOT_FOUND", + "VS Code's `code` CLI was not found on PATH", + "Open VS Code → Cmd+Shift+P → 'Shell Command: Install code in PATH', then re-run.", + ); + } + const result = run( + "code", + ["--install-extension", COMMUNITY_EXTENSION_ID], + { inherit: true }, + ); + if (result.code !== 0) { + throw new OpperError( + "AGENT_NOT_FOUND", + `Failed to install ${COMMUNITY_EXTENSION_ID} into VS Code`, + `Run \`code --install-extension ${COMMUNITY_EXTENSION_ID}\` manually and re-run.`, + ); + } +} + +/** + * Interactively offer to install the community extension. If the user + * declines or cancels (Esc), throws a USER_CANCELLED error so the calling + * configure step aborts cleanly without writing inert settings. + */ +async function confirmAndInstallExtension(): Promise { + log.info( + [ + "Routing Copilot Chat through Opper requires a third-party VS Code extension:", + ` • Extension: ${COMMUNITY_EXTENSION_ID}`, + ` • Marketplace: ${COMMUNITY_EXTENSION_MARKETPLACE_URL}`, + "", + "It is community-maintained (not by Opper or Microsoft).", + ].join("\n"), + ); + + const answer = await confirm({ + message: "Install it now?", + initialValue: true, + }); + if (isCancel(answer) || answer === false) { + throw new OpperError( + "USER_CANCELLED", + "Configuration cancelled — community extension not installed.", + `Install it manually with:\n code --install-extension ${COMMUNITY_EXTENSION_ID}\nThen re-run \`opper editors github-copilot-vscode\`.`, + ); + } + + await installCommunityExtension(); +} + +export interface GitHubCopilotVSCodeOptions { + channel?: VSCodeChannel; +} + +export interface ConfigureResult { + path: string; + wrote: boolean; +} + +/** + * Merge the Opper provider block into VS Code user `settings.json` so the + * "OAI Compatible Provider for Copilot" community extension picks it up. + * + * If the extension is missing, we prompt the user before doing anything + * else — without it the settings keys are inert. The prompt offers a + * clean cancel path so the user can install manually. + * + * VS Code allows JSONC (// comments, trailing commas) in settings, but + * `JSON.parse` rejects those. If parsing fails we throw so the user can + * merge by hand — silently re-serialising would lose their comments. + */ +export async function configureGitHubCopilotVSCode( + opts: GitHubCopilotVSCodeOptions = {}, +): Promise { + const channel = opts.channel ?? "stable"; + + if (!(await isCommunityExtensionInstalled())) { + await confirmAndInstallExtension(); + } + + const path = vscodeUserSettingsPath(channel); + + let existing: Record = {}; + if (existsSync(path)) { + const raw = readFileSync(path, "utf8").trim(); + if (raw.length > 0) { + try { + existing = JSON.parse(raw) as Record; + } catch { + throw new Error( + `Could not parse ${path} as JSON. If your settings file uses // comments or trailing commas, please add the Opper block manually for now (see docs/copilot-vscode/README.md).`, + ); + } + } + } + + existing["oaicopilot.baseUrl"] = OPPER_COMPAT_URL; + existing["oaicopilot.models"] = buildModelList(); + + mkdirSync(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(existing, null, 4)}\n`, "utf8"); + return { path, wrote: true }; +} + +export interface UnconfigureResult { + path: string; + removed: boolean; +} + +/** + * Strip only the keys this adapter owns. Other `oaicopilot.*` keys (delay, + * retry, commitLanguage, …) belong to the user's extension config and are + * left in place. + */ +export async function unconfigureGitHubCopilotVSCode( + opts: GitHubCopilotVSCodeOptions = {}, +): Promise { + const channel = opts.channel ?? "stable"; + const path = vscodeUserSettingsPath(channel); + if (!existsSync(path)) return { path, removed: false }; + + let parsed: Record; + try { + parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + } catch { + return { path, removed: false }; + } + + let changed = false; + for (const key of OWNED_SETTING_KEYS) { + if (key in parsed) { + delete parsed[key]; + changed = true; + } + } + if (!changed) return { path, removed: false }; + + await writeFile(path, `${JSON.stringify(parsed, null, 4)}\n`, "utf8"); + return { path, removed: true }; +} + +export function isGitHubCopilotVSCodeConfigured( + channel: VSCodeChannel = "stable", +): boolean { + const path = vscodeUserSettingsPath(channel); + if (!existsSync(path)) return false; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + return parsed["oaicopilot.baseUrl"] === OPPER_COMPAT_URL; + } catch { + return false; + } +} diff --git a/src/util/editor-paths.ts b/src/util/editor-paths.ts index 7cfad73..e597951 100644 --- a/src/util/editor-paths.ts +++ b/src/util/editor-paths.ts @@ -6,9 +6,35 @@ function home(): string { } export type Location = "global" | "local"; +export type VSCodeChannel = "stable" | "insiders"; export function opencodeConfigPath(location: Location): string { return location === "global" ? join(home(), ".config", "opencode", "opencode.json") : join(process.cwd(), "opencode.json"); } + +/** + * User-scope `settings.json` for either VS Code channel. Honours + * OPPER_EDITOR_HOME via `home()` so tests / sandbox runs land in a tmp dir. + * + * Insiders uses the same layout with a "Code - Insiders" folder name. + */ +export function vscodeUserSettingsPath(channel: VSCodeChannel): string { + const folder = channel === "insiders" ? "Code - Insiders" : "Code"; + if (process.platform === "darwin") { + return join( + home(), + "Library", + "Application Support", + folder, + "User", + "settings.json", + ); + } + if (process.platform === "win32") { + const appData = process.env.APPDATA ?? join(home(), "AppData", "Roaming"); + return join(appData, folder, "User", "settings.json"); + } + return join(home(), ".config", folder, "User", "settings.json"); +} diff --git a/test/agents/github-copilot-vscode.test.ts b/test/agents/github-copilot-vscode.test.ts new file mode 100644 index 0000000..db6e4df --- /dev/null +++ b/test/agents/github-copilot-vscode.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + rmSync, + writeFileSync, + readFileSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const whichMock = vi.fn(); +vi.mock("../../src/util/which.js", () => ({ which: whichMock })); + +const runMock = vi.fn(); +vi.mock("../../src/util/run.js", () => ({ run: runMock })); + +const confirmMock = vi.fn(); +vi.mock("@clack/prompts", () => ({ + confirm: confirmMock, + isCancel: (v: unknown) => typeof v === "symbol", + log: { info: vi.fn(), success: vi.fn() }, +})); + +const { githubCopilotVSCode } = await import( + "../../src/agents/github-copilot-vscode.js" +); +const { vscodeUserSettingsPath } = await import( + "../../src/util/editor-paths.js" +); + +/** Make `code --list-extensions` report the OAI extension as already + * installed so configure() short-circuits the install prompt. */ +function stubExtensionInstalled() { + whichMock.mockResolvedValue("/usr/local/bin/code"); + runMock.mockReturnValue({ + code: 0, + stdout: "johnny-zhao.oai-compatible-copilot\n", + stderr: "", + }); +} + +describe("github-copilot-vscode adapter", () => { + let sandbox: string; + let prevEditorHome: string | undefined; + + beforeEach(() => { + whichMock.mockReset(); + runMock.mockReset(); + confirmMock.mockReset(); + sandbox = mkdtempSync(join(tmpdir(), "opper-ghcp-")); + prevEditorHome = process.env.OPPER_EDITOR_HOME; + process.env.OPPER_EDITOR_HOME = sandbox; + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + if (prevEditorHome === undefined) delete process.env.OPPER_EDITOR_HOME; + else process.env.OPPER_EDITOR_HOME = prevEditorHome; + }); + + it("metadata is correct and adapter is configure-only (no spawn)", () => { + expect(githubCopilotVSCode.name).toBe("github-copilot-vscode"); + expect(githubCopilotVSCode.displayName).toBe("GitHub Copilot (VS Code)"); + expect(githubCopilotVSCode.spawn).toBeUndefined(); + expect(typeof githubCopilotVSCode.install).toBe("function"); + }); + + it("detect returns installed=false when `code` is not on PATH", async () => { + whichMock.mockResolvedValue(null); + const result = await githubCopilotVSCode.detect(); + expect(result.installed).toBe(false); + }); + + it("detect returns installed=true with the stable settings.json path", async () => { + whichMock.mockResolvedValue("/usr/local/bin/code"); + const result = await githubCopilotVSCode.detect(); + expect(result.installed).toBe(true); + expect(result.configPath).toBe(vscodeUserSettingsPath("stable")); + }); + + it("isConfigured is false when settings.json is missing", async () => { + expect(await githubCopilotVSCode.isConfigured()).toBe(false); + }); + + it("configure writes oaicopilot.baseUrl + oaicopilot.models with all picker entries", async () => { + stubExtensionInstalled(); + const { PICKER_MODELS } = await import("../../src/config/models.js"); + await githubCopilotVSCode.configure({}); + const path = vscodeUserSettingsPath("stable"); + expect(existsSync(path)).toBe(true); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(parsed["oaicopilot.baseUrl"]).toBe("https://api.opper.ai/v3/compat"); + const models = parsed["oaicopilot.models"] as Array<{ id: string }>; + expect(models).toHaveLength(PICKER_MODELS.length); + expect(models.map((m) => m.id)).toEqual(PICKER_MODELS.map((m) => m.id)); + expect(await githubCopilotVSCode.isConfigured()).toBe(true); + // No prompt fired because the extension was already present. + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("configure preserves unrelated keys and other oaicopilot.* extension settings", async () => { + stubExtensionInstalled(); + const path = vscodeUserSettingsPath("stable"); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync( + path, + JSON.stringify( + { + "workbench.colorTheme": "Dark Modern", + "oaicopilot.delay": 250, + "oaicopilot.commitLanguage": "English", + }, + null, + 4, + ), + "utf8", + ); + await githubCopilotVSCode.configure({}); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(parsed["workbench.colorTheme"]).toBe("Dark Modern"); + expect(parsed["oaicopilot.delay"]).toBe(250); + expect(parsed["oaicopilot.commitLanguage"]).toBe("English"); + expect(parsed["oaicopilot.baseUrl"]).toBe("https://api.opper.ai/v3/compat"); + }); + + it("configure is idempotent — re-running produces identical output", async () => { + stubExtensionInstalled(); + await githubCopilotVSCode.configure({}); + const first = readFileSync(vscodeUserSettingsPath("stable"), "utf8"); + await githubCopilotVSCode.configure({}); + const second = readFileSync(vscodeUserSettingsPath("stable"), "utf8"); + expect(second).toBe(first); + }); + + it("configure throws on JSONC files (// comments) so user comments aren't lost", async () => { + stubExtensionInstalled(); + const path = vscodeUserSettingsPath("stable"); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync( + path, + ['{', ' // user comment', ' "workbench.colorTheme": "Dark Modern"', "}"].join( + "\n", + ), + "utf8", + ); + await expect(githubCopilotVSCode.configure({})).rejects.toThrow( + /Could not parse/, + ); + }); + + it("configure prompts and installs when the extension is missing and user confirms", async () => { + let listCalls = 0; + whichMock.mockResolvedValue("/usr/local/bin/code"); + runMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "--list-extensions") { + listCalls += 1; + // First check: not installed. Second check (after install): installed. + return { + code: 0, + stdout: listCalls === 1 ? "" : "johnny-zhao.oai-compatible-copilot\n", + stderr: "", + }; + } + // The actual `code --install-extension X` invocation + return { code: 0, stdout: "", stderr: "" }; + }); + confirmMock.mockResolvedValue(true); + + await githubCopilotVSCode.configure({}); + + expect(confirmMock).toHaveBeenCalledTimes(1); + const installCall = runMock.mock.calls.find( + (c) => c[1][0] === "--install-extension", + ); + expect(installCall).toBeDefined(); + expect(installCall![1]).toEqual([ + "--install-extension", + "johnny-zhao.oai-compatible-copilot", + ]); + expect(await githubCopilotVSCode.isConfigured()).toBe(true); + }); + + it("configure aborts cleanly when the user declines the install prompt", async () => { + whichMock.mockResolvedValue("/usr/local/bin/code"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); // empty extensions list + confirmMock.mockResolvedValue(false); + + await expect(githubCopilotVSCode.configure({})).rejects.toMatchObject({ + code: "USER_CANCELLED", + }); + // No install attempted, no settings written. + const installCall = runMock.mock.calls.find( + (c) => c[1][0] === "--install-extension", + ); + expect(installCall).toBeUndefined(); + expect(existsSync(vscodeUserSettingsPath("stable"))).toBe(false); + }); + + it("unconfigure removes only the keys we own and leaves the rest", async () => { + stubExtensionInstalled(); + const path = vscodeUserSettingsPath("stable"); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync( + path, + JSON.stringify( + { + "workbench.colorTheme": "Dark Modern", + "oaicopilot.delay": 250, + }, + null, + 4, + ), + "utf8", + ); + await githubCopilotVSCode.configure({}); + await githubCopilotVSCode.unconfigure(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(parsed).not.toHaveProperty("oaicopilot.baseUrl"); + expect(parsed).not.toHaveProperty("oaicopilot.models"); + expect(parsed["workbench.colorTheme"]).toBe("Dark Modern"); + expect(parsed["oaicopilot.delay"]).toBe(250); + }); + + it("install runs `code --install-extension johnny-zhao.oai-compatible-copilot`", async () => { + whichMock.mockResolvedValue("/usr/local/bin/code"); + runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + await expect(githubCopilotVSCode.install!()).resolves.toBeUndefined(); + const [cmd, args] = runMock.mock.calls[0]!; + expect(cmd).toBe("code"); + expect(args).toEqual([ + "--install-extension", + "johnny-zhao.oai-compatible-copilot", + ]); + }); + + it("install throws AGENT_NOT_FOUND when `code` exits non-zero", async () => { + whichMock.mockResolvedValue("/usr/local/bin/code"); + runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); + await expect(githubCopilotVSCode.install!()).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + }); + }); + + it("install throws AGENT_NOT_FOUND when `code` is not on PATH", async () => { + whichMock.mockResolvedValue(null); + await expect(githubCopilotVSCode.install!()).rejects.toMatchObject({ + code: "AGENT_NOT_FOUND", + }); + }); +}); diff --git a/test/commands/editors.test.ts b/test/commands/editors.test.ts index f442873..d35b846 100644 --- a/test/commands/editors.test.ts +++ b/test/commands/editors.test.ts @@ -16,14 +16,14 @@ const { editorsListCommand, editorsOpenCodeCommand } = await import( useTempOpperHome(); describe("editors commands", () => { - it("list prints a placeholder message when no editor-only adapters are registered", async () => { + it("list shows the registered configure-only adapters", async () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); try { await editorsListCommand(); const out = log.mock.calls.map((c) => String(c[0])).join("\n"); - // OpenCode is launchable so it shows up under `opper agents list`, - // and Continue.dev was removed — list should report empty. - expect(out.toLowerCase()).toContain("no editor integrations"); + // GitHub Copilot (VS Code) is configure-only (no spawn) — should + // surface here. Launchable adapters live under `opper agents list`. + expect(out).toContain("GitHub Copilot (VS Code)"); } finally { log.mockRestore(); }