From 8aca6b6fde3abd6df74abed1c7e0a9ee2a8c9d70 Mon Sep 17 00:00:00 2001 From: sykuang Date: Tue, 21 Apr 2026 10:12:24 +0800 Subject: [PATCH 1/3] feat(providers): add GitHub Copilot CLI provider --- docs/providers.md | 45 +++++++ lib/provider-names.js | 4 +- src/providers/capabilities.js | 9 ++ src/providers/copilot/cli-builder.js | 36 ++++++ src/providers/copilot/index.js | 103 +++++++++++++++ src/providers/copilot/models.js | 24 ++++ src/providers/copilot/output-parser.js | 75 +++++++++++ src/providers/index.js | 2 + tests/unit/copilot-provider.test.js | 166 +++++++++++++++++++++++++ 9 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 src/providers/copilot/cli-builder.js create mode 100644 src/providers/copilot/index.js create mode 100644 src/providers/copilot/models.js create mode 100644 src/providers/copilot/output-parser.js create mode 100644 tests/unit/copilot-provider.test.js diff --git a/docs/providers.md b/docs/providers.md index 747cffcd..fb0b3516 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -71,3 +71,48 @@ Mount presets in `dockerMounts` include: `codex`, `gemini`, `gcloud`, `claude`, Use `--no-mounts` to disable all credential mounts (you will get a warning if credentials are missing). + +## GitHub Copilot CLI + +The `copilot` provider integrates the GitHub Copilot CLI. + +Install: + +```bash +npm install -g @github/copilot +``` + +Authenticate (interactive, one-time): + +```bash +copilot +# then inside the REPL: +/login +``` + +Credentials are stored under `~/.copilot/`. Logs are under `~/.copilot/logs/`. + +Usage with zeroshot: + +```bash +zeroshot run 123 --provider copilot +``` + +Models (level mapping): + +- `level1` → `gpt-5-mini` +- `level2` → `claude-sonnet-4.5` (default) +- `level3` → `claude-opus-4.6` + +Override per level via `providerSettings.copilot.levelOverrides`. + +Limitations: + +- Copilot CLI emits **plain text** with `--silent` (no structured streaming + JSON like Claude/Codex). Token usage is not reported, and live tool-call + events are not surfaced. The whole stdout stream is treated as text. +- `jsonSchema` is supported only by **prompt-injecting** the schema (no native + `--output-schema` flag); reliability depends on the underlying model. +- MCP servers, thinking mode, and reasoningEffort are not supported. +- Auto-approval is enabled via `--allow-all` (a.k.a. `--yolo`); use isolation + (`--worktree` / `--docker`) when running untrusted prompts. diff --git a/lib/provider-names.js b/lib/provider-names.js index 39f09264..f5a68623 100644 --- a/lib/provider-names.js +++ b/lib/provider-names.js @@ -6,9 +6,11 @@ const PROVIDER_ALIASES = { codex: 'codex', gemini: 'gemini', opencode: 'opencode', + copilot: 'copilot', + github: 'copilot', }; -const VALID_PROVIDERS = ['claude', 'codex', 'gemini', 'opencode']; +const VALID_PROVIDERS = ['claude', 'codex', 'gemini', 'opencode', 'copilot']; function normalizeProviderName(name) { if (!name || typeof name !== 'string') return name; diff --git a/src/providers/capabilities.js b/src/providers/capabilities.js index ceb8fb1f..96d2a53e 100644 --- a/src/providers/capabilities.js +++ b/src/providers/capabilities.js @@ -37,6 +37,15 @@ const CAPABILITIES = { thinkingMode: true, reasoningEffort: true, }, + copilot: { + dockerIsolation: true, + worktreeIsolation: true, + mcpServers: false, + jsonSchema: 'experimental', + streamJson: false, + thinkingMode: false, + reasoningEffort: false, + }, }; function checkCapability(provider, capability) { diff --git a/src/providers/copilot/cli-builder.js b/src/providers/copilot/cli-builder.js new file mode 100644 index 00000000..7b2defaa --- /dev/null +++ b/src/providers/copilot/cli-builder.js @@ -0,0 +1,36 @@ +function buildCommand(context, options = {}) { + const { modelSpec, jsonSchema, autoApprove, cliFeatures = {} } = options; + + let finalContext = context; + if (jsonSchema) { + const schemaStr = + typeof jsonSchema === 'string' ? jsonSchema : JSON.stringify(jsonSchema, null, 2); + finalContext = + context + + `\n\n## OUTPUT FORMAT (CRITICAL - REQUIRED)\n\nYou MUST respond with a JSON object that exactly matches this schema. NO markdown, NO explanation, NO code blocks. ONLY the raw JSON object.\n\nSchema:\n\`\`\`json\n${schemaStr}\n\`\`\`\n\nYour response must be ONLY valid JSON. Start with { and end with }. Nothing else.`; + } + + const args = ['-p', finalContext]; + + if (cliFeatures.supportsSilent !== false) { + args.push('--silent'); + } + + if (autoApprove !== false && cliFeatures.supportsAllowAll !== false) { + args.push('--allow-all'); + } + + if (modelSpec?.model && cliFeatures.supportsModel !== false) { + args.push('--model', modelSpec.model); + } + + return { + binary: 'copilot', + args, + env: {}, + }; +} + +module.exports = { + buildCommand, +}; diff --git a/src/providers/copilot/index.js b/src/providers/copilot/index.js new file mode 100644 index 00000000..7eb5163d --- /dev/null +++ b/src/providers/copilot/index.js @@ -0,0 +1,103 @@ +const BaseProvider = require('../base-provider'); +const { commandExists, getCommandPath, getHelpOutput } = require('../../../lib/provider-detection'); +const { buildCommand } = require('./cli-builder'); +const { parseEvent } = require('./output-parser'); +const { + MODEL_CATALOG, + LEVEL_MAPPING, + DEFAULT_LEVEL, + DEFAULT_MAX_LEVEL, + DEFAULT_MIN_LEVEL, +} = require('./models'); + +const warned = new Set(); + +class CopilotProvider extends BaseProvider { + constructor() { + super({ name: 'copilot', displayName: 'Copilot', cliCommand: 'copilot' }); + this._cliFeatures = null; + } + + isAvailable() { + return commandExists(this.cliCommand); + } + + getCliPath() { + return getCommandPath(this.cliCommand) || this.cliCommand; + } + + getInstallInstructions() { + return 'npm install -g @github/copilot'; + } + + getAuthInstructions() { + return 'Run `copilot` then type `/login` (requires a GitHub account with Copilot access).'; + } + + getCliFeatures() { + if (this._cliFeatures) return this._cliFeatures; + const help = getHelpOutput(this.cliCommand, []); + const unknown = !help; + + const features = { + supportsModel: unknown ? true : /--model\b/.test(help), + supportsAllowAll: unknown ? true : /--allow-all\b|--yolo\b/.test(help), + supportsSilent: unknown ? true : /--silent\b/.test(help), + supportsNoCustomInstructions: unknown ? true : /--no-custom-instructions\b/.test(help), + supportsAutoApprove: unknown ? true : /--allow-all\b|--yolo\b/.test(help), + unknown, + }; + + this._cliFeatures = features; + return features; + } + + getCredentialPaths() { + return ['~/.copilot']; + } + + buildCommand(context, options) { + const cliFeatures = options.cliFeatures || this.getCliFeatures(); + + if (options.modelSpec?.model && cliFeatures.supportsModel === false) { + this._warnOnce( + 'copilot-model', + 'Copilot CLI help did not advertise --model; passing it anyway may fail.' + ); + } + + return buildCommand(context, { ...options, cliFeatures }); + } + + parseEvent(line) { + return parseEvent(line); + } + + getModelCatalog() { + return MODEL_CATALOG; + } + + getLevelMapping() { + return LEVEL_MAPPING; + } + + getDefaultLevel() { + return DEFAULT_LEVEL; + } + + getDefaultMaxLevel() { + return DEFAULT_MAX_LEVEL; + } + + getDefaultMinLevel() { + return DEFAULT_MIN_LEVEL; + } + + _warnOnce(key, message) { + if (warned.has(key)) return; + warned.add(key); + console.warn(`⚠️ ${message}`); + } +} + +module.exports = CopilotProvider; diff --git a/src/providers/copilot/models.js b/src/providers/copilot/models.js new file mode 100644 index 00000000..5648546a --- /dev/null +++ b/src/providers/copilot/models.js @@ -0,0 +1,24 @@ +const MODEL_CATALOG = { + 'gpt-5-mini': { rank: 1 }, + 'gpt-5': { rank: 2 }, + 'claude-sonnet-4.5': { rank: 2 }, + 'claude-opus-4.6': { rank: 3 }, +}; + +const LEVEL_MAPPING = { + level1: { rank: 1, model: 'gpt-5-mini', reasoningEffort: null }, + level2: { rank: 2, model: 'claude-sonnet-4.5', reasoningEffort: null }, + level3: { rank: 3, model: 'claude-opus-4.6', reasoningEffort: null }, +}; + +const DEFAULT_LEVEL = 'level2'; +const DEFAULT_MAX_LEVEL = 'level3'; +const DEFAULT_MIN_LEVEL = 'level1'; + +module.exports = { + MODEL_CATALOG, + LEVEL_MAPPING, + DEFAULT_LEVEL, + DEFAULT_MAX_LEVEL, + DEFAULT_MIN_LEVEL, +}; diff --git a/src/providers/copilot/output-parser.js b/src/providers/copilot/output-parser.js new file mode 100644 index 00000000..100fcf67 --- /dev/null +++ b/src/providers/copilot/output-parser.js @@ -0,0 +1,75 @@ +/** + * Copilot output parser. + * + * GitHub Copilot CLI (`copilot -p ... --silent`) emits plain text on stdout + * (not structured JSON). Our parser: + * - Tries JSON.parse on each line in case a future format ever ships + * structured events (best-effort; ignored on failure). + * - Otherwise treats any non-empty line as a `{ type: 'text', text }` event. + * - Does NOT emit a synthetic `result` event; the agent wrapper handles + * completion via process exit. + */ + +function tryParseStructured(trimmed) { + if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return null; + let event; + try { + event = JSON.parse(trimmed); + } catch { + return null; + } + if (!event || typeof event !== 'object') return null; + + if (event.type === 'text' && typeof event.text === 'string') { + return { type: 'text', text: event.text }; + } + if (event.type === 'result') { + return { + type: 'result', + success: event.success !== false, + inputTokens: event.inputTokens || 0, + outputTokens: event.outputTokens || 0, + error: event.error, + }; + } + if (event.type === 'error') { + return { + type: 'result', + success: false, + error: event.error || event.message || 'Unknown error', + }; + } + return null; +} + +function parseEvent(line) { + if (line === null || line === undefined) return null; + const trimmed = String(line).trim(); + if (!trimmed) return null; + + const structured = tryParseStructured(trimmed); + if (structured) return structured; + + return { type: 'text', text: trimmed }; +} + +function parseChunk(chunk) { + if (!chunk) return []; + const events = []; + const lines = String(chunk).split('\n'); + for (const line of lines) { + const event = parseEvent(line); + if (!event) continue; + if (Array.isArray(event)) { + events.push(...event); + } else { + events.push(event); + } + } + return events; +} + +module.exports = { + parseEvent, + parseChunk, +}; diff --git a/src/providers/index.js b/src/providers/index.js index a43c0c8d..3872ccb3 100644 --- a/src/providers/index.js +++ b/src/providers/index.js @@ -2,6 +2,7 @@ const AnthropicProvider = require('./anthropic'); const OpenAIProvider = require('./openai'); const GoogleProvider = require('./google'); const OpencodeProvider = require('./opencode'); +const CopilotProvider = require('./copilot'); const { normalizeProviderName } = require('../../lib/provider-names'); const PROVIDERS = { @@ -9,6 +10,7 @@ const PROVIDERS = { codex: OpenAIProvider, gemini: GoogleProvider, opencode: OpencodeProvider, + copilot: CopilotProvider, }; function getProvider(name) { diff --git a/tests/unit/copilot-provider.test.js b/tests/unit/copilot-provider.test.js new file mode 100644 index 00000000..1858981c --- /dev/null +++ b/tests/unit/copilot-provider.test.js @@ -0,0 +1,166 @@ +const assert = require('assert'); +const { listProviders, getProvider } = require('../../src/providers'); +const { normalizeProviderName, VALID_PROVIDERS } = require('../../lib/provider-names'); +const { CAPABILITIES } = require('../../src/providers/capabilities'); +const { buildCommand } = require('../../src/providers/copilot/cli-builder'); +const { parseEvent, parseChunk } = require('../../src/providers/copilot/output-parser'); +const CopilotProvider = require('../../src/providers/copilot'); + +describe('Copilot provider', function () { + it('is registered in the provider list', function () { + assert.ok(listProviders().includes('copilot')); + assert.ok(VALID_PROVIDERS.includes('copilot')); + }); + + it('getProvider("copilot") returns a CopilotProvider', function () { + const provider = getProvider('copilot'); + assert.ok(provider instanceof CopilotProvider); + assert.strictEqual(provider.name, 'copilot'); + assert.strictEqual(provider.displayName, 'Copilot'); + assert.strictEqual(provider.cliCommand, 'copilot'); + }); + + it('normalizes the "github" alias to copilot', function () { + assert.strictEqual(normalizeProviderName('github'), 'copilot'); + assert.strictEqual(normalizeProviderName('copilot'), 'copilot'); + assert.strictEqual(normalizeProviderName('Copilot'), 'copilot'); + }); + + it('exposes capability flags', function () { + const caps = CAPABILITIES.copilot; + assert.ok(caps); + assert.strictEqual(caps.dockerIsolation, true); + assert.strictEqual(caps.worktreeIsolation, true); + assert.strictEqual(caps.mcpServers, false); + assert.strictEqual(caps.streamJson, false); + assert.strictEqual(caps.thinkingMode, false); + assert.strictEqual(caps.reasoningEffort, false); + assert.strictEqual(caps.jsonSchema, 'experimental'); + }); + + it('install/auth instructions reference @github/copilot and /login', function () { + const provider = new CopilotProvider(); + assert.match(provider.getInstallInstructions(), /@github\/copilot/); + assert.match(provider.getAuthInstructions(), /\/login/); + assert.deepStrictEqual(provider.getCredentialPaths(), ['~/.copilot']); + }); + + describe('buildCommand', function () { + it('passes the prompt as the value of -p and includes --silent', function () { + const result = buildCommand('hello world', { + autoApprove: true, + cliFeatures: { supportsModel: true, supportsAllowAll: true, supportsSilent: true }, + }); + + assert.strictEqual(result.binary, 'copilot'); + const pIndex = result.args.indexOf('-p'); + assert.notStrictEqual(pIndex, -1); + assert.strictEqual(result.args[pIndex + 1], 'hello world'); + assert.ok(result.args.includes('--silent')); + assert.ok(result.args.includes('--allow-all')); + }); + + it('omits --allow-all when autoApprove is false', function () { + const result = buildCommand('prompt', { + autoApprove: false, + cliFeatures: { supportsAllowAll: true }, + }); + assert.ok(!result.args.includes('--allow-all')); + }); + + it('includes --model X when modelSpec.model is set', function () { + const result = buildCommand('prompt', { + modelSpec: { model: 'claude-sonnet-4.5' }, + cliFeatures: { supportsModel: true, supportsAllowAll: true, supportsSilent: true }, + }); + const idx = result.args.indexOf('--model'); + assert.notStrictEqual(idx, -1); + assert.strictEqual(result.args[idx + 1], 'claude-sonnet-4.5'); + }); + + it('skips --model when feature flag says unsupported', function () { + const result = buildCommand('prompt', { + modelSpec: { model: 'claude-sonnet-4.5' }, + cliFeatures: { supportsModel: false }, + }); + assert.ok(!result.args.includes('--model')); + }); + + it('injects jsonSchema into the prompt as OUTPUT FORMAT block', function () { + const result = buildCommand('do a thing', { + jsonSchema: { type: 'object', properties: { ok: { type: 'boolean' } } }, + cliFeatures: {}, + }); + const pIndex = result.args.indexOf('-p'); + const ctx = result.args[pIndex + 1]; + assert.match(ctx, /## OUTPUT FORMAT/); + assert.match(ctx, /"ok"/); + }); + }); + + describe('parseEvent / parseChunk', function () { + it('returns text event for a non-empty plain line', function () { + assert.deepStrictEqual(parseEvent('hello world'), { type: 'text', text: 'hello world' }); + }); + + it('returns null for empty/whitespace lines', function () { + assert.strictEqual(parseEvent(''), null); + assert.strictEqual(parseEvent(' '), null); + assert.strictEqual(parseEvent(null), null); + }); + + it('parses structured JSON text events when present', function () { + const ev = parseEvent('{"type":"text","text":"hi"}'); + assert.deepStrictEqual(ev, { type: 'text', text: 'hi' }); + }); + + it('parses structured JSON error events as failed result', function () { + const ev = parseEvent('{"type":"error","error":"boom"}'); + assert.strictEqual(ev.type, 'result'); + assert.strictEqual(ev.success, false); + assert.strictEqual(ev.error, 'boom'); + }); + + it('parseChunk splits multiple lines into events', function () { + const events = parseChunk('first line\nsecond line\n'); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].text, 'first line'); + assert.strictEqual(events[1].text, 'second line'); + }); + }); + + describe('levels and models', function () { + const provider = new CopilotProvider(); + + it('exposes the expected level mapping', function () { + const mapping = provider.getLevelMapping(); + assert.strictEqual(mapping.level1.model, 'gpt-5-mini'); + assert.strictEqual(mapping.level2.model, 'claude-sonnet-4.5'); + assert.strictEqual(mapping.level3.model, 'claude-opus-4.6'); + }); + + it('uses sensible defaults', function () { + assert.strictEqual(provider.getDefaultLevel(), 'level2'); + assert.strictEqual(provider.getDefaultMinLevel(), 'level1'); + assert.strictEqual(provider.getDefaultMaxLevel(), 'level3'); + }); + + it('validateLevel accepts in-range, rejects out-of-range', function () { + assert.strictEqual(provider.validateLevel('level2', 'level1', 'level3'), 'level2'); + assert.throws(() => provider.validateLevel('level9', 'level1', 'level3'), /Invalid level/); + }); + + it('resolveModelSpec returns the level model', function () { + const spec = provider.resolveModelSpec('level3', {}); + assert.strictEqual(spec.model, 'claude-opus-4.6'); + }); + + it('catalog contains expected models', function () { + const catalog = provider.getModelCatalog(); + assert.ok(catalog['gpt-5-mini']); + assert.ok(catalog['claude-sonnet-4.5']); + assert.ok(catalog['claude-opus-4.6']); + assert.ok(catalog['gpt-5']); + }); + }); +}); From 4d2b192c225f0aa84b3932de2323073bac11dad2 Mon Sep 17 00:00:00 2001 From: sykuang Date: Tue, 21 Apr 2026 11:11:36 +0800 Subject: [PATCH 2/3] feat(copilot): add MCP server support via --additional-mcp-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CLI loads MCP from ~/.copilot/mcp-config.json and accepts per-run augmentation via --additional-mcp-config (JSON or @file). - capabilities.copilot.mcpServers: false → true - cli-builder: emit --additional-mcp-config per entry (string|object|array) - cli-builder: add addDirs → repeated --add-dir flags - index: detect --additional-mcp-config / --add-dir / --config-dir support - runner: thread providerSettings.mcpConfig + addDirs into buildCommand - docs: MCP servers section with example providerSettings - tests: +5 cases (object/string/array/disabled/addDirs), 25/25 green --- docs/providers.md | 37 +++++++++++++++++++- src/providers/capabilities.js | 2 +- src/providers/copilot/cli-builder.js | 21 ++++++++++- src/providers/copilot/index.js | 3 ++ task-lib/runner.js | 3 ++ tests/unit/copilot-provider.test.js | 52 +++++++++++++++++++++++++++- 6 files changed, 114 insertions(+), 4 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index fb0b3516..f31d696d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -113,6 +113,41 @@ Limitations: events are not surfaced. The whole stdout stream is treated as text. - `jsonSchema` is supported only by **prompt-injecting** the schema (no native `--output-schema` flag); reliability depends on the underlying model. -- MCP servers, thinking mode, and reasoningEffort are not supported. +- Thinking mode and reasoningEffort are not exposed by the CLI. - Auto-approval is enabled via `--allow-all` (a.k.a. `--yolo`); use isolation (`--worktree` / `--docker`) when running untrusted prompts. + +### MCP servers + +Copilot CLI loads MCP servers from `~/.copilot/mcp-config.json` by default. +zeroshot can additionally pass per-run MCP configs via the `--additional-mcp-config` +flag. Set `providerSettings.copilot.mcpConfig` to any of: + +- a JSON string (raw config), +- an object (zeroshot will `JSON.stringify` it), +- a file path prefixed with `@` (e.g. `"@./mcp.json"`), +- an array of any of the above (each entry emits one flag — augments, not + overrides, the user-level config). + +Example: + +```jsonc +{ + "providerSettings": { + "copilot": { + "mcpConfig": { + "mcpServers": { + "fs": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], + }, + }, + }, + }, + }, +} +``` + +GitHub's built-in MCP server can be tuned with `--add-github-mcp-tool` / +`--add-github-mcp-toolset` directly in `extraArgs` if you need to override the +default CLI subset. diff --git a/src/providers/capabilities.js b/src/providers/capabilities.js index 96d2a53e..515169bf 100644 --- a/src/providers/capabilities.js +++ b/src/providers/capabilities.js @@ -40,7 +40,7 @@ const CAPABILITIES = { copilot: { dockerIsolation: true, worktreeIsolation: true, - mcpServers: false, + mcpServers: true, jsonSchema: 'experimental', streamJson: false, thinkingMode: false, diff --git a/src/providers/copilot/cli-builder.js b/src/providers/copilot/cli-builder.js index 7b2defaa..4eda9cfa 100644 --- a/src/providers/copilot/cli-builder.js +++ b/src/providers/copilot/cli-builder.js @@ -1,5 +1,5 @@ function buildCommand(context, options = {}) { - const { modelSpec, jsonSchema, autoApprove, cliFeatures = {} } = options; + const { modelSpec, jsonSchema, autoApprove, mcpConfig, addDirs, cliFeatures = {} } = options; let finalContext = context; if (jsonSchema) { @@ -24,6 +24,25 @@ function buildCommand(context, options = {}) { args.push('--model', modelSpec.model); } + // MCP servers — Copilot CLI augments ~/.copilot/mcp-config.json with --additional-mcp-config. + // Accepts either a JSON string, a config object (will be JSON.stringified), + // a file path prefixed with @, or an array of any of the above (each emits one flag). + if (mcpConfig && cliFeatures.supportsMcpConfig !== false) { + const entries = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig]; + for (const entry of entries) { + if (entry === null || entry === undefined) continue; + const value = typeof entry === 'string' ? entry : JSON.stringify(entry); + args.push('--additional-mcp-config', value); + } + } + + // Allow extra directories for file access (useful when running outside cwd) + if (Array.isArray(addDirs) && cliFeatures.supportsAddDir !== false) { + for (const dir of addDirs) { + if (typeof dir === 'string' && dir) args.push('--add-dir', dir); + } + } + return { binary: 'copilot', args, diff --git a/src/providers/copilot/index.js b/src/providers/copilot/index.js index 7eb5163d..5c08eb1b 100644 --- a/src/providers/copilot/index.js +++ b/src/providers/copilot/index.js @@ -45,6 +45,9 @@ class CopilotProvider extends BaseProvider { supportsSilent: unknown ? true : /--silent\b/.test(help), supportsNoCustomInstructions: unknown ? true : /--no-custom-instructions\b/.test(help), supportsAutoApprove: unknown ? true : /--allow-all\b|--yolo\b/.test(help), + supportsMcpConfig: unknown ? true : /--additional-mcp-config\b/.test(help), + supportsAddDir: unknown ? true : /--add-dir\b/.test(help), + supportsConfigDir: unknown ? true : /--config-dir\b/.test(help), unknown, }; diff --git a/task-lib/runner.js b/task-lib/runner.js index 807008c7..6acd8781 100644 --- a/task-lib/runner.js +++ b/task-lib/runner.js @@ -37,6 +37,9 @@ export async function spawnTask(prompt, options = {}) { cwd, autoApprove: true, cliFeatures, + mcpConfig: providerSettings.mcpConfig, + addDirs: providerSettings.addDirs, + providerSettings, }); const finalArgs = resolveFinalArgs(commandSpec, providerName, options); diff --git a/tests/unit/copilot-provider.test.js b/tests/unit/copilot-provider.test.js index 1858981c..d47270f9 100644 --- a/tests/unit/copilot-provider.test.js +++ b/tests/unit/copilot-provider.test.js @@ -31,7 +31,7 @@ describe('Copilot provider', function () { assert.ok(caps); assert.strictEqual(caps.dockerIsolation, true); assert.strictEqual(caps.worktreeIsolation, true); - assert.strictEqual(caps.mcpServers, false); + assert.strictEqual(caps.mcpServers, true); assert.strictEqual(caps.streamJson, false); assert.strictEqual(caps.thinkingMode, false); assert.strictEqual(caps.reasoningEffort, false); @@ -96,6 +96,56 @@ describe('Copilot provider', function () { assert.match(ctx, /## OUTPUT FORMAT/); assert.match(ctx, /"ok"/); }); + + it('passes mcpConfig as JSON string via --additional-mcp-config', function () { + const result = buildCommand('p', { + mcpConfig: '{"mcpServers":{"x":{"command":"true"}}}', + cliFeatures: { supportsMcpConfig: true }, + }); + const i = result.args.indexOf('--additional-mcp-config'); + assert.notStrictEqual(i, -1); + assert.strictEqual(result.args[i + 1], '{"mcpServers":{"x":{"command":"true"}}}'); + }); + + it('serializes object mcpConfig to JSON', function () { + const cfg = { mcpServers: { fs: { command: 'npx', args: ['-y', 'pkg'] } } }; + const result = buildCommand('p', { + mcpConfig: cfg, + cliFeatures: { supportsMcpConfig: true }, + }); + const i = result.args.indexOf('--additional-mcp-config'); + assert.strictEqual(result.args[i + 1], JSON.stringify(cfg)); + }); + + it('emits --additional-mcp-config once per array entry, supports @file paths', function () { + const result = buildCommand('p', { + mcpConfig: ['@./mcp-a.json', { mcpServers: { b: { command: 'b' } } }], + cliFeatures: { supportsMcpConfig: true }, + }); + const flags = result.args.filter((a) => a === '--additional-mcp-config'); + assert.strictEqual(flags.length, 2); + assert.ok(result.args.includes('@./mcp-a.json')); + assert.ok(result.args.includes(JSON.stringify({ mcpServers: { b: { command: 'b' } } }))); + }); + + it('omits --additional-mcp-config when CLI does not support it', function () { + const result = buildCommand('p', { + mcpConfig: '{}', + cliFeatures: { supportsMcpConfig: false }, + }); + assert.ok(!result.args.includes('--additional-mcp-config')); + }); + + it('passes addDirs as repeated --add-dir flags', function () { + const result = buildCommand('p', { + addDirs: ['/tmp/a', '/tmp/b'], + cliFeatures: { supportsAddDir: true }, + }); + const flags = result.args.filter((a) => a === '--add-dir'); + assert.strictEqual(flags.length, 2); + assert.ok(result.args.includes('/tmp/a')); + assert.ok(result.args.includes('/tmp/b')); + }); }); describe('parseEvent / parseChunk', function () { From 0cdce2242dade3520151a5c2177af243b3048d3f Mon Sep 17 00:00:00 2001 From: sykuang Date: Tue, 21 Apr 2026 11:41:45 +0800 Subject: [PATCH 3/3] feat(copilot): wire copilot into CLI --provider help and preflight validator - cli/index.js: include 'copilot' in --provider option description - src/preflight.js: add copilot validator + update unknown-provider hint --- cli/index.js | 2 +- src/preflight.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/index.js b/cli/index.js index bb02265f..d41cb8ac 100755 --- a/cli/index.js +++ b/cli/index.js @@ -2287,7 +2287,7 @@ program .option('--workers ', 'Max sub-agents for worker to spawn in parallel', parseInt) .option( '--provider ', - 'Override all agents to use a provider (claude, codex, gemini, opencode)' + 'Override all agents to use a provider (claude, codex, gemini, opencode, copilot)' ) .option('--model ', 'Override all agent models (provider-specific model id)') .option( diff --git a/src/preflight.js b/src/preflight.js index cd14da6a..f6bc569d 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -428,6 +428,16 @@ function validateProvider(providerName, options) { 'Command "opencode" not installed', ['Install Opencode CLI: see https://opencode.ai', 'Then run: opencode --version'] ), + copilot: () => + validateCliProvider( + 'copilot', + 'GitHub Copilot CLI not available', + 'Command "copilot" not installed', + [ + 'Install Copilot CLI: npm install -g @github/copilot', + 'Then run: copilot (and use /login inside the REPL)', + ] + ), }; const validator = validatorByProvider[providerName]; @@ -435,7 +445,7 @@ function validateProvider(providerName, options) { return { errors: [ formatError('Unknown provider', `Provider "${providerName}" is not supported`, [ - 'Use claude, codex, gemini, or opencode', + 'Use claude, codex, gemini, opencode, or copilot', ]), ], warnings: [],