Cursor Copilot Bridge:把 Cursor 的 40+ 模型塞进 GitHub Copilot Chat
Cursor 和 GitHub Copilot 是目前最流行的两个 AI 编程助手,但它们各有各的生态围墙。Cursor 有丰富的模型选择(Claude Opus/Sonnet、GPT-4o、Gemini Pro 等 40+ 模型)和强大的 Agent 能力,但只能在 Cursor IDE 中使用。GitHub Copilot Chat 有更好的 VSCode 原生集成和社区生态,但模型选择受限于 GitHub 提供的几个。
能不能两边的好处都要?Cursor Copilot Bridge 就是为了回答这个问题而生的——它是一个 VSCode 扩展,通过 Language Model Chat Provider API 把 Cursor CLI 作为模型后端注册到 Copilot Chat。用户在 Copilot Chat 的模型选择器里直接选 Cursor 的模型,发消息,流式回答——全程在 VSCode 原生 UI 中完成。
本文记录这个项目的设计背景、架构选择和关键实现细节,包括那些在开发过程中反复踩坑才定型的部分。
为什么不用现有方案
在开始之前,先看看市面上已有的方案和它们各自的问题:
HTTP 代理方案
早期社区的做法是在本地起一个 HTTP 代理,伪装成 Copilot 的 API 端点。但这需要用户手动配置代理地址,进程管理也是个问题——代理挂了 Copilot 就用不了。
OAI Compatible Provider
OAI Compatible Provider for Copilot 是一个优秀的扩展,它通过 OpenAI 兼容 API 将本地模型接入 Copilot Chat。但如果你的目标是用 Cursor 的模型,需要额外搭一个代理把 Cursor API 转成 OpenAI 格式。链路变成:
Copilot Chat → OAI Compatible Provider → HTTP 代理 → Cursor API两层中间件,每层都可能出问题。
直接用 Cursor IDE
最简单的方案当然是直接用 Cursor IDE。但有些场景下你就是想(或必须)用 VSCode——比如你的团队统一用 VSCode、你依赖某些只有 VSCode 才有的扩展、或者你只想把 Cursor 当成一个模型来源而不是整个 IDE。
设计目标
基于上面的分析,设计目标很清晰:
- 零中间件:不需要 HTTP 代理,不需要额外进程。扩展直接调用 Cursor CLI。
- 原生集成:模型出现在 Copilot Chat 的模型选择器中,用户体验和原生 Copilot 一致。
- 下载即用:安装扩展 + 装好 Cursor CLI + 配置 API Key,三步完成。
- 流式输出:模型回答逐字出现,不是等全部生成完才显示。
- 会话持久化:同一个聊天窗口的多轮对话应该在一个 CLI 会话中,而不是每条消息都开新会话。
架构
┌──────────────┐ messages ┌────────────────────┐
│ Copilot Chat │ ───────────────► │ Cursor Copilot │
│ (VSCode UI) │ │ Bridge Extension │
│ │ ◄─────────────── │ │
└──────────────┘ streamed text └────────┬───────────┘
│ spawn child process
▼
┌────────────────────┐
│ Cursor CLI (agent) │
│ --print --resume │
│ --output-format │
│ stream-json │
└────────┬───────────┘
│ HTTPS
▼
┌────────────────────┐
│ Cursor Cloud │
│ (Claude / GPT / │
│ Gemini / ...) │
└────────────────────┘关键选择是直接 spawn Cursor CLI 作为子进程,而不是自己实现 Cursor 的 API 协议。这样做的好处是:
- 认证、模型路由、Token 管理全部由 Cursor CLI 处理,不需要逆向 Cursor 的私有 API
- CLI 更新时自动获得新模型支持,扩展不需要改代码
- Cursor 的 Agent 能力(文件读写、命令执行)也可以通过 CLI 使用
代价是引入了一个子进程的开销,但 CLI 启动很快(< 1 秒),而且通过会话复用可以摊薄这个成本。
模块拆分
项目结构按职责分为六个模块:
src/
├── extension.ts # VSCode 入口,注册 Provider 和 Commands
├── provider.ts # LanguageModelChatProvider 实现
├── config.ts # 配置加载
├── log.ts # 日志管理
├── message-converter.ts # VSCode ↔ CLI 消息格式转换
└── lib/
├── process.ts # 子进程 spawn 和流式读取
├── agent-cmd-args.ts # CLI 参数构建
├── session-manager.ts # 会话生命周期管理
└── cli-stream-parser.ts # CLI stream-json 输出解析最核心的三个模块是 provider.ts(协调整个请求流程)、session-manager.ts(会话管理)和 cli-stream-parser.ts(流式输出解析)。
模型发现
Cursor CLI 提供了 --list-models 参数来获取当前订阅可用的所有模型。输出格式是 ANSI 着色的文本列表:
claude-4-opus - Claude 4 Opus (current, default)
claude-sonnet-4 - Claude Sonnet 4
gpt-4o - GPT-4o
gemini-2.5-pro - Gemini 2.5 Pro
...解析逻辑需要:
- 剥离 ANSI 转义序列(
\x1B[...m等) - 用正则匹配
id - name格式 - 去除
(current)(default)等标记 - 去重(CLI 有时会输出重复项)
const ANSI_RE = /\x1B(?:\[[0-9;]*[A-Za-z]|\].*?(?:\x07|\x1B\\))/g;
function parseModelList(output: string): ModelInfo[] {
const lines = output.replace(ANSI_RE, "").split(/\r?\n/).map(l => l.trim());
const models: ModelInfo[] = [];
const seen = new Set<string>();
for (const line of lines) {
const match = line.match(/^([A-Za-z0-9][A-Za-z0-9._:/-]*)\s+-\s+(.*)$/);
if (!match) continue;
const id = match[1];
if (!seen.has(id)) {
seen.add(id);
models.push({ id, name: match[2].replace(/\s*\((?:current|default)[^)]*\)/g, "").trim() || id });
}
}
return models;
}模型列表有缓存机制:provideLanguageModelChatInformation 在 silent: true 时返回缓存,只有用户手动刷新或首次加载时才真正调用 CLI。
模式检测
这是开发过程中反复调整了三次才稳定下来的功能。
问题
Copilot Chat 有三种模式:Agent(可读可写)、Ask(只读问答)、Edit(编辑模式)。用户在 UI 上切换模式时,Provider 需要知道当前是哪个模式,以便传递正确的 --mode 参数给 Cursor CLI。
但 VSCode 的 Provider API 没有直接暴露模式信息。
方案演进
第一版:配置项。在 Settings 中加一个 cursorBridge.mode 下拉框,让用户手动选。很快发现这不对——Copilot Chat 自己已经有模式选择器了,再在设置里加一个是多余的,而且用户经常忘了切。
第二版:通过 tools 数量判断。检查 options.tools 是否为空来判断模式。但发现 Ask 模式下 Copilot 也会发送工具(只是都是只读工具),所以 tools.length > 0 无法区分 Agent 和 Ask。
第三版:通过写操作工具判断。这是最终方案。Agent 模式会包含 create_file、run_in_terminal 等写操作工具,Ask 模式只包含 read_file、grep_search 等读操作工具。通过维护一个写操作工具名的白名单来判断:
const WRITE_TOOL_NAMES = new Set([
"create_file", "create_directory", "replace_string_in_file",
"run_in_terminal", "create_and_run_task", "edit_notebook_file",
"run_vscode_command", "install_extension", "kill_terminal",
// ...
]);
const hasWriteTools = toolNames.some(n => WRITE_TOOL_NAMES.has(n));
const mode = hasWriteTools ? "agent" : "ask";这个方案的前提假设是:如果 Copilot 给了 Provider 写操作工具,说明用户处于 Agent 模式。实测验证了这个假设在 Copilot Chat 1.x 中成立。
会话管理:最复杂的部分
Cursor CLI 支持 create-chat 创建会话和 --resume <chatId> 恢复会话。合理使用这个机制可以让多轮对话共享上下文,而不是每条消息都从头开始。
但 VSCode 的 Provider API 不提供会话 ID。每次调用只给你完整的消息历史。你需要自己判断:「这些消息是之前那个对话的延续,还是一个全新的对话?」
匹配算法
核心思路是用户消息序列匹配。
每个 CLI 会话记录了它处理过的用户消息序列。当新请求到来时,提取新请求中所有用户消息,和已有会话的用户消息序列做前缀匹配:
已有会话: [user_A, user_B, user_C]
新请求: [user_A, user_B, user_C, user_D]
→ 前缀完全匹配(长度 3),且新请求多了一条 → resume已有会话: [user_A, user_B, user_C]
新请求: [user_A, user_X, user_Y]
→ 前缀只匹配到第 1 条 → checkpoint,创建新会话提取用户原始输入
直接比对 Copilot 发送的消息内容会遇到一个隐蔽的坑:同一条用户消息在不同模式下会被 Copilot 包装成不同格式。比如用户输入 "hello":
- Agent 模式下可能是
<context>...很多编辑器上下文...</context>\n<userRequest>hello</userRequest> - Ask 模式下可能是
<userRequest>hello</userRequest>(上下文少很多)
如果直接比对完整内容,切换模式后所有会话都会匹配失败。
解决办法是只比对 <userRequest> 标签内的文本:
const USER_REQUEST_RE = /<userRequest>\s*([\s\S]*?)\s*<\/userRequest>/;
function extractUserText(content: string): string {
const m = content.match(USER_REQUEST_RE);
return m ? m[1].trim() : content;
}按模式隔离
另一个教训:Agent 模式和 Ask 模式的会话必须隔离。
假设用户在 Agent 模式下问了一个问题,Cursor CLI 创建了一个 Agent 会话。然后用户切到 Ask 模式继续问——如果复用同一个 CLI 会话,CLI 会收到 --mode ask 但会话上下文已经有 Agent 操作的记录,可能导致不可预期的行为。
所以 findSession 在匹配时会额外检查模式是否一致:
private findSession(incomingUsers: string[], mode: "agent" | "ask"): Session | null {
for (const session of this.sessions.values()) {
if (session.mode !== mode) continue;
const matchLen = userPrefixLen(incomingUsers, session.userTexts);
if (matchLen > bestMatchLen) {
bestSession = session;
bestMatchLen = matchLen;
}
}
return bestMatchLen >= 1 ? bestSession : null;
}生命周期
会话不是永久保留的:
- TTL 过期:默认 30 分钟无活动后自动清除(后台定时器每分钟扫一次)
- 上限淘汰:最多 50 个活跃会话,超过时淘汰最久未使用的
- 扩展停用:
deactivate()时清除所有会话
流式输出解析
Cursor CLI 的 --output-format stream-json 每行输出一个 JSON 对象。和 OpenAI 的 SSE 不同,CLI 的输出是累积式的——每个 type: "assistant" 消息包含从开头到当前的完整文本,而不是增量 delta。
这意味着如果 CLI 输出了:
{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world!"}]}}第二行的 Hello world 已经包含了第一行的 Hello。如果直接把每行的 text 都 report 出去,用户会看到重复内容。
解析器需要做增量提取:
export function createStreamParser(onText, onDone) {
let accumulated = "";
return (line) => {
const obj = JSON.parse(line);
if (obj.type === "assistant" && obj.message?.content) {
const text = obj.message.content
.filter(p => p.type === "text" && p.text)
.map(p => p.text)
.join("");
if (text === accumulated) return; // 重复,跳过
if (text.startsWith(accumulated) && accumulated.length > 0) {
const delta = text.slice(accumulated.length); // 提取增量
if (delta) onText(delta);
accumulated = text;
} else {
onText(text);
accumulated += text;
}
}
if (obj.type === "result" && obj.subtype === "success") {
onDone();
}
};
}最后一行 type: "result" 的完整 assistant 消息会和 accumulated 完全相同,被跳过。用户只看到平滑的流式文本。
Windows 平台的 .cmd 绕行
Windows 上 Cursor CLI 的入口是 agent.cmd,本质是一个 cmd.exe 脚本。Node.js 的 spawn 调用 .cmd 文件时会隐式通过 cmd.exe /c 执行,而 cmd.exe 对参数中的特殊字符(<, >, &, |, 换行)有自己的解析规则,会导致参数被截断或误解释。
用户消息中包含这些字符是常见场景(比如问 HTML 标签、shell 命令),所以必须绕过 cmd.exe。
做法是解析 .cmd 文件,找到同目录下的 node.exe 和 index.js,直接用 node.exe index.js 替代 agent.cmd:
function resolveAgent(agentPath: string) {
if (process.platform === "win32" && /\.cmd$/i.test(agentPath)) {
const dir = path.dirname(path.resolve(agentPath));
const nodeBin = path.join(dir, "node.exe");
const script = path.join(dir, "index.js");
if (fs.existsSync(nodeBin) && fs.existsSync(script)) {
return { cmd: nodeBin, prefixArgs: [script] };
}
}
return { cmd: agentPath, prefixArgs: [] };
}同时设置 CURSOR_INVOKED_AS: "agent.cmd" 环境变量,让 CLI 内部逻辑正常工作。
错误处理哲学
一个重要的设计决策:所有错误都在聊天窗口中反馈,而不是只记日志。
用户在 Copilot Chat 中发消息,如果出错了,他们期望在聊天窗口中看到反馈——而不是去翻 Output 面板。所以扩展对每种错误都生成格式化的 Markdown 错误消息,通过 progress.report() 推送到聊天窗口:
const replyError = (title: string, detail: string, hints: string[]) => {
const lines = [`**⚠️ Cursor Bridge Error: ${title}**`, "", detail];
if (hints.length > 0) {
lines.push("", "**How to fix:**");
for (const h of hints) lines.push(`- ${h}`);
}
lines.push("", "_Open Output → Cursor Bridge for full logs._");
reply(lines.join("\n"));
};覆盖的错误场景包括:
| 错误类型 | 触发条件 | 建议操作 |
|---|---|---|
| CLI Not Found | ENOENT,路径不存在 | 安装 CLI 或配置路径 |
| Authentication Failed | 401 / 未登录 | 配置 API Key 或 agent login |
| Invalid Model | 模型名不存在 | 刷新模型列表 |
| Rate Limited | 429 / 请求过多 | 等待或切换模型 |
| Network Error | 连接失败 | 检查网络 |
| Timeout | CLI 超时被 kill | 增加超时或简化提问 |
| Empty Response | CLI 成功但无输出 | 换模型或换提问方式 |
每个错误都包含明确的操作建议,而不是一个笼统的 "Something went wrong"。
已知限制和未解决的问题
CLI 的 --mode ask 不完全生效
测试中发现,即使扩展正确检测到 Ask 模式并传递了 --mode ask 参数,Cursor CLI 有时仍然会执行文件写入操作(editToolCall)。这不是扩展的 bug——我们传的参数是对的,是 CLI 自身没有严格执行 Ask 模式的只读约束。
Proposed API 的不确定性
chatProvider 随时可能在 VSCode 更新中发生 breaking change。这是所有基于 Proposed API 构建的扩展都面临的风险。目前的应对策略是:固定 engines.vscode 最低版本,每次 VSCode 大版本更新后验证兼容性。
首条消息的延迟
每个新会话的第一条消息需要 create-chat(约 1-2 秒)+ CLI spawn + 模型响应。后续消息因为 --resume 省掉了会话创建,延迟会好很多。如果用户对首条消息的延迟敏感,可以在配置中增大 sessionTtlMinutes 来减少会话过期重建的频率。
小结
Cursor Copilot Bridge 本质上解决的是一个「跨生态复用」的问题:你有 Cursor 的订阅和模型,你习惯 VSCode 的 UI 和生态,两边各有优势,为什么不能一起用?
技术上,它是一个把 CLI 工具包装成 VSCode Provider 的胶水层。复杂度主要集中在三个地方:会话管理(没有会话 ID 的情况下做序列匹配)、模式检测(从工具信号推断 UI 状态)、流式解析(累积式输出转增量推送)。这些都是 VSCode Provider API 的设计留白带来的工程挑战,不是特别优雅,但在当前 API 约束下是可行的方案。
项目开源在 GitHub,欢迎试用和反馈。