Compare commits

..
Author SHA1 Message Date
leookun c96cb0a110 feat(console): add initial console for Cursor BYOK with provider management and LLM call tracking 2026-08-17 10:14:10 +08:00
leookun 4db2061611 all tools 2026-08-16 17:29:29 +08:00
leokun eafede22e3 refactor: 0.1.0-beta 2026-08-13 22:01:18 +08:00
leokun 3e7a15017d refactor: 0.1.0-beta 2026-08-13 22:01:11 +08:00
leokun a3ec2a0dfc release: 0.0.48 2026-08-12 20:32:19 +08:00
leokunandGitHub 38472a3c63 Merge pull request #299 from leookun/fix/issue-298-per-user-ca
Fix shared root CA by generating one per installation
2026-08-12 20:11:43 +08:00
leokun 2b8d1c9e3d fix: generate a per-installation root CA 2026-08-12 20:04:29 +08:00
leokunandGitHub 7a67e76617 Merge pull request #270 from zfscgy/feat/image-read
Feat/image read: 本地文件读取工具支持读取图片
2026-08-12 01:22:35 +08:00
leookun 08ce12aa55 Merge branch 'main' into codex/pr270-content-addressed-read-images
# Conflicts:
#	frontend/src/i18n/generated/catalog.json
2026-08-12 01:14:24 +08:00
leookun 8f8d28880d feat(forwarder): persist read images by content hash 2026-08-12 01:10:14 +08:00
leokunandGitHub 988ba63d40 Merge pull request #291 from Sxuan-Coder/feat/optional-reasoning-effort
fix(model): 支持不设置 reasoning effort
2026-08-11 23:44:52 +08:00
上玄 edd59dc684 fix(model): 支持不设置推理强度 2026-08-11 21:18:10 +08:00
leokunandGitHub da5fa34a4d 更新 README-CN.md 2026-08-10 23:19:34 +08:00
leokunandGitHub 4bd2359282 Update README-CN.md 2026-08-10 23:14:14 +08:00
leokunandGitHub 7838ffc6a2 Update README.md 2026-08-10 23:13:48 +08:00
leokunandGitHub ee30775be1 Update Chinese translation link in README 2026-08-10 23:10:17 +08:00
leokunandGitHub 80a5093aa9 Update README.md 2026-08-10 23:09:41 +08:00
leokunandGitHub b9742b1667 Update README-CN.md 2026-08-10 23:09:04 +08:00
leokunandGitHub 7d3e74be59 Update README.md 2026-08-10 23:08:36 +08:00
leokunandGitHub 1285bf9d62 Update README-CN.md 2026-08-10 23:05:44 +08:00
leokunandGitHub 7a724595eb Update README.md 2026-08-10 23:05:31 +08:00
leokunandGitHub 00aec40e50 Update README.md 2026-08-10 23:03:50 +08:00
leokunandGitHub c10a2d475d Merge pull request #285 from leookun/release/0.0.47
release: 0.0.47
2026-08-10 22:56:00 +08:00
leookun 4864d3675b release: 0.0.47 2026-08-10 22:55:16 +08:00
leokunandGitHub 3f95318a49 Merge pull request #284 from leookun/fix/compress
Enhance checkpoint handling and error management in forwarder
2026-08-10 22:34:40 +08:00
郑非 85a43115c7 read image tests 2026-08-06 21:06:13 +08:00
郑非 b475166ba8 Support read image 2026-08-06 21:05:08 +08:00
596 changed files with 44846 additions and 180005 deletions
View File
-356
View File
@@ -1,356 +0,0 @@
---
name: coding-guidance
description: 本地模式实现指南
---
当用户在处理本地模式的时候,使用此指南
先遵守这个约束:
- 不要修改已安装的 Cursor 客户端代码、bundle 或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议与仓库代码。
- 如果用户提到“临时 patch 客户端做 e2e”,也要改成只读排查:核对实际运行副本、采集证据、对照仓库实现,然后把修复落在本仓库代码或输出明确结论。
如果问题已经涉及以下任一事项,请同时读取 `../cursor-client-e2e-debugging/SKILL.md`
- 需要只读核对已安装的 Cursor 客户端 bundle、日志或运行副本
- 需要确认当前到底是哪一个 app 副本在运行
- 需要同时排查客户端 bundle 与本仓库 forwarder 的协同问题
- 需要对照已安装客户端行为与本仓库实现差异
本地模式协议需要优先核对这些文件:
- proto/agent_v1.proto
- proto/aiserver_v1.proto
客户端是:/Users/leokun/Library/Application\ Support/Cursor
客户端 bundle 是:/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js
## 可选抓包调试工具
仓库提供了独立的 Cursor 协议抓包调试器。开发者在手动排查协议问题时,可以运行:
```bash
go run ./cmd/cursor-proxy-debugger
```
默认代理地址是 `http://127.0.0.1:9090`,调试界面是 `http://127.0.0.1:9091`。该工具可以辅助查看:
- `agent.v1.AgentService/RunSSE`
- `aiserver.v1.BidiService/BidiAppend`
- Connect 帧、gzip 压缩内容、Protobuf 解码结果和原始二进制数据
- 同一 `request_id` 对应的上下行消息
开发者启动工具后,需要自行完成以下配置:
1. 在 Cursor 的代理设置中,将代理修改为工具启动时显示的代理地址,默认是 `http://127.0.0.1:9090`
2. 在 Cursor 的 Network 设置中开启 HTTP/1.1。
3.`http://127.0.0.1:9091/api/ca.crt` 下载代理 CA 证书,并确保 Cursor 信任该证书。
这只是供开发者手动使用的辅助工具,不属于自动化 Debug 流程。不要因为加载此指南就自动启动代理、修改 Cursor 或系统设置、安装证书,或操作 Cursor 发起请求。只有开发者明确表示已经启用抓包时,才把调试界面中的数据作为当前运行证据。调试结束后,提醒开发者恢复原来的 Cursor 代理和 Network 设置。
## Cursor 客户端格式化快照
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill。
- 如果本仓库存在 `.cursor-app-formatted/`,排查客户端 bundle 时优先读取这里的格式化副本。
- `.cursor-app-formatted/` 是从 `/Applications/Cursor.app/Contents/Resources/app` 只读提取后格式化生成的本地快照;它不应写回、替换或影响已安装的 Cursor.app。
- 常用格式化路径:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
- 如果 `.cursor-app-formatted/` 不存在、明显过期,或需要核对真实安装包 hash,再只读读取 `/Applications/Cursor.app` 原始 bundle。
## 本仓库已固定的会话承接规则
- 下一轮真实请求给 LLM 的历史承接,以 `history/<conversationId>/state.json` + `history/<conversationId>/context.json` 为持久化事实源。
- `state.json` 保存会话元数据和当前状态,例如 `next_turn_seq``next_entry_seq``context_version``current_todos``current_plans``latest_request_prefix``last_provider_call`
- `context.json.items` 保存 append-only 的语义历史 entriesprovider messages 不是主存储事实,而是由 `ProjectPromptReplay()` 从 entries 投影出来。
- 模型渠道唯一性不再由 `modelID` 决定;当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hashresolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 可 replay 的历史应以 entry 顺序稳定追加,不能把已发送给模型且仍需保留的历史移动到新位置。
- 最新态、易变态,例如 active todo、current plan、最新编辑保护和动态 reminder,应优先作为 `state.json` 状态或本轮 latest-only suffix;不要无意持久化成会在后续轮次无限 replay 的历史。
- 新一轮 `run_request` 到来时,服务端应通过 `LoadConversation()` 读取 `state.json + context.json`,再由 projector 投影 prompt replay;客户端带回来的 checkpoint/replay 不参与历史承接真相判定。
- `summary.json``replay.json``runtime.json``request.json``conversation.json``entries.jsonl``turns/` 和数字 turn 目录都属于旧持久化产物,会被 history maintenance 当 legacy artifact 清理。
- 如果发现请求历史与本地状态不一致,优先检查 `context.json.items` 是否缺失、重复、顺序异常,以及 `state.json``next_entry_seq``next_turn_seq``context_version`、当前状态字段是否与 entries 派生结果一致;不要再按旧 `summary.json` 路径排查。
# 已确认结论
## 1. `AgentServerMessage` 不是统一都要“回复完成”
要按 `oneof message` 分类看:
- `exec_server_message`
- 这是服务端发给客户端的“执行请求”。
- 客户端需要显式回 `ExecClientMessage`
- 流式/异常场景下还会回 `ExecClientControlMessage`,常见是:
- `stream_close`
- `throw`
- `heartbeat`
- `interaction_query`
- 这是服务端发给客户端的“交互请求”。
- 客户端需要显式回 `InteractionResponse`
- `interaction_update`
- 这是展示/状态更新消息,通常不需要客户端回包。
- `conversation_checkpoint_update`
- 这是 checkpoint 同步消息,通常不需要客户端回包。
- `kv_server_message`
- 这是 KV 同步消息,通常不需要客户端回包。
- `exec_server_control_message`
- 这是服务端对执行桥的控制消息(例如 abort),客户端要按控制语义处理,但不是通用“完成 ack”。
## 2. Cursor 客户端没有“收到任意 `ServerMessage` 自动回 ack”的通用层
`cursor-always-local/dist/main.js` 里,`BidiTransport.startYieldingInputsToTheServer` 只会把“客户端主动产出的消息”送到 `BidiAppend`
- 它对输入 iterable 做 `p.value.toBinary()` 后 hex 编码,再发 `BidiAppendRequest.data`
- 说明只有客户端业务逻辑主动产出的 `AgentClientMessage` 才会上行
- 没有发现“收到一个 `AgentServerMessage` 就自动回 completed/ack”的统一机制
因此客户端是否回包,取决于上层业务逻辑有没有因为某个下行消息而主动构造新的 `AgentClientMessage`
## 2.1 更具体的客户端侧结论
`cursor-always-local/dist/main.js` 里能直接确认:
- `AgentServerMessage` 的下行类型里有:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `conversation_checkpoint_update`
- `interaction_query`
- `AgentClientMessage` 的上行类型里有:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
这意味着本地模式不是“server message -> 通用 ack”模型,而是:
- `exec_server_message`
-> 客户端执行本地工具
-> 产出 `exec_client_message`
-> 以及可选 `exec_client_control_message`
- `interaction_query`
-> 客户端展示或处理交互
-> 产出 `interaction_response`
- 其他下行消息
-> 一般只更新 UI / checkpoint /流状态
-> 不会自然地产生一个“完成 ack”
## 2.2 `exec_server_message` 常见的客户端回包形态
客户端协议模型里已确认这些回包类型:
- `ExecClientMessage`
- 正常结果面
- 包括 `read_result` / `write_result` / `grep_result` / `ls_result` / `diagnostics_result` / `mcp_result` / `shell_stream`
- `ExecClientControlMessage`
- 控制面
- 包括:
- `stream_close`
- `throw`
- `heartbeat`
所以调查本地模式 exec 问题时,不要只盯 `ExecClientMessage`
- 有些工具只回一次结果面消息
- shell 之类的流式工具会混合回:
- 多次 `shell_stream`
- 以及控制消息(例如 `stream_close` / `heartbeat`
## 2.3 `exec_server_message` 的完整回包形态
事实依据:
- `proto/agent_v1.proto`
- `ExecServerMessage.oneof message`
- `ExecClientMessage.oneof message`
- `ExecClientControlMessage.oneof message`
- `cursor-always-local/dist/main.js`
- bundle 内含同名 proto 模型
- `BidiTransport.startYieldingInputsToTheServer` 说明客户端上行消息来自业务逻辑主动构造,不存在通用自动 ack
### 结果面回包:`ExecServerMessage` -> `ExecClientMessage`
`ExecServerMessage``message` 分支与 `ExecClientMessage``message` 分支是一一对应的:
- `shell_args`
-> `shell_result`
- `write_args`
-> `write_result`
- `delete_args`
-> `delete_result`
- `grep_args`
-> `grep_result`
- `read_args`
-> `read_result`
- `ls_args`
-> `ls_result`
- `diagnostics_args`
-> `diagnostics_result`
- `request_context_args`
-> `request_context_result`
- `mcp_args`
-> `mcp_result`
- `shell_stream_args`
-> `shell_stream`
- `background_shell_spawn_args`
-> `background_shell_spawn_result`
- `list_mcp_resources_exec_args`
-> `list_mcp_resources_exec_result`
- `read_mcp_resource_exec_args`
-> `read_mcp_resource_exec_result`
- `fetch_args`
-> `fetch_result`
- `record_screen_args`
-> `record_screen_result`
- `computer_use_args`
-> `computer_use_result`
- `write_shell_stdin_args`
-> `write_shell_stdin_result`
- `execute_hook_args`
-> `execute_hook_result`
- `subagent_args`
-> `subagent_result`
所有这些结果面回包都带:
- `id`
- `exec_id`
服务端匹配时通常优先用:
1. `exec_id`
2. `id`
### 控制面回包:`ExecServerMessage` -> `ExecClientControlMessage`
除了结果面回包外,客户端还可能回控制面消息:
- `stream_close`
- 表示当前 exec 流已关闭
- 只有 `id`
- `throw`
- 表示执行异常
- 只有 `id` + `error` + 可选 `stack_trace`
- `heartbeat`
- 表示执行过程中的心跳
- 只有 `id`
### 关键理解
- `ExecClientControlMessage` 不是某个单独 `ExecServerMessage` 分支的“专属结果类型”
- 它是跨 exec 通用的控制面回包
- 因此调查时必须同时看两类上行:
- `ExecClientMessage`
- `ExecClientControlMessage`
### 调查规则
对于任意 `exec_server_message`,至少要确认以下之一是否发生:
- 收到对应的 `ExecClientMessage`
- 或收到 `ExecClientControlMessage.throw`
- 对流式 exec,还要看:
- 是否有多次增量 `ExecClientMessage`
- 是否最终有 `stream_close`
如果只看到 started / pending,没有任何结果面或控制面回包,服务端 pending 大概率不会收口。
排查时优先搜索这些关键字:
## 3. forwarder 状态机实现规则
在本仓库修本地模式 forwarder 时,默认遵守下面这些稳定约束,避免再次引入“工具晚到污染当前轮”或“同一 request 在 `[DONE]` 后又续跑一轮”的问题。
### 3.1 resume 必须按 provider pass 隔离
- `request_id` 不是 provider 调用代次;同一个 request 可以合法包含多次 provider pass。
- `scheduleProviderResume` 不能只依赖 request 级布尔态(例如单个 `ResumePending`)。
- resume 请求必须带来源 pass,至少要能区分:
- 当前 pass 的结果触发的合法续跑
- 上一轮工具终态晚到造成的陈旧 resume
- `driveProvider` 开始与结束时都要显式清理上一轮的 resume 状态,不能让旧状态跨 pass 残留。
### 3.2 工具晚到是常态,只能影响所属 pass
- `ExecClientMessage` / `ExecClientControlMessage` 晚于 provider `[DONE]` 到达是正常现象。
- 晚到结果只能驱动其所属 pass 的 checkpoint / history / resume 判定,不能影响后续 pass。
- 非流式 exec 的 `stream_close` synthetic recovery 也必须沿用原工具的来源 pass,不能按 request 级全局状态续跑。
### 3.3 pending exec 必须严格按 id 匹配
- `selectPendingExec` / `selectPendingExecByControl` 只允许按:
- `exec_id`
- `message_id`
进行匹配。
- 不允许再用“当前只有一个 pending,就直接返回它”的兜底逻辑。
- 迟到的 result / `stream_close` / `throw` 如果 pending 已不存在:
- 优先看 `RecentCompletedExecs` 做幂等忽略
- 不要把它重新落到当前轮的 pending 上
### 3.4 看到这些现象时,优先怀疑 stale resume / stale exec
如果出现下面任一现象,先查 forwarder 状态机,不要先怪客户端:
- 同一个 `request_id``[DONE]` 后又出现新的 `model_call_id`
- `turns/<n+1>/request.json``turns/<n>/request.json` messages 几乎完全相同
- 上一轮工具 `grepResult/readResult/...` 晚于上一轮 `[DONE]`
- 晚到的 `stream_close` 恰好跨到下一轮 provider 已经启动之后
优先核对:
- `ProviderPassCount`
- resume 请求的来源 pass
- `PendingExec.ProviderPass`
- `selectPendingExec` 是否存在跨轮误匹配
- `startYieldingInputsToTheServer`
- `bidiAppend({requestId:A,appendSeqno`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
## 3. 对本地模式最重要的协议理解
- `exec_server_message` / `interaction_query` 属于“请求型下行消息”
- 如果客户端不回对应结果,服务端 pending 不会收口
- 后续重连后可能出现 “No tool output found for function call ...” 这类 provider 400
- `interaction_update` / `conversation_checkpoint_update` 属于“通知型下行消息”
- 它们用于 UI 展示、同一 backend 进程内的 live checkpoint 同步、状态同步
- 一般不要求客户端再回一个“完成”消息
## 4. 调查本地模式时的优先顺序
1. 先确认收到的 `AgentServerMessage` 是哪一类
2. 如果是 `exec_server_message`
- 查客户端是否回了 `ExecClientMessage`
- 查是否只回了 `stream_close` 但没有真正结果
-`exec_id` / `id` 是否匹配
3. 如果是 `interaction_query`
- 查客户端是否回了 `InteractionResponse`
4. 如果是 `conversation_checkpoint_update`
- 重点查里面的 `pending_tool_calls` / `root_prompt_messages_json` / `turns`
- 不要误以为它本身需要回 ack
## 5. 对服务端实现的直接要求
- 服务端必须区分“请求型下行”和“通知型下行”
- 服务端不能把 `ServerMessage` 统一建模成“发出去就等一个完成 ack”
- 对请求型消息,必须在本地状态机里维护 pending:
- `PendingExec`
- `PendingInteraction`
- 同一 backend 进程内的 `RunSSE` 重连,要优先看 checkpoint / `pending_tool_calls` 里的 live pending
- backend 重启后,不要把 checkpoint 当持久恢复点;跨轮承接与持久恢复只看 `history/<conversationId>/state.json` + `history/<conversationId>/context.json`
### 5.1 checkpoint 投影必须幂等且只有一个事实源
- 把 checkpoint 当作 `state.json + context.json` 的纯投影,不要把它写成第二套语义历史。
- 不要创建或维护 `checkpoint.json`、checkpoint history、独立 checkpoint entry 序列等持久化事实源。
- 允许在当前 stream 内存中保留 latest checkpoint 供 retry/resume 使用;进程重启后必须能从唯一事实源重新投影。
- 对同一份 semantic history 重复投影时,要求 state、turn 顺序、blob ID 和 blob 内容在语义上完全一致;投影函数不得修改输入 history。
- 把重复发送视为同一快照的幂等覆盖,不要追加一条新的会话历史;内容寻址 blob 的重复写入必须可安全忽略。
-`turns` 投影为 UI 可恢复的完整结构,保留所有需要展示的 `ThinkingMessage``ToolCall` 和工具结果;不要为了模型 prompt 过滤而删除 UI step。
-`root_prompt_messages_json` 单独投影为模型 replay;只在这条投影上应用 provider/context 过滤,不能反向改变 `turns`
- 将工具完成结果合并回同一 `ToolCall`,保留开始态的 `args`、调用 ID 和开始时间,再补齐 `result` 与完成时间;不要制造协议不存在的独立 `ToolResult` step。
- 用 TDD 覆盖至少这些性质:重复投影相等、投影不修改 history、开始态字段在结果合并后仍存在、UI turns 保留思考/工具内容而模型 replay 仍遵守独立过滤规则。
@@ -1,4 +0,0 @@
interface:
display_name: "本地模式实现指南"
short_description: "当用户在尝试解决本地模式问题时,使用此技能"
default_prompt: "使用 $coding-guidance 来解决本地模式问题。"
@@ -1,107 +0,0 @@
---
name: cursor-app-formatted
description: Use when extracting, formatting, refreshing, or investigating a read-only formatted snapshot of the installed Cursor.app bundle under .cursor-app-formatted; includes git-ignore rules, snapshot generation workflow, and the rule to inspect formatted code without patching either the snapshot code or the installed app.
---
# Cursor App Formatted Snapshot
Use this skill whenever a task involves reading, searching, formatting, refreshing, or relying on a formatted copy of the installed Cursor client bundle.
## Invariants
- Never modify `/Applications/Cursor.app`, any installed app bundle, signatures, or app copies.
- Never patch bundled code under `.cursor-app-formatted/` as a fix target. It is an ignored investigation snapshot only.
- If `.cursor-app-formatted/` is stale or wrong, regenerate it from the installed app instead of hand-editing its code.
- Fixes should land in this repository's real source code, scripts, or docs, not in formatted snapshot code.
- Keep `.cursor-app-formatted/` git-ignored. Do not stage or commit generated snapshot contents.
## Preferred Investigation Flow
1. If `.cursor-app-formatted/` exists, search and read that formatted snapshot first.
2. Use `/Applications/Cursor.app` only for read-only authenticity checks, hash comparison, or when the snapshot is missing or stale.
3. Prefer stable formatted paths for line references and control-flow reading:
- `.cursor-app-formatted/extensions/cursor-always-local/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js`
- `.cursor-app-formatted/extensions/cursor-agent-worker/dist/main.js`
- `.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js`
- `.cursor-app-formatted/out/vs/workbench/api/node/extensionHostProcess.js`
4. When investigating installed-client behavior, compare formatted findings back to original source hashes or original bundle content only as needed.
## Git Ignore Rule
Ensure `.gitignore` contains:
```gitignore
.cursor-app-formatted/
```
If the entry is missing and the user asked to create or refresh the snapshot, add it before generating the snapshot.
## Snapshot Generation Workflow
Run from the repository root. This workflow copies only from the installed app into the ignored snapshot, then formats the copy.
```bash
set -euo pipefail
SNAPSHOT=.cursor-app-formatted
SOURCE=/Applications/Cursor.app/Contents/Resources/app
rm -rf "$SNAPSHOT"
mkdir -p "$SNAPSHOT"
/usr/bin/ditto "$SOURCE/extensions" "$SNAPSHOT/extensions"
mkdir -p "$SNAPSHOT/out/vs/workbench/api/node"
/usr/bin/ditto "$SOURCE/out/vs/workbench/workbench.desktop.main.js" "$SNAPSHOT/out/vs/workbench/workbench.desktop.main.js"
/usr/bin/ditto "$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" "$SNAPSHOT/out/vs/workbench/api/node/extensionHostProcess.js"
/usr/bin/shasum -a 256 \
"$SOURCE/out/vs/workbench/workbench.desktop.main.js" \
"$SOURCE/out/vs/workbench/api/node/extensionHostProcess.js" \
> "$SNAPSHOT/source-sha256.txt"
/usr/bin/find "$SOURCE/extensions" -type f \( -name '*.js' -o -name '*.json' -o -name '*.css' \) -print \
| /usr/bin/sed "s#^$SOURCE/##" \
| while IFS= read -r rel; do
/usr/bin/shasum -a 256 "$SOURCE/$rel"
done >> "$SNAPSHOT/source-sha256.txt"
```
Format large JS bundles with `js-beautify`; Prettier can OOM on very large Cursor bundles and also skips ignored paths unless forced.
```bash
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
find .cursor-app-formatted -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \) ! -size +1M -print \
| while IFS= read -r file; do
npx --yes js-beautify --type js --indent-size 2 --end-with-newline --replace --quiet "$file"
done
EMPTY_IGNORE="$(mktemp)"
trap 'rm -f "$EMPTY_IGNORE"' EXIT
find .cursor-app-formatted -type f \( -name '*.json' -o -name '*.css' \) -print0 \
| xargs -0 -n 25 npx --yes prettier --ignore-path "$EMPTY_IGNORE" --with-node-modules --write --log-level warn
```
Optionally add a small `.cursor-app-formatted/README.md` describing the source path, observed Cursor version, and that the snapshot is read-only.
## Validation
After generation, verify the snapshot is ignored and key files are readable:
```bash
git status --short --ignored | rg '\.cursor-app-formatted'
wc -l \
.cursor-app-formatted/out/vs/workbench/workbench.desktop.main.js \
.cursor-app-formatted/extensions/cursor-always-local/dist/main.js \
.cursor-app-formatted/extensions/cursor-agent-exec/dist/main.js
```
Useful investigation check:
```bash
rg -n 'localMode|runLocalAgent|localProvider|BidiTransport|startYieldingInputsToTheServer' .cursor-app-formatted
```
@@ -1,97 +0,0 @@
---
name: cursor-client-e2e-debugging
description: Use when debugging Cursor client agent/local-mode/tool/backend-store/provider-replay failures in this repo, especially after the state/context history-store refactor, when triaging installed app bundles read-only, correlating installed-client behavior with repo code, mapping a user-provided id to conversation/request/model-call evidence, replaying provider requests from debug logs, or locating the current client/backend/protocol/log files quickly.
---
当用户反馈 Cursor agent、本地模式、工具调用、协议桥接、客户端 bundle 行为异常,或需要只读核对已安装客户端与仓库实现/日志差异时,使用此技能。
当用户只给一个 UUID / id,希望反查它是 `conversationId``requestId``modelCallId``toolCallId` 还是其它运行期 id,并继续定位对应的会话 history、provider 调用状态或协议日志时,也使用此技能。
当用户遇到 provider 400/参数错误、SSE `event: error`、需要从 `debug/provider.jsonl` 抽取最终 provider body 并用 curl 独立复现时,也使用此技能。
## 首要约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、协议事件与本仓库实现。
- 如果本仓库存在 `.cursor-app-formatted/`,优先读取这个格式化快照来搜索和引用客户端 bundle;只有在快照缺失、过期或需要 hash/真实性核对时,才只读读取 `/Applications/Cursor.app`
- 如果用户要求提取、格式化、刷新或规范化 Cursor.app 快照流程,使用 `cursor-app-formatted` skill;调查时可以读格式化代码,但不要 patch 格式化快照里的 bundle 代码。
- 如果用户要求 patch 客户端做 e2e,要改成只读证据采集与差异定位,不执行客户端修改。
- 当前本仓库已经重构为 `state.json + context.json` history-store;不要沿用旧 `data.sqlite``conversation.json``turns/<n>/request.json|sse.jsonl|summary.json` 排查路径。
## 先做路由判断
- `history + logs` 反查层
- 现象:用户发来一个 id,要判断它是 `conversationId``requestId``modelCallId``toolCallId`;需要从 `history/<conversationId>/state.json``history/<conversationId>/context.json` 追运行状态和语义历史。
- 先读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- `provider replay / debug`
- 现象:provider 返回 400/参数错误、SSE `event: error`、需要验证最终出站 provider body 是否能被独立 curl 复现。
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md),必要时使用 [scripts/provider-replay.sh](scripts/provider-replay.sh)
- `cursor-agent`
- 现象:`CursorAgentProvider``ClaudeSDKClient``AnthropicProxy``registerAgentProvider``InteractionUpdate` 映射、模型桥接异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- `cursor-always-local` / 本地模式协议层
- 现象:`BidiAppend``RunSSE``AgentServerMessage``ExecClientMessage``InteractionResponse`、live checkpoint / pending 收口异常。
- 先读 [references/file-map.md](references/file-map.md) 和 [references/search-patterns.md](references/search-patterns.md)
- 客户端 bundle 只读定位层
- 现象:需要核对已安装 app bundle、确认实际运行副本、只读验证行为是否命中,并判断差异来自客户端还是本仓库。
- 先读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
如果问题同时涉及多层,优先从最靠近故障表象的一层开始,不要一开始就同时追所有链路。
## 当前工作流
1. 如果用户给了一个 id,先用 `history/` 目录、`context.json.items``state.json``logs/app.log` 判断它属于哪类 id;不要假设它一定是 `requestId`
2. 一旦拿到 `conversationId`,同时看两份事实源:
- `history/<conversationId>/state.json`:会话元数据和当前状态,例如 loop、token、current todo/plan、`latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`append-only 的语义历史 entriesprompt replay 由 `ProjectPromptReplay()` 从这里投影。
3. 不要去找旧 provider 调用工件:当前 `RecordLLMRequest` 不再落 `request.json``AppendLLMResponseChunk` 是 no-op`RecordLLMSummary` 只补齐内存态并更新 `state.latest_request_prefix` / usage。
4. 再确认故障主要落在 `cursor-agent``cursor-always-local`,还是本仓库 `internal/backend` 的协议兼容层。
5. 用 references 里的固定搜索词快速找到入口函数、协议消息和桥接点。
6. 如果 provider 返回 400/参数错误、SSE `event: error`,或需要验证最终出站 provider body
- 先读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)。
- 通过 id 反查拿到 `conversationId``requestId``modelCallId`,再定位 `history/<conversationId>/debug/provider.jsonl`
- 必要时运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),只保存 replay 产物,不把 API key 或完整 request body 写进技能/回复。
7. 如果用户要核对 prefix cache / cache hit
- 优先运行 `go run ./scripts/historymetrics [conversationId|path]`
- 它读取当前 `history/<conversationId>/state.json``history/<conversationId>/context.json`,并结合 `history/usage.json` 统计。
- 关注 `cache_read_tokens / prompt_tokens_total`,并检查 `context.json.items` 是否缺失、重复或顺序异常。
8. 如果需要对照已安装 app 与仓库行为:
- 只做只读核对与证据采集,不修改客户端 bundle / app 副本 / 签名。
- 优先用 `.cursor-app-formatted/` 中的格式化副本定位符号、行号和控制流;再按需只读核对 `/Applications/Cursor.app` 原始文件 hash 或运行副本。
- 先确认实际运行的 app 副本和目标 bundle 路径。
- 再读取 bundle 内容、日志、端口与 history 状态,并与本仓库实现对照。
9. 如果证据显示问题更像是客户端 bundle 行为差异:
- 记录具体文件、符号、日志和协议证据链。
- 继续判断本仓库是否可以兼容、绕过,或直接输出分析结论。
- 不要对已安装 Cursor 客户端做 patch、重签名、替换文件或写入式验证。
## 约束
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 不要默认复刻整套 Cursor backend;先确认是不是只需要改模型桥接层。
- 不要先假设用户给的是 `requestId`;必须同时考虑 `conversationId``requestId``modelCallId``toolCallId`
- 不要把 `history/<conversationId>/state.json``history/<conversationId>/context.json` 混为一谈:前者是元数据与当前状态,后者是 replayable 语义历史。
- 不要再依赖 `agent_request_runs``agent_conversations``agent_history_entries``protocol_traces``data.sqlite`;当前实现已经不支持 DB-backed store / trace debug UI。
- 不要把当前排查进度、临时结论、一次性的 request_id / 端口 / token 写进技能。
- 技能里只保留稳定流程、固定入口、可复用搜索词和只读验证规则。
## 模型渠道规则
- 模型渠道唯一性不再由 `modelID` 决定。
- 当前规范化渠道 ID 是 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 `SHA-256` hash(前 16 个十六进制字符)。
- resolver 仍兼容 legacy 渠道 ID`baseURL + modelID + apiKey + displayName`
- `modelID` 只表示 provider model;排查选择器、默认模型和命中渠道时,要优先看渠道 ID 和 `openAIEndpoint`
## 参考加载规则
- `history + logs` 路径、id 反查、state/context 生成链路:读 [references/backend-store-log-tracing.md](references/backend-store-log-tracing.md)
- provider 400/参数错误、SSE `event: error`、最终出站 provider body curl 重放:读 [references/provider-replay-debugging.md](references/provider-replay-debugging.md)
- 文件地图:读 [references/file-map.md](references/file-map.md)
- 搜索词与判断树:读 [references/search-patterns.md](references/search-patterns.md)
- 已安装客户端的只读核对、进程确认、行为验证:读 [references/installed-client-readonly-validation.md](references/installed-client-readonly-validation.md)
## 自带脚本
- 统计 prefix cache / cache hit:运行 `go run ./scripts/historymetrics [conversationId|path]`
- 兼容壳脚本:运行 [scripts/cache-hit-rate.mjs](scripts/cache-hit-rate.mjs)
- provider curl 重放:运行 [scripts/provider-replay.sh](scripts/provider-replay.sh),必填 `REQUEST_LOG``REQUEST_ID``MODEL_CALL_ID`
@@ -1,177 +0,0 @@
# backend/store 日志反查
当用户只给一个 id,或明确让你从本地记录里反查一次请求、会话、模型调用、工具调用或 provider 错误时,优先读这份参考。
## 当前固定路径
当前用户机器上的固定助手根目录:
- `~/.cursor-local-assistant-v2`
当前最关键的是三类内容:
- `history/<conversationId>/state.json`
- 会话元数据与当前状态。
- 重点字段:`request_id` `conversation_id``root_conversation_id``parent_conversation_id``parent_tool_call_id``mode``current_loop_status``current_request_id``current_turn_seq``context_version``next_turn_seq``next_entry_seq``latest_request_prefix``last_provider_call``current_todos``current_plans`、token / compaction 字段。
- `history/<conversationId>/context.json`
- append-only 语义历史。
- 重点字段:`version``items[]`
- `items[]` 中每个 entry 通常有 `seq``turn_seq``request_id``role``kind``tool_call_id``parent_tool_call_id``payload``created_at`
- `history/usage.json`
- provider call 与 turn usage 聚合。
- 重点字段:`totals``daily``recent_events``event_index`
`logs/app.log` 是运行日志;它只用于补充运行时证据,不是会话事实源。
当前实现不再支持:
- DB-backed store / searchable conversation memory
- HTTP/protocol trace debug UI
- `data/data.sqlite` / `protocol_traces`
- `history/<conversationId>/conversation.json`
- `history/<conversationId>/turns/<n>/request.json|sse.jsonl|summary.json`
- 根目录或会话目录下的旧 `latest.json``summary.json``replay.json``runtime.json``request.json``recovery.json``entries.jsonl`、数字 turn 目录
这些旧产物会被 `internal/backend/forwarder/history_maintenance.go` 清理。不要把它们当成当前事实源。
## 用户发来一个 id 时的固定步骤
### 1. 先判断 id 类型
不要先假设它是 `requestId`。按这个顺序缩小范围:
1. 是否是 `conversationId`
- 检查 `history/<id>/state.json``history/<id>/context.json` 是否存在。
2. 是否是 `requestId`
-`history/*/state.json` 中查 `current_request_id``latest_request_prefix.request_id``last_provider_call.request_id`
-`history/*/context.json``items[].request_id` 中查。
-`logs/app.log` 中查。
3. 是否是 `modelCallId`
-`state.json` 中查 `latest_request_prefix.model_call_id``last_provider_call.model_call_id`
-`context.json.items[].payload` 中查 `model_call_id`
-`logs/app.log` 中查 `model_call_id=<id>`
4. 是否是 `toolCallId` / `exec_id`
-`context.json.items[].tool_call_id``items[].payload` 中查。
- 在协议/工具相关日志中查。
可以用本地脚本或 `rg` 做只读反查。不要再用 SQLite 查询模板。
### 2. 拿到 `conversationId` 后看两份事实源
```bash
HISTORY_ROOT="$HOME/.cursor-local-assistant-v2/history"
CONV_ID="<conversation-id>"
ls -la "$HISTORY_ROOT/$CONV_ID"
```
重点检查:
- `state.json`
- `current_loop_status``idle``running``waiting_tool``completed``canceled``provider_error``failed`
- `current_request_id``current_turn_seq`
- `latest_request_prefix`:最近一次 provider 请求的 provider/model/openai_endpoint/model_call_id/prompt token 摘要
- `last_provider_call`:最近 provider 状态与错误文本
- `next_entry_seq``next_turn_seq``context_version`
- `current_todos``current_plans`
- `context.json`
- `version` 是否与 `state.context_version` 对齐
- `items[]` 是否按 `seq` 稳定递增
- 同一 `turn_seq` 下是否有预期的 user/request_context/prompt_context/assistant/tool_result/metadata entries
- 是否有重复、缺失或顺序异常
- `usage.json`
- 通过 `event_index``recent_events` 查 request/model-call 相关 usage
- `totals.cache_read_tokens / (totals.cache_read_tokens + totals.input_tokens)` 可粗略看 cache hit
### 3. Provider 调用证据现在在哪里
当前 provider artifact recorder 的行为:
- `RecordLLMRequest(...)`
- 只缓存当前 provider call 的请求摘要。
- 如果 payload 可解析 provider/model/openai_endpoint,会更新 `state.latest_request_prefix`
- 不再写 `request.json`
- `AppendLLMResponseChunk(...)`
- 当前是 no-op。
- 不再写 `sse.jsonl`
- `RecordLLMSummary(...)`
- 只补齐当前 provider call summary,并更新 `state.latest_request_prefix.prompt_tokens_total`
- usage 聚合写入 `history/usage.json`
- 不再写 `summary.json`
所以 provider 错误排查应优先看:
- `state.last_provider_call`
- `state.latest_request_prefix`
- `context.json.items` 里的 `metadata/provider_error/turn_completed` 等 payload
- `history/usage.json`
- `logs/app.log`
- `internal/backend/agent/model/openai.go` / `anthropic.go` 的请求构造和错误解析
## 这些文件是怎么生成的
### 根路径
- `internal/appdata/paths.go`
- `RootDir()` 固定为 `~/.cursor-local-assistant-v2`
- `HistoryRootPath()``~/.cursor-local-assistant-v2/history`
- `UsageFilePath()``~/.cursor-local-assistant-v2/history/usage.json`
- `LogsRootPath()``~/.cursor-local-assistant-v2/logs`
### `state.json + context.json`
来源链路:
- `internal/backend/forwarder/file_store.go`
- `CreateConversation`
- `LoadConversation`
- `AppendEntries`
- `SaveConversationWithEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `internal/backend/forwarder/service.go`
- `handleRunIntent` 开始新 loop / turn
- `appendConversationEntries` 追加语义事件
- `internal/backend/forwarder/projector.go`
- `ProjectPromptReplay()``context.json.items` 投影为 provider messages
稳定结论:
- `state.json` 是当前状态和可变元数据。
- `context.json.items` 是 replayable 语义历史。
- 发给 LLM 的历史由 projector 从 `context.json.items` 投影,不是从 provider artifacts 重放。
- `state.json.entries` 只是内存结构 `ConversationFile` 的字段;落盘时可投影历史在 `context.json.items`
### `usage.json`
来源链路:
- `internal/backend/forwarder/usage_store.go`
- `UsageFileStore.UpsertEvent`
- `UsageFileStore.LookupEvent`
- `internal/backend/forwarder/token_usage.go`
- `internal/historymetrics/`
稳定结论:
- `usage.json` 是全局 usage 聚合,不属于单个 conversation 的语义历史。
- `recent_events` 只保留最近有限数量事件;长期总量看 `totals` / `daily`
### legacy 清理
来源链路:
- `internal/backend/forwarder/history_maintenance.go`
稳定结论:
- `turns/``conversation.json``entries.jsonl``request.json``summary.json` 等都是 legacy artifact。
- 启动后的 history maintenance 会清理这些旧产物。
## 快速判断规则
- 用户只发一个 id 时,先查 `history/<id>/state.json` 是否存在;不存在再扫 `state.json/context.json/logs`
- 请求失败时,先看 `state.last_provider_call``context.json.items` 的错误 metadata、`logs/app.log`;不要找 `turns/<n>/summary.json`
- pending / 工具不收口时,先看同一 `turn_seq` 的 tool call 和 tool result entries,再对照协议上行 `exec_client_message` / `exec_client_control_message` / `interaction_response`
- prefix cache 异常时,先看 `context.json.items` 的稳定追加顺序和 `usage.json` 的 cache token 字段。
- 如果 history 与日志冲突,优先相信当前仍在更新的 `state.json/context.json`,再用日志解释运行时经过了哪条路径。
@@ -1,147 +0,0 @@
# 文件地图
## 已安装客户端 bundle
优先核对这些实际运行中的客户端文件:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/api/node/extensionHostProcess.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/gitWorker.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
当前安装包里 `cursor-agent` 已拆成 split bundle;旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 通常不存在。不要按旧路径下结论;需要先列出 `extensions/`,再确认实际存在的 `cursor-agent-exec``cursor-agent-worker``cursor-always-local` 及其 `dist/` 文件。
大致归属:
- `out/vs/workbench/workbench.desktop.main.js`:主 UI、agent window / titlebar、feature flag、用户可点击入口和只读/禁用态。
- `cursor-always-local/dist/main.js`:本地模式协议、`BidiAppend``RunSSE``AgentServerMessage` / `AgentClientMessage` 桥接。
- `cursor-agent-exec/dist/main.js` 与同目录数字 chunkagent 执行侧、SDK/canvas runtime、工具执行、proto 消息定义与拆分 chunk。当前构建中 `411.js` 可能包含 agent 执行链路关键片段,但 chunk 编号不是稳定接口,先用 `dist/*.js` 搜索。
- `cursor-agent-worker/dist/main.js`agent worker 侧后台逻辑。
用户机器上可能还存在其它 app 副本,例如:
- `~/Applications/Cursor Hooked.app`
- `/Applications/Cursor Patched.app`
不要假设哪一个在跑,先看进程路径。
## 当前 backend/store 与 history
当前用户机器上的固定助手目录:
- `~/.cursor-local-assistant-v2/`
重点看:
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/usage.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/context.json`
- `~/.cursor-local-assistant-v2/history/<conversationId>/conversation.lock`
- `~/.cursor-local-assistant-v2/logs/app.log`
其中:
- `state.json` 是会话元数据、loop 状态、latest provider/request prefix、当前 todos/plans、token/compaction 状态。
- `context.json.items` 是 append-only 语义历史,也是 prompt replay 的事实源。
- `usage.json` 是全局 provider call / turn usage 聚合。
- `conversation.lock` 是会话级文件锁。
- checkpoint 只表示同一 backend 进程内的 live state,不是持久化恢复事实源。
- legacy artifacts`conversation.json``entries.jsonl``turns/``request.json``summary.json``sse.jsonl``replay.json``runtime.json``latest.json`、数字 turn 目录,当前会被 history maintenance 清理。
这些内容的生成入口主要在:
- `internal/appdata/paths.go`
- `internal/backend/host.go`
- `internal/backend/README.md`
- `internal/backend/forwarder/file_store.go`
- `internal/backend/forwarder/history_maintenance.go`
- `internal/backend/forwarder/usage_store.go`
- `internal/backend/forwarder/token_usage.go`
- `internal/backend/forwarder/artifacts.go`
## 本仓库协议与本地模式实现
协议定义:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `proto/from_extensions/agent_v1.proto`
- `proto/from_extensions/aiserver_v1.proto`
扩展快照与提取:
- `proto/extensions-cursor-app/cursor-always-local/package.json`
- `proto/extract_extensions_proto.sh`
- `proto/ext_tool/main.go`
本地后端入口:
- `internal/backend/host.go`
- `internal/backend/server/route.go`
- `internal/backend/server/policy.go`
- `internal/backend/server/local.go`
- `internal/backend/server/config/types.go`
- `internal/backend/server/config/manager.go`
- `internal/backend/server/config/resolver.go`
forwarder 主链路:
- `internal/backend/forwarder/module.go`
- `internal/backend/forwarder/service.go`
- `internal/backend/forwarder/actor.go`
- `internal/backend/forwarder/broker.go`
- `internal/backend/forwarder/events.go`
- `internal/backend/forwarder/compiler.go`
- `internal/backend/forwarder/projector.go`
- `internal/backend/forwarder/provider.go`
- `internal/backend/forwarder/checkpoint_memory.go`
- `internal/backend/forwarder/runtime_summary.go`
协议解码:
- `internal/backend/agent/protocol/inbound.go`
执行桥 / 交互桥:
- `internal/backend/agent/bridge/exec/bridge.go`
- `internal/backend/agent/bridge/interaction/bridge.go`
模型适配:
- `internal/backend/agent/model/router.go`
- `internal/backend/agent/model/openai.go`
- `internal/backend/agent/model/anthropic.go`
- `internal/backend/agent/model/artifacts.go`
- `internal/backend/agent/model/http_error.go`
- `internal/backend/agent/model/tool_call_id.go`
- `internal/modelchannel/identity.go`
- `internal/runtime/local_runtime.go`
Prompt / replay
- `internal/backend/agent/prompt/engine.go`
- `internal/backend/agent/prompt/replay.go`
- `internal/backend/agent/prompt/content_parts.go`
- `internal/backend/forwarder/prompt_context.go`
- `internal/backend/forwarder/request_context.go`
- `internal/backend/forwarder/reminders.go`
- `internal/backend/forwarder/prompt_guard.go`
## 构建相关参考(只读)
仓库内已有 macOS 构建与签名相关文件,可用于理解产物结构或历史处理方式,但不要把它们当成修改已安装 Cursor 客户端的操作指南:
- `Taskfile.yml`
- `build/darwin/Taskfile.yml`
- `build/dmg-extras/提示损坏?点我.command`
重点看:
- `build/darwin/Taskfile.yml` 中的 `codesign:adhoc`
- `build/dmg-extras/提示损坏?点我.command` 中的 `xattr -cr`
@@ -1,87 +0,0 @@
# 已安装客户端只读核对与验证
首要原则:
- 不要修改已安装的 Cursor 客户端代码、bundle、签名或 app 副本。
- 允许且推荐读取、搜索、比对和分析客户端 bundle、日志、端口和 history 状态。
- 目标是定位差异、收集证据、判断问题归属,而不是 patch 客户端。
## 1. 先确认实际运行的 app 副本
优先用非交互命令核对:
```bash
pgrep -fal 'Cursor Hooked|Cursor Patched|/Contents/MacOS/Cursor'
ps -axo pid,ppid,command | rg 'Cursor(.app)?/Contents/MacOS/Cursor|extension-host'
```
不要在没确认实际运行副本前就下结论,也不要修改客户端文件。
## 2. 只读定位目标 bundle 与关键文件
优先定位并读取这些文件,而不是改写它们:
```bash
ls -l "/absolute/path/Target.app/Contents/Resources/app/extensions"
shasum -a 256 "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
```
重点关注:
- `out/vs/workbench/workbench.desktop.main.js`
- `out/vs/workbench/api/node/extensionHostProcess.js`
- `cursor-always-local/dist/main.js`
- `cursor-always-local/dist/gitWorker.js`
- `cursor-agent-exec/dist/main.js`
- `cursor-agent-exec/dist/*.js`
- `cursor-agent-worker/dist/main.js`
当前安装包里旧路径 `cursor-agent/dist/main.js` 通常不存在;先确认 `extensions/` 里的实际扩展名和 `dist/` 文件,再选择 `workbench` / `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 对应排查。
## 3. 只读读取 bundle 内容
常用定位关键词:
```bash
rg -n 'BidiTransport|ExecClientMessage|InteractionResponse|conversation_checkpoint_update' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js"
rg -n 'CursorAgentProvider|AnthropicProxy|ANTHROPIC_BASE_URL|InteractionUpdate|checkpoint|agent window' "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js"
rg -n 'agent window|open_agent_window|NameAgent|UpdateConversationMetadata|shouldShowAgentWindowTitleHelperText' "/absolute/path/Target.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js" "/absolute/path/Target.app/Contents/Resources/app/extensions/cursor-agent-exec/dist"/*.js
```
读取具体文件内容时,优先用读取工具按需查看相关片段,不要修改 bundle。
如果需要和仓库实现对照,优先同时打开:
- `proto/agent_v1.proto`
- `proto/aiserver_v1.proto`
- `internal/backend/...`
- `internal/runtime/local_runtime.go`
## 4. 验证行为是否命中目标副本
至少做其中两项:
- 进程路径是否是目标 app
- 目标扩展 host 是否起来
- 本地监听端口是否存在
- `~/.cursor-local-assistant-v2/logs/app.log` 是否更新
- `~/.cursor-local-assistant-v2/history/<conversationId>/state.json` / `context.json` 是否更新
- 请求/协议事件是否真的经过你正在分析的 bundle 文件
常用验证:
```bash
pgrep -fal '/absolute/path/Target.app/Contents/MacOS/Cursor'
lsof -nP -iTCP -sTCP:LISTEN | rg 'Cursor|127.0.0.1'
```
## 5. 记录证据并输出归因
如果确认“已安装 app 行为”和“仓库代码理解”存在差异,优先记录:
1. 实际运行的 app 路径
2. 命中的 bundle 文件路径与关键符号
3. 对应日志、端口、`history/state.json``history/context.json``usage.json` 证据
4. 仓库里对应实现的位置
如果结论指向客户端侧,也停留在分析和归因,不要继续 patch、重签名、替换文件或做写入式验证。
@@ -1,153 +0,0 @@
# Provider replay / debug
当现象是 provider 返回错误、SSE 里只有 `event: error`、需要确认最终出站 provider body 是否能被独立复现时,优先读这份参考。
这套流程只用于还原“后端最终发给 provider 的请求形状”和“provider 对该请求的真实响应”。它不是语义 history,也不是客户端输入事实源。
## 证据边界
- `history/<conversationId>/debug/provider.jsonl`
- 最接近 provider 出站边界。
- `event=llm_request``payload.body` 是最终 provider request body。
- 用它做 curl replay,判断问题是否已经出现在出站请求形状。
- `history/<conversationId>/state.json`
- 当前状态和最近 provider 摘要。
- 重点看 `latest_request_prefix``last_provider_call`
- `history/<conversationId>/context.json`
- replayable 语义历史。
- 用来解释为什么会形成这次 prompt,不用来直接重放 provider HTTP 请求。
- `history/<conversationId>/debug/bidi.raw.jsonl`
- 客户端原始上行字节证据。
- `history/<conversationId>/debug/bidi.decoded.jsonl`
- 当前 known-schema 解码后的客户端上行证据。
- `history/<conversationId>/debug/runtime.jsonl`
- 后端把哪些字段挂到 active request / stream 上。
- `history/<conversationId>/debug/runsse.jsonl`
- 后端尝试发回客户端的消息。
不要把这些证据混用:provider replay 只能证明最终 provider HTTP 请求与响应,不能单独证明客户端原始上传了什么,也不能替代 `context.json` 的语义历史。
## 前置条件
- 请求发生时 `config.yaml``log: true`,或已有 `history/<conversationId>/debug/provider.jsonl`
- 已经通过 id 反查拿到:
- `conversationId`
- `requestId`
- `modelCallId`
- 已经确认要测的是 Anthropic-compatible `/v1/messages` 请求。
如果没有 debug 文件,先回到 `state.json``context.json``usage.json``logs/app.log` 做推断,并明确“没有直接 provider body 证据”。
## 最小流程
1. 定位 provider debug 文件:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
CONV="<conversationId>"
REQ="<requestId>"
MODEL_CALL="<modelCallId>"
REQUEST_LOG="$ROOT/history/$CONV/debug/provider.jsonl"
```
2. 确认 `llm_request` 存在:
```bash
jq -c --arg req "$REQ" --arg mc "$MODEL_CALL" '
select(.event == "llm_request" and .request_id == $req and .model_call_id == $mc)
| {at, conversation_id, request_id, model_call_id, provider: .payload.provider, model: .payload.body.model}
' "$REQUEST_LOG"
```
3. 执行通用重放脚本:
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
CHANNEL_NAME="GLM" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
也可以直接传入 provider 配置,避免读取 `config.yaml`
```bash
REQUEST_LOG="$REQUEST_LOG" \
REQUEST_ID="$REQ" \
MODEL_CALL_ID="$MODEL_CALL" \
BASE_URL="<provider-base-url>" \
API_KEY="<provider-api-key>" \
OUT_DIR="/tmp/cursor-provider-replay-$REQ" \
.agents/skills/cursor-client-e2e-debugging/scripts/provider-replay.sh
```
4. 查看产物:
- `request.body.json`:抽取出的最终 provider body。
- `response.headers`HTTP 响应头。
- `response.sse`SSE 响应体。
- `replay.meta.json`:本次重放引用的 id 和 provider log 路径。
## 结果判断
- `curl_exit_code != 0`
- 网络、TLS、超时、连接或本机 curl 问题。
- 先看 stderr、`response.headers` 是否存在,再判断是否真的到达 provider。
- HTTP 非 2xx
- provider 网关或鉴权层拒绝。
- 优先看 `response.headers` 和 provider 错误体。
- HTTP 2xx 但 SSE 中有 `event: error`
- provider 已接受连接,但认为请求参数不合法或模型侧拒绝。
- 这种情况下重点比对 `request.body.json` 的消息结构、tool schema、thinking/reasoning 参数、model 名称和 endpoint 兼容性。
- SSE 正常流式输出
- 原始 provider 请求形状基本可用。
- 如果客户端仍失败,回到 `runsse.jsonl`、forwarder 状态机或客户端协议层继续查。
## 常见收敛方向
provider 参数错误时,优先检查:
- `model` 是否是目标 endpoint 支持的名称。
- `messages` 是否符合 Anthropic-compatible 形态。
- `system` 是否被目标 provider 支持,或需要改成 message。
- `tools` / `tool_choice` 是否符合目标 provider 方言。
- `thinking` / `reasoning` 字段是否被目标 provider 支持。
- 图片、文件、cache_control、metadata 等扩展字段是否超出 provider 兼容范围。
- `max_tokens``temperature``top_p``stop_sequences` 是否落在 provider 允许范围内。
## 敏感信息规则
- 不把 API key 写进 skill、reference、脚本默认值或提交内容。
- 回复用户时不要粘贴完整 API key;最多说明“已使用用户提供的 key / config 中的 key”。
- 不把完整 `request.body.json` 大段贴给用户;只摘和结论相关的字段形状。
- 不把一次性 `conversationId/requestId/modelCallId` 写进 skill 文档。
- 临时 replay 产物默认放 `/tmp`;如果需要保留,明确说明路径和原因。
## 脚本参数
`scripts/provider-replay.sh` 使用环境变量控制:
- 必填:
- `REQUEST_LOG`
- `REQUEST_ID`
- `MODEL_CALL_ID`
- 可选:
- `BASE_URL`
- `API_KEY`
- `GLM_BASE_URL`
- `GLM_API_KEY`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `CONFIG_FILE`,默认 `~/.cursor-local-assistant-v2/config.yaml`
- `CHANNEL_NAME`,默认 `GLM`
- `OUT_DIR`,默认 `/tmp/cursor-provider-replay-<requestId>`
- `MAX_TIME`,默认 `240`
- `ENDPOINT_PATH`,默认 `/v1/messages`
脚本输出四个稳定产物:
- `request.body.json`
- `response.headers`
- `response.sse`
- `replay.meta.json`
@@ -1,223 +0,0 @@
# 搜索词与判断树
## 先判断层级
### `history + logs` / id 反查层
当现象是“用户只给了一个 id”“需要判断它是 `conversationId``requestId``modelCallId``toolCallId`”“要从本地 history 和日志追状态”:
优先搜索:
- `state.json`
- `context.json`
- `usage.json`
- `logs/app.log`
- `conversation_id`
- `current_request_id`
- `request_id`
- `model_call_id`
- `tool_call_id`
- `latest_request_prefix`
- `last_provider_call`
- `current_loop_status`
- `context_version`
- `next_entry_seq`
- `next_turn_seq`
- `LoadConversation`
- `CreateConversation`
- `SaveConversationWithEntries`
- `AppendEntries`
- `UpdateConversationMeta`
- `ReplaceEntries`
- `ProjectPromptReplay`
- `UsageFileStore`
- `UpsertEvent`
- `LookupEvent`
不要再优先搜索或依赖:
- `data.sqlite`
- `protocol_traces`
- `agent_request_runs`
- `conversation.json`
- `entries.jsonl`
- `turns/<n>`
- `request.json`
- `sse.jsonl`
- `summary.json`
这些是旧实现或 legacy artifact 相关线索,只在排查迁移/清理逻辑时作为历史背景。
### `cursor-agent-exec` / `cursor-agent-worker` 层
当现象涉及 agent 主循环、模型桥接、`InteractionUpdate` 映射、工具 started/completed、session/provider 状态:
优先在已安装客户端 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-worker/dist/main.js`
优先搜索:
- `registerAgentProvider`
- `CursorAgentProvider`
- `CursorAgentProviderHandle`
- `ClaudeSDKClient`
- `streamInteractionUpdates`
- `handlePartialMessage`
- `AnthropicProxy`
- `getAnthropicProxyPort`
- `getAnthropicProxyAuthToken`
- `ANTHROPIC_BASE_URL`
- `ANTHROPIC_API_KEY`
- `InteractionUpdate`
- `checkpoint`
旧路径 `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent/dist/main.js` 可能不存在。先确认实际 `extensions/` 结构,再按 `cursor-agent-exec` / `cursor-agent-worker` / `cursor-always-local` 分层排查。
### agent window / conversation metadata UI 层
当现象涉及 agent window 标题、窗口信息、titlebar 按钮、是否可点击修改、会话名/metadata 更新:
优先在已安装客户端主 UI 和 split bundle 中搜索:
- `/Applications/Cursor.app/Contents/Resources/app/out/vs/workbench/workbench.desktop.main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-agent-exec/dist/*.js`
- `/Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js`
优先搜索:
- `shouldShowAgentWindowTitleHelperText`
- `glass_open_agents_titlebar_button`
- `open_agent_window_top`
- `open_agent_window_bottom_convo`
- `glass.enable_open_agent_in_window`
- `NameAgentRequest`
- `NameAgentResponse`
- `UpdateConversationMetadataRequest`
- `UpdateConversationMetadataResponse`
- `CreateTranscriptOverviewRequest`
- `createTranscriptOverview`
- `updateConversationMetadata`
- `conversation_checkpoint_update`
判断规则:
- `ConversationStateStructure` / `conversation_checkpoint_update` 是 UI 同步快照,不应作为持久化修改入口。
- 如果客户端调用 `UpdateConversationMetadata` / `NameAgent`,要继续确认本地后端是否显式注册对应 `/agent.v1.AgentService/*` 路由;不能只看 proto message 存在。
- 本地模式修改会话名/metadata 时,应落到 `history/<conversationId>/state.json` 或等价持久化会话元数据,再 publish checkpoint 同步 UI。
### `cursor-always-local` / 协议层
当现象涉及本地模式、客户端没有回包、pending 不收口、同一 backend 进程内的 live checkpoint 重连错乱:
优先搜索:
- `BidiTransport`
- `startYieldingInputsToTheServer`
- `BidiAppend`
- `RunSSE`
- `AgentServerMessage`
- `AgentClientMessage`
- `ExecServerMessage`
- `ExecClientMessage`
- `ExecClientControlMessage`
- `InteractionQuery`
- `InteractionResponse`
- `conversation_checkpoint_update`
### 本仓库 forwarder 层
当现象涉及本地后端收发、provider 继续/暂停、exec/interaction 桥接、history 投影:
优先搜索:
- `handleRunIntent`
- `driveProvider`
- `startStreamActor`
- `streamCommandEnvelope`
- `handleToolInvocation`
- `handleExecResult`
- `handleExecControl`
- `publishCheckpoint`
- `CheckpointConversation`
- `snapshotCheckpointConversation`
- `appendConversationEntries`
- `OpenExec`
- `OpenQuery`
- `StartStream`
- `deriveConversationLoopState`
- `historyEntryToolCallID`
- `recordProviderUsage`
- `recordTurnUsage`
### provider / 模型适配层
当现象是 provider 400/500、thinking/reasoning、tool_call_id、OpenAI/Anthropic 请求形状、usage/cache 不对:
优先搜索:
- `StartStream`
- `StreamRequest`
- `ResolvedChannelID`
- `ResolvedChannelName`
- `ProviderModelID`
- `ThinkingEnabled`
- `buildAnthropicThinkingConfig`
- `normalizeAnthropicProviderMessages`
- `normalizeOpenAIProviderMessages`
- `normalizeOpenAIResponsesInput`
- `reasoning_content`
- `ReasoningContent`
- `ReasoningSignature`
- `RecordLLMRequest`
- `RecordLLMSummary`
- `http_error`
- `namespaceToolCallID`
## 快速判断规则
- 如果问题是“给你一个 id,让你先判断是什么 ID,再找日志”,先看 `history/<id>/state.json` 是否存在,再扫 `history/*/state.json``history/*/context.json``logs/app.log`
- 如果问题是“模型输出语义不对”,先看 `context.json.items``ProjectPromptReplay()` 的投影,再看 provider request normalization。
- 如果问题是“provider 报 400/参数错误”,先看模型适配层请求构造、`state.latest_request_prefix``state.last_provider_call``logs/app.log`
- 如果问题是“客户端没回某个工具结果 / pending 不收口”,先看 `cursor-always-local` 与 forwarder,同时核对同一 `turn_seq` 是否有 `tool_result` 或控制面错误 entry。
- 如果问题是“backend 重启后为什么 checkpoint 没法继续恢复 pending”,不要找磁盘 checkpointcheckpoint 是 live state,重启后的事实源是 `state.json + context.json`
- 如果问题是“为什么同一个 `modelID` 还能出现多个渠道”,先检查渠道 ID:规范化后 `baseURL + modelID + apiKey + displayName + openAIEndpoint` 的短 SHA-256resolver 仍兼容 legacy `baseURL + modelID + apiKey + displayName`
- 如果问题是“只想桥接到其他 LLM”,优先看模型桥接层,不要默认深入整套 local runtime。
- 如果问题是“已安装 app 行为和仓库代码不一致”,先核对实际运行 bundle,再做只读比对;不要 patch 客户端。
## 协议关键词
上行:
- `run_request`
- `exec_client_message`
- `exec_client_control_message`
- `interaction_response`
下行:
- `interaction_update`
- `exec_server_message`
- `exec_server_control_message`
- `interaction_query`
- `conversation_checkpoint_update`
如果只看到下行请求,没有对应上行结果或控制消息,优先排查:
- `exec_id`
- `id`
- `tool_call_id`
- `request_id`
- `model_call_id`
- pending 收口逻辑
如果用户给的是一个裸 id,不要直接把它当成 `request_id`。先同时查:
- `history/<id>/state.json`
- `history/*/state.json``current_request_id``latest_request_prefix``last_provider_call`
- `history/*/context.json``items[].request_id``items[].tool_call_id``items[].payload`
- `history/usage.json``event_index` / `recent_events`
- `logs/app.log`
@@ -1,33 +0,0 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(import.meta.url);
const skillRoot = path.resolve(path.dirname(scriptPath), "..");
const repoRoot = path.resolve(skillRoot, "../../..");
const args = ["run", "./scripts/historymetrics", ...process.argv.slice(2)];
const child = spawn("go", args, {
cwd: repoRoot,
stdio: "inherit",
});
child.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`cache-hit-rate.mjs failed: ${message}`);
process.exitCode = 1;
});
child.on("exit", (code, signal) => {
if (typeof code === "number") {
process.exitCode = code;
return;
}
if (signal) {
console.error(`cache-hit-rate.mjs terminated by signal: ${signal}`);
}
process.exitCode = 1;
});
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
REQUEST_LOG=<path/to/provider.jsonl> REQUEST_ID=<request-id> MODEL_CALL_ID=<model-call-id> \
[BASE_URL=<provider-base-url>] [API_KEY=<provider-api-key>] [CONFIG_FILE=<config.yaml>] \
[OUT_DIR=<output-dir>] [MAX_TIME=240] provider-replay.sh
Required:
REQUEST_LOG Path to history/<conversationId>/debug/provider.jsonl
REQUEST_ID Provider request_id to replay
MODEL_CALL_ID Provider model_call_id to replay
Optional:
BASE_URL Provider base URL. Falls back to GLM_BASE_URL or ANTHROPIC_BASE_URL.
API_KEY Provider API key. Falls back to ANTHROPIC_API_KEY or GLM_API_KEY.
CONFIG_FILE Defaults to ~/.cursor-local-assistant-v2/config.yaml.
CHANNEL_NAME Display name to read from config.yaml when BASE_URL/API_KEY is missing. Defaults to GLM.
OUT_DIR Output directory. Defaults to /tmp/cursor-provider-replay-<request-id>.
MAX_TIME curl max-time seconds. Defaults to 240.
ENDPOINT_PATH Provider path. Defaults to /v1/messages.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
REQUEST_LOG="${REQUEST_LOG:-}"
REQUEST_ID="${REQUEST_ID:-}"
MODEL_CALL_ID="${MODEL_CALL_ID:-}"
CONFIG_FILE="${CONFIG_FILE:-$HOME/.cursor-local-assistant-v2/config.yaml}"
CHANNEL_NAME="${CHANNEL_NAME:-GLM}"
BASE_URL="${BASE_URL:-${GLM_BASE_URL:-${ANTHROPIC_BASE_URL:-}}}"
API_KEY="${API_KEY:-${ANTHROPIC_API_KEY:-${GLM_API_KEY:-}}}"
ENDPOINT_PATH="${ENDPOINT_PATH:-/v1/messages}"
MAX_TIME="${MAX_TIME:-240}"
OUT_DIR="${OUT_DIR:-/tmp/cursor-provider-replay-${REQUEST_ID:-unknown}}"
BODY_FILE="$OUT_DIR/request.body.json"
RESP_FILE="$OUT_DIR/response.sse"
HEADER_FILE="$OUT_DIR/response.headers"
META_FILE="$OUT_DIR/replay.meta.json"
require_value() {
local name="$1"
local value="$2"
if [[ -z "$value" ]]; then
echo "缺少 $name。运行 --help 查看用法。" >&2
exit 2
fi
}
read_channel_config() {
local field="$1"
python3 - "$CONFIG_FILE" "$CHANNEL_NAME" "$field" <<'PY'
import sys
from pathlib import Path
config_path = Path(sys.argv[1]).expanduser()
channel_name = sys.argv[2]
field = sys.argv[3]
if not config_path.exists():
raise SystemExit
lines = config_path.read_text(encoding="utf-8").splitlines()
in_channel = False
for line in lines:
stripped = line.strip()
if stripped.startswith("- displayName:"):
in_channel = stripped.split(":", 1)[1].strip().strip('"') == channel_name
continue
if in_channel and stripped.startswith(field + ":"):
print(stripped.split(":", 1)[1].strip().strip('"'))
raise SystemExit
PY
}
require_value "REQUEST_LOG" "$REQUEST_LOG"
require_value "REQUEST_ID" "$REQUEST_ID"
require_value "MODEL_CALL_ID" "$MODEL_CALL_ID"
if [[ ! -f "$REQUEST_LOG" ]]; then
echo "REQUEST_LOG 不存在: $REQUEST_LOG" >&2
exit 2
fi
if [[ -z "$BASE_URL" ]]; then
BASE_URL="$(read_channel_config baseURL || true)"
fi
if [[ -z "$API_KEY" ]]; then
API_KEY="$(read_channel_config apiKey || true)"
fi
require_value "BASE_URL/GLM_BASE_URL/ANTHROPIC_BASE_URL 或 config[$CHANNEL_NAME].baseURL" "$BASE_URL"
require_value "API_KEY/ANTHROPIC_API_KEY/GLM_API_KEY 或 config[$CHANNEL_NAME].apiKey" "$API_KEY"
mkdir -p "$OUT_DIR"
: > "$HEADER_FILE"
: > "$RESP_FILE"
python3 - "$REQUEST_LOG" "$REQUEST_ID" "$MODEL_CALL_ID" "$BODY_FILE" "$META_FILE" <<'PY'
import json
import sys
from pathlib import Path
log_path = Path(sys.argv[1]).expanduser()
request_id = sys.argv[2]
model_call_id = sys.argv[3]
body_path = Path(sys.argv[4])
meta_path = Path(sys.argv[5])
body = None
meta = None
with log_path.open(encoding="utf-8") as f:
for raw in f:
if not raw.strip():
continue
row = json.loads(raw)
if row.get("event") != "llm_request":
continue
if row.get("request_id") != request_id:
continue
if row.get("model_call_id") != model_call_id:
continue
payload = row.get("payload") or {}
body = payload.get("body")
meta = {
"at": row.get("at"),
"event": row.get("event"),
"conversation_id": row.get("conversation_id"),
"request_id": row.get("request_id"),
"model_call_id": row.get("model_call_id"),
"provider_log": str(log_path),
}
break
if body is None:
raise SystemExit(f"未找到 llm_request: request_id={request_id} model_call_id={model_call_id}")
body_path.write_text(json.dumps(body, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
PY
url="${BASE_URL%/}${ENDPOINT_PATH}"
set +e
curl --no-buffer --silent --show-error \
--connect-timeout 30 \
--max-time "$MAX_TIME" \
--request POST "$url" \
--header "content-type: application/json" \
--header "anthropic-version: 2023-06-01" \
--header "User-Agent: claude-cli/1.0.25" \
--header "x-api-key: $API_KEY" \
--header "Authorization: Bearer $API_KEY" \
--data-binary "@$BODY_FILE" \
--dump-header "$HEADER_FILE" \
--output "$RESP_FILE"
code=$?
set -e
echo "curl_exit_code=$code"
echo "body=$BODY_FILE"
echo "headers=$HEADER_FILE"
echo "response=$RESP_FILE"
echo "meta=$META_FILE"
echo "--- response headers ---"
if [[ -f "$HEADER_FILE" ]]; then
python3 - "$HEADER_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:40]:
print(line)
PY
fi
echo "--- response first 120 lines ---"
if [[ -f "$RESP_FILE" ]]; then
python3 - "$RESP_FILE" <<'PY'
from pathlib import Path
import sys
for line in Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines()[:120]:
print(line)
PY
else
echo "响应文件不存在。"
fi
exit "$code"
-176
View File
@@ -1,176 +0,0 @@
---
name: cursor-debug-log
description: 当需要调查 Cursor 本地模式 debug/log 证据时使用:config.yaml 的 log 热加载、history/<conversationId>/debug JSONL 文件、Bidi 原始/解码记录、RunSSE 记录、runtime/provider debug 记录、debug 文件缺失原因,或解释这些 debug 文件如何生成与如何查询。
---
# Cursor Debug Log
使用这个技能来解释和检查本地 debug log 体系。目标是在不修改已安装 Cursor 客户端、不依赖旧版 legacy artifact 的前提下,还原一次请求附近发生了什么。
## 作用定位
debug log 是本地模式请求链路的可选证据层。它和模型可见历史是分开的:
- 用来回答“客户端到底发了什么”。
- 用来回答“后端解码后认为这是什么请求”。
- 用来回答“哪些字段被挂到了当前 active request 上”。
- 用来回答“最终 provider request body 是什么样”。
- 用来回答“RunSSE 实际给客户端发送了什么”。
- 不要把它当成 replay history、prompt 输入或状态事实源。
稳定事实源仍然是:
- `history/<conversationId>/state.json`
- `history/<conversationId>/context.json`
- `history/usage.json`
- `logs/app.log`
debug 文件是在这些事实源之外,补充原始或近原始链路证据。
## 固定路径
- 助手根目录:`~/.cursor-local-assistant-v2`
- 配置文件:`~/.cursor-local-assistant-v2/config.yaml`
- history 根目录:`~/.cursor-local-assistant-v2/history`
- app 日志:`~/.cursor-local-assistant-v2/logs/app.log`
- 会话 debug 目录:`history/<conversationId>/debug/`
- 孤儿 debug 目录:`history/_debug/orphan/<requestId>/`
通过配置开启 debug logging
```yaml
log: true
```
当前实现会用轻量文件快照检查热加载 `config.yaml`。改完 `log` 后,预留大约 500ms,再期待下一次请求事件使用新值。旧二进制可能仍然需要重启。
## 文件如何生成
debug 层随着请求穿过后端边界逐步落盘:
1. `BidiAppend` 收到客户端上行数据。
- 原始 hex 写入 `bidi.raw.jsonl`
- 解码后的 known-schema protobuf 与后端提取出的 intent 写入 `bidi.decoded.jsonl`
2. forwarder 把解码结果转成 active runtime state。
- stream/request 状态决策写入 `runtime.jsonl`
3. provider pass 被准备并执行。
- adapter 前的请求摘要、`model_call_id``provider_pass` 等写入 `provider.jsonl`
- provider artifact callback 追加最终 request/summary payload 到 `provider.jsonl`
4. `RunSSE` 把后端输出流式发送给客户端。
- 已发送消息、终态事件、发送错误、断连和 heartbeat 写入 `runsse.jsonl`
如果某条消息到达时后端还不知道 `conversationId`,早期事件可能写到 `_debug/orphan/<requestId>/`。后续一旦知道 `conversationId`,新事件应进入 `history/<conversationId>/debug/`。还原早期或乱序请求时,两处都要查。
## Debug 文件含义
`bidi.raw.jsonl`
- 方向:客户端到后端。
- 包含 `request_id`、可选 `conversation_id``append_seqno``status`、原始 `data_hex`
- 当需要精确确认客户端上传字节时先看它。
`bidi.decoded.jsonl`
- 方向:客户端到后端,protobuf 解码后。
- 当前 schema v2 包含完整的 known-schema `AgentClientMessage` protojson`message`
- 同时包含后端从上行包提取出的 intent:`intent`,其中会展开相关 proto 子对象,例如 `client_message``user_message``request_context``conversation_state`、exec/interaction/kv 回包等。
- 还包含 `message_case``requested_model``conversation_action` 等检索索引;这些索引只方便搜索,不是完整证据本体。
- 当需要确认后端如何理解客户端请求时看它。若要证明客户端原始上传字节,仍以 `bidi.raw.jsonl` 为准。
- 旧二进制或旧日志可能只有 schema v1 摘要,未必展开 `message``intent` 里的完整字段。
`runtime.jsonl`
- 方向:后端内部 runtime。
- 包含状态流转,以及挂到 active stream/request 上的字段。
- 当需要把 decoded input 和后续 provider 行为串起来时看它。
`provider.jsonl`
- 方向:后端到 provider adapter/provider。
- 包含 provider pass 元数据、`model_call_id`、request knobs、最终 provider request artifact、provider summary artifact。
- 当最终出站 provider body 或 provider summary 是关键证据时看它。
`runsse.jsonl`
- 方向:后端到客户端。
- 包含解码后的 `AgentServerMessage` 发送、终态事件、发送错误、断连和 heartbeat。
- 用来检查后端尝试返回给客户端的内容。它是解码后的消息证据,不是原始 HTTP/SSE framing。
## 查询流程
1. 先判断 id 类型。
- 先查 `history/<id>/state.json`,确认它是不是 `conversationId`
- 再在 `history/*/{state.json,context.json}``history/usage.json``logs/app.log` 里搜索 request/model-call/tool id。
2. 拿到 `conversationId` 后,列出 debug 目录。
- `ls -la "$HOME/.cursor-local-assistant-v2/history/<conversationId>/debug"`
3. 如果 debug 目录不存在,确认请求发生时 debug 是否已开启。
- 读取 `config.yaml`
- 对比 `config.yaml``state.json``context.json` 的 mtime。
- 搜索 `logs/app.log` 里的 config hot reload 或 provider start 记录。
4. 按时间顺序读 JSONL,并用这些字段串联:
- `request_id`
- `conversation_id`
- `model_call_id`
- `provider_pass`
- `append_seqno`
- event timestamp
5. 最终回复只总结结论所需字段。不要粘贴 secret、API key、完整 provider body 或大段原始 payload。
常用命令:
```bash
ROOT="$HOME/.cursor-local-assistant-v2"
REQ="<requestId>"
CONV="<conversationId>"
rg -n "$REQ" "$ROOT/history" "$ROOT/logs/app.log"
find "$ROOT/history" -path "*/debug/*" -type f | sort
rg -n "$REQ|model_call_id|provider_request_prepared|llm_request" "$ROOT/history/$CONV/debug"
```
紧凑查看 JSONL
```bash
jq -c 'select(.request_id == "<requestId>")' "$ROOT/history/$CONV/debug/provider.jsonl"
jq -c 'select(.request_id == "<requestId>") | {append_seqno, message_case, conversation_action, message, intent}' "$ROOT/history/$CONV/debug/bidi.decoded.jsonl"
```
## 证据怎么用
根据问题选择对应文件:
- 客户端原始上行问题:先看 `bidi.raw.jsonl`。这是精确原始包证据。
- 客户端 known-schema 字段问题:看 `bidi.decoded.jsonl``message`。例如 `user_message.message_id`、selected image、conversation state bytes 等字段是否在解码结果里。
- 后端如何理解请求:看 `bidi.decoded.jsonl``intent`,再接 `runtime.jsonl`
- provider request 问题:看 `provider.jsonl`,尤其是 `llm_request`
- UI/流式输出问题:看 `runsse.jsonl`
- 请求状态问题:先看 `state.json``context.json``usage.json`,再用 debug 文件补证。
- debug 缺失问题:看 `config.yaml`、mtime、app log、orphan debug 目录。
runtime model parameters,例如 thinking strength,只是 provider request 证据的一类例子:
- `bidi.raw.jsonl` 说明客户端原始上传了什么。
- `bidi.decoded.jsonl.message` 说明上行包按当前 known schema 解码出了什么。
- `bidi.decoded.jsonl.intent` 说明后端从 decoded input 里提取并准备使用了什么。
- `runtime.jsonl` 说明后端把什么挂到了请求状态上。
- `provider.jsonl` 说明最终为 provider 准备了什么。
只有普通 history 时不要过度断言。例如 `context.json` 里的 `reasoning_content` 能说明产生过 reasoning 文本,但不能单独证明是哪一个 runtime parameter value 导致的。
注意证据边界:
- `bidi.decoded.jsonl` 使用当前已知 proto schema 做解码。未知字段或原始 framing 差异不能靠 decoded 证明,必须回到 `bidi.raw.jsonl`
- `context.json` 仍是持久化历史事实源;debug 文件只能证明某次请求链路附近发生过什么。
- `provider.jsonl` 的 provider body 和 `bidi.raw.jsonl` / `bidi.decoded.jsonl` 都可能很大,回复用户时只摘必要字段,不粘贴完整图片、完整 body 或 secret。
## Debug 文件缺失
如果某个 request 没有 debug 文件,要明确说明“没有直接 debug 证据”。常见原因:
- 请求发生时 `log: false`
- 正在运行的二进制版本早于 debug logging 或 hot reload 实现。
- 事件发生时还没有解析到 conversation id,记录在 `_debug/orphan/<requestId>/`
- 请求在开启 `log` 前已经完成。
- 写文件失败;如果该版本有相关记录,app log 里可能有 warning。
debug 证据缺失时,回退到 `state.json``context.json``usage.json``logs/app.log`,并把结论标成推断,而不是直接证明。
-42
View File
@@ -1,42 +0,0 @@
---
name: i18n-requirements
description: Use when adding or changing frontend UI text, locale support, translation JSON, the static i18n scanner, language selection, or native tray labels in this repository; keeps source messages, generated catalogs, translations, and runtime locale registration consistent.
---
# I18n Requirements
## Source Messages
- Treat `zh-CN` as the only source locale.
- Write user-visible frontend text as Chinese source literals in scanned files under `frontend/src/`.
- Do not branch on locale or hard-code English, Japanese, Russian, or other translated UI text in components or state modules.
- Let `frontend/plugins/static-i18n-plugin.js` replace source literals with runtime helpers. Do not hand-write generated message IDs in application code.
- Keep internal matching tokens out of the catalog. Use a regex for Chinese protocol/error matching instead of a user-visible string literal when the text is not intended for display.
- Do not place ordinary user-visible source messages under `frontend/src/i18n/`; the scanner excludes that directory. Native language names in `LOCALE_OPTIONS` are an intentional exception.
## Generated Catalogs
- Treat `frontend/src/i18n/generated/catalog.json` and the source-locale entries as scanner output. Do not manually edit catalog references or message IDs.
- Run `npm run build` from `frontend/` after changing UI text. The build must run with `--scan` and update every locale file.
- Preserve every placeholder exactly across locales, including `{0}`, `{1}`, newlines, and formula fragments such as `${1}`.
- Provide a non-empty translation for every catalog key in every non-source locale. Do not rely on the Chinese fallback for completed locale support.
## Adding A Locale
Update all of these integration points together:
- `SUPPORTED_LOCALES` in `frontend/plugins/static-i18n-plugin.js`.
- `SUPPORTED_LOCALES` and `LOCALE_OPTIONS` in `frontend/src/i18n/config.js`.
- The locale JSON import, `localeMessages`, and primary-language mapping in `frontend/src/i18n/runtime.js`.
- `frontend/src/i18n/locales/<locale>.json` with the complete catalog key set.
- Native tray labels in `internal/app/runner.go`.
## Verification
After the scan build:
1. Confirm `npm run build` succeeds.
2. Confirm every locale JSON has the same keys as `catalog.json`.
3. Confirm non-source locale files contain no empty values.
4. Confirm translated placeholders match the source entry placeholders.
5. Run the build twice when scanner behavior changed and confirm generated files are stable.
@@ -1,4 +0,0 @@
interface:
display_name: "I18n Requirements"
short_description: "Keep UI translations and generated catalogs in sync"
default_prompt: "Use $i18n-requirements to update localized UI text safely."
@@ -1,35 +0,0 @@
---
name: prefix-cache-stability
description: Use when changing prompt compilation, history replay, persisted conversation state, model request construction, or dynamic reminders in this repo; protects prefix-cache hit rate by keeping model-visible history append-only and dynamic attention scoped to the latest request.
---
# Prefix Cache Stability
Use this skill before editing prompt, history replay, persisted conversation state, or provider request code.
## Hard Constraints
- Model-visible history is append-only. If a message was sent to the model and is meant to remain historical context, persist it and replay it at the same relative position.
- Do not move previously sent model-visible messages to a new position in later requests.
- Keep the largest stable prefix first: system prompt, imported replay, persisted user/request/tool history, then current-turn suffix context.
- Truly dynamic attention is latest-only. Current state blocks, latest edit guards, and other volatile reminders should be appended near the end of the current request and should not become long-lived prefix content unless they are intentionally persisted as historical facts.
- Persisted prompt context must be worded so it is safe as history. Avoid stale wording like "currently" unless the context is only latest-only.
- Never optimize cache by dropping correctness-critical context.
- Never remove, strip, reorder, or suppress historical `reasoning_content` replay merely to reduce repetitive thinking. Some providers need prior reasoning for valid continuation; optimize the latest tool guidance or current-turn prompt behavior instead.
## Implementation Pattern
1. Classify each prompt addition:
- Stable system policy: belongs in the fixed system prompt.
- Historical model-visible context: persist as replayable history.
- Latest-only attention: append as current suffix, do not persist.
2. For persisted context, store enough metadata to dedupe the same turn, usually `source` plus a content hash.
3. Replay persisted context from history/projector, not by regenerating and inserting it into old positions.
4. On provider retries or same-turn follow-up passes, do not duplicate an already persisted prompt context.
5. Persisted conversation state must include replayable prompt context so a restarted conversation preserves the same prefix. In this repo, use `context.json.items` for replayable semantic history and `state.json` for mutable latest state.
## Verification
- Compare adjacent provider request artifacts or captured canonical request bodies and compute the longest common prefix.
- Check final raw SSE usage fields before blaming local metrics. Some OpenAI-compatible providers do not return cached-token fields.
- A healthy change should make old request prefixes stable while allowing only the newest suffix to vary.
-9
View File
@@ -1,9 +0,0 @@
*
!go.mod
!go.sum
!cursor-tab-server/
!cursor-tab-server/**
!gen/
!gen/**
!internal/
!internal/**
-12
View File
@@ -1,12 +0,0 @@
## 变更说明 / What
<!-- 简要描述这个 PR 做了什么、为什么 / Briefly describe what this PR does and why -->
## 关联 Issue / Related Issue
<!-- 例如 / e.g. Closes #161 -->
## 测试方式 / How to Test
<!-- 描述如何验证这个变更 / Describe how to verify this change -->
+7 -8
View File
@@ -1,4 +1,5 @@
claude-server.tar
.DS_Store
dist
.task
bin
@@ -8,7 +9,12 @@ logs.zip
frontend/bindings
dist
node_modules
*.tsbuildinfo
cursor-server.tar
/cursor-server/target/
/cursor-server/*.db
/cursor-server/*.db-shm
/cursor-server/*.db-wal
server-node/cursor.tar
server-go/cursor.tar
server-go/log/
@@ -16,12 +22,5 @@ server-go/log/
.cursor-local-assistant-v2
.cursor-app-formatted/
proto/extensions-cursor-app/
ads-server-linux-amd64.tar
cmd/ads-server/*.db
cmd/ads-server/*.db-*
cmd/ads-server/*.sqlite
cmd/ads-server/*.sqlite-*
cmd/ads-server/data/
cmd/ads-server/ads-server
cmd/ads-server/ads-server-linux-amd64.tar
cursor-tab-server/cursor-tab-server-linux-amd64.tar
/cursor-proto/proto/
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
- Do not preserve backward compatibility. Remove obsolete paths instead of adding compatibility layers, fallbacks, or migrations.
- Choose the simplest implementation that fully meets the current requirements. Avoid speculative abstractions, configuration, and indirection.
- Grow the system in layers. Start from the smallest version that works end to end, and add each new capability on top of a product that already works. Never trade a working product for unfinished complexity.
- Keep components modular and concerns clearly separated.
- Prefer established, well-maintained libraries when they reduce overall complexity or improve reliability. Do not reimplement common functionality without a clear reason.
- Lean on the dependencies already in the project before writing your own implementation or adding packages. Do not assume a library lacks a capability without checking its documentation and types.
- Make architectural decisions for the long term. Do not accept a stopgap that only works for now and is meant to be replaced later.
-90
View File
@@ -1,90 +0,0 @@
# 贡献指南
> English version: [CONTRIBUTING_EN.md](./CONTRIBUTING_EN.md)
感谢你考虑为 cursor-byok 做出贡献!
## 开发环境
| 依赖 | 版本要求 |
|------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Linux 额外依赖:`libgtk-3-dev``libwebkit2gtk-4.1-dev`Wails 运行时需要)。
## 快速开始
```bash
# 安装前端依赖
cd frontend && yarn install --frozen-lockfile && cd ..
# 启动开发模式(热重载)
task dev
# 构建当前平台分发包
task build
```
## 项目结构
```
├── main.go # 入口
├── internal/ # Go 后端(代理、转发、客户端管理等)
├── frontend/ # Vue 3 + Vite + Tailwind 前端
│ ├── src/
│ │ ├── views/ # 页面
│ │ ├── components/ # 组件
│ │ ├── i18n/ # 国际化(zh-CN / en-US / ja-JP / ru-RU
│ │ └── state/ # 全局状态
│ └── plugins/ # Vite 插件(i18n 静态扫描等)
├── prompt/ # 内置 Agent prompt 模板
├── proto/ # Protobuf 定义
├── build/ # 构建配置与平台 Taskfile
├── scripts/ # 辅助脚本(release、metrics
└── Taskfile.yml # 顶层任务编排
```
## 开发规范
### 提交信息
采用 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 风格:
```
feat(proxy): 支持自定义 upstream 超时
fix(i18n): 补全日语翻译缺失 key
release: 0.0.42
```
### 代码风格
- Go:遵循 `gofmt` / `go vet`,不引入额外 linter 配置。
- 前端:Vue SFC + Composition APITailwind 工具类优先。
- 新增 UI 文案必须同步更新所有 locale 文件(`frontend/src/i18n/locales/`)。
### 分支与 PR
1.`main` 创建功能分支:`feat/xxx``fix/xxx`
2. 保持 PR 小而聚焦,一个 PR 解决一个问题。
3. PR 描述中说明动机和测试方式。
## 构建与发布
```bash
# 构建全平台(仅 macOS 主机)
task build:all
# 准备发布资产
task release:prepare
# 发布到 GitHub Releases
task release:github
```
## 许可证
提交代码即表示你同意以 [MIT License](./LICENSE) 授权你的贡献。
-90
View File
@@ -1,90 +0,0 @@
# Contributing Guide
> 中文版本:[CONTRIBUTING.md](./CONTRIBUTING.md)
Thank you for considering contributing to cursor-byok!
## Prerequisites
| Dependency | Version |
|------------|---------|
| Go | >= 1.25 |
| Node.js | >= 20 |
| Yarn | 1.x (classic) |
| [Task](https://taskfile.dev) | >= 3 |
| [Wails v3 CLI](https://v3alpha.wails.dev) | alpha.74+ |
Additional Linux dependencies: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev` (required by Wails runtime).
## Quick Start
```bash
# Install frontend dependencies
cd frontend && yarn install --frozen-lockfile && cd ..
# Start dev mode (hot reload)
task dev
# Build for current platform
task build
```
## Project Structure
```
├── main.go # Entry point
├── internal/ # Go backend (proxy, forwarding, client management)
├── frontend/ # Vue 3 + Vite + Tailwind frontend
│ ├── src/
│ │ ├── views/ # Pages
│ │ ├── components/ # Components
│ │ ├── i18n/ # Internationalization (zh-CN / en-US / ja-JP / ru-RU)
│ │ └── state/ # Global state
│ └── plugins/ # Vite plugins (i18n static scanner, etc.)
├── prompt/ # Built-in agent prompt templates
├── proto/ # Protobuf definitions
├── build/ # Build configs & platform Taskfiles
├── scripts/ # Helper scripts (release, metrics)
└── Taskfile.yml # Top-level task orchestration
```
## Development Guidelines
### Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(proxy): support custom upstream timeout
fix(i18n): add missing Japanese translation keys
release: 0.0.42
```
### Code Style
- Go: follow `gofmt` / `go vet`; no additional linter config.
- Frontend: Vue SFC + Composition API, Tailwind utility-first.
- New UI strings must be added to ALL locale files (`frontend/src/i18n/locales/`).
### Branching & PRs
1. Create feature branches from `main`: `feat/xxx`, `fix/xxx`.
2. Keep PRs small and focused — one problem per PR.
3. Describe motivation and how to test in the PR description.
## Build & Release
```bash
# Build all platforms (macOS host only)
task build:all
# Prepare release assets
task release:prepare
# Publish to GitHub Releases
task release:github
```
## License
By contributing, you agree that your contributions will be licensed under the [MIT License](./LICENSE).
File diff suppressed because it is too large Load Diff
-95
View File
@@ -1,95 +0,0 @@
<div align="center">
# cursor-byok
[使用教程](https://docs.leokun.cn) · [下载最新版](https://github.com/leookun/cursor-byok/releases/latest) · [问题反馈](https://github.com/leookun/cursor-byok/issues) · [English](./README.md)
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
</div>
![cursor-byok 支持接入多种模型 API](./images/cn-brand.png)
![cursor-byok 主界面](./images/cn-home.png)
## 项目介绍
cursor-byok 是一个开源的 Cursor 本地模型接入工具。它通过运行在本机的服务连接 Cursor 与你配置的模型 API,让模型请求使用自己的渠道处理,同时保留 Cursor Agent 的工具调用、Skills 和 MCP 等能力。
你可以接入 OpenAI、Anthropic 及其兼容服务,自由配置接口地址、模型、密钥和请求参数,不再局限于平台预设的模型渠道。
> [!IMPORTANT]
> cursor-byok 本身免费开源,但你接入的模型 API 可能由对应服务商收费。本项目不是 Cursor 官方产品,与 Cursor 或其开发公司无隶属关系。
## 核心能力
- **自定义模型渠道**:配置自己的 API 地址、访问密钥和模型标识。
- **多种接口协议**:支持 OpenAI、Anthropic 兼容接口及自定义端点。
- **模型管理**:添加、复制、编辑、排序和批量测试多个模型配置。
- **连接性能测试**:查看首字延迟、生成速度与模型服务的原始响应。
- **Agent 工作流**:支持工具调用、Skills、MCP 和多轮会话。
- **会话统计**:查看 Token 消耗、缓存命中率、对话轮次和价值估算。
- **跨平台运行**:支持 macOS、Windows 和 Linux。
## 快速开始
1. 从 [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest) 下载对应平台的最新版本。
2. 启动 cursor-byok,打开“模型配置”,填写接口地址、API Key 和模型标识。
3. 测试模型配置;测试通过后返回主界面启动服务。
4. 打开 Cursor,选择已配置的模型并开始使用 Agent。
更完整的安装、系统配置和常见问题说明,请查看 [详细使用教程](https://docs.leokun.cn)。
## 模型管理
模型配置支持 OpenAI 与 Anthropic 两类接口协议。每个模型渠道可以独立设置上下文窗口、最大输出 Token、推理强度、自定义请求头和额外请求参数。
![cursor-byok 模型配置](./images/cn-model.png)
## 工作原理
```text
Cursor 客户端
│ Agent 请求与工具结果
cursor-byok 本地服务
│ OpenAI / Anthropic 兼容请求
你配置的模型 API
```
cursor-byok 在本机负责协议适配、模型请求转发、工具调用衔接与会话状态管理。模型 API Key 和应用配置保存在本机;实际请求仍会发送到你所配置的模型服务商。
## 为什么做这个项目
很多 Agent 产品会将工具能力、模型选择、订阅方案和计费方式绑定在一起,用户只能使用平台提供的模型渠道。
我希望将模型选择权交还给用户:开发者可以充分利用已有的模型 API 和额度,自由选择适合自己的模型与服务商,也可以在需要时自托管相关服务。
## 路线图
项目将继续改进模型兼容性、Agent 工具链、本地运行稳定性和自托管体验,并探索更多 IDE、Chat 与 Agent 场景。
详细计划与进展请查看 [正式版路线图](https://github.com/leookun/cursor-byok/discussions/32)。
## 社区与支持
- [使用教程](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram 交流群](https://t.me/cursor_byok)
- QQ 交流群:`1095916242``1094411438``1095918002``1094419321`
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## 开发与贡献
欢迎提交 Issue 和 Pull Request。开发环境、构建命令、项目结构及提交规范请阅读 [贡献指南](./CONTRIBUTING.md)。
## 许可证
本项目基于 [MIT License](./LICENSE) 开源。
+67 -75
View File
@@ -1,95 +1,87 @@
<div align="center">
# cursor-byok
[User Guide](https://docs.leokun.cn) · [Latest Release](https://github.com/leookun/cursor-byok/releases/latest) · [Report an Issue](https://github.com/leookun/cursor-byok/issues) · [简体中文](./README-CN.md)
`cursor-byok` 是一个基于真实 Cursor Agent 流量与 protobuf 实现的自托管服务端,用于把 Cursor 客户端接入用户指定的 LLM Provider。
[![Release](https://img.shields.io/github/v/release/leookun/cursor-byok?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/leookun/cursor-byok/total?style=flat-square)](https://github.com/leookun/cursor-byok/releases)
[![License](https://img.shields.io/github/license/leookun/cursor-byok?style=flat-square)](./LICENSE)
[![Platforms](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey?style=flat-square)](https://github.com/leookun/cursor-byok/releases/latest)
当前 Rust 服务 `cursor-server` 已实现:
</div>
- Cursor `RunSSE + BidiAppend` 双向协议与 Connect envelope。
- OpenAI Chat、OpenAI Responses、Anthropic 三种无状态流式端点。
- `LLM → 客户端工具 → 结果提交 → 下一轮 LLM` 的通用 Loop。
- append-only canonical messages、不可变 revision 分支和同对话 Run 抢占。
- Cursor typed tool UI、Exec/Interaction、多阶段编辑、MCP 与子代理。
- Blob CAS、KV GET/SET ACK、两阶段 checkpoint 和 pending ToolRound 恢复。
- 未匹配 Cursor backend 路由原样流式转发到上游。
- React + Vite + TypeScript + Tailwind 管理台,支持 Provider 配置、模型发现和调用明细。
- 每次 Provider 调用的时间、模型快照、状态和 authoritative usage;详细模式保存脱敏请求与原始流响应。
![Connect cursor-byok to a wide range of model APIs](./images/en-brand.png)
启动方式见 [cursor-server/README.md](./cursor-server/README.md)。协议证据见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md),当前实现约束见 [一次性重构计划计划.md](./docs/一次性重构计划计划.md)
![cursor-byok dashboard](./images/en-home.png)
## About
cursor-byok is an open-source local model gateway for Cursor. It runs a service on your machine that connects Cursor to the model APIs you configure, routes model requests through your own providers, and preserves Cursor Agent capabilities such as tool calling, Skills, and MCP.
You can connect OpenAI- and Anthropic-compatible services, customize endpoints, model IDs, API keys, and request parameters, and use model channels beyond the options built into the platform.
> [!IMPORTANT]
> cursor-byok is free and open source, but the model APIs you connect may charge for usage. This is an independent project and is not affiliated with or endorsed by Cursor or its developers.
## Features
- **Bring your own model channels:** Configure your own API endpoint, credentials, and model IDs.
- **Multiple API protocols:** Use OpenAI- and Anthropic-compatible APIs or a custom endpoint.
- **Model management:** Add, duplicate, edit, reorder, and batch-test multiple model configurations.
- **Connection benchmarks:** Measure time to first token, generation speed, and inspect raw provider responses.
- **Agent workflows:** Keep tool calling, Skills, MCP, and multi-turn conversations available.
- **Session metrics:** Track token usage, cache hit rate, conversation turns, and estimated value.
- **Cross-platform:** Run on macOS, Windows, and Linux.
## Quick Start
1. Download the latest build for your platform from [GitHub Releases](https://github.com/leookun/cursor-byok/releases/latest).
2. Launch cursor-byok, open **Model Settings**, and enter the endpoint, API key, and model ID.
3. Test the model configuration. Once it passes, return to the dashboard and start the service.
4. Open Cursor, select the configured model, and start using Agent.
For complete installation steps, system configuration, and troubleshooting, see the [User Guide](https://docs.leokun.cn).
## Model Management
Model configurations support both OpenAI and Anthropic API protocols. Each model channel can independently define its context window, maximum output tokens, reasoning effort, custom headers, and additional request parameters.
![cursor-byok model settings](./images/en-model.png)
## How It Works
## 核心数据流
```text
Cursor client
│ Agent requests and tool results
cursor-byok local service
│ OpenAI- / Anthropic-compatible requests
Your model API
HTTP / Connect
Cursor adapter
↓ ClientCommand / ClientEvent
RunEngine
canonical messages + selected revision
typed ModelRequest
Provider adapter → HTTP/SSE → ModelEvent
```
cursor-byok handles protocol adaptation, model request forwarding, tool-call coordination, and conversation state on your machine. API keys and application settings are stored locally; requests are still sent to the model provider you configure.
Loop 不依赖 Cursor protobuf、Blob、checkpoint、数字 wire id 或具体 Provider JSON。Cursor adapter 和 Provider adapter 只在各自边界做协议投射。
## Why This Project
## 状态与 checkpoint
Many Agent products bundle their tool capabilities with a fixed set of models, subscriptions, and billing options, leaving users limited to the channels offered by the platform.
- SQLite 中的 immutable messages 与 revision 父链是对话事实源;回滚只选择旧 revision 并建立新分支。
- ToolRound 保存完整 assistant、原始 call 顺序和真实 result 完成顺序;结果未齐时不会把悬空 tool call 投给下一轮模型。
- BlobID 是 `SHA-256(data)`;Blob 类型来自引用字段,不编码在 ID 中。
- checkpoint 引用的新 Blob 必须先收到对应 KV SET ACK。协议中不存在 checkpoint ACK,也不保存跨流 outbox。
- staged checkpoint 内联完整 pending assistantToolRound 全部结果提交后才折叠进 stable roots。抓包没有单 ToolResult checkpoint,因此实现也不制造该状态。
- settled checkpoint 必须先于下一轮 LLM 调用。最终文本轮严格发送 `turn_ended → staged → settled → settled 重发 → EndStream`
- Cursor.app 只把 `turn_ended` 前的 checkpoint 作为自动恢复候选;恢复 pending assistant 时先继续工具,不重复调用 LLM。
cursor-byok is built to return model choice to the user. Developers can make full use of the APIs and credits they already have, choose the models and providers that fit their needs, and self-host related services when required.
## 目录边界
## Roadmap
```text
cursor-server/src/
├── control/ # 客户端无关的 Provider、模型、调用观测 HTTP API
├── client/ # 所有客户端共用的最小 command/event port
├── model/ # canonical message、revision、ModelSpec、typed history
├── run/ # 协议无关 Loop、ModelCycle、ToolRound 和 RunRegistry
├── provider/ # 各 LLM 端点请求与流事件适配
├── cursor/
│ ├── request/ # RunRequest → PreparedRun/CursorRunContext
│ ├── prompting/ # Cursor prompt、工具 catalog 和 mode manifest
│ ├── projection/ # Cursor AI-SDK stable/pending JSON 编解码
│ ├── interaction/ # UI 更新、InteractionQuery、typed ToolCall 渲染
│ ├── tools/ # Cursor 工具 transport、runtime、dispatch 和 result
│ └── checkpoint/ # root/Turn/derived/recovery 与串行 worker
└── store/ # SQLite messages、revision、ToolRound、Run 和 Blob CAS
The project will continue to improve model compatibility, Agent tooling, local runtime stability, and the self-hosting experience while exploring support for more IDE, chat, and Agent workflows.
console/ # React 管理台;只依赖 control API,不依赖 Cursor protobuf
```
See the [release roadmap](https://github.com/leookun/cursor-byok/discussions/32) for plans and progress.
详细到文件的目标目录和验收项只在重构计划中维护,README 不复制第二份易漂移的完整文件清单。
## Community and Support
## 工程原则
- [User Guide](https://docs.leokun.cn)
- [GitHub Issues](https://github.com/leookun/cursor-byok/issues)
- [Telegram community](https://t.me/cursor_byok)
- QQ groups: `1095916242`, `1094411438`, `1095918002`, `1094419321`
- 不保留旧路径、兼容层或失败后的隐式 fallback。
- 同一状态只有一个所有者;协议层不做 Loop 决策。
- PromptSpec、ModelSpec 和 selected revision 决定可重放的 ModelRequestrequest id、时间和 model call id 不进入模型输入。
- 前缀稳定限定在相同 PromptSpec/ModelSpec/Provider route;新 Run 切换模型或模式时只替换 Cursor system root,其他历史 message roots 继续复用。
- Provider replay state 只回传给产生它的端点;可展示 thinking 不是跨端点 reasoning 字段。
- Provider usage 只采用端点报告的单轮最终值,不自行估算。
- 同一个取消信号覆盖等待 HTTP 响应头和读取 SSE 两段。
<a href="https://trendshift.io/repositories/39260?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-39260" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/39260" alt="leookun/cursor-byok | Trendshift" width="250" height="55" /></a>
## 验证
## Development and Contributing
Issues and pull requests are welcome. See the [Contributing Guide](./CONTRIBUTING_EN.md) for prerequisites, build commands, project structure, and contribution guidelines.
## License
This project is open source under the [MIT License](./LICENSE).
```bash
cd cursor-server
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
```
-569
View File
@@ -1,569 +0,0 @@
version: "3"
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
vars:
APP_NAME: "Cursor助手"
BIN_DIR: "bin"
APP_VERSION:
sh: 'go run ./scripts/release version -config ./build/config.yml'
RELEASE_REPO: "leookun/cursor-byok"
RELEASE_BASE_NAME: "cursor-byok"
RELEASE_SOURCE_PATH: "release-notes.md"
RELEASE_DIR: '{{.BIN_DIR}}/release/{{.APP_VERSION}}'
RELEASE_NOTES_PATH: '{{.RELEASE_DIR}}/.release-notes.md'
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
SCAN: '{{.SCAN | default "false"}}'
CURRENT_WINDOWS_ARCH: '{{if eq ARCH "386"}}386{{else}}amd64{{end}}'
CURRENT_WINDOWS_NAME: '{{if eq .CURRENT_WINDOWS_ARCH "386"}}windows-32{{else}}windows-64{{end}}'
CURRENT_DARWIN_NAME: '{{if eq ARCH "arm64"}}macos-arm64{{else}}macos-intel{{end}}'
CURRENT_LINUX_ARCH: "amd64"
CURRENT_LINUX_NAME: "linux-amd64"
tasks:
build:
summary: 构建当前系统分发包
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "windows" ] || [ "{{OS}}" = "linux" ]'
msg: "仅支持在 macOS、Windows 或 Linux 上执行 task build"
cmds:
- task: clean:dist
- task: build:current
run:
summary: 运行当前系统版本
cmds:
- task: "{{OS}}:run"
build:all:
summary: 构建全部 macOS/Windows 分发包(Linux 需在原生 Linux 主机单独构建)
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "task build:all 仅支持在 macOS 上执行;Linux 需在原生 Linux 主机构建。"
cmds:
- task: clean:dist
- task: build:darwin:arm64
- task: build:darwin:amd64
- task: build:windows:386
- task: build:windows:amd64
build:current:
internal: true
cmds:
- task: '{{if eq OS "darwin"}}build:darwin:current{{else if eq OS "linux"}}build:linux:current{{else}}build:windows:current{{end}}'
build:darwin:current:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: '{{ARCH}}'
BINARY_NAME: '{{.CURRENT_DARWIN_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_DARWIN_NAME}}'
APP_BUNDLE: '{{.CURRENT_DARWIN_NAME}}.app'
DMG_NAME: '{{.CURRENT_DARWIN_NAME}}.dmg'
SCAN: '{{.SCAN}}'
build:darwin:arm64:
internal: false
cmds:
- task: darwin:package:dmg
vars:
ARCH: arm64
BINARY_NAME: macos-arm64
OUTPUT: '{{.BIN_DIR}}/macos-arm64'
APP_BUNDLE: macos-arm64.app
DMG_NAME: macos-arm64.dmg
SCAN: '{{.SCAN}}'
build:darwin:amd64:
internal: true
cmds:
- task: darwin:package:dmg
vars:
ARCH: amd64
BINARY_NAME: macos-intel
OUTPUT: '{{.BIN_DIR}}/macos-intel'
APP_BUNDLE: macos-intel.app
DMG_NAME: macos-intel.dmg
SCAN: '{{.SCAN}}'
build:windows:current:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: '{{.CURRENT_WINDOWS_ARCH}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_WINDOWS_NAME}}.exe'
ZIP_NAME: '{{.CURRENT_WINDOWS_NAME}}.zip'
SCAN: '{{.SCAN}}'
build:windows:386:
internal: true
cmds:
- task: windows:create:zip
vars:
ARCH: 386
OUTPUT: '{{.BIN_DIR}}/windows-32.exe'
ZIP_NAME: windows-32.zip
SCAN: '{{.SCAN}}'
build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/windows-64.exe'
ZIP_NAME: windows-64.zip
SCAN: '{{.SCAN}}'
build:linux:current:
internal: true
cmds:
- task: linux:package:archive
vars:
ARCH: '{{.CURRENT_LINUX_ARCH}}'
BINARY_NAME: '{{.CURRENT_LINUX_NAME}}'
OUTPUT: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/{{.CURRENT_LINUX_NAME}}.tar.gz'
SCAN: '{{.SCAN}}'
build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: linux-amd64
OUTPUT: '{{.BIN_DIR}}/linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.BIN_DIR}}/linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
cursor-tab-server:docker:amd64:
summary: 构建 cursor-tab-server 的 linux/amd64 Docker 镜像并保存到 cursor-tab-server 目录
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "cursor-tab-server:linux-amd64"}}'
IMAGE_TAR: '{{.IMAGE_TAR | default "cursor-tab-server/cursor-tab-server-linux-amd64.tar"}}'
CONTEXT_DIR: cursor-tab-server
DOCKERFILE: cursor-tab-server/Dockerfile
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f "{{.DOCKERFILE}}"
msg: "未找到 cursor-tab-server/Dockerfile。"
- sh: test -f "{{.CONTEXT_DIR}}/go.mod"
msg: "未找到 cursor-tab-server/go.mod。"
cmds:
- mkdir -p "{{.CONTEXT_DIR}}"
- docker build --platform linux/amd64 -t "{{.IMAGE_NAME}}" -f "{{.DOCKERFILE}}" "{{.CONTEXT_DIR}}"
- docker save "{{.IMAGE_NAME}}" -o "{{.IMAGE_TAR}}"
- ls -lh "{{.IMAGE_TAR}}"
clean:dist:
internal: true
cmds:
- mkdir -p "{{.BIN_DIR}}"
- rm -f "{{.BIN_DIR}}/macos-arm64.dmg" "{{.BIN_DIR}}/macos-intel.dmg" "{{.BIN_DIR}}/windows-32.zip" "{{.BIN_DIR}}/windows-64.zip" "{{.BIN_DIR}}/linux-amd64.tar.gz"
- rm -f "{{.BIN_DIR}}/macos-arm64" "{{.BIN_DIR}}/macos-intel" "{{.BIN_DIR}}/windows-32.exe" "{{.BIN_DIR}}/windows-64.exe" "{{.BIN_DIR}}/linux-amd64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
- rm -rf "{{.BIN_DIR}}/macos-arm64.app" "{{.BIN_DIR}}/macos-intel.app" "{{.BIN_DIR}}/{{.APP_NAME}}.app" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
dev:
summary: 启动开发模式(前台,终端会保持占用)
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
proxy-debugger:
summary: 启动独立 Cursor 协议调试代理
cmds:
- go run ./cmd/cursor-proxy-debugger
proxy-debugger:build:
summary: 构建独立 Cursor 协议调试代理
cmds:
- go build -o ./bin/cursor-proxy-debugger ./cmd/cursor-proxy-debugger
ads:install:
summary: 安装广告页依赖
dir: '{{.TASKFILE_DIR}}/ads-page'
sources:
- package.json
- yarn.lock
generates:
- node_modules
cmds:
- yarn install --frozen-lockfile
ads:dev:
summary: 启动广告页开发服务(http://127.0.0.1:5174/ad/
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn dev
ads:build:
summary: 构建广告 ZIP 产物
dir: '{{.TASKFILE_DIR}}/ads-page'
deps:
- task: ads:install
cmds:
- yarn build
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
cmds:
- task: common:setup:docker
release:clean:
summary: 清理当前版本的发布目录
cmds:
- rm -rf "{{.RELEASE_DIR}}"
- mkdir -p "{{.RELEASE_DIR}}"
release:ensure:dir:
internal: true
cmds:
- mkdir -p "{{.RELEASE_DIR}}"
release:notes:
summary: 从 release-notes.md 生成当前版本的发布说明文件(README.md 仅用于公开仓库同步)
internal: true
preconditions:
- sh: test -f "{{.RELEASE_SOURCE_PATH}}"
msg: "未找到 release-notes.md,请先创建发布日志文件。"
- sh: test -n "$(tr -d '[:space:]' < "{{.RELEASE_SOURCE_PATH}}")"
msg: "release-notes.md 不能为空,请先填写发布日志。"
cmds:
- go run ./scripts/release notes -config ./build/config.yml -out "{{.RELEASE_NOTES_PATH}}" -source "{{.RELEASE_SOURCE_PATH}}"
release:build:macos:arm64:
cmds:
- task: darwin:package:archive
vars:
ARCH: arm64
BINARY_NAME: cursor-release-macos-arm64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-arm64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:macos:amd64:
internal: true
cmds:
- task: darwin:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-macos-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-macos-amd64'
APP_BUNDLE: '{{.APP_NAME}}.app'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:build:windows:amd64:
cmds:
- task: windows:create:zip
vars:
ARCH: amd64
OUTPUT: '{{.BIN_DIR}}/{{.RELEASE_BASE_NAME}}-windows-amd64.exe'
ZIP_NAME: 'release/{{.APP_VERSION}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip'
SCAN: '{{.SCAN}}'
release:build:linux:amd64:
cmds:
- task: linux:package:archive
vars:
ARCH: amd64
BINARY_NAME: cursor-release-linux-amd64
OUTPUT: '{{.BIN_DIR}}/cursor-release-linux-amd64'
ARCHIVE_BINARY_NAME: '{{.APP_NAME}}'
ARCHIVE_PATH: '{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz'
SCAN: '{{.SCAN}}'
release:manifest:
summary: 生成 update.json
internal: true
cmds:
- go run ./scripts/release manifest -config ./build/config.yml -assets-dir "{{.RELEASE_DIR}}" -out "{{.RELEASE_DIR}}/update.json" -repo "{{.RELEASE_REPO}}" -base-name "{{.RELEASE_BASE_NAME}}" -notes "{{.RELEASE_NOTES_PATH}}"
release:prepare:
summary: 在当前主机上生成对应的发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "release:prepare 仅支持在 macOS 或 Linux 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: '{{if eq OS "darwin"}}release:prepare:darwin{{else}}release:prepare:linux{{end}}'
release:prepare:darwin:
summary: 在 macOS 上生成 macOS/Windows/Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "darwin" ]'
msg: "release:prepare:darwin 仅支持在 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:clean
- task: common:update:build-assets
- task: release:notes
- task: release:build:macos:arm64
- task: release:build:macos:amd64
- task: release:build:windows:amd64
- task: release:build:linux:amd64
release:prepare:linux:
summary: 在当前主机上生成 Linux 发布资产
preconditions:
- sh: '[ "{{OS}}" = "linux" ] || [ "{{OS}}" = "darwin" ]'
msg: "release:prepare:linux 当前仅支持在 Linux 或 macOS 上执行。"
- sh: wails3 version >/dev/null 2>&1
msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds:
- task: release:ensure:dir
- task: common:update:build-assets
- task: release:notes
- task: release:build:linux:amd64
release:verify:assets:
summary: 验证当前版本发布资产是否齐全
preconditions:
- sh: test -f "{{.RELEASE_NOTES_PATH}}"
msg: "未找到发布说明,请先执行对应的 release:prepare 任务。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-arm64.tar.gz"
msg: "缺少 macOS arm64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-macos-amd64.tar.gz"
msg: "缺少 macOS amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-windows-amd64.zip"
msg: "缺少 Windows amd64 发布资产。"
- sh: test -f "{{.RELEASE_DIR}}/{{.RELEASE_BASE_NAME}}-{{.APP_VERSION}}-linux-amd64.tar.gz"
msg: "缺少 Linux amd64 发布资产。"
cmds:
- echo "release assets verified"
release:sync:readme:
summary: 同步 README 到公开发布仓库(发布日志来源固定为 release-notes.md
internal: true
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- |
python3 - <<'PY'
import base64
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
readme_path = pathlib.Path("README.md")
content = readme_path.read_bytes()
encoded = base64.b64encode(content).decode("ascii")
get_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"message": "docs: sync README from source repo",
"content": encoded,
"branch": "main",
}
if result.returncode == 0:
existing = json.loads(result.stdout)
existing_content = base64.b64decode(existing["content"])
if existing_content == content:
sys.exit(0)
payload["sha"] = existing["sha"]
put_cmd = [
"gh", "api",
f"repos/{repo}/contents/README.md",
"--method", "PUT",
"--input", "-",
]
completed = run_gh(put_cmd, input_text=json.dumps(payload))
if completed.returncode != 0 and completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
PY
release:github:
summary: 发布当前版本到 GitHub Releases
env:
GH_PAGER: cat
GIT_PAGER: cat
PAGER: cat
preconditions:
- sh: gh --version >/dev/null 2>&1
msg: "未检测到 gh,请先安装 GitHub CLI。"
- sh: gh auth token >/dev/null 2>&1
msg: "gh 未登录,请先执行 gh auth login。"
cmds:
- task: release:verify:assets
- task: release:manifest
- task: release:sync:readme
- |
python3 - <<'PY'
import mimetypes
import json
import pathlib
import subprocess
import sys
import time
RETRYABLE_TOKENS = ("EOF", "timeout", "TLS", "temporarily unavailable", "connection reset", "connection refused")
def run_gh(cmd, *, input_text=None, allow_404=False, retries=4):
last = None
for attempt in range(1, retries + 1):
completed = subprocess.run(
cmd,
input=input_text,
text=True,
capture_output=True,
)
if completed.returncode == 0:
return completed
stderr = (completed.stderr or "").strip()
stdout = (completed.stdout or "").strip()
combined = f"{stderr}\n{stdout}".strip()
if allow_404 and "404" in combined:
return completed
last = completed
if not any(token.lower() in combined.lower() for token in RETRYABLE_TOKENS):
return completed
if attempt < retries:
time.sleep(min(2 ** (attempt - 1), 5))
return last
repo = "{{.RELEASE_REPO}}"
version = "{{.APP_VERSION}}"
tag = f"v{version}"
notes = pathlib.Path("{{.RELEASE_NOTES_PATH}}").read_text(encoding="utf-8")
assets = sorted([
pathlib.Path("{{.RELEASE_DIR}}/update.json"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.tar.gz"),
*pathlib.Path("{{.RELEASE_DIR}}").glob("*.zip"),
])
get_cmd = [
"gh", "api",
f"repos/{repo}/releases/tags/{tag}",
]
result = run_gh(get_cmd, allow_404=True)
payload = {
"tag_name": tag,
"target_commitish": "main",
"name": tag,
"body": notes,
"draft": False,
"prerelease": False,
}
if result.returncode == 0:
release = json.loads(result.stdout)
release_id = release["id"]
edit_cmd = [
"gh", "api",
f"repos/{repo}/releases/{release_id}",
"--method", "PATCH",
"--input", "-",
]
completed = run_gh(edit_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
if completed.stderr:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
refreshed = run_gh(get_cmd)
if refreshed.returncode != 0:
if refreshed.stderr:
sys.stderr.write(refreshed.stderr)
sys.exit(refreshed.returncode)
release = json.loads(refreshed.stdout)
else:
create_cmd = [
"gh", "api",
f"repos/{repo}/releases",
"--method", "POST",
"--input", "-",
]
completed = run_gh(create_cmd, input_text=json.dumps(payload))
if completed.returncode != 0:
sys.stderr.write(completed.stderr)
sys.exit(completed.returncode)
release = json.loads(completed.stdout)
upload_url = release["upload_url"].split("{", 1)[0]
existing = {asset["name"]: asset["id"] for asset in release.get("assets", [])}
for asset in assets:
name = asset.name
if name in existing:
delete_cmd = [
"gh", "api",
f"repos/{repo}/releases/assets/{existing[name]}",
"--method", "DELETE",
]
deleted = run_gh(delete_cmd)
if deleted.returncode != 0:
if deleted.stderr:
sys.stderr.write(deleted.stderr)
sys.exit(deleted.returncode)
content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
upload_cmd = [
"gh", "api",
f"{upload_url}?name={name}",
"--method", "POST",
"--header", f"Content-Type: {content_type}",
"--input", str(asset),
]
uploaded = run_gh(upload_cmd)
if uploaded.returncode != 0:
if uploaded.stderr:
sys.stderr.write(uploaded.stderr)
sys.exit(uploaded.returncode)
PY
-3
View File
@@ -1,3 +0,0 @@
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path','User') + ';$HOME\go\bin'; task build"
pause
-2
View File
@@ -1,2 +0,0 @@
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + ";$HOME\go\bin"
task build
-160
View File
@@ -1,160 +0,0 @@
version: "3"
vars:
ROOT_DIR:
sh: 'cd "{{.TASKFILE_DIR}}/.." && pwd'
BUILD_DIR:
sh: 'cd "{{.TASKFILE_DIR}}" && pwd'
tasks:
extract:proto:
summary: 从 Cursor 扩展快照提取 proto
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: 'test -z "{{.PROTO_INPUT}}" || test -f "{{.PROTO_INPUT}}"'
msg: "PROTO_INPUT 指向的 Cursor 扩展 bundle 不存在。"
- sh: 'test -n "{{.PROTO_INPUT}}" || test -f /Applications/Cursor.app/Contents/Resources/app/extensions/cursor-always-local/dist/main.js'
msg: "未找到已安装 Cursor 的扩展 bundle;请传入 PROTO_INPUT=/path/to/cursor-always-local/dist/main.js。"
cmds:
- chmod +x ./proto/extract_extensions_proto.sh
- '{{if .PROTO_INPUT}}./proto/extract_extensions_proto.sh "{{.PROTO_INPUT}}"{{else}}./proto/extract_extensions_proto.sh{{end}}'
sync:proto:
summary: 从 Cursor 扩展快照同步 proto 并重新生成 Go 代码
dir: '{{.ROOT_DIR}}'
deps:
- task: extract:proto
cmds:
- cp ./proto/from_extensions/agent_v1.proto ./proto/agent_v1.proto
- cp ./proto/from_extensions/aiserver_v1.proto ./proto/aiserver_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/agent/v1;agentv1";|option go_package = "cursor/gen/agentv1;agentv1";|' ./proto/agent_v1.proto
- perl -0pi -e 's|option go_package = "react-admin/cursor-server/gen/aiserver/v1;aiserverv1";|option go_package = "cursor/gen/aiserverv1;aiserverv1";|' ./proto/aiserver_v1.proto
- ./proto/check_proto_sync.sh
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- task: generate:proto
check:proto:
summary: 检查根 proto 与扩展提取快照是否一致
dir: '{{.ROOT_DIR}}'
cmds:
- ./proto/check_proto_sync.sh
generate:proto:
summary: 生成 proto Go/Connect 代码
dir: '{{.ROOT_DIR}}'
sources:
- proto/*.proto
generates:
- gen/agentv1/*.go
- gen/aiserverv1/*.go
- gen/aiserverv1/aiserverv1connect/*.go
preconditions:
- sh: protoc --version
msg: "未检测到 protoc,请先安装 Protocol Buffers 编译器。"
- sh: protoc-gen-go --version >/dev/null 2>&1 || which protoc-gen-go
msg: "未检测到 protoc-gen-go,请先安装。"
- sh: protoc-gen-connect-go --version >/dev/null 2>&1 || which protoc-gen-connect-go
msg: "未检测到 protoc-gen-connect-go,请先安装。"
cmds:
- rm -rf ./gen/agentv1 ./gen/aiserverv1
- protoc -I ./proto --go_out=. --go_opt=module=cursor --connect-go_out=. --connect-go_opt=module=cursor ./proto/agent_v1.proto ./proto/aiserver_v1.proto
- gofmt -w $(find ./gen -name '*.go' -type f)
go:mod:tidy:
summary: 整理 Go 依赖
internal: true
dir: '{{.ROOT_DIR}}'
cmds:
- go mod tidy
install:frontend:deps:
summary: 安装前端依赖(Yarn
dir: '{{.ROOT_DIR}}/frontend'
sources:
- package.json
- yarn.lock
generates:
- node_modules
preconditions:
- sh: yarn --version
msg: "未检测到 Yarn,请先安装 Yarn。"
cmds:
- yarn install --frozen-lockfile
generate:bindings:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: 生成前端绑定
dir: '{{.ROOT_DIR}}'
deps:
- task: generate:proto
- task: go:mod:tidy
sources:
- "**/*.[jt]s"
- exclude: frontend/**/*
- frontend/bindings/**/*
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
build:frontend:
label: build:frontend (DEV={{.DEV}} SCAN={{.SCAN}})
summary: 构建前端资源
dir: '{{.ROOT_DIR}}/frontend'
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- yarn run {{.BUILD_COMMAND}}{{if eq .SCAN "true"}} --scan{{end}}
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
SCAN: '{{.SCAN | default "false"}}'
dev:frontend:
summary: 启动前端开发服务
dir: '{{.ROOT_DIR}}/frontend'
deps:
- task: install:frontend:deps
cmds:
- yarn run dev --port {{.VITE_PORT}} --strictPort
generate:icons:
summary: 仅使用 appicon.png 生成 Windows 与 macOS 图标
dir: '{{.BUILD_DIR}}'
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
update:build-assets:
summary: 根据配置更新构建资产
dir: '{{.BUILD_DIR}}'
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
- rm -rf ios
setup:docker:
summary: 构建 Linux 交叉编译 Docker 镜像
dir: '{{.ROOT_DIR}}'
preconditions:
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: test -f ./build/docker/Dockerfile.cross
msg: "未找到 build/docker/Dockerfile.cross。"
cmds:
- docker build --platform linux/amd64{{if .BASE_IMAGE}} --build-arg BASE_IMAGE="{{.BASE_IMAGE}}"{{end}}{{if .GO_TARBALL_URL}} --build-arg GO_TARBALL_URL="{{.GO_TARBALL_URL}}"{{end}} -t cursor-linux-amd64-cross -f ./build/docker/Dockerfile.cross ./build/docker
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

-43
View File
@@ -1,43 +0,0 @@
version: "3"
# 构建元信息(修改后可执行:wails3 task common:update:build-assets
info:
companyName: "Cursor助手"
productName: "Cursor助手"
productIdentifier: "com.cursor.wuxianxubei"
description: "Cursor助手"
copyright: "© 2026, Cursor助手"
comments: "Cursor助手"
version: "0.0.46"
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
- "*.js"
- "*.ts"
git_ignore: true
executes:
- cmd: wails3 build DEV=true
type: blocking
- cmd: wails3 task common:dev:frontend
type: background
- cmd: wails3 task run
type: primary
fileAssociations:
other:
- name: 其他数据
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.46</string>
<key>CFBundleVersion</key>
<string>0.0.46</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Cursor助手</string>
<key>CFBundleGetInfoString</key>
<string>Cursor助手</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>com.cursor.wuxianxubei</string>
<key>CFBundleName</key>
<string>Cursor助手</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.46</string>
<key>CFBundleVersion</key>
<string>0.0.46</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, Cursor助手</string>
</dict>
</plist>
-273
View File
@@ -1,273 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 macOS 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 macOS 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
build:universal:
summary: 构建 macOS 通用包(amd64 + arm64
internal: true
deps:
- task: build
vars:
ARCH: amd64
BINARY_NAME: "{{.APP_NAME}}-amd64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
BINARY_NAME: "{{.APP_NAME}}-arm64"
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: 打包 macOS .app(可通过 APP_BUNDLE 指定输出包名)
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:app:bundle
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
package:dmg:
summary: 打包 macOS DMG(可通过 APP_BUNDLE/DMG_NAME 指定输出文件名)
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:dmg
vars:
ARCH:
ref: .ARCH
APP_BUNDLE:
ref: .APP_BUNDLE
DMG_NAME:
ref: .DMG_NAME
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:archive:
summary: 打包 macOS updater 归档(.tar.gz
internal: true
cmds:
- task: package
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
APP_BUNDLE:
ref: .APP_BUNDLE
package:universal:
summary: 打包 macOS 通用 .app
internal: true
cmds:
- task: build:universal
- task: create:app:bundle
create:app:bundle:
summary: 生成 .app 包
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/Resources"
- cp "{{.BINARY_PATH}}" "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_BUNDLE}}/Contents"
- task: codesign:adhoc
vars:
APP_BUNDLE:
ref: .APP_BUNDLE
codesign:adhoc:
summary: 使用临时签名(开发测试)
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
cmds:
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
create:dmg:
summary: 生成 macOS DMG 包(优先使用 create-dmg,不存在时回退 hdiutil
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
DMG_NAME: '{{.DMG_NAME | default (printf "%s.dmg" .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.dmg-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp -R "{{.BIN_DIR}}/{{.APP_BUNDLE}}" "{{.STAGING_DIR}}/{{.APP_NAME}}.app"
- |
if [ -f "build/dmg-extras/提示损坏?点我.command" ]; then
cp "build/dmg-extras/提示损坏?点我.command" "{{.STAGING_DIR}}/提示损坏?点我.command"
chmod +x "{{.STAGING_DIR}}/提示损坏?点我.command"
fi
- |
if command -v create-dmg >/dev/null 2>&1; then
rm -f "{{.BIN_DIR}}/{{.DMG_NAME}}"
create-dmg --volname "{{.APP_NAME}}" --window-size 600 400 --icon-size 100 --app-drop-link 450 200 "{{.BIN_DIR}}/{{.DMG_NAME}}" "{{.STAGING_DIR}}"
else
hdiutil create -volname "{{.APP_NAME}}" -srcfolder "{{.STAGING_DIR}}" -ov -format UDZO "{{.BIN_DIR}}/{{.DMG_NAME}}"
fi
- rm -rf "{{.STAGING_DIR}}"
create:archive:
summary: 生成 macOS updater tar.gz 归档
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
cmds:
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.BIN_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.APP_BUNDLE}}"
cleanup:package:artifacts:
summary: 清理打包中间产物
internal: true
vars:
APP_BUNDLE: '{{.APP_BUNDLE | default (printf "%s.app" .APP_NAME)}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
- rm -rf "{{.BIN_DIR}}/{{.APP_BUNDLE}}"
run:
summary: 运行本地开发包
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
EXECUTABLE_NAME: '{{.EXECUTABLE_NAME | default .APP_NAME}}'
cmds:
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
- cp "{{.BIN_DIR}}/{{.BINARY_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}"
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.EXECUTABLE_NAME}}'
Binary file not shown.
@@ -1,13 +0,0 @@
#!/bin/bash
APP_PATH="/Applications/Cursor助手.app"
if [ ! -d "$APP_PATH" ]; then
echo "请先把 Cursor助手 拖到“应用程序”目录后再运行本脚本。"
read -r -p "按回车退出..."
exit 1
fi
echo "正在移除隔离属性: $APP_PATH"
xattr -cr "$APP_PATH"
echo "处理完成,现在可以正常打开 Cursor助手。"
read -r -p "按回车退出..."
-26
View File
@@ -1,26 +0,0 @@
# Docker image for building this app's Linux amd64 binary from macOS/Linux hosts.
ARG BASE_IMAGE=debian:trixie
FROM ${BASE_IMAGE}
ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git nodejs yarnpkg pkg-config gcc libc6-dev \
libgtk-3-dev libwebkit2gtk-4.1-dev \
&& curl -fsSL "${GO_TARBALL_URL}" -o /tmp/go.tar.gz \
&& rm -rf /usr/local/go \
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
&& ln -sf /usr/local/go/bin/go /usr/local/bin/go \
&& ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt \
&& ln -sf /usr/bin/yarnpkg /usr/local/bin/yarn \
&& rm -f /tmp/go.tar.gz \
&& rm -rf /var/lib/apt/lists/*
COPY build-linux.sh /usr/local/bin/build-linux.sh
RUN chmod +x /usr/local/bin/build-linux.sh
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/build-linux.sh"]
-81
View File
@@ -1,81 +0,0 @@
#!/bin/sh
set -eu
log() {
printf '[linux-build] %s\n' "$*"
}
export GOOS=linux
export GOARCH=amd64
export CGO_ENABLED=1
export CGO_CFLAGS="-w"
APP=${APP_NAME:-$(basename "$(pwd)")}
BIN_PATH="bin/${APP}-linux-amd64"
LOG_MODE=${GO_BUILD_LOG_MODE:-quiet}
case "$LOG_MODE" in
quiet|heartbeat)
;;
verbose|trace)
log "workspace: $(pwd)"
log "target: ${APP} (${GOOS}/${GOARCH})"
log "go version: $(go version)"
log "log mode: ${LOG_MODE}"
;;
*)
log "unknown GO_BUILD_LOG_MODE=${LOG_MODE}, falling back to quiet"
LOG_MODE="quiet"
;;
esac
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
log "frontend/dist missing, building frontend assets in container"
(
cd frontend
yarn install --frozen-lockfile
yarn run build
)
fi
mkdir -p bin
LDFLAGS="-s -w"
if [ -n "${EXTRA_LDFLAGS:-}" ]; then
LDFLAGS="$LDFLAGS $EXTRA_LDFLAGS"
fi
TAGS="production"
if [ -n "${EXTRA_TAGS:-}" ]; then
TAGS="${TAGS},${EXTRA_TAGS}"
fi
set -- go build
case "$LOG_MODE" in
quiet)
;;
heartbeat)
;;
verbose)
set -- "$@" -v
;;
trace)
set -- "$@" -x -v
;;
esac
set -- "$@" -tags "$TAGS" -trimpath -buildvcs=false -ldflags "$LDFLAGS" -o "$BIN_PATH" .
if "$@"; then
:
else
status="$?"
log "go build failed with exit code ${status}"
exit "$status"
fi
case "$LOG_MODE" in
verbose|trace)
log "built: ${BIN_PATH}"
;;
esac
@@ -1,9 +0,0 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -1,2 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
-192
View File
@@ -1,192 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
vars:
CROSS_IMAGE: cursor-linux-amd64-cross
PROJECT_ROOT:
sh: 'cd "{{.TASKFILE_DIR}}/../.." && pwd'
tasks:
build:
summary: 构建 Linux 程序
internal: true
vars:
TARGET_ARCH: '{{.ARCH | default ARCH}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
HAS_CC:
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
preconditions:
- sh: '[ "{{.TARGET_ARCH}}" = "amd64" ]'
msg: "当前仅支持 linux/amd64 构建。"
cmds:
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用本机构建 Linux 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 原生构建仅支持在 Linux 主机上执行。"
- sh: pkg-config --version >/dev/null 2>&1
msg: "未检测到 pkg-config,请先安装。"
- sh: 'command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1'
msg: "未检测到 gcc 或 clang,请先安装 C 编译器。"
- sh: pkg-config --exists gtk+-3.0 webkit2gtk-4.1 gdk-3.0 gio-unix-2.0
msg: "缺少 Linux GUI 构建依赖,请安装 gtk+-3.0、webkit2gtk-4.1、gdk-3.0 和 gio-unix-2.0 开发包。"
cmds:
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.BINARY_NAME}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
build:docker:
summary: 使用 Docker 构建 Linux 程序
internal: true
dir: '{{.PROJECT_ROOT}}'
deps:
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
preconditions:
- sh: '[ "{{OS}}" = "darwin" ] || [ "{{OS}}" = "linux" ]'
msg: "Docker Linux 构建当前仅支持在 macOS 或 Linux 主机上执行。"
- sh: docker info >/dev/null 2>&1
msg: "未检测到可用的 Docker daemon,请先启动 Docker。"
- sh: docker image inspect {{.CROSS_IMAGE}} >/dev/null 2>&1
msg: "未找到 Docker 镜像 '{{.CROSS_IMAGE}}',请先执行 task setup:docker。"
cmds:
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint /bin/sh -v "{{.PROJECT_ROOT}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e GOPATH=/go -e GOMODCACHE=/go/pkg/mod -e APP_NAME="{{.APP_NAME}}" -e EXTRA_LDFLAGS="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}" -e GO_BUILD_LOG_MODE="{{.GO_BUILD_LOG_MODE}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" /app/build/docker/build-linux.sh
- docker run --rm --platform "{{.DOCKER_PLATFORM}}" --entrypoint chown -v "{{.PROJECT_ROOT}}:/app" "{{.CROSS_IMAGE}}" -R $(id -u):$(id -g) /app/bin
- mkdir -p "{{.BIN_DIR}}"
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
vars:
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
DOCKER_PLATFORM: 'linux/{{.DOCKER_ARCH}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
GO_BUILD_LOG_MODE: '{{.GO_BUILD_LOG_MODE | default "quiet"}}'
OUTPUT: '{{.OUTPUT | default .DEFAULT_OUTPUT}}'
GO_CACHE_MOUNT:
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
REPLACE_MOUNTS:
sh: |
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
if [ "${path#/}" = "$path" ]; then
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
fi
if [ -d "$path" ]; then
echo "-v $path:$path:ro"
fi
done | tr '\n' ' '
package:archive:
summary: 打包 Linux updater 归档(.tar.gz
internal: true
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: create:archive
vars:
OUTPUT:
ref: .OUTPUT
ARCHIVE_BINARY_NAME:
ref: .ARCHIVE_BINARY_NAME
ARCHIVE_PATH:
ref: .ARCHIVE_PATH
ARCH:
ref: .ARCH
- task: cleanup:package:artifacts
vars:
OUTPUT:
ref: .OUTPUT
BINARY_NAME:
ref: .BINARY_NAME
create:archive:
summary: 生成 Linux updater tar.gz 归档
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
ARCHIVE_BINARY_NAME: '{{.ARCHIVE_BINARY_NAME | default .APP_NAME}}'
ARCHIVE_PATH: '{{.ARCHIVE_PATH | default (printf "%s/%s.tar.gz" .BIN_DIR .APP_NAME)}}'
STAGING_DIR: '{{.BIN_DIR}}/.linux-archive-{{.ARCH | default ARCH}}'
cmds:
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.BINARY_PATH}}" "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- chmod +x "{{.STAGING_DIR}}/{{.ARCHIVE_BINARY_NAME}}"
- rm -f "{{.ARCHIVE_PATH}}"
- env LC_ALL=C tar -C "{{.STAGING_DIR}}" -czf "{{.ARCHIVE_PATH}}" "{{.ARCHIVE_BINARY_NAME}}"
- rm -rf "{{.STAGING_DIR}}"
cleanup:package:artifacts:
summary: 清理 Linux 打包中间产物
internal: true
vars:
BINARY_NAME: '{{.BINARY_NAME | default .APP_NAME}}'
BINARY_PATH: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY_NAME)}}'
cmds:
- rm -f "{{.BINARY_PATH}}"
run:
summary: 运行 Linux 可执行文件
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}'
preconditions:
- sh: '[ "{{OS}}" = "linux" ]'
msg: "Linux 运行任务仅支持在 Linux 主机上执行。"
cmds:
- '{{.OUTPUT}}'
-13
View File
@@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Cursor助手
Comment=Cursor助手
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/Cursor助手 %u
Terminal=false
Type=Application
Icon=Cursor助手
Categories=Utility;
StartupWMClass=Cursor助手
-67
View File
@@ -1,67 +0,0 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "Cursor助手"
arch: ${GOARCH}
platform: "linux"
version: "0.0.46"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Cursor助手"
vendor: "Cursor助手"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/Cursor助手"
dst: "/usr/local/bin/Cursor助手"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Cursor助手.png"
- src: "./build/linux/Cursor助手.desktop"
dst: "/usr/share/applications/Cursor助手.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
rpm:
depends:
- gtk3
- webkit2gtk4.1
# Arch Linux packages (WebKit 4.1)
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-156
View File
@@ -1,156 +0,0 @@
version: "3"
includes:
common: ../Taskfile.yml
tasks:
build:
summary: 构建 Windows 程序
internal: true
cmds:
- task: build:native
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
build:native:
summary: 使用 Go 原生交叉编译构建 Windows 程序
internal: true
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- task: common:generate:icons
cmds:
- task: generate:syso
vars:
ARCH:
ref: .ARCH
- go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [darwin, linux]
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l" -ldflags="-X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui -X cursor/internal/buildinfo.Version={{.APP_VERSION}}"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 0
GOARCH: '{{.ARCH | default ARCH}}'
package:
summary: 打包 Windows 安装程序
internal: true
cmds:
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
generate:syso:
summary: 生成 Windows 图标与版本信息资源
internal: true
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: 生成 NSIS 安装包
internal: true
dir: build/windows/nsis
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- |
{{if eq OS "windows"}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
{{else}}
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
create:msix:package:
summary: 生成 MSIX 安装包
internal: true
deps:
- task: build
vars:
SCAN:
ref: .SCAN
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
create:zip:
summary: 生成 Windows ZIP 包(包含 exe 与 certs
internal: true
vars:
ARCH: '{{.ARCH | default ARCH}}'
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
ZIP_NAME: '{{.ZIP_NAME | default (printf "%s-windows-%s.zip" .APP_NAME .ARCH)}}'
STAGING_DIR: '{{.BIN_DIR}}/.zip-{{.ARCH}}'
cmds:
- task: build
vars:
ARCH:
ref: .ARCH
OUTPUT:
ref: .OUTPUT
BUILD_FLAGS:
ref: .BUILD_FLAGS
EXTRA_TAGS:
ref: .EXTRA_TAGS
DEV:
ref: .DEV
SCAN:
ref: .SCAN
- rm -rf "{{.STAGING_DIR}}"
- mkdir -p "{{.STAGING_DIR}}"
- cp "{{.OUTPUT}}" "{{.STAGING_DIR}}/"
- (cd "{{.STAGING_DIR}}" && zip -qry "../{{.ZIP_NAME}}" .)
- rm -rf "{{.STAGING_DIR}}"
- rm -f "{{.OUTPUT}}"
run:
summary: 运行 Windows 可执行文件(仅在 Windows 可用)
vars:
OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}'
cmds:
- '{{.OUTPUT}}'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-15
View File
@@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "0.0.46"
},
"info": {
"0000": {
"ProductVersion": "0.0.46",
"CompanyName": "Cursor助手",
"FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手",
"ProductName": "Cursor助手",
"Comments": "Cursor助手"
}
}
}
-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
IgnorableNamespaces="uap3">
<Identity
Name="com.cursor.wuxianxubei"
Publisher="CN=Cursor助手"
Version="0.1.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="zh-cn" />
</Resources>
<Applications>
<Application Id="com.cursor.wuxianxubei" Executable="Cursor助手" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="Cursor助手"
Description="本地 MITM 代理客户端,支持中转转发与托盘控制"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop:Extension Category="windows.fullTrustProcess" Executable="Cursor助手" />
</Extensions>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
-54
View File
@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<MsixPackagingToolTemplate
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
<Settings
AllowTelemetry="false"
ApplyACLsToPackageFiles="true"
GenerateCommandLineFile="true"
AllowPromptForPassword="false">
</Settings>
<Installer
Path="Cursor助手"
Arguments=""
InstallLocation="C:\Program Files\Cursor助手\Cursor助手">
</Installer>
<PackageInformation
PackageName="Cursor助手"
PackageDisplayName="Cursor助手"
PublisherName="CN=Cursor助手"
PublisherDisplayName="Cursor助手"
Version="0.1.0.0"
PackageDescription="Cursor助手">
<Capabilities>
<Capability Name="runFullTrust" />
</Capabilities>
<Applications>
<Application
Id="com.cursor.wuxianxubei"
Description="Cursor助手"
DisplayName="Cursor助手"
ExecutableName="Cursor助手"
EntryPoint="Windows.FullTrustApplication">
</Application>
</Applications>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Properties>
<Framework>false</Framework>
<DisplayName>Cursor助手</DisplayName>
<PublisherDisplayName>Cursor助手</PublisherDisplayName>
<Description>Cursor助手</Description>
<Logo>Assets\AppIcon.png</Logo>
</Properties>
</PackageInformation>
<SaveLocation PackagePath="Cursor助手.msix" />
<PackageIntegrity>
<CertificatePath></CertificatePath>
</PackageIntegrity>
</MsixPackagingToolTemplate>
Binary file not shown.
-116
View File
@@ -1,116 +0,0 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "cursorclient"
## !define INFO_COMPANYNAME "My Company" # Default "My Company"
## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© 2026, My Company"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
SetOutPath $INSTDIR
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
-236
View File
@@ -1,236 +0,0 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Cursor助手"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Cursor助手"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.46"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
!macroend
-22
View File
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.46" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
-52
View File
@@ -1,52 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
proxydebugger "cursor/cursor-proxy-debugger"
"github.com/pkg/browser"
)
func main() {
config := proxydebugger.Config{}
openBrowser := true
flag.StringVar(&config.ProxyAddr, "proxy-addr", "127.0.0.1:9090", "HTTP/HTTPS 代理监听地址")
flag.StringVar(&config.UIAddr, "ui-addr", "127.0.0.1:9091", "调试界面监听地址")
flag.StringVar(&config.TargetHost, "target-host", "api2.cursor.sh", "需要解密和抓取的目标主机")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := proxydebugger.New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor 协议调试代理已启动\n")
fmt.Printf("代理地址: http://%s\n", server.ProxyAddr())
fmt.Printf("调试界面: %s\n", server.UIURL())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Close(shutdownContext); err != nil {
log.Printf("关闭调试代理失败:%v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cursor BYOK</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2533
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "cursor-byok-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@tanstack/react-query": "^5.90.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.1.0",
"typescript": "~5.9.0",
"vite": "^7.1.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { CallDetail, LlmCall, Provider, ProviderInput, ProviderModel } from './types'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { 'content-type': 'application/json', ...init?.headers },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }))
throw new Error(error.message ?? `HTTP ${response.status}`)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export const api = {
providers: () => request<Provider[]>('/api/providers'),
createProvider: (input: ProviderInput) =>
request<Provider>('/api/providers', { method: 'POST', body: JSON.stringify(input) }),
updateProvider: (id: number, input: ProviderInput) =>
request<Provider>(`/api/providers/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
deleteProvider: (id: number) => request<void>(`/api/providers/${id}`, { method: 'DELETE' }),
discoverModels: (id: number) =>
request<{ models: string[] }>(`/api/providers/${id}/models/discover`, { method: 'POST' }),
saveModels: (id: number, models: unknown[]) =>
request<ProviderModel[]>(`/api/providers/${id}/models`, {
method: 'POST',
body: JSON.stringify({ models }),
}),
models: () => request<ProviderModel[]>('/api/models'),
deleteModel: (hash: string) => request<void>(`/api/models/${hash}`, { method: 'DELETE' }),
calls: () => request<LlmCall[]>('/api/llm-calls'),
call: (id: string) => request<CallDetail>(`/api/llm-calls/${encodeURIComponent(id)}`),
observability: () => request<{ detailed: boolean }>('/api/settings/observability'),
setObservability: (detailed: boolean) => request<{ detailed: boolean }>('/api/settings/observability', {
method: 'PUT', body: JSON.stringify({ detailed }),
}),
}
+61
View File
@@ -0,0 +1,61 @@
export type ProviderType = 'openai-chat' | 'openai-responses' | 'anthropic'
export interface Provider {
provider_id: number
name: string
provider_type: ProviderType
base_url: string
has_api_key: boolean
custom_headers: Record<string, string | null>
created_at_ms: number
updated_at_ms: number
}
export interface ProviderInput {
name: string
provider_type: ProviderType
base_url: string
api_key?: string
custom_headers: Record<string, string | null>
}
export interface ProviderModel {
model_hash: string
provider_id: number
model_id: string
display_name: string
enabled: boolean
sort_order: number
context_window_tokens?: number
max_output_tokens?: number
reasoning_enabled: boolean
reasoning_effort?: string
extra_params: Record<string, unknown>
created_at_ms: number
updated_at_ms: number
}
export interface LlmCall {
call_id: string
run_id: string
conversation_id: string
model_hash?: string
model_id: string
display_name: string
provider_type: ProviderType
status: string
created_at_ms: number
duration_ms?: number
ttfb_ms?: number
ttft_ms?: number
input_tokens?: number
output_tokens?: number
total_tokens?: number
detailed: boolean
}
export interface CallDetail {
call: LlmCall
request?: { headers: Record<string, string>; body: unknown; byte_count: number }
response_chunks: { seq: number; received_offset_ms: number; data: string; byte_count: number }[]
}
+48
View File
@@ -0,0 +1,48 @@
import { NavLink, Route, Routes } from 'react-router-dom'
import { CallDetailPage } from '../features/calls/CallDetailPage'
import { CallsPage } from '../features/calls/CallsPage'
import { ModelsPage } from '../features/models/ModelsPage'
import { ProvidersPage } from '../features/providers/ProvidersPage'
import { ObservabilityPage } from '../features/settings/ObservabilityPage'
const links = [
['/', 'Providers'],
['/models', 'Models'],
['/calls', 'LLM Calls'],
['/settings', 'Settings'],
] as const
export function App() {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100">
<header className="border-b border-zinc-800 bg-zinc-950/90">
<div className="mx-auto flex max-w-7xl items-center gap-8 px-6 py-4">
<div className="text-lg font-semibold">Cursor BYOK</div>
<nav className="flex gap-2">
{links.map(([to, label]) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm ${isActive ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white'}`
}
>
{label}
</NavLink>
))}
</nav>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-8">
<Routes>
<Route path="/" element={<ProvidersPage />} />
<Route path="/models" element={<ModelsPage />} />
<Route path="/calls" element={<CallsPage />} />
<Route path="/calls/:callId" element={<CallDetailPage />} />
<Route path="/settings" element={<ObservabilityPage />} />
</Routes>
</main>
</div>
)
}
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router-dom'
import { api } from '../../api/client'
export function CallDetailPage() {
const { callId = '' } = useParams()
const detail = useQuery({ queryKey: ['call', callId], queryFn: () => api.call(callId) })
if (!detail.data) return <p></p>
const { call, request, response_chunks: chunks } = detail.data
return <section className="grid gap-6">
<div><h1>{call.display_name}</h1><p><code>{call.call_id}</code> · {call.status}</p></div>
<div className="grid gap-3 md:grid-cols-4">
<Metric label="TTFB" value={call.ttfb_ms} suffix="ms" /><Metric label="TTFT" value={call.ttft_ms} suffix="ms" />
<Metric label="Duration" value={call.duration_ms} suffix="ms" /><Metric label="Total tokens" value={call.total_tokens} />
</div>
<Payload title="Request" value={request ?? '详细模式未记录'} />
<Payload title="Response stream" value={chunks.length ? chunks : '详细模式未记录'} />
</section>
}
function Metric({ label, value, suffix = '' }: { label: string; value?: number; suffix?: string }) {
return <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"><p>{label}</p><div className="mt-2 text-xl">{value ?? '—'} {value == null ? '' : suffix}</div></div>
}
function Payload({ title, value }: { title: string; value: unknown }) {
return <div><h2>{title}</h2><pre className="mt-2 max-h-[32rem] overflow-auto rounded-xl border border-zinc-800 bg-black p-4 text-xs text-zinc-300">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>
}
+17
View File
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
import { Link } from 'react-router-dom'
export function CallsPage() {
const calls = useQuery({ queryKey: ['calls'], queryFn: api.calls, refetchInterval: 3000 })
return <section><div><h1>LLM Calls</h1><p> Provider </p></div>
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<table><thead><tr><th></th><th></th><th></th><th>TTFT</th><th></th><th>Tokens</th></tr></thead>
<tbody>{calls.data?.map((call) => <tr key={call.call_id}>
<td><Link className="text-blue-400 hover:underline" to={`/calls/${encodeURIComponent(call.call_id)}`}>{new Date(call.created_at_ms).toLocaleString()}</Link></td><td>{call.display_name}<small>{call.model_id}</small></td><td>{call.status}</td>
<td>{call.ttft_ms == null ? '—' : `${call.ttft_ms} ms`}</td><td>{call.duration_ms == null ? '—' : `${call.duration_ms} ms`}</td><td>{call.total_tokens ?? '—'}</td>
</tr>)}</tbody></table>
</div>
</section>
}
@@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api } from '../../api/client'
import type { ProviderModel } from '../../api/types'
export function ModelsPage() {
const client = useQueryClient()
const models = useQuery({ queryKey: ['models'], queryFn: api.models })
const save = useMutation({
mutationFn: ({ model, input }: { model: ProviderModel; input: ModelEdit }) =>
api.saveModels(model.provider_id, [{
model_id: model.model_id, display_name: input.displayName, enabled: input.enabled, sort_order: model.sort_order,
context_window_tokens: input.contextWindow || undefined, max_output_tokens: input.maxOutput || undefined,
reasoning_enabled: input.reasoning, reasoning_effort: input.effort || undefined,
extra_params: model.extra_params,
}]),
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
})
return <section><div><h1>Models</h1><p>Hash Cursor 使</p></div>
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
<table><thead><tr><th>Hash / Provider ID</th><th>Display name</th><th>Context</th><th>Max output</th><th>Reasoning</th><th></th><th></th></tr></thead>
<tbody>{models.data?.map((model) => <ModelRow key={model.model_hash} model={model} onSave={(input) => save.mutate({ model, input })} />)}</tbody></table>
</div>
</section>
}
interface ModelEdit { displayName: string; enabled: boolean; contextWindow: number; maxOutput: number; reasoning: boolean; effort: string }
function ModelRow({ model, onSave }: { model: ProviderModel; onSave: (input: ModelEdit) => void }) {
const [displayName, setDisplayName] = useState(model.display_name)
const [contextWindow, setContextWindow] = useState(model.context_window_tokens ?? 0)
const [maxOutput, setMaxOutput] = useState(model.max_output_tokens ?? 0)
const [reasoning, setReasoning] = useState(model.reasoning_enabled)
const [effort, setEffort] = useState(model.reasoning_effort ?? '')
const value = (enabled: boolean): ModelEdit => ({ displayName, enabled, contextWindow, maxOutput, reasoning, effort })
return <tr>
<td><code>{model.model_hash}</code><small>{model.model_id}</small></td>
<td><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></td>
<td><input type="number" value={contextWindow || ''} onChange={(event) => setContextWindow(Number(event.target.value))} /></td>
<td><input type="number" value={maxOutput || ''} onChange={(event) => setMaxOutput(Number(event.target.value))} /></td>
<td><div className="flex items-center gap-2"><input className="h-4 w-4" type="checkbox" checked={reasoning} onChange={(event) => setReasoning(event.target.checked)} /><input placeholder="effort" value={effort} onChange={(event) => setEffort(event.target.value)} /></div></td>
<td>{model.enabled ? 'Enabled' : 'Disabled'}</td>
<td><div className="flex gap-2"><button onClick={() => onSave(value(model.enabled))}></button><button onClick={() => onSave(value(!model.enabled))}>{model.enabled ? '停用' : '启用'}</button></div></td>
</tr>
}
@@ -0,0 +1,38 @@
import { useState } from 'react'
import type { Provider, ProviderInput, ProviderType } from '../../api/types'
export function ProviderForm({ provider, onSave, busy }: { provider?: Provider; onSave: (value: ProviderInput) => void; busy: boolean }) {
const [name, setName] = useState(provider?.name ?? '')
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type ?? 'openai-chat')
const [baseUrl, setBaseUrl] = useState(provider?.base_url ?? 'https://api.openai.com/v1')
const [apiKey, setApiKey] = useState('')
const [headers, setHeaders] = useState(JSON.stringify(provider?.custom_headers ?? {}, null, 2))
return (
<form
className="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 md:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
onSave({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey || undefined, custom_headers: JSON.parse(headers) })
}}
>
<Field label="名称"><input value={name} onChange={(e) => setName(e.target.value)} required /></Field>
<Field label="类型">
<select value={providerType} onChange={(e) => setProviderType(e.target.value as ProviderType)}>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic</option>
</select>
</Field>
<Field label="Base URL"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} required /></Field>
<Field label="API Key"><input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></Field>
<Field label="Custom headers (JSON)"><textarea value={headers} onChange={(e) => setHeaders(e.target.value)} /></Field>
<div className="md:col-span-2"><button disabled={busy}>{busy ? '保存中…' : provider ? '保存 Provider' : '添加 Provider'}</button></div>
</form>
)
}
function Field({ label, children }: React.PropsWithChildren<{ label: string }>) {
return <label className="grid gap-2 text-sm text-zinc-400"><span>{label}</span>{children}</label>
}
@@ -0,0 +1,65 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api } from '../../api/client'
import { ProviderForm } from './ProviderForm'
import type { Provider } from '../../api/types'
export function ProvidersPage() {
const client = useQueryClient()
const providers = useQuery({ queryKey: ['providers'], queryFn: api.providers })
const [discoveries, setDiscoveries] = useState<Record<number, string[]>>({})
const [editing, setEditing] = useState<Provider>()
const create = useMutation({
mutationFn: api.createProvider,
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
})
const update = useMutation({
mutationFn: ({ id, value }: { id: number; value: Parameters<typeof api.updateProvider>[1] }) => api.updateProvider(id, value),
onSuccess: () => { setEditing(undefined); client.invalidateQueries({ queryKey: ['providers'] }) },
})
const remove = useMutation({
mutationFn: api.deleteProvider,
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
})
const discover = useMutation({
mutationFn: api.discoverModels,
onSuccess: (result, id) => setDiscoveries((current) => ({ ...current, [id]: result.models })),
})
const save = useMutation({
mutationFn: ({ id, model }: { id: number; model: string }) => api.saveModels(id, [{
model_id: model, display_name: model, enabled: true, sort_order: 0,
reasoning_enabled: false, extra_params: {},
}]),
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
})
return (
<section className="grid gap-8">
<div><h1>Provider</h1><p> Provider </p></div>
<ProviderForm key={editing?.provider_id ?? 'new'} provider={editing} onSave={(value) => editing
? update.mutate({ id: editing.provider_id, value })
: create.mutate(value)} busy={create.isPending || update.isPending} />
<div className="grid gap-4">
{providers.data?.map((provider) => (
<article key={provider.provider_id} className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<div className="flex items-start justify-between gap-4">
<div><h2>{provider.name}</h2><p>{provider.provider_type} · {provider.base_url}</p></div>
<div className="flex gap-2"><button onClick={() => setEditing(provider)}></button><button onClick={() => discover.mutate(provider.provider_id)}></button><button className="danger" onClick={() => remove.mutate(provider.provider_id)}></button></div>
</div>
{discoveries[provider.provider_id] && (
<div className="mt-4 grid gap-2 border-t border-zinc-800 pt-4">
{discoveries[provider.provider_id].map((model) => (
<div key={model} className="flex items-center justify-between rounded-md bg-zinc-950 px-3 py-2 text-sm">
<code>{model}</code>
<button onClick={() => save.mutate({ id: provider.provider_id, model })}></button>
</div>
))}
</div>
)}
</article>
))}
</div>
</section>
)
}
@@ -0,0 +1,15 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
export function ObservabilityPage() {
const client = useQueryClient()
const settings = useQuery({ queryKey: ['observability'], queryFn: api.observability })
const update = useMutation({ mutationFn: api.setObservability, onSuccess: () => client.invalidateQueries({ queryKey: ['observability'] }) })
return <section><h1>Observability</h1><p></p>
<label className="mt-6 flex max-w-xl items-center justify-between rounded-xl border border-zinc-800 bg-zinc-900 p-5">
<span><strong></strong><p></p></span>
<input className="h-5 w-5" type="checkbox" checked={settings.data?.detailed ?? false} onChange={(event) => update.mutate(event.target.checked)} />
</label>
</section>
}
+19
View File
@@ -0,0 +1,19 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import { App } from './app/App'
import './styles/index.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<HashRouter>
<App />
</HashRouter>
</QueryClientProvider>
</StrictMode>,
)
+17
View File
@@ -0,0 +1,17 @@
@import "tailwindcss";
@layer base {
body { @apply m-0 bg-zinc-950 font-sans text-zinc-100 antialiased; }
h1 { @apply text-2xl font-semibold tracking-tight; }
h2 { @apply text-base font-semibold; }
p { @apply mt-1 text-sm text-zinc-400; }
input, select, textarea { @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-zinc-100 outline-none focus:border-blue-500; }
textarea { @apply min-h-24 font-mono text-xs; }
button { @apply rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50; }
table { @apply w-full border-collapse bg-zinc-900 text-left text-sm; }
th { @apply bg-zinc-950 px-4 py-3 font-medium text-zinc-400; }
td { @apply border-t border-zinc-800 px-4 py-3; }
td small { @apply block text-zinc-500; }
code { @apply font-mono text-xs text-blue-300; }
button.danger { @apply bg-red-950 text-red-300 hover:bg-red-900; }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/console/',
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: { '/api': 'http://127.0.0.1:3000' },
},
})
+70
View File
@@ -0,0 +1,70 @@
# Cursor Protocol Debugger
[中文](README.md) | [English](README.en.md)
This standalone local Cursor API debugging service forwards every HTTP request outside the `__debuger__` debugging namespace to the fixed upstream `https://api2.cursor.sh`, preserving the method, path, query, headers, and body. It continues to capture `BidiAppend`, `RunSSE`, Fork Chat, and model-discovery traffic.
It is not a general-purpose HTTP proxy, does not handle `CONNECT`, requires no CA certificate, and does not modify the system proxy.
## Start
Generate the sibling `cursor-proto` module's Go code before the first build:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
The service listens on a single port:
- Cursor API service: `http://127.0.0.1:9090`
- Debugging UI: `http://127.0.0.1:9090/__debuger__/`
- Debugging API: `http://127.0.0.1:9090/__debuger__/api/*`
- Fixed upstream: `https://api2.cursor.sh`
The debugging UI opens automatically after startup.
## Configure Cursor
Quit Cursor completely, then launch it from a terminal with the local API address:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` overrides the Agent API endpoint. `CURSOR_API_BASE_URL` also routes requests such as authentication that use the base API address through this service. Cursor proxy and Network settings do not need to be changed.
## Build
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## Dependency Layout
The debugger is an independent Go module. Cursor protobuf message packages are generated by the sibling `cursor-proto` module. The generated `gen/` directory is not committed, so run its `scripts/generate.sh` before the first build. This project does not depend on the outer `cursor-byok` Go module.
## Options
```text
-addr Cursor API service listen address; default: 127.0.0.1:9090
-max-exchanges Maximum exchanges retained in memory; default: 200
-db SQLite database path; defaults to the user configuration directory
-open Open the browser after startup; default: true
```
## Data Handling
- Every request received by the service is forwarded to `https://api2.cursor.sh`; clients cannot select another upstream.
- The `__debuger__` namespace is reserved for the local debugging page and API and is never forwarded upstream.
- `RunSSE` is decoded incrementally using the 5-byte Connect frame header and supports per-frame gzip decompression.
- `BidiAppendRequest.data` is further decoded as `agent.v1.AgentClientMessage`.
- Fork Chat's `ForkBackgroundComposer`, `NotifyConversationClone`, and `UploadConversationBlobs` traffic is decoded bidirectionally as protobuf JSON.
- `CppService/AvailableModels`, `AiService/AvailableModels`, `GetDefaultModel`, and `GetDefaultModelNudgeData` are decoded bidirectionally.
- Requests can be filtered by time and protocol `request_id`; the UI can query by `conversation_id` and group requests by conversation.
- Complete captures are stored in SQLite and remain queryable after restart; `max-exchanges` only limits hot in-memory data.
- Sensitive headers such as `Authorization`, `Cookie`, and `Set-Cookie` are hidden in the UI by default.
- Raw bodies are retained up to 2 MiB per side by default; capture limits never truncate forwarded traffic.
+70
View File
@@ -0,0 +1,70 @@
# Cursor 协议调试器
[中文](README.md) | [English](README.en.md)
这是一个独立运行的本地 Cursor API 调试服务。除 `__debuger__` 调试命名空间外,进入服务端口的 HTTP 请求都会保留方法、路径、查询参数、请求头和请求体,并转发到固定上游 `https://api2.cursor.sh`。服务同时记录 `BidiAppend``RunSSE`、Fork Chat 和模型发现等流量。
它不是通用 HTTP 代理,不处理 `CONNECT`,不需要 CA 证书,也不会修改系统代理。
## 启动
首次构建前先生成相邻 `cursor-proto` 项目的 Go 代码:
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go run .
```
服务只监听一个端口:
- Cursor API 服务:`http://127.0.0.1:9090`
- 调试界面:`http://127.0.0.1:9090/__debuger__/`
- 调试 API`http://127.0.0.1:9090/__debuger__/api/*`
- 固定上游:`https://api2.cursor.sh`
启动后会自动打开调试界面。
## 配置 Cursor
完全退出 Cursor 后,从终端指定本地 API 地址启动:
```bash
CURSOR_API_ENDPOINT=http://127.0.0.1:9090 \
CURSOR_API_BASE_URL=http://127.0.0.1:9090 \
/Applications/Cursor.app/Contents/MacOS/Cursor
```
`CURSOR_API_ENDPOINT` 覆盖 Agent API 地址;`CURSOR_API_BASE_URL` 让使用基础 API 地址的认证等请求也经过本服务。无需修改 Cursor 代理设置或 Network 设置。
## 构建
```bash
(cd ../cursor-proto && ./scripts/generate.sh)
go build -o ./bin/cursor-proxy-debugger .
```
## 依赖说明
调试器是独立 Go module。Cursor protobuf 消息包由相邻的 `cursor-proto` module 生成;生成的 `gen/` 目录不提交到 Git,因此首次构建前需要运行其 `scripts/generate.sh`。本项目不依赖外层 `cursor-byok` Go module。
## 参数
```text
-addr Cursor API 服务监听地址,默认 127.0.0.1:9090
-max-exchanges 内存中保留的最大请求数,默认 200
-db SQLite 数据库路径,默认位于用户配置目录
-open 启动后是否打开浏览器,默认 true
```
## 数据处理
- 所有服务端口收到的请求都固定转发到 `https://api2.cursor.sh`,不会接受客户端指定的其他上游。
- `__debuger__` 命名空间由本地调试页面和调试 API 保留,不会转发到上游。
- `RunSSE` 按 5 字节 Connect 帧头增量拆帧,支持逐帧 gzip 解压。
- `BidiAppendRequest.data` 会继续解码为 `agent.v1.AgentClientMessage`
- Fork Chat 的 `ForkBackgroundComposer``NotifyConversationClone``UploadConversationBlobs` 会双向解码为 protobuf JSON。
- `CppService/AvailableModels``AiService/AvailableModels``GetDefaultModel``GetDefaultModelNudgeData` 会双向解码模型相关数据。
- 请求列表支持按时间和协议 `request_id` 过滤;调试界面可按 `conversation_id` 查询并按会话分组。
- 完整抓包写入 SQLite,重启后仍可查询;`max-exchanges` 只限制内存热数据数量。
- `Authorization``Cookie``Set-Cookie` 等敏感请求头在界面中默认隐藏。
- 单侧原始正文默认最多保留 2 MiB;转发内容不会被抓取上限截断。
@@ -1,4 +1,5 @@
package proxydebugger
// capture.go 在不影响上游转发的前提下截取有限大小的 HTTP 流。
package main
import (
"bytes"
@@ -7,6 +8,7 @@ import (
"sync"
)
// captureReadCloser 包装响应体并并发安全地累计诊断副本。
type captureReadCloser struct {
source io.ReadCloser
mu sync.Mutex
@@ -19,6 +21,7 @@ type captureReadCloser struct {
onDone func(captured []byte, size int64, truncated bool, readErr error)
}
// newCaptureReadCloser 创建带分块和完成回调的捕获读取器。
func newCaptureReadCloser(
source io.ReadCloser,
limit int,
@@ -33,6 +36,7 @@ func newCaptureReadCloser(
}
}
// Read 转发读取结果并保存不超过限制的副本。
func (reader *captureReadCloser) Read(payload []byte) (int, error) {
read, err := reader.source.Read(payload)
if read > 0 {
@@ -61,12 +65,14 @@ func (reader *captureReadCloser) Read(payload []byte) (int, error) {
return read, err
}
// Close 关闭原始响应体并保证完成回调只执行一次。
func (reader *captureReadCloser) Close() error {
err := reader.source.Close()
reader.finish(err)
return err
}
// finish 固化捕获快照并在锁外调用完成回调。
func (reader *captureReadCloser) finish(readErr error) {
reader.mu.Lock()
if reader.done {
@@ -83,6 +89,7 @@ func (reader *captureReadCloser) finish(readErr error) {
}
}
// rawHex 把捕获字节编码为便于 JSON 持久化的十六进制文本。
func rawHex(payload []byte) string {
return hex.EncodeToString(payload)
}
+345
View File
@@ -0,0 +1,345 @@
// capture_pipeline.go 负责服务请求响应体的捕获、解码和事件追加。
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
)
// exchangeIDContextKey 隔离反向服务内部使用的捕获编号。
type exchangeIDContextKey struct{}
// captureRequest 捕获请求元数据并安装请求体读取器。
func (server *Server) captureRequest(request *http.Request) *http.Request {
if request == nil {
return request
}
server.captureMu.RLock()
id := strconv.FormatUint(server.counter.Add(1), 10)
path := request.URL.Path
upstreamURL := *request.URL
upstreamURL.Scheme = server.upstream.Scheme
upstreamURL.Host = server.upstream.Host
upstreamURL.User = nil
requestContentType := request.Header.Get("Content-Type")
requestCodec := requestContentCodec(path, request.Header)
exchange := &Exchange{
ExchangeSummary: ExchangeSummary{
ID: id,
StartedAt: time.Now(),
Method: request.Method,
URL: upstreamURL.String(),
Host: server.upstream.Host,
Path: path,
State: "pending",
},
Request: Payload{
Headers: sortedHeaders(request.Header),
ContentType: requestContentType,
ContentCodec: requestCodec,
Frames: make([]FrameView, 0),
},
Response: Payload{Headers: make([]Header, 0), Frames: make([]FrameView, 0)},
}
server.store.create(exchange)
server.captureMu.RUnlock()
request = request.WithContext(context.WithValue(request.Context(), exchangeIDContextKey{}, id))
request.Close = false
if request.Body == nil {
server.finishRequestBody(id, path, requestContentType, requestCodec, nil, 0, false, nil)
return request
}
var frameDecoder *connectFrameDecoder
if messageType := streamingRequestMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
requestCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendRequestFrame(id, frame) },
)
}
request.Body = newCaptureReadCloser(
request.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishRequestBody(id, path, requestContentType, requestCodec, captured, size, truncated, readErr)
},
)
return request
}
// clearExchanges 清空内存和持久化捕获,并重置递增编号。
func (server *Server) clearExchanges() error {
server.captureMu.Lock()
defer server.captureMu.Unlock()
if err := server.store.clear(); err != nil {
return err
}
server.counter.Store(0)
return nil
}
// captureResponse 创建响应记录更新并包装响应体捕获器。
func (server *Server) captureResponse(response *http.Response) error {
if response == nil {
return nil
}
id := exchangeID(response.Request)
if id == "" {
return nil
}
path := ""
if response.Request != nil && response.Request.URL != nil {
path = response.Request.URL.Path
}
responseCodec := responseContentCodec(path, response.Header)
responseContentType := response.Header.Get("Content-Type")
server.store.update(id, func(exchange *Exchange) {
exchange.Status = response.StatusCode
exchange.State = "streaming"
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.Response.Headers = sortedHeaders(response.Header)
exchange.Response.ContentType = responseContentType
exchange.Response.ContentCodec = responseCodec
})
if response.Body == nil {
server.finishResponseBody(id, path, responseContentType, responseCodec, nil, 0, false, nil)
return nil
}
var frameDecoder *connectFrameDecoder
if messageType := streamingResponseMessageType(path); messageType != "" {
frameDecoder = newConnectFrameDecoder(
messageType,
responseCodec,
server.config.MaxFrames,
func(frame FrameView) { server.appendResponseFrame(id, frame) },
)
}
response.Body = newCaptureReadCloser(
response.Body,
server.config.MaxCaptureBytes,
func(chunk []byte) {
if frameDecoder != nil {
frameDecoder.Write(chunk)
}
},
func(captured []byte, size int64, truncated bool, readErr error) {
if frameDecoder != nil {
frameDecoder.Close()
}
server.finishResponseBody(id, path, responseContentType, responseCodec, captured, size, truncated, readErr)
},
)
return nil
}
// failExchange 保存反向转发失败状态。
func (server *Server) failExchange(request *http.Request, upstreamErr error) {
id := exchangeID(request)
if id == "" || upstreamErr == nil {
return
}
server.store.update(id, func(exchange *Exchange) {
exchange.State = "error"
exchange.Error = upstreamErr.Error()
exchange.DurationMS = elapsedMS(exchange.StartedAt)
})
}
// finishRequestBody 解压、解码并保存完整请求体的最终状态。
func (server *Server) finishRequestBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryRequest(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("请求正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, requestID, conversationID, decodeErr := "", "", "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, requestID, conversationID, decodeErr = decodeUnaryRequest(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
server.store.update(id, func(exchange *Exchange) {
exchange.RequestBytes = size
exchange.Request.Size = size
exchange.Request.RawHex = rawHex(captured)
exchange.Request.RawTruncated = truncated
if decodedJSON != "" {
exchange.Request.DecodedJSON = decodedJSON
exchange.Request.DecodedLang = decodedLang
}
if kind != "" {
exchange.RequestKind = kind
}
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
if decodeErr != nil {
exchange.Request.DecodeError = decodeErr.Error()
}
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.Error = readErr.Error()
}
})
}
// requestContentCodec 读取请求方向的 Connect 或 HTTP 压缩编码。
func requestContentCodec(path string, headers http.Header) string {
if streamingRequestMessageType(path) != "" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
// responseContentCodec 读取响应方向的 Connect 或 HTTP 压缩编码。
func responseContentCodec(path string, headers http.Header) string {
if streamingResponseMessageType(path) != "" {
return strings.TrimSpace(headers.Get("Connect-Content-Encoding"))
}
if !decodesUnaryResponse(path) {
if codec := strings.TrimSpace(headers.Get("Connect-Content-Encoding")); codec != "" {
return codec
}
}
return strings.TrimSpace(headers.Get("Content-Encoding"))
}
// finishResponseBody 解压、解码并保存完整响应体的最终状态。
func (server *Server) finishResponseBody(id, path, contentType, codec string, captured []byte, size int64, truncated bool, readErr error) {
decodePayload := captured
var contentDecodeErr error
decodeProto := decodesUnaryResponse(path) && isUnaryProtoContentType(contentType)
if decodeProto && truncated {
contentDecodeErr = errors.New("响应正文超过抓取上限,无法完整解码")
} else if decodeProto && codec != "" && !strings.EqualFold(codec, "identity") {
decodePayload, contentDecodeErr = decompressPayload(captured, codec)
}
decodedJSON, decodedLang, kind, decodeErr := "", "", "", contentDecodeErr
if decodeProto && decodeErr == nil {
decodedJSON, kind, decodeErr = decodeUnaryResponse(path, decodePayload)
}
if decodeErr == nil && decodedJSON != "" {
decodedLang = "json"
} else if !decodeProto {
decodedJSON, decodedLang, decodeErr = decodeCapturedContent(captured, contentType, codec)
}
server.store.update(id, func(exchange *Exchange) {
exchange.ResponseBytes = size
exchange.Response.Size = size
exchange.Response.RawHex = rawHex(captured)
exchange.Response.RawTruncated = truncated
if decodedJSON != "" {
exchange.Response.DecodedJSON = decodedJSON
exchange.Response.DecodedLang = decodedLang
}
if kind != "" {
exchange.ResponseKind = kind
}
if decodeErr != nil {
exchange.Response.DecodeError = decodeErr.Error()
}
exchange.DurationMS = elapsedMS(exchange.StartedAt)
exchange.State = "completed"
if readErr != nil && !errors.Is(readErr, io.EOF) {
exchange.State = "error"
exchange.Error = readErr.Error()
}
})
}
// appendRequestFrame 把请求方向的流式帧追加到临时快照。
func (server *Server) appendRequestFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Request.Frames) < server.config.MaxFrames {
exchange.Request.Frames = append(exchange.Request.Frames, frame)
}
if frame.Kind != "" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
})
}
// appendResponseFrame 把响应方向的流式帧追加到临时快照。
func (server *Server) appendResponseFrame(id string, frame FrameView) {
server.store.updateTransient(id, func(exchange *Exchange) {
if len(exchange.Response.Frames) < server.config.MaxFrames {
exchange.Response.Frames = append(exchange.Response.Frames, frame)
}
exchange.FrameCount = len(exchange.Response.Frames)
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
if frame.Error != "" {
exchange.Response.DecodeError = frame.Error
}
})
}
// exchangeID 从请求上下文读取捕获记录编号。
func exchangeID(request *http.Request) string {
if request == nil {
return ""
}
value, ok := request.Context().Value(exchangeIDContextKey{}).(string)
if !ok {
return ""
}
return value
}
// browserAddress 把通配监听地址转换为浏览器可访问的回环地址。
func browserAddress(address string) string {
host, port, err := net.SplitHostPort(address)
if err != nil {
return address
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
return net.JoinHostPort(host, port)
}
// validateLoopbackAddress 拒绝把调试服务暴露到非回环网卡。
func validateLoopbackAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("调试服务监听地址无效:%w", err)
}
if strings.EqualFold(host, "localhost") {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("调试服务只能监听本机回环地址")
}
return nil
}
+380
View File
@@ -0,0 +1,380 @@
// decode.go 解析 Connect 帧、压缩载荷和 Cursor protobuf 消息视图。
package main
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/proto"
)
// maxConnectFrameBytes 防止异常帧长度导致调试器分配过大内存。
const maxConnectFrameBytes = 64 << 20
// 协议路径常量用于选择精确的 protobuf 请求、响应和流式消息类型。
const (
bidiAppendPath = "/aiserver.v1.BidiService/BidiAppend"
forkBackgroundComposerPath = "/aiserver.v1.BackgroundComposerService/ForkBackgroundComposer"
notifyConversationClonePath = "/agent.v1.AgentService/NotifyConversationClone"
uploadConversationBlobsPath = "/agent.v1.AgentService/UploadConversationBlobs"
cppAvailableModelsPath = "/aiserver.v1.CppService/AvailableModels"
aiAvailableModelsPath = "/aiserver.v1.AiService/AvailableModels"
aiGetDefaultModelPath = "/aiserver.v1.AiService/GetDefaultModel"
aiDefaultModelNudgeDataPath = "/aiserver.v1.AiService/GetDefaultModelNudgeData"
mcpGetKnownServersPath = "/aiserver.v1.MCPRegistryService/GetKnownServers"
serverGetConfigPath = "/aiserver.v1.ServerConfigService/GetServerConfig"
runSSEPath = "/agent.v1.AgentService/RunSSE"
)
// connectFrameDecoder 在任意读取边界下累计并解析 Connect 五字节帧。
type connectFrameDecoder struct {
buffer []byte
messageType string
codec string
maxFrames int
frameCount int
onFrame func(FrameView)
}
// newConnectFrameDecoder 创建指定 protobuf 类型的流式解码器。
func newConnectFrameDecoder(messageType string, codec string, maxFrames int, onFrame func(FrameView)) *connectFrameDecoder {
return &connectFrameDecoder{
messageType: messageType,
codec: strings.TrimSpace(codec),
maxFrames: maxFrames,
onFrame: onFrame,
}
}
// Write 追加任意长度的网络片段并尽可能产出完整帧。
func (decoder *connectFrameDecoder) Write(payload []byte) {
if len(payload) == 0 || decoder.frameCount >= decoder.maxFrames {
return
}
decoder.buffer = append(decoder.buffer, payload...)
for len(decoder.buffer) >= 5 && decoder.frameCount < decoder.maxFrames {
flags := decoder.buffer[0]
length := int(binary.BigEndian.Uint32(decoder.buffer[1:5]))
if length < 0 || length > maxConnectFrameBytes {
decoder.emit(FrameView{Flags: flags, Length: length, Error: "Connect 帧长度异常"})
decoder.buffer = nil
return
}
if len(decoder.buffer) < 5+length {
return
}
framePayload := append([]byte(nil), decoder.buffer[5:5+length]...)
decoder.buffer = decoder.buffer[5+length:]
decoder.emit(decoder.decode(flags, framePayload))
}
}
// Close 标记流结束并暴露尚未完整的尾部错误。
func (decoder *connectFrameDecoder) Close() {
if len(decoder.buffer) > 0 && decoder.frameCount < decoder.maxFrames {
decoder.emit(FrameView{
Length: len(decoder.buffer),
RawHex: clippedHex(decoder.buffer, 4096),
Error: "流结束时仍有不完整的 Connect 帧",
})
}
decoder.buffer = nil
}
// emit 在达到帧数上限前调用帧回调。
func (decoder *connectFrameDecoder) emit(frame FrameView) {
frame.Index = decoder.frameCount
decoder.frameCount++
if decoder.onFrame != nil {
decoder.onFrame(frame)
}
}
// decode 解压并解析单条 Connect 帧。
func (decoder *connectFrameDecoder) decode(flags uint8, payload []byte) FrameView {
frame := FrameView{
Flags: flags,
Length: len(payload),
Compressed: flags&0x01 != 0,
EndStream: flags&0x02 != 0,
RawHex: clippedHex(payload, 4096),
}
decoded := payload
if frame.Compressed {
var err error
decoded, err = decompressPayload(payload, decoder.codec)
if err != nil {
frame.Error = err.Error()
return frame
}
}
if frame.EndStream {
frame.Kind = "end_stream"
frame.MessageType = "connect.error.v1.EndStreamResponse"
frame.JSON = prettyJSON(decoded)
return frame
}
message := newMessage(decoder.messageType)
if message == nil {
frame.Error = "未知的 protobuf 消息类型"
return frame
}
if err := proto.Unmarshal(decoded, message); err != nil {
frame.Error = fmt.Sprintf("protobuf 解码失败:%v", err)
return frame
}
frame.MessageType = decoder.messageType
frame.Kind = activeOneofName(message)
if requestID, ok := message.(*aiserverv1.BidiRequestId); ok {
frame.RequestID = strings.TrimSpace(requestID.GetRequestId())
}
frame.JSON = marshalProtoJSON(message)
return frame
}
// decompressPayload 使用协议声明的编码解压载荷。
func decompressPayload(payload []byte, codec string) ([]byte, error) {
if codec != "" && !strings.EqualFold(codec, "gzip") {
return nil, fmt.Errorf("暂不支持压缩算法 %q", codec)
}
reader, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("gzip 解压失败:%w", err)
}
defer reader.Close()
decoded, err := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
if err != nil {
return nil, fmt.Errorf("读取 gzip 内容失败:%w", err)
}
if len(decoded) > maxConnectFrameBytes {
return nil, fmt.Errorf("gzip 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
return decoded, nil
}
// decodeUnaryRequest 解析单次 RPC 请求并提取关键关联标识。
func decodeUnaryRequest(path string, payload []byte) (decodedJSON string, kind string, requestID string, conversationID string, err error) {
switch path {
case bidiAppendPath:
request := &aiserverv1.BidiAppendRequest{}
if err := proto.Unmarshal(payload, request); err != nil {
return "", "", "", "", err
}
requestID := strings.TrimSpace(request.GetRequestId().GetRequestId())
outer := marshalProtoJSON(request)
clientMessage, clientKind, decodeErr := decodeBidiClientMessage(request)
if decodeErr != nil || clientMessage == nil {
return outer, "bidi_append", requestID, "", decodeErr
}
combined := struct {
BidiAppendRequest json.RawMessage `json:"bidi_append_request"`
AgentClientKind string `json:"agent_client_kind"`
AgentClient json.RawMessage `json:"agent_client_message"`
}{
BidiAppendRequest: json.RawMessage(outer),
AgentClientKind: clientKind,
AgentClient: json.RawMessage(marshalProtoJSON(clientMessage)),
}
formatted, marshalErr := json.MarshalIndent(combined, "", " ")
return string(formatted), clientKind, requestID, conversationIDFromClientMessage(clientMessage), marshalErr
}
message, kind := unaryRequestMessage(path)
if message == nil {
return "", "", "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", "", "", err
}
return marshalProtoJSON(message), kind, "", conversationIDFromUnaryRequest(message), nil
}
// decodeBidiClientMessage 解析 BidiAppend 携带的十六进制 Agent 消息。
func decodeBidiClientMessage(request *aiserverv1.BidiAppendRequest) (*agentv1.AgentClientMessage, string, error) {
if request == nil {
return nil, "", nil
}
if strings.TrimSpace(request.GetData()) != "" {
payload, err := hex.DecodeString(strings.TrimSpace(request.GetData()))
if err != nil {
return nil, "", fmt.Errorf("decode hex agent client message failed: %w", err)
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(payload, message); err != nil {
return nil, "", fmt.Errorf("decode agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
if len(request.GetDataBinary()) == 0 {
return nil, "", nil
}
message := &agentv1.AgentClientMessage{}
if err := proto.Unmarshal(request.GetDataBinary(), message); err != nil {
return nil, "", fmt.Errorf("decode binary agent client message failed: %w", err)
}
return message, activeOneofName(message), nil
}
// conversationIDFromClientMessage 从 Agent 消息的会话字段提取会话标识。
func conversationIDFromClientMessage(message *agentv1.AgentClientMessage) string {
if message == nil {
return ""
}
if runRequest := message.GetRunRequest(); runRequest != nil {
return strings.TrimSpace(runRequest.GetConversationId())
}
if prewarmRequest := message.GetPrewarmRequest(); prewarmRequest != nil {
return strings.TrimSpace(prewarmRequest.GetConversationId())
}
return ""
}
// conversationIDFromUnaryRequest 从已知 RPC 请求中提取会话标识。
func conversationIDFromUnaryRequest(message proto.Message) string {
switch typed := message.(type) {
case *agentv1.NotifyConversationCloneRequest:
return strings.TrimSpace(typed.GetConversationId())
case *agentv1.UploadConversationBlobsRequest:
return strings.TrimSpace(typed.GetConversationId())
default:
return ""
}
}
// decodeUnaryResponse 解析单次 RPC 响应并生成 JSON 视图。
func decodeUnaryResponse(path string, payload []byte) (decodedJSON string, kind string, err error) {
message, kind := unaryResponseMessage(path)
if message == nil {
return "", "", nil
}
if err := proto.Unmarshal(payload, message); err != nil {
return "", "", err
}
return marshalProtoJSON(message), kind, nil
}
// hydrateStoredExchange 为历史捕获补齐正文和 Connect 帧视图。
func hydrateStoredExchange(exchange *Exchange) bool {
if exchange == nil || (exchange.State != "completed" && exchange.State != "streaming") {
return false
}
changed := false
if messageType := streamingRequestMessageType(exchange.Path); messageType != "" &&
len(exchange.Request.Frames) == 0 && exchange.Request.RawHex != "" && !exchange.Request.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Request.RawHex, messageType, exchange.Request.ContentCodec)
if err != nil {
exchange.Request.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Request.Frames = frames
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.RequestKind = frame.Kind
}
if frame.RequestID != "" {
exchange.RequestID = frame.RequestID
}
}
}
changed = true
}
if messageType := streamingResponseMessageType(exchange.Path); messageType != "" &&
len(exchange.Response.Frames) == 0 && exchange.Response.RawHex != "" && !exchange.Response.RawTruncated {
frames, err := decodeStoredConnectFrames(exchange.Response.RawHex, messageType, exchange.Response.ContentCodec)
if err != nil {
exchange.Response.DecodeError = err.Error()
} else if len(frames) > 0 {
exchange.Response.Frames = frames
exchange.FrameCount = len(frames)
for _, frame := range frames {
if frame.Kind != "" && frame.Kind != "end_stream" {
exchange.ResponseKind = frame.Kind
}
}
}
changed = true
}
if isUnaryProtoContentType(exchange.Request.ContentType) && exchange.Request.DecodedJSON == "" && !exchange.Request.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Request.RawHex, exchange.Request.ContentCodec)
if err == nil {
decoded, kind, requestID, conversationID, decodeErr := decodeUnaryRequest(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Request.DecodedJSON = decoded
exchange.Request.DecodedLang = "json"
exchange.RequestKind = kind
if requestID != "" {
exchange.RequestID = requestID
}
if conversationID != "" {
exchange.ConversationID = conversationID
}
changed = true
}
}
if err != nil {
exchange.Request.DecodeError = err.Error()
changed = true
}
}
if isUnaryProtoContentType(exchange.Response.ContentType) && exchange.Response.DecodedJSON == "" && !exchange.Response.RawTruncated {
payload, err := decodeStoredRawPayload(exchange.Response.RawHex, exchange.Response.ContentCodec)
if err == nil {
decoded, kind, decodeErr := decodeUnaryResponse(exchange.Path, payload)
if decodeErr != nil {
err = decodeErr
} else if decoded != "" {
exchange.Response.DecodedJSON = decoded
exchange.Response.DecodedLang = "json"
exchange.ResponseKind = kind
changed = true
}
}
if err != nil {
exchange.Response.DecodeError = err.Error()
changed = true
}
}
if exchange.Request.DecodedJSON == "" && exchange.Request.RawHex != "" &&
!isProtoContentType(exchange.Request.ContentType) && streamingRequestMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Request) {
changed = true
}
}
if exchange.Response.DecodedJSON == "" && exchange.Response.RawHex != "" &&
!isProtoContentType(exchange.Response.ContentType) && streamingResponseMessageType(exchange.Path) == "" {
if hydrateStoredTextPayload(&exchange.Response) {
changed = true
}
}
return changed
}
// hydrateStoredTextPayload 为历史文本载荷补齐 JSON 视图。
func hydrateStoredTextPayload(payload *Payload) bool {
raw, err := hex.DecodeString(strings.TrimSpace(payload.RawHex))
if err != nil {
payload.DecodeError = fmt.Sprintf("解析已存储正文失败:%v", err)
return true
}
decoded, language, decodeErr := decodeCapturedContent(raw, payload.ContentType, payload.ContentCodec)
if decoded == "" && decodeErr == nil {
return false
}
payload.DecodedJSON = decoded
payload.DecodedLang = language
if decodeErr != nil {
payload.DecodeError = decodeErr.Error()
}
return true
}
// decodeCapturedContent 按媒体类型和压缩编码解码任意捕获正文。
+397
View File
@@ -0,0 +1,397 @@
// decode_stored.go 负责从持久化捕获记录恢复文本、帧和 protobuf 视图。
package main
import (
"bytes"
"compress/zlib"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/andybalholm/brotli"
agentv1 "github.com/leookun/cursor-byok/cursor-proto/gen/agent/v1"
aiserverv1 "github.com/leookun/cursor-byok/cursor-proto/gen/aiserver/v1"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/dynamicpb"
)
// decodeCapturedContent 按内容类型和压缩编码生成可读正文视图。
func decodeCapturedContent(payload []byte, contentType, codec string) (string, string, error) {
decoded, err := decodeHTTPContent(payload, codec)
if err != nil {
return "", "", err
}
if len(decoded) == 0 {
return "", "", nil
}
mediaType := normalizedMediaType(contentType)
if json.Valid(decoded) {
var formatted bytes.Buffer
if err := json.Indent(&formatted, decoded, "", " "); err != nil {
return string(decoded), "json", err
}
return formatted.String(), "json", nil
}
if mediaType == "application/x-www-form-urlencoded" && utf8.Valid(decoded) {
values, parseErr := url.ParseQuery(string(decoded))
if parseErr != nil {
return string(decoded), "plaintext", parseErr
}
formatted, marshalErr := json.MarshalIndent(values, "", " ")
return string(formatted), "json", marshalErr
}
if !isTextMediaType(mediaType) || !utf8.Valid(decoded) {
return "", "", nil
}
if strings.ContainsRune(string(decoded), '\x00') {
return "", "", nil
}
language := textLanguage(mediaType)
if strings.HasSuffix(mediaType, "+json") || mediaType == "application/json" {
return string(decoded), "json", fmt.Errorf("JSON 正文格式无效")
}
return string(decoded), language, nil
}
// decodeHTTPContent 解压 HTTP 内容编码并返回正文副本。
func decodeHTTPContent(payload []byte, codec string) ([]byte, error) {
encodings := strings.Split(strings.TrimSpace(codec), ",")
decoded := payload
for index := len(encodings) - 1; index >= 0; index-- {
encoding := strings.ToLower(strings.TrimSpace(encodings[index]))
switch encoding {
case "", "identity":
case "gzip", "x-gzip":
var err error
decoded, err = decompressPayload(decoded, "gzip")
if err != nil {
return nil, err
}
case "deflate":
reader, err := zlib.NewReader(bytes.NewReader(decoded))
if err != nil {
return nil, fmt.Errorf("deflate 解压失败:%w", err)
}
result, readErr := io.ReadAll(io.LimitReader(reader, maxConnectFrameBytes+1))
closeErr := reader.Close()
if readErr != nil {
return nil, fmt.Errorf("读取 deflate 内容失败:%w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("关闭 deflate 内容失败:%w", closeErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("deflate 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
case "br":
result, readErr := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(decoded)), maxConnectFrameBytes+1))
if readErr != nil {
return nil, fmt.Errorf("读取 Brotli 内容失败:%w", readErr)
}
if len(result) > maxConnectFrameBytes {
return nil, fmt.Errorf("Brotli 解压后超过 %d 字节限制", maxConnectFrameBytes)
}
decoded = result
default:
return nil, fmt.Errorf("暂不支持内容编码 %q", encoding)
}
}
return decoded, nil
}
// normalizedMediaType 删除参数并统一媒体类型大小写。
func normalizedMediaType(contentType string) string {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err == nil {
return strings.ToLower(mediaType)
}
return strings.ToLower(strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0]))
}
// isProtoContentType 判断媒体类型是否表示 protobuf 二进制。
func isProtoContentType(contentType string) bool {
return strings.Contains(normalizedMediaType(contentType), "proto")
}
// isTextMediaType 判断媒体类型是否适合直接作为文本展示。
func isTextMediaType(mediaType string) bool {
return strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") ||
strings.HasSuffix(mediaType, "+xml") || mediaType == "application/json" ||
mediaType == "application/xml" || mediaType == "application/javascript" ||
mediaType == "application/x-javascript" || mediaType == "application/graphql"
}
// textLanguage 为前端编辑器选择文本语言。
func textLanguage(mediaType string) string {
switch {
case strings.Contains(mediaType, "json"):
return "json"
case strings.Contains(mediaType, "xml"):
return "xml"
case strings.Contains(mediaType, "html"):
return "html"
case strings.Contains(mediaType, "javascript"):
return "javascript"
case strings.Contains(mediaType, "css"):
return "css"
default:
return "plaintext"
}
}
// decodeStoredConnectFrames 从持久化十六进制载荷恢复流式帧。
func decodeStoredConnectFrames(rawHexValue, messageType, codec string) ([]FrameView, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储 Connect 正文失败:%w", err)
}
frames := make([]FrameView, 0)
decoder := newConnectFrameDecoder(messageType, codec, defaultMaxFrames, func(frame FrameView) {
frames = append(frames, frame)
})
decoder.Write(payload)
decoder.Close()
return frames, nil
}
// isUnaryProtoContentType 判断媒体类型是否为可直接解码的 protobuf。
func isUnaryProtoContentType(contentType string) bool {
mediaType := normalizedMediaType(contentType)
return mediaType == "application/proto" || mediaType == "application/protobuf" || mediaType == "application/x-protobuf"
}
// decodeStoredRawPayload 解码持久化原始载荷并应用压缩处理。
func decodeStoredRawPayload(rawHexValue, codec string) ([]byte, error) {
payload, err := hex.DecodeString(strings.TrimSpace(rawHexValue))
if err != nil {
return nil, fmt.Errorf("解析已存储正文失败:%w", err)
}
if codec == "" || strings.EqualFold(codec, "identity") {
return payload, nil
}
return decompressPayload(payload, codec)
}
// unaryRequestMessage 根据 RPC 路径创建请求消息和稳定类型名。
func unaryRequestMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerRequest{}, "fork_background_composer_request"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneRequest{}, "notify_conversation_clone_request"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsRequest{}, "upload_conversation_blobs_request"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsRequest{}, "available_cpp_models_request"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsRequest{}, "available_models_request"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelRequest{}, "get_default_model_request"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataRequest{}, "get_default_model_nudge_data_request"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersRequest{}, "get_known_servers_request"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigRequest{}, "get_server_config_request"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Input()), protoMessageKind(method.Input())
}
}
// unaryResponseMessage 根据 RPC 路径创建响应消息和稳定类型名。
func unaryResponseMessage(path string) (proto.Message, string) {
switch path {
case forkBackgroundComposerPath:
return &aiserverv1.ForkBackgroundComposerResponse{}, "fork_background_composer_response"
case notifyConversationClonePath:
return &agentv1.NotifyConversationCloneResponse{}, "notify_conversation_clone_response"
case uploadConversationBlobsPath:
return &agentv1.UploadConversationBlobsResponse{}, "upload_conversation_blobs_response"
case cppAvailableModelsPath:
return &aiserverv1.AvailableCppModelsResponse{}, "available_cpp_models_response"
case aiAvailableModelsPath:
return &aiserverv1.AvailableModelsResponse{}, "available_models_response"
case aiGetDefaultModelPath:
return &aiserverv1.GetDefaultModelResponse{}, "get_default_model_response"
case aiDefaultModelNudgeDataPath:
return &aiserverv1.GetDefaultModelNudgeDataResponse{}, "get_default_model_nudge_data_response"
case mcpGetKnownServersPath:
return &aiserverv1.GetKnownServersResponse{}, "get_known_servers_response"
case serverGetConfigPath:
return &aiserverv1.GetServerConfigResponse{}, "get_server_config_response"
default:
method := rpcMethodDescriptor(path)
if method == nil || method.IsStreamingClient() || method.IsStreamingServer() {
return nil, ""
}
return dynamicpb.NewMessage(method.Output()), protoMessageKind(method.Output())
}
}
// streamingRequestMessageType 返回流式请求的 protobuf 类型名。
func streamingRequestMessageType(path string) string {
if path == runSSEPath {
return "aiserver.v1.BidiRequestId"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Input().FullName())
}
// streamingResponseMessageType 返回流式响应的 protobuf 类型名。
func streamingResponseMessageType(path string) string {
if path == runSSEPath {
return "agent.v1.AgentServerMessage"
}
method := rpcMethodDescriptor(path)
if method == nil || (!method.IsStreamingClient() && !method.IsStreamingServer()) {
return ""
}
return string(method.Output().FullName())
}
// decodesUnaryRequest 判断是否存在已知的一元请求解码器。
func decodesUnaryRequest(path string) bool {
if path == bidiAppendPath {
return true
}
message, _ := unaryRequestMessage(path)
return message != nil
}
// decodesUnaryResponse 判断是否存在已知的一元响应解码器。
func decodesUnaryResponse(path string) bool {
message, _ := unaryResponseMessage(path)
return message != nil
}
// newMessage 按完整 protobuf 类型名从注册表创建消息实例。
func newMessage(messageType string) proto.Message {
switch messageType {
case "aiserver.v1.BidiRequestId":
return &aiserverv1.BidiRequestId{}
case "agent.v1.AgentServerMessage":
return &agentv1.AgentServerMessage{}
default:
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(messageType))
if err != nil {
return nil
}
messageDescriptor, ok := descriptor.(protoreflect.MessageDescriptor)
if !ok {
return nil
}
return dynamicpb.NewMessage(messageDescriptor)
}
}
// rpcMethodDescriptor 通过完整 RPC 路径查找注册表中的方法描述。
func rpcMethodDescriptor(path string) protoreflect.MethodDescriptor {
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil
}
descriptor, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(parts[0]))
if err != nil {
return nil
}
service, ok := descriptor.(protoreflect.ServiceDescriptor)
if !ok {
return nil
}
return service.Methods().ByName(protoreflect.Name(parts[1]))
}
// protoMessageKind 从消息描述推导稳定的 JSON kind 名称。
func protoMessageKind(descriptor protoreflect.MessageDescriptor) string {
if descriptor == nil {
return ""
}
return snakeCase(string(descriptor.Name()))
}
// snakeCase 将 protobuf 名称转换为前端稳定的下划线命名。
func snakeCase(value string) string {
var result strings.Builder
for index, character := range value {
if unicode.IsUpper(character) {
if index > 0 {
result.WriteByte('_')
}
result.WriteRune(unicode.ToLower(character))
continue
}
result.WriteRune(character)
}
return result.String()
}
// marshalProtoJSON 把 protobuf 消息编码为前端可读 JSON。
func marshalProtoJSON(message proto.Message) string {
if message == nil {
return ""
}
payload, err := (protojson.MarshalOptions{
UseProtoNames: true,
EmitUnpopulated: false,
Indent: " ",
}).Marshal(message)
if err != nil {
return ""
}
return string(payload)
}
// activeOneofName 返回 Agent 消息当前激活的 oneof 名称。
func activeOneofName(message proto.Message) string {
if message == nil {
return ""
}
reflected := message.ProtoReflect()
oneofs := reflected.Descriptor().Oneofs()
for index := 0; index < oneofs.Len(); index++ {
oneof := oneofs.Get(index)
field := reflected.WhichOneof(oneof)
if field != nil {
return string(field.Name())
}
}
return string(reflected.Descriptor().Name())
}
// prettyJSON 尝试格式化 JSON,失败时返回原始文本。
func prettyJSON(payload []byte) string {
var target any
if err := json.Unmarshal(payload, &target); err != nil {
return string(payload)
}
formatted, err := json.MarshalIndent(target, "", " ")
if err != nil {
return string(payload)
}
return string(formatted)
}
// clippedHex 限制原始载荷展示长度并标记省略部分。
func clippedHex(payload []byte, max int) string {
if len(payload) > max {
return hex.EncodeToString(payload[:max]) + "..."
}
return hex.EncodeToString(payload)
}
+25
View File
@@ -0,0 +1,25 @@
module github.com/leookun/cursor-proxy-debugger
go 1.25.8
require (
github.com/andybalholm/brotli v1.2.0
github.com/leookun/cursor-byok/cursor-proto v0.0.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.50.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace github.com/leookun/cursor-byok/cursor-proto => ../cursor-proto
+62
View File
@@ -0,0 +1,62 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+50
View File
@@ -0,0 +1,50 @@
// cursor-proxy-debugger 提供独立 Cursor API 调试服务的进程入口。
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"github.com/pkg/browser"
)
// main 解析启动参数,并管理调试服务的完整生命周期。
func main() {
config := Config{}
openBrowser := true
flag.StringVar(&config.ServiceAddr, "addr", defaultServiceAddr, "Cursor API 调试服务监听地址")
flag.IntVar(&config.MaxExchanges, "max-exchanges", 200, "内存中保留的最大请求数")
flag.StringVar(&config.DatabasePath, "db", "", "SQLite 数据库路径(默认使用用户配置目录)")
flag.BoolVar(&openBrowser, "open", true, "启动后打开浏览器")
flag.Parse()
server, err := New(config)
if err != nil {
log.Fatal(err)
}
if err := server.Start(); err != nil {
log.Fatal(err)
}
fmt.Printf("Cursor API 调试服务已启动\n")
fmt.Printf("服务地址: http://%s\n", server.ServiceAddr())
fmt.Printf("固定上游: %s\n", defaultUpstreamURL)
fmt.Printf("调试界面: %s\n", server.UIURL())
fmt.Printf("SQLite: %s\n", server.DatabasePath())
if openBrowser {
_ = browser.OpenURL(server.UIURL())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
<-signals
signal.Stop(signals)
if err := server.Close(); err != nil {
log.Printf("关闭调试服务失败:%v", err)
}
}
+144
View File
@@ -0,0 +1,144 @@
// server.go 负责固定上游服务、流量捕获和调试界面的生命周期。
package main
import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
"sync/atomic"
"time"
)
// Server 运行 Cursor API 转发服务及其本机调试界面。
type Server struct {
config Config
upstream *url.URL
store *exchangeStore
counter atomic.Uint64
serviceServer *http.Server
serviceLn net.Listener
runMu sync.Mutex
captureMu sync.RWMutex
}
// New 创建固定转发到 Cursor API 的协议调试服务。
func New(config Config) (*Server, error) {
config = config.normalized()
if err := validateLoopbackAddress(config.ServiceAddr); err != nil {
return nil, fmt.Errorf("服务监听地址无效:%w", err)
}
upstream, err := url.Parse(defaultUpstreamURL)
if err != nil {
return nil, fmt.Errorf("解析固定上游地址:%w", err)
}
store, err := newPersistentExchangeStore(config.DatabasePath, config.MaxExchanges)
if err != nil {
return nil, err
}
server := &Server{
config: config,
upstream: upstream,
store: store,
}
server.counter.Store(store.maxNumericID())
server.serviceServer = &http.Server{
Handler: server.newServiceHandler(),
ErrorLog: log.New(io.Discard, "", 0),
}
return server, nil
}
// Start 启动同时承载 API 转发和调试界面的单端口服务。
func (server *Server) Start() error {
server.runMu.Lock()
defer server.runMu.Unlock()
if server.serviceLn != nil {
return errors.New("Cursor API 调试服务已经启动")
}
serviceListener, err := net.Listen("tcp", server.config.ServiceAddr)
if err != nil {
return fmt.Errorf("启动 API 服务监听失败:%w", err)
}
server.serviceLn = serviceListener
go func() { _ = server.serviceServer.Serve(serviceListener) }()
return nil
}
// Close 立即关闭监听器、活跃连接并释放捕获存储。
func (server *Server) Close() error {
server.runMu.Lock()
serviceServer := server.serviceServer
server.serviceLn = nil
server.runMu.Unlock()
var errorsList []error
if serviceServer != nil {
if err := serviceServer.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errorsList = append(errorsList, err)
}
}
if server.store != nil {
if err := server.store.close(); err != nil {
errorsList = append(errorsList, err)
}
}
return errors.Join(errorsList...)
}
// ServiceAddr 返回 Cursor API 服务监听地址。
func (server *Server) ServiceAddr() string { return server.config.ServiceAddr }
// UIURL 返回可在浏览器中打开的调试界面地址。
func (server *Server) UIURL() string {
return "http://" + browserAddress(server.config.ServiceAddr) + debugBasePath + "/"
}
// DatabasePath 返回捕获数据库路径。
func (server *Server) DatabasePath() string {
return server.config.DatabasePath
}
// newServiceHandler 创建单端口调试路由和固定上游流式转发。
func (server *Server) newServiceHandler() http.Handler {
reverseProxy := httputil.NewSingleHostReverseProxy(server.upstream)
reverseProxy.FlushInterval = -1
reverseProxy.ErrorLog = log.New(io.Discard, "", 0)
originalDirector := reverseProxy.Director
reverseProxy.Director = func(request *http.Request) {
originalDirector(request)
request.Host = server.upstream.Host
request.Header["X-Forwarded-For"] = nil
}
reverseProxy.Transport = &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
DisableCompression: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
reverseProxy.ModifyResponse = server.captureResponse
reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, upstreamErr error) {
server.failExchange(request, upstreamErr)
http.Error(writer, "Cursor API upstream unavailable", http.StatusBadGateway)
}
forwardHandler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
reverseProxy.ServeHTTP(writer, server.captureRequest(request))
})
debugHandler := http.StripPrefix(debugBasePath, server.newUIHandler())
mux := http.NewServeMux()
mux.Handle(debugBasePath+"/", debugHandler)
mux.HandleFunc(debugBasePath, func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, debugBasePath+"/", http.StatusTemporaryRedirect)
})
mux.Handle("/", forwardHandler)
return mux
}
+243
View File
@@ -0,0 +1,243 @@
// store.go 管理调试捕获的内存索引、SQLite 持久化和订阅通知。
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// exchangeStore 保存有限内存窗口以及可选的持久化数据库连接。
type exchangeStore struct {
mu sync.RWMutex
max int
order []string
exchanges map[string]*Exchange
subscribers map[chan storeEvent]struct{}
db *sql.DB
databasePath string
lastError string
}
// newExchangeStore 创建仅使用内存的捕获存储。
func newExchangeStore(max int) *exchangeStore {
return &exchangeStore{
max: max,
exchanges: make(map[string]*Exchange),
subscribers: make(map[chan storeEvent]struct{}),
}
}
// newPersistentExchangeStore 创建 SQLite 持久化捕获存储并恢复最近记录。
func newPersistentExchangeStore(path string, max int) (*exchangeStore, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("SQLite 数据库路径不能为空")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("创建 SQLite 数据目录失败: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("打开 SQLite 数据库失败: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := newExchangeStore(max)
store.db = db
store.databasePath = path
if err := store.initializeDatabase(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.backfillDecodedExchanges(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
if err := store.loadRecent(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
// backfillDecodedExchanges 为旧记录补齐解码视图并写回数据库。
func (store *exchangeStore) backfillDecodedExchanges(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, "SELECT payload_json FROM exchanges")
if err != nil {
return fmt.Errorf("读取待回填的 SQLite 抓包记录失败: %w", err)
}
var exchanges []Exchange
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
_ = rows.Close()
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
_ = rows.Close()
return fmt.Errorf("解析待回填的 SQLite 抓包记录失败: %w", err)
}
if hydrateStoredExchange(&exchange) {
exchanges = append(exchanges, exchange)
}
}
if err := rows.Close(); err != nil {
return err
}
if err := rows.Err(); err != nil {
return err
}
if len(exchanges) == 0 {
return nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for index := range exchanges {
payload, marshalErr := json.Marshal(&exchanges[index])
if marshalErr != nil {
return marshalErr
}
if _, err := tx.ExecContext(ctx, `UPDATE exchanges SET payload_json = ?, conversation_id = ?,
request_id = ?, updated_at_ms = ? WHERE id = ?`, payload, exchanges[index].ConversationID,
exchanges[index].RequestID, time.Now().UnixMilli(), exchanges[index].ID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil
}
// initializeDatabase 创建调试器使用的 SQLite 表结构。
func (store *exchangeStore) initializeDatabase(ctx context.Context) error {
for _, statement := range []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA secure_delete = ON",
`CREATE TABLE IF NOT EXISTS exchanges (
id TEXT PRIMARY KEY,
started_at_ms INTEGER NOT NULL,
conversation_id TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
request_bytes INTEGER NOT NULL DEFAULT 0,
response_bytes INTEGER NOT NULL DEFAULT 0,
payload_json BLOB NOT NULL,
updated_at_ms INTEGER NOT NULL
)`,
"CREATE INDEX IF NOT EXISTS exchanges_conversation_started_idx ON exchanges(conversation_id, started_at_ms DESC)",
"CREATE INDEX IF NOT EXISTS exchanges_request_idx ON exchanges(request_id)",
} {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("初始化 SQLite 数据库失败: %w", err)
}
}
return nil
}
// loadRecent 从数据库恢复内存窗口中的最新捕获。
func (store *exchangeStore) loadRecent(ctx context.Context) error {
rows, err := store.db.QueryContext(ctx, `SELECT payload_json, conversation_id
FROM exchanges ORDER BY started_at_ms DESC, id DESC LIMIT ?`, store.max)
if err != nil {
return fmt.Errorf("读取 SQLite 抓包记录失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var payload []byte
var conversationID string
if err := rows.Scan(&payload, &conversationID); err != nil {
return err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return fmt.Errorf("解析 SQLite 抓包记录失败: %w", err)
}
exchange.ConversationID = conversationID
store.exchanges[exchange.ID] = &exchange
store.order = append(store.order, exchange.ID)
}
return rows.Err()
}
// create 添加一条新的捕获并通知订阅者。
func (store *exchangeStore) create(exchange *Exchange) {
store.mu.Lock()
store.exchanges[exchange.ID] = exchange
store.order = append([]string{exchange.ID}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
store.persistLocked(exchange)
store.mu.Unlock()
store.publish(storeEvent{Type: "created", ID: exchange.ID})
}
// update 持久化修改并发布最终捕获快照。
func (store *exchangeStore) update(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, true)
}
// updateTransient 只更新内存并发布流式过程快照。
func (store *exchangeStore) updateTransient(id string, apply func(*Exchange)) {
store.updateWithPersistence(id, apply, false)
}
// updateWithPersistence 在统一锁内完成修改、关联和可选持久化。
func (store *exchangeStore) updateWithPersistence(id string, apply func(*Exchange), persist bool) {
store.mu.Lock()
exchange := store.exchanges[id]
if exchange == nil && store.db != nil {
var err error
exchange, err = store.loadPersistedLocked(id)
if err != nil {
store.lastError = err.Error()
}
if exchange != nil {
store.exchanges[id] = exchange
store.order = append([]string{id}, store.order...)
for len(store.order) > store.max {
oldest := store.order[len(store.order)-1]
store.order = store.order[:len(store.order)-1]
delete(store.exchanges, oldest)
}
}
}
if exchange != nil {
previousRequestID := exchange.RequestID
previousConversationID := exchange.ConversationID
apply(exchange)
if exchange.RequestID != previousRequestID || exchange.ConversationID != previousConversationID {
store.associateConversationLocked(exchange)
}
if persist {
store.persistLocked(exchange)
}
}
store.mu.Unlock()
store.publish(storeEvent{Type: "updated", ID: id})
}
// summaries 返回按时间倒序排列的请求摘要。
+354
View File
@@ -0,0 +1,354 @@
// store_queries.go 负责调试捕获记录的查询、持久化辅助和订阅通知。
package main
import (
"database/sql"
"encoding/json"
"sort"
"time"
)
// summaries 返回指定会话的捕获摘要列表。
func (store *exchangeStore) summaries(conversationID string) ([]ExchangeSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db != nil {
query := `SELECT payload_json, conversation_id FROM exchanges`
arguments := []any{}
if conversationID != "" {
query += " WHERE conversation_id = ?"
arguments = append(arguments, conversationID)
}
query += " ORDER BY started_at_ms DESC, id DESC"
rows, err := store.db.Query(query, arguments...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ExchangeSummary, 0)
for rows.Next() {
var payload []byte
var persistedConversationID string
if err := rows.Scan(&payload, &persistedConversationID); err != nil {
return nil, err
}
var exchange Exchange
if err := json.Unmarshal(payload, &exchange); err != nil {
return nil, err
}
exchange.ConversationID = persistedConversationID
if current := store.exchanges[exchange.ID]; current != nil {
result = append(result, current.ExchangeSummary)
} else {
result = append(result, exchange.ExchangeSummary)
}
}
return result, rows.Err()
}
result := make([]ExchangeSummary, 0, len(store.order))
for _, id := range store.order {
if exchange := store.exchanges[id]; exchange != nil {
result = append(result, exchange.ExchangeSummary)
}
}
return result, nil
}
// get 返回内存或数据库中的完整捕获副本。
func (store *exchangeStore) get(id string) (Exchange, bool, error) {
store.mu.RLock()
defer store.mu.RUnlock()
exchange := store.exchanges[id]
if exchange != nil {
return cloneExchange(*exchange), true, nil
}
if store.db == nil {
return Exchange{}, false, nil
}
persisted, err := store.loadPersistedLocked(id)
if err != nil {
return Exchange{}, false, err
}
if persisted == nil {
return Exchange{}, false, nil
}
return *persisted, true, nil
}
// loadPersistedLocked 从 SQLite 读取单条捕获并在必要时解码回填。
func (store *exchangeStore) loadPersistedLocked(id string) (*Exchange, error) {
var payload []byte
var conversationID string
err := store.db.QueryRow("SELECT payload_json, conversation_id FROM exchanges WHERE id = ?", id).Scan(&payload, &conversationID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var persisted Exchange
if err := json.Unmarshal(payload, &persisted); err != nil {
return nil, err
}
persisted.ConversationID = conversationID
return &persisted, nil
}
// clear 清除数据库、内存索引和会话关联。
func (store *exchangeStore) clear() error {
store.mu.Lock()
var err error
if store.db != nil {
_, err = store.db.Exec("DELETE FROM exchanges")
if err != nil {
store.lastError = err.Error()
}
}
if err == nil {
store.order = nil
store.exchanges = make(map[string]*Exchange)
store.lastError = ""
}
store.mu.Unlock()
if err == nil {
store.publish(storeEvent{Type: "cleared"})
}
return err
}
// conversations 按会话聚合持久化捕获统计。
func (store *exchangeStore) conversations() ([]ConversationSummary, error) {
store.mu.RLock()
defer store.mu.RUnlock()
if store.db == nil {
groups := make(map[string]*ConversationSummary)
for _, exchange := range store.exchanges {
group := groups[exchange.ConversationID]
if group == nil {
group = &ConversationSummary{ConversationID: exchange.ConversationID}
groups[exchange.ConversationID] = group
}
group.ExchangeCount++
group.RequestBytes += exchange.RequestBytes
group.ResponseBytes += exchange.ResponseBytes
if exchange.StartedAt.After(group.LastStartedAt) {
group.LastStartedAt = exchange.StartedAt
}
}
result := make([]ConversationSummary, 0, len(groups))
for _, group := range groups {
result = append(result, *group)
}
sort.Slice(result, func(i, j int) bool { return result[i].LastStartedAt.After(result[j].LastStartedAt) })
return result, nil
}
rows, err := store.db.Query(`SELECT conversation_id, COUNT(*), MAX(started_at_ms),
COALESCE(SUM(request_bytes), 0), COALESCE(SUM(response_bytes), 0)
FROM exchanges GROUP BY conversation_id ORDER BY MAX(started_at_ms) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]ConversationSummary, 0)
for rows.Next() {
var summary ConversationSummary
var startedAtMS int64
if err := rows.Scan(&summary.ConversationID, &summary.ExchangeCount, &startedAtMS, &summary.RequestBytes, &summary.ResponseBytes); err != nil {
return nil, err
}
summary.LastStartedAt = time.UnixMilli(startedAtMS)
result = append(result, summary)
}
return result, rows.Err()
}
// persistLocked 将当前捕获快照写入 SQLite。
func (store *exchangeStore) persistLocked(exchange *Exchange) {
if store.db == nil || exchange == nil {
return
}
payload, err := json.Marshal(exchange)
if err == nil {
_, err = store.db.Exec(`INSERT INTO exchanges (
id, started_at_ms, conversation_id, request_id, state, request_bytes,
response_bytes, payload_json, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
started_at_ms = excluded.started_at_ms,
conversation_id = excluded.conversation_id,
request_id = excluded.request_id,
state = excluded.state,
request_bytes = excluded.request_bytes,
response_bytes = excluded.response_bytes,
payload_json = excluded.payload_json,
updated_at_ms = excluded.updated_at_ms`,
exchange.ID, exchange.StartedAt.UnixMilli(), exchange.ConversationID,
exchange.RequestID, exchange.State, exchange.RequestBytes,
exchange.ResponseBytes, payload, time.Now().UnixMilli())
}
if err != nil {
store.lastError = err.Error()
} else {
store.lastError = ""
}
}
// associateConversationLocked 根据请求标识补齐会话关联。
func (store *exchangeStore) associateConversationLocked(exchange *Exchange) {
if exchange.RequestID == "" {
return
}
if exchange.ConversationID == "" {
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID != "" {
exchange.ConversationID = candidate.ConversationID
break
}
}
}
if exchange.ConversationID == "" && store.db != nil {
_ = store.db.QueryRow(`SELECT conversation_id FROM exchanges
WHERE request_id = ? AND conversation_id != ''
ORDER BY started_at_ms DESC LIMIT 1`, exchange.RequestID).Scan(&exchange.ConversationID)
}
if exchange.ConversationID == "" {
return
}
for _, candidate := range store.exchanges {
if candidate.RequestID == exchange.RequestID && candidate.ConversationID == "" {
candidate.ConversationID = exchange.ConversationID
store.persistLocked(candidate)
}
}
if store.db != nil {
if _, err := store.db.Exec(`UPDATE exchanges SET conversation_id = ?, updated_at_ms = ?
WHERE request_id = ? AND conversation_id = ''`, exchange.ConversationID, time.Now().UnixMilli(), exchange.RequestID); err != nil {
store.lastError = err.Error()
}
}
}
// maxNumericID 返回数据库中已使用的最大数字捕获编号。
func (store *exchangeStore) maxNumericID() uint64 {
store.mu.RLock()
defer store.mu.RUnlock()
var maximum uint64
if store.db != nil {
_ = store.db.QueryRow("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) FROM exchanges").Scan(&maximum)
}
return maximum
}
// close 关闭数据库连接并终止后续订阅通知。
func (store *exchangeStore) close() error {
store.mu.Lock()
defer store.mu.Unlock()
if store.db == nil {
return nil
}
err := store.db.Close()
store.db = nil
return err
}
// status 返回数据库路径和最近一次数据库错误。
func (store *exchangeStore) status() (string, string) {
store.mu.RLock()
defer store.mu.RUnlock()
return store.databasePath, store.lastError
}
// subscribe 注册一个捕获变化订阅者。
func (store *exchangeStore) subscribe() (<-chan storeEvent, func()) {
updates := make(chan storeEvent, 32)
store.mu.Lock()
store.subscribers[updates] = struct{}{}
store.mu.Unlock()
return updates, func() {
store.mu.Lock()
if _, ok := store.subscribers[updates]; ok {
delete(store.subscribers, updates)
close(updates)
}
store.mu.Unlock()
}
}
// publish 非阻塞地广播捕获变化事件。
func (store *exchangeStore) publish(event storeEvent) {
store.mu.RLock()
defer store.mu.RUnlock()
for subscriber := range store.subscribers {
select {
case subscriber <- event:
default:
}
}
}
// cloneExchange 深拷贝捕获及其请求响应载荷。
func cloneExchange(exchange Exchange) Exchange {
exchange.Request = clonePayload(exchange.Request)
exchange.Response = clonePayload(exchange.Response)
return exchange
}
// clonePayload 深拷贝头信息和 Connect 帧切片。
func clonePayload(payload Payload) Payload {
payload.Headers = append([]Header(nil), payload.Headers...)
payload.Frames = append([]FrameView(nil), payload.Frames...)
return payload
}
// elapsedMS 计算从开始时间到当前时间的毫秒耗时。
func elapsedMS(startedAt time.Time) int64 {
if startedAt.IsZero() {
return 0
}
return time.Since(startedAt).Milliseconds()
}
// sortedHeaders 生成脱敏且按名称排序的请求头列表。
func sortedHeaders(headers map[string][]string) []Header {
result := make([]Header, 0, len(headers))
for name, values := range headers {
value := ""
for index, item := range values {
if index > 0 {
value += ", "
}
value += item
}
if isSensitiveHeader(name) && value != "" {
value = "[已隐藏]"
}
result = append(result, Header{Name: name, Value: value})
}
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
return result
}
// isSensitiveHeader 判断请求头是否包含鉴权或隐私信息。
func isSensitiveHeader(name string) bool {
switch httpCanonicalLower(name) {
case "authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key":
return true
default:
return false
}
}
// httpCanonicalLower 将请求头名称规范化为小写形式。
func httpCanonicalLower(value string) string {
buffer := make([]byte, len(value))
for index := range value {
character := value[index]
if character >= 'A' && character <= 'Z' {
character += 'a' - 'A'
}
buffer[index] = character
}
return string(buffer)
}
+189
View File
@@ -0,0 +1,189 @@
// types.go 定义协议调试器配置、捕获详情和会话摘要模型。
package main
import (
"os"
"path/filepath"
"time"
)
// 默认值限制调试器只监听本机并约束内存捕获规模。
const (
defaultServiceAddr = "127.0.0.1:9090"
defaultUpstreamURL = "https://api2.cursor.sh"
debugBasePath = "/__debuger__"
defaultMaxExchanges = 200
defaultMaxCaptureBytes = 2 << 20
defaultMaxFrames = 2000
defaultDatabaseName = "cursor-proxy-debugger.db"
)
// Config 控制独立协议调试器的监听和存储限制。
type Config struct {
// ServiceAddr 是 Cursor API 调试服务监听地址。
ServiceAddr string
// MaxExchanges 是内存保留的最大请求数。
MaxExchanges int
// MaxCaptureBytes 是单向载荷保存上限。
MaxCaptureBytes int
// MaxFrames 是单条流保存的 Connect 帧上限。
MaxFrames int
// DatabasePath 是 SQLite 捕获数据库路径。
DatabasePath string
}
// normalized 补齐空值并拒绝无效的容量配置。
func (config Config) normalized() Config {
if config.ServiceAddr == "" {
config.ServiceAddr = defaultServiceAddr
}
if config.MaxExchanges <= 0 {
config.MaxExchanges = defaultMaxExchanges
}
if config.MaxCaptureBytes <= 0 {
config.MaxCaptureBytes = defaultMaxCaptureBytes
}
if config.MaxFrames <= 0 {
config.MaxFrames = defaultMaxFrames
}
if config.DatabasePath == "" {
config.DatabasePath = defaultDatabasePath()
}
return config
}
// defaultDatabasePath 返回当前用户配置目录下的默认数据库路径。
func defaultDatabasePath() string {
configDir, err := os.UserConfigDir()
if err != nil || configDir == "" {
return defaultDatabaseName
}
return filepath.Join(configDir, "cursor-byok", defaultDatabaseName)
}
// ExchangeSummary 是请求列表使用的紧凑捕获摘要。
type ExchangeSummary struct {
// ID 是进程内递增的捕获标识。
ID string `json:"id"`
// StartedAt 是请求开始时间。
StartedAt time.Time `json:"startedAt"`
// Method 是 HTTP 方法。
Method string `json:"method"`
// URL 是完整请求地址。
URL string `json:"url"`
// Host 是请求目标主机。
Host string `json:"host"`
// Path 是 RPC 或 HTTP 路径。
Path string `json:"path"`
// Status 是 HTTP 响应状态码。
Status int `json:"status"`
// State 是捕获处理阶段。
State string `json:"state"`
// DurationMS 是请求总耗时毫秒数。
DurationMS int64 `json:"durationMs"`
// RequestBytes 是完整请求体字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是完整响应体字节数。
ResponseBytes int64 `json:"responseBytes"`
// RequestID 是协议请求标识。
RequestID string `json:"requestId,omitempty"`
// ConversationID 是关联会话标识。
ConversationID string `json:"conversationId,omitempty"`
// RequestKind 是解码后的请求消息类型。
RequestKind string `json:"requestKind,omitempty"`
// ResponseKind 是解码后的响应消息类型。
ResponseKind string `json:"responseKind,omitempty"`
// FrameCount 是双向 Connect 帧总数。
FrameCount int `json:"frameCount"`
// Error 是转发或解码错误。
Error string `json:"error,omitempty"`
}
// Exchange 保存调试界面展示的请求和响应详情。
type Exchange struct {
ExchangeSummary
// Request 是请求方向载荷。
Request Payload `json:"request"`
// Response 是响应方向载荷。
Response Payload `json:"response"`
}
// Payload 保存请求头、原始副本、解码正文和协议帧。
type Payload struct {
// Headers 是脱敏且排序稳定的 HTTP 请求头。
Headers []Header `json:"headers"`
// ContentType 是规范化媒体类型。
ContentType string `json:"contentType,omitempty"`
// ContentCodec 是内容压缩算法。
ContentCodec string `json:"contentCodec,omitempty"`
// Size 是完整方向载荷字节数。
Size int64 `json:"size"`
// RawHex 是受限原始副本的十六进制文本。
RawHex string `json:"rawHex,omitempty"`
// RawTruncated 表示原始副本达到保存上限。
RawTruncated bool `json:"rawTruncated,omitempty"`
// DecodedJSON 是格式化后的结构化正文。
DecodedJSON string `json:"decodedJson,omitempty"`
// DecodedLang 是前端编辑器使用的语言标识。
DecodedLang string `json:"decodedLanguage,omitempty"`
// DecodeError 是不影响转发的解码错误。
DecodeError string `json:"decodeError,omitempty"`
// Frames 是 Connect 流的逐帧视图。
Frames []FrameView `json:"frames,omitempty"`
}
// Header 是排序稳定的 HTTP 请求头键值对。
type Header struct {
// Name 是请求头名称。
Name string `json:"name"`
// Value 是已脱敏的请求头值。
Value string `json:"value"`
}
// FrameView 描述一条 Connect 流式信封。
type FrameView struct {
// Index 是帧在当前方向的序号。
Index int `json:"index"`
// Flags 是 Connect 原始标志位。
Flags uint8 `json:"flags"`
// Length 是解压前帧载荷长度。
Length int `json:"length"`
// Compressed 表示帧载荷使用压缩。
Compressed bool `json:"compressed"`
// EndStream 表示帧携带流结束标志。
EndStream bool `json:"endStream"`
// Kind 是解码后的业务消息类型。
Kind string `json:"kind,omitempty"`
// MessageType 是 protobuf 完整消息名。
MessageType string `json:"messageType,omitempty"`
// RequestID 是帧中解析出的请求标识。
RequestID string `json:"requestId,omitempty"`
// JSON 是 protobuf 的 JSON 视图。
JSON string `json:"json,omitempty"`
// RawHex 是无法解码时保留的载荷文本。
RawHex string `json:"rawHex,omitempty"`
// Error 是当前帧的解压或解码错误。
Error string `json:"error,omitempty"`
}
// storeEvent 是 SSE 通知使用的最小变化事件。
type storeEvent struct {
// Type 是捕获记录变化类型。
Type string `json:"type"`
// ID 是关联捕获标识。
ID string `json:"id,omitempty"`
}
// ConversationSummary 描述按会话聚合的持久化流量。
type ConversationSummary struct {
// ConversationID 是会话稳定标识。
ConversationID string `json:"conversationId"`
// ExchangeCount 是会话捕获记录数。
ExchangeCount int `json:"exchangeCount"`
// LastStartedAt 是会话最近请求时间。
LastStartedAt time.Time `json:"lastStartedAt"`
// RequestBytes 是会话累计请求字节数。
RequestBytes int64 `json:"requestBytes"`
// ResponseBytes 是会话累计响应字节数。
ResponseBytes int64 `json:"responseBytes"`
}
@@ -1,4 +1,5 @@
package proxydebugger
// web.go 提供调试器只读 API、SSE 更新流和内嵌静态页面。
package main
import (
"embed"
@@ -8,43 +9,60 @@ import (
"net/http"
"strings"
"time"
"cursor/internal/certs"
)
// webAssets 保存无需外部文件即可启动的调试页面资源。
//
//go:embed web/*
var webAssets embed.FS
// newUIHandler 注册只绑定本机界面的调试 API 和静态资源。
func (server *Server) newUIHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/status", server.handleStatus)
mux.HandleFunc("GET /api/exchanges", server.handleExchangeList)
mux.HandleFunc("GET /api/exchanges/{id}", server.handleExchangeDetail)
mux.HandleFunc("GET /api/conversations", server.handleConversationList)
mux.HandleFunc("DELETE /api/exchanges", server.handleClearExchanges)
mux.HandleFunc("GET /api/events", server.handleEvents)
mux.HandleFunc("GET /api/ca.crt", server.handleCACertificate)
assets, _ := fs.Sub(webAssets, "web")
fileServer := http.FileServer(http.FS(assets))
mux.Handle("/", fileServer)
return securityHeaders(mux)
}
// handleStatus 返回监听地址、固定上游和数据库状态。
func (server *Server) handleStatus(writer http.ResponseWriter, _ *http.Request) {
databasePath, databaseError := server.store.status()
writeJSON(writer, http.StatusOK, map[string]any{
"proxyAddr": server.config.ProxyAddr,
"uiAddr": server.config.UIAddr,
"targetHost": server.config.TargetHost,
"running": true,
"serviceAddr": server.config.ServiceAddr,
"debugPath": debugBasePath + "/",
"upstreamURL": server.upstream.String(),
"running": true,
"databasePath": databasePath,
"databaseError": databaseError,
})
}
func (server *Server) handleExchangeList(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, server.store.summaries())
// handleExchangeList 按可选会话标识列出请求摘要。
func (server *Server) handleExchangeList(writer http.ResponseWriter, request *http.Request) {
conversationID := strings.TrimSpace(request.URL.Query().Get("conversation_id"))
summaries, err := server.store.summaries(conversationID)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, summaries)
}
// handleExchangeDetail 返回单条请求的完整捕获详情。
func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *http.Request) {
id := strings.TrimSpace(request.PathValue("id"))
exchange, ok := server.store.get(id)
exchange, ok, err := server.store.get(id)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !ok {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "请求记录不存在"})
return
@@ -52,11 +70,26 @@ func (server *Server) handleExchangeDetail(writer http.ResponseWriter, request *
writeJSON(writer, http.StatusOK, exchange)
}
// handleClearExchanges 清除内存和 SQLite 中的捕获记录。
func (server *Server) handleClearExchanges(writer http.ResponseWriter, _ *http.Request) {
server.store.clear()
if err := server.clearExchanges(); err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writer.WriteHeader(http.StatusNoContent)
}
// handleConversationList 返回持久化流量的会话分组。
func (server *Server) handleConversationList(writer http.ResponseWriter, _ *http.Request) {
conversations, err := server.store.conversations()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(writer, http.StatusOK, conversations)
}
// handleEvents 通过 SSE 推送捕获记录变化和保活心跳。
func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Request) {
flusher, ok := writer.(http.Flusher)
if !ok {
@@ -90,23 +123,19 @@ func (server *Server) handleEvents(writer http.ResponseWriter, request *http.Req
}
}
func (server *Server) handleCACertificate(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/x-x509-ca-cert")
writer.Header().Set("Content-Disposition", `attachment; filename="cursor-local-proxy-ca.crt"`)
_, _ = writer.Write(certs.EmbeddedCACertPEM())
}
// writeJSON 写入统一 JSON 响应。
func writeJSON(writer http.ResponseWriter, status int, payload any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(payload)
}
// securityHeaders 为本地调试页面添加最小浏览器安全策略。
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self'; worker-src 'self' blob:")
next.ServeHTTP(writer, request)
})
}
+500
View File
@@ -0,0 +1,500 @@
// app.js 管理协议调试器列表筛选、详情编辑器和实时事件交互。
import { getLocale, setLocale, t, translateDocument } from "./i18n.js";
import { bindEvents, renderPauseState } from "./app_events.js";
import { escapeHTML, formatBytes, formatDuration, formatHex, formatState, renderDecodeError, renderTruncated } from "./view_helpers.js";
const monacoReady = loadMonaco();
const editorSlots = {
request: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
response: { editor: null, model: null, host: null, token: 0, value: "", language: "plaintext" },
};
const state = {
status: null,
exchanges: [],
conversations: [],
selectedId: null,
selected: null,
search: "",
requestId: "",
conversationId: "",
endpoint: "all",
bidiMessageKinds: new Set(),
showOptions: false,
sortOrder: "desc",
paused: false,
pendingRefresh: false,
connection: { connected: false, key: "status.connecting", values: {} },
tabs: {
request: "body",
response: "body",
},
};
const elements = {
statusDot: document.querySelector("#status-dot"),
statusText: document.querySelector("#status-text"),
serviceAddress: document.querySelector("#service-address"),
upstreamURL: document.querySelector("#upstream-url"),
connectionLabel: document.querySelector("#connection-label"),
trafficSummary: document.querySelector("#traffic-summary"),
searchInput: document.querySelector("#search-input"),
requestIdInput: document.querySelector("#request-id-input"),
conversationSelect: document.querySelector("#conversation-select"),
endpointFilter: document.querySelector("#endpoint-filter"),
bidiMessageFilter: document.querySelector("#bidi-message-filter"),
bidiMessageOptions: document.querySelector("#bidi-message-options"),
showOptionsCheckbox: document.querySelector("#show-options-checkbox"),
sortOrder: document.querySelector("#sort-order"),
requestCount: document.querySelector("#request-count"),
requestList: document.querySelector("#request-list"),
emptyState: document.querySelector("#empty-state"),
selectionSummary: document.querySelector("#selection-summary"),
requestContent: document.querySelector("#request-content"),
responseContent: document.querySelector("#response-content"),
pauseButton: document.querySelector("#pause-button"),
clearButton: document.querySelector("#clear-button"),
localeSelect: document.querySelector("#locale-select"),
workspace: document.querySelector("#workspace"),
splitter: document.querySelector("#horizontal-splitter"),
};
async function fetchJSON(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
if (response.status === 204) return null;
return response.json();
}
async function loadStatus() {
state.status = await fetchJSON("api/status");
elements.statusDot.classList.toggle("online", Boolean(state.status.running));
renderRuntimeStatus();
elements.serviceAddress.textContent = `http://${state.status.serviceAddr}`;
elements.upstreamURL.textContent = state.status.upstreamURL;
elements.showOptionsCheckbox.checked = state.showOptions;
}
async function refreshList() {
const query = state.conversationId ? `?conversation_id=${encodeURIComponent(state.conversationId)}` : "";
[state.exchanges, state.conversations] = await Promise.all([
fetchJSON(`api/exchanges${query}`),
fetchJSON("api/conversations"),
]);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
if (state.selectedId && state.exchanges.some((item) => item.id === state.selectedId)) {
await refreshDetail(state.selectedId);
} else if (state.selectedId) {
state.selectedId = null;
state.selected = null;
renderDetail();
}
}
function renderConversationOptions() {
const selected = state.conversationId;
const options = [`<option value="">${escapeHTML(t("filters.allConversations"))}</option>`];
for (const conversation of state.conversations) {
if (!conversation.conversationId) continue;
const label = `${conversation.conversationId} (${conversation.exchangeCount})`;
options.push(`<option value="${escapeHTML(conversation.conversationId)}">${escapeHTML(label)}</option>`);
}
elements.conversationSelect.innerHTML = options.join("");
elements.conversationSelect.value = selected;
}
async function refreshDetail(id) {
if (!id) return;
try {
const detail = await fetchJSON(`api/exchanges/${encodeURIComponent(id)}`);
if (state.selectedId !== id) return;
state.selected = detail;
renderDetail();
} catch (error) {
if (state.selectedId === id) {
state.selected = null;
renderDetailError(error);
}
}
}
function scheduleRefresh() {
if (state.paused) {
state.pendingRefresh = true;
return;
}
if (state.pendingRefresh) return;
state.pendingRefresh = true;
window.setTimeout(async () => {
state.pendingRefresh = false;
try {
await refreshList();
} catch (error) {
setConnectionState(false, "connection.refreshFailed", { message: error.message });
}
}, 90);
}
function connectEvents() {
const events = new EventSource("api/events");
events.addEventListener("open", () => setConnectionState(true, "connection.live"));
events.addEventListener("update", scheduleRefresh);
events.addEventListener("error", () => setConnectionState(false, "connection.retrying"));
}
function setConnectionState(connected, key, values = {}) {
state.connection = { connected, key, values };
renderConnectionState();
}
function renderRuntimeStatus() {
if (!state.status) {
elements.statusText.textContent = t("status.connecting");
return;
}
elements.statusText.textContent = t(state.status.running ? "status.running" : "status.stopped");
}
function renderConnectionState() {
const { connected, key, values } = state.connection;
elements.connectionLabel.textContent = t(key, values);
elements.statusDot.classList.toggle("online", connected && Boolean(state.status?.running));
}
function filteredExchanges() {
const query = state.search.trim().toLowerCase();
const requestId = state.requestId.trim().toLowerCase();
const direction = state.sortOrder === "asc" ? 1 : -1;
return state.exchanges
.filter((item) => {
if (!state.showOptions && String(item.method || "").toUpperCase() === "OPTIONS") return false;
if (state.endpoint === "runsse" && !item.path.toLowerCase().includes("runsse")) return false;
if (state.endpoint === "bidiappend" && !item.path.toLowerCase().includes("bidiappend")) return false;
if (state.endpoint === "bidiappend" && state.bidiMessageKinds.size > 0 && !state.bidiMessageKinds.has(item.requestKind || "")) return false;
if (requestId && !String(item.requestId || "").toLowerCase().includes(requestId)) return false;
if (!query) return true;
return [item.url, item.requestId, item.requestKind, item.responseKind, item.state, String(item.status)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
})
.sort((left, right) => {
const startedAtDelta = new Date(left.startedAt).getTime() - new Date(right.startedAt).getTime();
if (startedAtDelta !== 0) return startedAtDelta * direction;
return left.id.localeCompare(right.id, undefined, { numeric: true }) * direction;
});
}
function renderBidiMessageFilter() {
const visible = state.endpoint === "bidiappend";
elements.bidiMessageFilter.hidden = !visible;
if (!visible) elements.bidiMessageFilter.open = false;
const availableKinds = state.exchanges
.filter((item) => item.path.toLowerCase().includes("bidiappend") && item.requestKind)
.map((item) => item.requestKind);
const kinds = [...new Set([...availableKinds, ...state.bidiMessageKinds])].sort((left, right) => left.localeCompare(right));
elements.bidiMessageFilter.querySelector("summary").textContent = state.bidiMessageKinds.size
? t("filters.selectedMessageTypes", { count: state.bidiMessageKinds.size })
: t("filters.allMessageTypes");
elements.bidiMessageOptions.innerHTML = [
`<label class="multi-select-option all-option"><input type="checkbox" value=""${state.bidiMessageKinds.size === 0 ? " checked" : ""}><span>${escapeHTML(t("filters.allMessageTypes"))}</span></label>`,
...kinds.map((kind) => `<label class="multi-select-option"><input type="checkbox" value="${escapeHTML(kind)}"${state.bidiMessageKinds.has(kind) ? " checked" : ""}><span title="${escapeHTML(kind)}">${escapeHTML(kind)}</span></label>`),
].join("");
}
function renderList() {
const exchanges = filteredExchanges();
elements.requestCount.textContent = t("count.requests", { count: exchanges.length });
elements.emptyState.classList.toggle("hidden", exchanges.length > 0);
const groups = new Map();
for (const item of exchanges) {
const conversationID = item.conversationId || "";
if (!groups.has(conversationID)) groups.set(conversationID, []);
groups.get(conversationID).push(item);
}
elements.requestList.innerHTML = [...groups.entries()]
.map(([conversationID, items]) => {
const label = conversationID || t("groups.unassigned");
const header = `<tr class="conversation-group"><td colspan="9"><span>${escapeHTML(t("groups.conversation"))}</span><code title="${escapeHTML(label)}">${escapeHTML(label)}</code><strong>${items.length}</strong></td></tr>`;
const rows = items.map((item) => {
const selected = item.id === state.selectedId ? " selected" : "";
const statusClass = item.status >= 400 ? "error" : item.status ? "success" : "";
const kind = item.responseKind || item.requestKind || "-";
return `<tr class="${selected.trim()}" data-id="${escapeHTML(item.id)}">
<td><span class="row-state ${escapeHTML(item.state)}"></span></td>
<td><code>${escapeHTML(item.id)}</code></td>
<td title="${escapeHTML(item.url)}"><code>${escapeHTML(item.url)}</code></td>
<td title="${escapeHTML(item.requestId || "")}"><code class="request-id-text">${escapeHTML(item.requestId || "-")}</code></td>
<td><span class="kind-text">${escapeHTML(kind)}</span></td>
<td><span class="method-text">${escapeHTML(item.method)}</span></td>
<td><span class="status-text ${statusClass}">${item.status || "-"}</span></td>
<td>${formatBytes(item.responseBytes)}</td>
<td>${formatDuration(item.durationMs)}</td>
</tr>`;
}).join("");
return header + rows;
})
.join("");
}
function renderTrafficSummary() {
const totals = state.exchanges.reduce(
(result, item) => {
result.up += item.requestBytes || 0;
result.down += item.responseBytes || 0;
return result;
},
{ up: 0, down: 0 },
);
elements.trafficSummary.textContent = `${formatBytes(totals.up)} ↓ ${formatBytes(totals.down)}`;
}
function renderDetail() {
if (!state.selected) {
disposeEditor("request");
disposeEditor("response");
elements.selectionSummary.innerHTML = `<span class="method-badge">POST</span><span class="status-badge">${escapeHTML(t("selection.waiting"))}</span><code>${escapeHTML(t("selection.prompt"))}</code>`;
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noRequest"))}</div>`;
elements.responseContent.innerHTML = `<div class="notice">${escapeHTML(t("notices.noResponse"))}</div>`;
return;
}
const item = state.selected;
const statusClass = item.status >= 200 && item.status < 400 ? "success" : "";
elements.selectionSummary.innerHTML = `<span class="method-badge">${escapeHTML(item.method)}</span><span class="status-badge ${statusClass}">${escapeHTML(item.status || formatState(item.state))}</span><code>${escapeHTML(item.url)}</code>`;
renderPayload("request", item.request, state.tabs.request);
renderPayload("response", item.response, state.tabs.response);
}
function renderDetailError(error) {
disposeEditor("request");
disposeEditor("response");
elements.requestContent.classList.remove("editor-active");
elements.responseContent.classList.remove("editor-active");
elements.requestContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
elements.responseContent.innerHTML = `<div class="notice error">${escapeHTML(error.message)}</div>`;
}
function renderPayload(side, payload, tab) {
const container = elements[`${side}Content`];
if (!payload) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noContent"))}</div>`);
return;
}
if (tab === "headers") {
renderStaticPayload(side, renderHeaders(payload.headers));
return;
}
if (tab === "raw") {
if (!payload.rawHex) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noRaw"))}</div>`);
return;
}
renderEditorPayload(side, formatHex(payload.rawHex), "plaintext", renderTruncated(payload.rawTruncated));
return;
}
if (tab === "frames") {
const document = frameEditorDocument(payload.frames);
if (!document) {
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noFrames"))}</div>`);
return;
}
renderEditorPayload(side, document, "json", "");
return;
}
if (payload.decodedJson) {
renderEditorPayload(side, payload.decodedJson, payload.decodedLanguage || "json", `${renderDecodeError(payload.decodeError)}${renderTruncated(payload.rawTruncated)}`);
return;
}
if (payload.frames?.length) {
renderEditorPayload(side, frameEditorDocument(payload.frames), "json", "");
return;
}
if (payload.decodeError) {
renderStaticPayload(side, `<div class="notice error">${escapeHTML(payload.decodeError)}</div>`);
return;
}
container.classList.remove("editor-active");
renderStaticPayload(side, `<div class="notice">${escapeHTML(t("notices.noBody"))}</div>`);
}
function renderHeaders(headers = []) {
const items = Array.isArray(headers) ? headers : [];
if (!items.length) return `<div class="notice">${escapeHTML(t("notices.noHeaders"))}</div>`;
return `<table class="headers-table"><tbody>${items
.map((header) => `<tr><th>${escapeHTML(header.name)}</th><td>${escapeHTML(header.value)}</td></tr>`)
.join("")}</tbody></table>`;
}
function frameEditorDocument(frames = []) {
const items = Array.isArray(frames) ? frames : [];
if (!items.length) return "";
const normalized = items.map((frame) => {
let message = frame.rawHex || null;
if (frame.json) {
try {
message = JSON.parse(frame.json);
} catch {
message = frame.json;
}
}
return {
index: frame.index,
kind: frame.kind || frame.messageType || t("notices.unknown"),
messageType: frame.messageType || undefined,
flags: `0x${Number(frame.flags || 0).toString(16).padStart(2, "0")}`,
length: frame.length,
compressed: Boolean(frame.compressed),
endStream: Boolean(frame.endStream),
requestId: frame.requestId || undefined,
error: frame.error || undefined,
message,
};
});
return JSON.stringify(normalized, null, 2);
}
function renderStaticPayload(side, markup) {
disposeEditor(side);
const container = elements[`${side}Content`];
container.classList.remove("editor-active");
container.innerHTML = markup;
}
function renderEditorPayload(side, value, language, notices) {
const container = elements[`${side}Content`];
const slot = editorSlots[side];
slot.value = value;
slot.language = language;
container.classList.add("editor-active");
let host = container.querySelector(".editor-host");
if (!host || slot.host !== host) {
disposeEditor(side);
slot.value = value;
slot.language = language;
container.innerHTML = `<div class="editor-host"><pre class="editor-fallback">${escapeHTML(value)}</pre></div><div class="editor-notices">${notices}</div>`;
host = container.querySelector(".editor-host");
void createEditor(side, host, value, language);
return;
}
container.querySelector(".editor-notices").innerHTML = notices;
const fallback = host.querySelector(".editor-fallback");
if (fallback) fallback.textContent = value;
updateEditor(slot, value, language);
}
async function createEditor(side, host, value, language) {
const slot = editorSlots[side];
const token = ++slot.token;
slot.host = host;
try {
const monaco = await monacoReady;
if (token !== slot.token || !host.isConnected) return;
host.textContent = "";
const model = monaco.editor.createModel(slot.value || value, slot.language || language);
const editor = monaco.editor.create(host, {
model,
theme: "vs-dark",
readOnly: true,
domReadOnly: true,
automaticLayout: true,
fontFamily: "SFMono-Regular, Consolas, Liberation Mono, monospace",
fontSize: 12,
lineHeight: 19,
minimap: { enabled: false },
glyphMargin: false,
folding: true,
lineNumbersMinChars: 3,
overviewRulerLanes: 0,
overviewRulerBorder: false,
renderLineHighlight: "none",
scrollBeyondLastLine: false,
smoothScrolling: true,
wordWrap: "off",
padding: { top: 8, bottom: 16 },
stickyScroll: { enabled: false },
contextmenu: true,
});
slot.editor = editor;
slot.model = model;
slot.host = host;
} catch {
// Monaco 初始化失败时保留文本回退视图。
}
}
function updateEditor(slot, value, language) {
if (!slot.editor || !slot.model) return;
const monaco = window.monaco;
if (monaco && slot.model.getLanguageId() !== language) monaco.editor.setModelLanguage(slot.model, language);
if (slot.model.getValue() === value) return;
const viewState = slot.editor.saveViewState();
slot.model.setValue(value);
if (viewState) slot.editor.restoreViewState(viewState);
}
function disposeEditor(side) {
const slot = editorSlots[side];
slot.token += 1;
slot.editor?.dispose();
slot.model?.dispose();
slot.editor = null;
slot.model = null;
slot.host = null;
slot.value = "";
slot.language = "plaintext";
}
function loadMonaco() {
return new Promise((resolve, reject) => {
const amdRequire = window.require;
if (typeof amdRequire !== "function" || typeof amdRequire.config !== "function") {
reject(new Error("Monaco loader is unavailable"));
return;
}
amdRequire.config({ paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs" } });
amdRequire(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
});
}
function applyLocale() {
translateDocument();
elements.localeSelect.value = getLocale();
renderRuntimeStatus();
renderConnectionState();
renderPauseState(state, elements);
renderConversationOptions();
renderBidiMessageFilter();
renderList();
renderTrafficSummary();
renderDetail();
}
bindEvents({
state,
elements,
fetchJSON,
refreshList,
refreshDetail,
renderList,
renderDetail,
renderBidiMessageFilter,
setConnectionState,
applyLocale,
});
async function bootstrap() {
applyLocale();
renderDetail();
try {
await Promise.all([loadStatus(), refreshList()]);
connectEvents();
} catch (error) {
setConnectionState(false, "connection.connectFailed", { message: error.message });
}
}
void bootstrap();
+159
View File
@@ -0,0 +1,159 @@
// app_events.js 绑定调试器筛选、详情、暂停和布局交互事件。
import { t } from "./i18n.js";
import { currentCopyText } from "./view_helpers.js";
// renderPauseState 更新暂停按钮的文本和可访问性属性。
export function renderPauseState(state, elements) {
elements.pauseButton.textContent = state.paused ? "▶" : "Ⅱ";
const actionKey = state.paused ? "actions.resume" : "actions.pause";
elements.pauseButton.title = t(actionKey);
elements.pauseButton.setAttribute("aria-label", t(actionKey));
}
// bindEvents 绑定调试器页面的筛选、详情、暂停和布局交互。
export function bindEvents({ state, elements, fetchJSON, refreshList, refreshDetail, renderList, renderDetail, renderBidiMessageFilter, setConnectionState, applyLocale }) {
elements.requestList.addEventListener("click", async (event) => {
const row = event.target.closest("tr[data-id]");
if (!row) return;
state.selectedId = row.dataset.id;
state.selected = null;
renderList();
renderDetail();
await refreshDetail(state.selectedId);
});
elements.searchInput.addEventListener("input", (event) => {
state.search = event.target.value;
renderList();
});
elements.requestIdInput.addEventListener("input", (event) => {
state.requestId = event.target.value;
renderList();
});
elements.conversationSelect.addEventListener("change", async (event) => {
state.conversationId = event.target.value;
state.selectedId = null;
state.selected = null;
await refreshList();
renderDetail();
});
elements.endpointFilter.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.endpoint = button.dataset.value;
for (const item of elements.endpointFilter.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderBidiMessageFilter();
renderList();
});
elements.bidiMessageOptions.addEventListener("change", (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) return;
if (!checkbox.value) {
state.bidiMessageKinds.clear();
} else if (checkbox.checked) {
state.bidiMessageKinds.add(checkbox.value);
} else {
state.bidiMessageKinds.delete(checkbox.value);
}
renderBidiMessageFilter();
elements.bidiMessageFilter.open = true;
renderList();
});
document.addEventListener("click", (event) => {
if (!elements.bidiMessageFilter.contains(event.target)) elements.bidiMessageFilter.open = false;
});
elements.sortOrder.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
state.sortOrder = button.dataset.value;
for (const item of elements.sortOrder.querySelectorAll("button")) {
item.classList.toggle("active", item === button);
}
renderList();
});
document.querySelectorAll(".payload-panel").forEach((panel) => {
panel.querySelector(".tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (!button) return;
const side = panel.dataset.side;
state.tabs[side] = button.dataset.tab;
panel.querySelectorAll(".tabs button").forEach((item) => item.classList.toggle("active", item === button));
renderDetail();
});
});
document.querySelectorAll("[data-copy-side]").forEach((button) => {
button.addEventListener("click", async () => {
const text = currentCopyText(button.dataset.copySide, state);
if (!text) return;
await navigator.clipboard.writeText(text);
button.textContent = t("actions.copied");
window.setTimeout(() => {
button.textContent = t("actions.copy");
}, 900);
});
});
elements.pauseButton.addEventListener("click", async () => {
state.paused = !state.paused;
elements.pauseButton.classList.toggle("active", state.paused);
renderPauseState(state, elements);
setConnectionState(!state.paused, state.paused ? "connection.paused" : "connection.live");
if (!state.paused && state.pendingRefresh) {
state.pendingRefresh = false;
await refreshList();
}
});
elements.localeSelect.addEventListener("change", (event) => {
setLocale(event.target.value);
applyLocale();
});
elements.showOptionsCheckbox.addEventListener("change", (event) => {
state.showOptions = event.target.checked;
if (!state.showOptions && String(state.selected?.method || "").toUpperCase() === "OPTIONS") {
state.selectedId = null;
state.selected = null;
renderDetail();
}
renderList();
});
elements.clearButton.addEventListener("click", async () => {
await fetchJSON("api/exchanges", { method: "DELETE" });
state.selectedId = null;
state.selected = null;
state.conversationId = "";
await refreshList();
renderDetail();
});
let draggingSplitter = false;
elements.splitter.addEventListener("pointerdown", (event) => {
draggingSplitter = true;
elements.splitter.classList.add("dragging");
elements.splitter.setPointerCapture(event.pointerId);
});
elements.splitter.addEventListener("pointermove", (event) => {
if (!draggingSplitter) return;
const bounds = elements.workspace.getBoundingClientRect();
const top = Math.max(180, Math.min(bounds.height - 225, event.clientY - bounds.top));
elements.workspace.style.gridTemplateRows = `${top}px 5px minmax(220px, 1fr)`;
});
elements.splitter.addEventListener("pointerup", () => {
draggingSplitter = false;
elements.splitter.classList.remove("dragging");
});
}
@@ -1,3 +1,4 @@
// i18n.js 提供协议调试器中英文消息和运行时语言切换。
const SOURCE_LOCALE = "zh-CN";
const DEFAULT_LOCALE = "en-US";
const STORAGE_KEY = "cursor-proxy-debugger:locale:v1";
@@ -7,10 +8,8 @@ const messages = {
"zh-CN": {
"app.title": "Cursor 协议调试器",
"status.connecting": "正在连接",
"status.running": "代理运行中",
"status.stopped": "代理已停止",
"actions.downloadCA": "下载代理 CA 证书",
"actions.caCertificate": "CA 证书",
"status.running": "服务运行中",
"status.stopped": "服务已停止",
"actions.pause": "暂停界面更新",
"actions.resume": "继续界面更新",
"actions.clear": "清空",
@@ -20,13 +19,19 @@ const messages = {
"filters.region": "请求过滤器",
"filters.urlPlaceholder": "过滤 URL、请求类型或状态",
"filters.requestIdPlaceholder": "按 Request ID 过滤",
"filters.conversation": "按 Conversation ID 查询",
"filters.allConversations": "全部会话",
"filters.endpoint": "接口过滤",
"filters.all": "全部",
"filters.fork": "Fork",
"filters.allMessageTypes": "全部消息类型",
"filters.selectedMessageTypes": "已选 {count} 种消息",
"filters.showOptions": "显示 OPTIONS",
"filters.sort": "排序方向",
"filters.ascending": "正序",
"filters.descending": "倒序",
"count.requests": "{count} 条",
"groups.conversation": "会话",
"groups.unassigned": "未关联会话",
"table.url": "网址",
"table.message": "消息",
"table.method": "方法",
@@ -67,10 +72,8 @@ const messages = {
"en-US": {
"app.title": "Cursor Protocol Debugger",
"status.connecting": "Connecting",
"status.running": "Proxy running",
"status.stopped": "Proxy stopped",
"actions.downloadCA": "Download proxy CA certificate",
"actions.caCertificate": "CA Certificate",
"status.running": "Service running",
"status.stopped": "Service stopped",
"actions.pause": "Pause UI updates",
"actions.resume": "Resume UI updates",
"actions.clear": "Clear",
@@ -80,13 +83,19 @@ const messages = {
"filters.region": "Request filters",
"filters.urlPlaceholder": "Filter by URL, message type, or status",
"filters.requestIdPlaceholder": "Filter by Request ID",
"filters.conversation": "Query by Conversation ID",
"filters.allConversations": "All conversations",
"filters.endpoint": "Endpoint filter",
"filters.all": "All",
"filters.fork": "Fork",
"filters.allMessageTypes": "All message types",
"filters.selectedMessageTypes": "{count} message types",
"filters.showOptions": "Show OPTIONS",
"filters.sort": "Sort order",
"filters.ascending": "Oldest first",
"filters.descending": "Newest first",
"count.requests": "{count} requests",
"groups.conversation": "Conversation",
"groups.unassigned": "Unassigned",
"table.url": "URL",
"table.message": "Message",
"table.method": "Method",
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Cursor 协议调试器</title>
<link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="app" class="app-shell">
@@ -17,7 +17,7 @@
<div class="runtime-status" aria-live="polite">
<span id="status-dot" class="status-dot"></span>
<span id="status-text" data-i18n="status.connecting">正在连接</span>
<code id="proxy-address"></code>
<code id="service-address"></code>
</div>
<div class="toolbar-actions">
<label class="locale-picker">
@@ -27,7 +27,6 @@
<option value="en-US">EN</option>
</select>
</label>
<a class="button secondary" href="/api/ca.crt" title="下载代理 CA 证书" data-i18n="actions.caCertificate" data-i18n-title="actions.downloadCA">CA 证书</a>
<button id="pause-button" class="icon-button" type="button" title="暂停界面更新" aria-label="暂停界面更新" data-i18n-title="actions.pause" data-i18n-aria-label="actions.pause"></button>
<button id="clear-button" class="button danger" type="button" data-i18n="actions.clear">清空</button>
</div>
@@ -42,12 +41,25 @@
<span aria-hidden="true"></span>
<input id="request-id-input" type="search" placeholder="按 Request ID 过滤" data-i18n-placeholder="filters.requestIdPlaceholder" autocomplete="off" />
</div>
<label class="conversation-filter">
<span class="visually-hidden" data-i18n="filters.conversation">会话</span>
<select id="conversation-select" aria-label="按 Conversation ID 查询" data-i18n-aria-label="filters.conversation">
<option value="" data-i18n="filters.allConversations">全部会话</option>
</select>
</label>
<div id="endpoint-filter" class="segmented-control" role="group" aria-label="接口过滤" data-i18n-aria-label="filters.endpoint">
<button class="active" type="button" data-value="all" data-i18n="filters.all">全部</button>
<button type="button" data-value="runsse">RunSSE</button>
<button type="button" data-value="bidiappend">BidiAppend</button>
<button type="button" data-value="fork" data-i18n="filters.fork">Fork</button>
</div>
<details id="bidi-message-filter" class="multi-select-filter" hidden>
<summary data-i18n="filters.allMessageTypes">全部消息类型</summary>
<div id="bidi-message-options" class="multi-select-menu"></div>
</details>
<label class="checkbox-control">
<input id="show-options-checkbox" type="checkbox" />
<span data-i18n="filters.showOptions">显示 OPTIONS</span>
</label>
<div id="sort-order" class="segmented-control sort-control" role="group" aria-label="排序方向" data-i18n-aria-label="filters.sort">
<button type="button" data-value="asc" data-i18n="filters.ascending">正序</button>
<button class="active" type="button" data-value="desc" data-i18n="filters.descending">倒序</button>
@@ -103,8 +115,8 @@
<strong data-i18n="panel.response">响应</strong>
<nav class="tabs" aria-label="响应详情" data-i18n-aria-label="panel.responseDetails">
<button type="button" data-tab="headers" data-i18n="tabs.headers">标头</button>
<button type="button" data-tab="body" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" class="active" data-i18n="tabs.frames"></button>
<button type="button" data-tab="body" class="active" data-i18n="tabs.body">正文</button>
<button type="button" data-tab="frames" data-i18n="tabs.frames"></button>
<button type="button" data-tab="raw" data-i18n="tabs.raw">原始</button>
</nav>
<button class="copy-button" type="button" data-copy-side="response" title="复制" data-i18n="actions.copy" data-i18n-title="actions.copy">复制</button>
@@ -118,9 +130,10 @@
<footer class="statusbar">
<span id="connection-label" data-i18n="connection.live">实时连接中</span>
<span id="traffic-summary">↑ 0 B ↓ 0 B</span>
<span id="target-host"></span>
<span id="upstream-url"></span>
</footer>
</div>
<script type="module" src="/app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.56.0/min/vs/loader.js"></script>
<script type="module" src="./app.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
/* styles.css 组合调试器的基础、控件、详情和响应式样式。 */
@import url("./styles_base.css");
@import url("./styles_controls.css");
@import url("./styles_detail.css");
@import url("./styles_responsive.css");
+445
View File
@@ -0,0 +1,445 @@
/* styles.css 定义协议调试器的暗色布局、组件和响应式样式。 */
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #171818;
color: #dedfdd;
font-synthesis: none;
--surface-0: #171818;
--surface-1: #1d1f1f;
--surface-2: #242626;
--surface-3: #2c2f2f;
--border: #343737;
--border-strong: #454949;
--muted: #8d9390;
--text: #dedfdd;
--accent: #4ea58b;
--accent-soft: #25473d;
--cyan: #55a8ba;
--orange: #c88762;
--danger: #c56d65;
--selection: #245b73;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--surface-0);
}
button,
input,
select,
a {
font: inherit;
letter-spacing: 0;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
a:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -1px;
}
.app-shell {
display: grid;
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 760px;
background: var(--surface-0);
}
.topbar,
.filterbar,
.statusbar {
display: flex;
align-items: center;
border-color: var(--border);
background: var(--surface-1);
}
.topbar {
justify-content: space-between;
gap: 18px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
}
.brand,
.runtime-status,
.toolbar-actions {
display: flex;
align-items: center;
min-width: 0;
}
.brand {
gap: 9px;
white-space: nowrap;
}
.brand strong {
font-size: 14px;
font-weight: 650;
}
.brand-mark {
width: 12px;
height: 12px;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--surface-1);
background: var(--accent);
}
.runtime-status {
justify-content: center;
gap: 7px;
min-width: 240px;
color: #bec3c0;
font-size: 12px;
}
.runtime-status code {
overflow: hidden;
max-width: 260px;
color: var(--muted);
font-family: var(--mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7d8380;
}
.status-dot.online {
background: #42bd79;
box-shadow: 0 0 0 3px rgb(66 189 121 / 14%);
}
.toolbar-actions {
justify-content: flex-end;
gap: 7px;
}
.locale-picker {
display: flex;
}
.locale-picker select {
width: 58px;
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 0 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.button,
.icon-button,
.copy-button {
height: 29px;
border: 1px solid var(--border-strong);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
text-decoration: none;
}
.button {
display: inline-flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
}
.button:hover,
.icon-button:hover,
.copy-button:hover {
background: var(--surface-3);
}
.button.danger:hover {
border-color: #744640;
color: #f2b0aa;
}
.icon-button {
width: 31px;
padding: 0;
font-family: var(--mono);
font-weight: 700;
}
.icon-button.active {
border-color: var(--orange);
color: #f1bb98;
}
.filterbar {
flex-wrap: wrap;
gap: 10px;
padding: 7px 14px;
border-bottom: 1px solid var(--border);
}
.search-box {
display: flex;
align-items: center;
flex: 1;
min-width: 260px;
max-width: 640px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
background: #191b1b;
color: var(--muted);
}
.search-box > span {
padding-left: 9px;
font-size: 17px;
}
.search-box input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0 9px;
outline: 0;
background: transparent;
color: var(--text);
font-size: 12px;
}
.search-box input::placeholder {
color: #6f7572;
}
.url-filter {
min-width: 280px;
max-width: 420px;
}
.request-id-filter {
flex: 0 1 320px;
min-width: 220px;
max-width: 340px;
}
.conversation-filter {
flex: 0 1 300px;
min-width: 190px;
}
.conversation-filter select {
width: 100%;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 28px 0 9px;
background: #191b1b;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
}
.segmented-control {
display: flex;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.segmented-control button {
min-width: 62px;
border: 0;
border-right: 1px solid var(--border);
padding: 0 10px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.segmented-control button:last-child {
border-right: 0;
}
.segmented-control button.active {
background: var(--accent-soft);
color: #bce8d9;
}
.sort-control button {
min-width: 52px;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 7px;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 9px;
background: #1b1d1d;
color: var(--muted);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.checkbox-control:has(input:checked) {
border-color: #376858;
background: var(--accent-soft);
color: #bce8d9;
}
.checkbox-control input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-filter {
position: relative;
flex: 0 0 170px;
height: 31px;
color: var(--text);
font-size: 12px;
}
.multi-select-filter[hidden] {
display: none;
}
.multi-select-filter summary {
overflow: hidden;
height: 31px;
border: 1px solid var(--border);
border-radius: 5px;
padding: 7px 28px 0 9px;
background: #191b1b;
cursor: pointer;
list-style: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-filter summary::-webkit-details-marker {
display: none;
}
.multi-select-filter summary::after {
position: absolute;
top: 10px;
right: 10px;
content: "";
border: 4px solid transparent;
border-top-color: var(--muted);
}
.multi-select-filter[open] summary {
border-color: var(--border-strong);
}
.multi-select-menu {
position: absolute;
z-index: 20;
top: 35px;
right: 0;
overflow: auto;
width: 260px;
max-height: 320px;
border: 1px solid var(--border-strong);
border-radius: 5px;
padding: 4px;
background: var(--surface-2);
box-shadow: 0 8px 24px rgb(0 0 0 / 34%);
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
height: 29px;
border-radius: 3px;
padding: 0 7px;
cursor: pointer;
}
.multi-select-option:hover {
background: var(--surface-3);
}
.multi-select-option input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.multi-select-option span {
overflow: hidden;
font-family: var(--mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.multi-select-option.all-option {
border-bottom: 1px solid var(--border);
border-radius: 0;
margin-bottom: 3px;
}
.request-count {
margin-left: auto;
color: var(--muted);
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
+189
View File
@@ -0,0 +1,189 @@
/* styles_controls.css 定义调试器筛选栏、请求列表和基础交互控件。 */
.workspace {
display: grid;
grid-template-rows: minmax(180px, 52%) 5px minmax(220px, 48%);
min-height: 0;
overflow: hidden;
}
.request-list-pane {
position: relative;
min-height: 0;
overflow: auto;
background: #181a1a;
}
.request-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
}
.request-table thead {
position: sticky;
top: 0;
z-index: 2;
background: #202222;
}
.request-table th,
.request-table td {
height: 30px;
border-right: 1px solid #2c2f2f;
border-bottom: 1px solid #292c2c;
padding: 0 9px;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-table th {
color: #9da29f;
font-weight: 550;
}
.request-table tbody tr {
cursor: default;
}
.request-table tbody tr:hover {
background: #222525;
}
.request-table tbody tr.selected {
background: var(--selection);
color: #f3f8f8;
}
.request-table tbody tr.conversation-group,
.request-table tbody tr.conversation-group:hover {
cursor: default;
background: #202323;
}
.request-table tbody tr.conversation-group td {
height: 28px;
border-top: 1px solid var(--border-strong);
color: var(--muted);
}
.conversation-group span {
margin-right: 8px;
color: #9ba19e;
}
.conversation-group code {
color: #73becb;
}
.conversation-group strong {
margin-left: 8px;
color: #78817d;
font-size: 10px;
font-weight: 500;
}
.request-table code {
font-family: var(--mono);
}
.status-column {
width: 30px;
}
.index-column {
width: 54px;
}
.request-id-column {
width: 250px;
}
.kind-column {
width: 180px;
}
.method-column {
width: 72px;
}
.code-column {
width: 66px;
}
.size-column {
width: 86px;
}
.time-column {
width: 74px;
}
.row-state {
display: block;
width: 8px;
height: 8px;
margin: auto;
border-radius: 50%;
background: #7c8380;
}
.row-state.streaming {
background: #45ba77;
}
.row-state.completed {
background: var(--cyan);
}
.row-state.error {
background: var(--danger);
}
.method-text {
color: #61b9df;
font-family: var(--mono);
font-weight: 650;
}
.status-text.success {
color: #68c991;
}
.status-text.error {
color: #e18b83;
}
.kind-text {
color: #d3a17f;
font-family: var(--mono);
}
.request-id-text {
color: #8bc2cc;
}
.empty-state {
position: absolute;
inset: 34px 0 0;
display: grid;
place-items: center;
color: #686e6b;
font-size: 13px;
}
.empty-state.hidden {
display: none;
}
.horizontal-splitter {
cursor: row-resize;
background: #343737;
}
.horizontal-splitter:hover,
.horizontal-splitter.dragging {
background: var(--cyan);
}
+287
View File
@@ -0,0 +1,287 @@
/* styles_detail.css 定义请求详情、载荷面板和状态提示布局。 */
.detail-pane {
display: grid;
grid-template-rows: 38px minmax(0, 1fr);
min-height: 0;
background: var(--surface-0);
}
.selection-summary {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 0 14px;
border-bottom: 1px solid var(--border);
background: #1b1d1d;
}
.selection-summary code {
overflow: hidden;
color: #aeb4b1;
font-family: var(--mono);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge,
.status-badge,
.frame-badge {
display: inline-flex;
align-items: center;
height: 22px;
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 7px;
font-family: var(--mono);
font-size: 11px;
white-space: nowrap;
}
.method-badge {
border-color: #34667a;
color: #74c8e8;
}
.status-badge.success {
border-color: #3f7157;
color: #83d5a5;
}
.detail-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.payload-panel {
display: grid;
grid-template-rows: 36px minmax(0, 1fr);
min-width: 0;
min-height: 0;
border-right: 1px solid var(--border);
}
.payload-panel:last-child {
border-right: 0;
}
.panel-header {
display: flex;
align-items: center;
min-width: 0;
border-bottom: 1px solid var(--border);
background: #202222;
}
.panel-header > strong {
padding: 0 10px;
color: #c9cdca;
font-size: 12px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tabs button {
position: relative;
min-width: 46px;
border: 0;
padding: 0 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
}
.tabs button:hover {
color: #d7dad8;
}
.tabs button.active {
color: #71c7e2;
}
.tabs button.active::after {
position: absolute;
right: 8px;
bottom: 0;
left: 8px;
height: 2px;
background: var(--cyan);
content: "";
}
.copy-button {
width: 48px;
height: 24px;
margin-right: 7px;
margin-left: auto;
font-size: 11px;
}
.panel-content {
min-height: 0;
overflow: auto;
background: #181a1a;
}
.panel-content.editor-active {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
}
.editor-host {
min-width: 0;
min-height: 0;
background: #1e1e1e;
}
.editor-fallback {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 10px 14px 24px;
overflow: auto;
color: #d4d4d4;
font: 12px/1.58 var(--mono);
white-space: pre;
}
.editor-notices:empty {
display: none;
}
.code-view,
.hex-view {
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 12px 14px 30px;
color: #ccd1ce;
font: 11px/1.55 var(--mono);
tab-size: 2;
white-space: pre;
}
.hex-view {
color: #b6c2bd;
}
.headers-table {
width: 100%;
border-collapse: collapse;
font: 11px/1.4 var(--mono);
}
.headers-table th,
.headers-table td {
border-bottom: 1px solid #292c2c;
padding: 7px 10px;
text-align: left;
vertical-align: top;
}
.headers-table th {
width: 38%;
color: #62b3cd;
font-weight: 500;
overflow-wrap: anywhere;
}
.headers-table td {
color: #c5c9c6;
overflow-wrap: anywhere;
}
.frame-list {
min-width: 480px;
}
.frame-item {
border-bottom: 1px solid #292c2c;
}
.frame-item summary {
display: grid;
grid-template-columns: 58px minmax(150px, 1fr) 90px 82px;
align-items: center;
height: 32px;
padding: 0 10px;
color: #c5cac7;
cursor: pointer;
font: 11px var(--mono);
list-style: none;
}
.frame-item summary::-webkit-details-marker {
display: none;
}
.frame-item summary:hover {
background: #222525;
}
.frame-item[open] summary {
background: #242727;
}
.frame-index {
color: #747b77;
}
.frame-kind {
overflow: hidden;
color: #dfaa85;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size,
.frame-flags {
color: #7faeb7;
text-align: right;
}
.frame-error {
margin: 10px 14px;
color: #ec968e;
font: 11px/1.5 var(--mono);
}
.notice {
padding: 14px;
color: #7d8581;
font: 12px/1.6 var(--mono);
}
.notice.error {
color: #df8b83;
}
.truncated-notice {
position: sticky;
bottom: 0;
padding: 5px 10px;
border-top: 1px solid #674f3f;
background: #3d3028;
color: #e5b28e;
font-size: 11px;
}
.statusbar {
justify-content: flex-end;
gap: 16px;
padding: 0 10px;
border-top: 1px solid var(--border);
color: #848b87;
font: 10px var(--mono);
}
.statusbar span:first-child {
margin-right: auto;
}
+195
View File
@@ -0,0 +1,195 @@
/* styles_responsive.css 定义调试器在窄屏下的响应式布局。 */
@media (max-width: 920px) {
.app-shell {
grid-template-rows: 48px auto minmax(0, 1fr) 26px;
min-width: 0;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
}
.url-filter,
.request-id-filter {
flex: 1 1 300px;
max-width: none;
}
.runtime-status code,
.kind-column,
.request-table td:nth-child(5) {
display: none;
}
.detail-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(180px, 1fr) minmax(180px, 1fr);
overflow: auto;
}
.payload-panel {
min-height: 260px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 640px) {
.app-shell {
grid-template-rows: 82px auto minmax(0, 1fr) 26px;
}
.topbar {
position: relative;
align-content: center;
flex-wrap: wrap;
gap: 4px 10px;
padding: 8px 10px;
}
.brand {
flex: 1;
overflow: hidden;
}
.brand strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.runtime-status {
order: 3;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.runtime-status code {
display: block;
max-width: none;
}
.toolbar-actions {
gap: 4px;
}
.toolbar-actions .button {
padding: 0 7px;
}
.filterbar {
align-content: center;
flex-wrap: wrap;
gap: 6px;
padding: 7px 10px;
}
.url-filter,
.request-id-filter {
flex: 0 0 100%;
width: 100%;
min-width: 0;
max-width: none;
}
.request-count {
order: 5;
margin-left: auto;
}
#endpoint-filter {
order: 3;
flex: 1;
}
.sort-control {
order: 4;
flex: 0 0 104px;
}
.segmented-control button {
flex: 1;
min-width: 0;
}
.workspace {
grid-template-rows: minmax(150px, 40%) 5px minmax(260px, 60%);
}
.request-table th,
.request-table td {
padding: 0 6px;
}
.request-table .index-column,
.request-table th:nth-child(2),
.request-table td:nth-child(2),
.size-column,
.request-table th:nth-child(8),
.request-table td:nth-child(8),
.time-column,
.request-table th:nth-child(9),
.request-table td:nth-child(9) {
display: none;
}
.request-id-column {
width: 130px;
}
.method-column {
width: 58px;
}
.code-column {
width: 50px;
}
.selection-summary {
padding: 0 8px;
}
.detail-columns {
grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr);
}
.panel-header > strong {
width: 72px;
padding: 0 7px;
font-size: 11px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 42px;
padding: 0 6px;
}
.copy-button {
width: 42px;
margin-right: 4px;
}
.frame-list {
min-width: 0;
}
.frame-item summary {
grid-template-columns: 42px minmax(100px, 1fr) 62px 72px;
padding: 0 7px;
}
.statusbar {
gap: 8px;
}
#upstream-url {
display: none;
}
}
+71
View File
@@ -0,0 +1,71 @@
// view_helpers.js 提供调试器界面使用的格式化、转义和复制文本辅助函数。
import { t } from "./i18n.js";
// renderDecodeError 将解码错误转换为安全的提示片段。
export function renderDecodeError(error) {
return error ? `<div class="frame-error">${escapeHTML(error)}</div>` : "";
}
// renderTruncated 生成正文被截断时的提示片段。
export function renderTruncated(truncated) {
return truncated ? `<div class="truncated-notice">${escapeHTML(t("notices.truncated"))}</div>` : "";
}
// formatState 将捕获状态转换为当前语言的展示文本。
export function formatState(value) {
const key = {
pending: "state.pending",
streaming: "state.streaming",
completed: "state.completed",
error: "state.error",
}[value];
return key ? t(key) : value || "-";
}
// currentCopyText 根据当前标签页提取可复制的载荷文本。
export function currentCopyText(side, state) {
const payload = state.selected?.[side];
if (!payload) return "";
const tab = state.tabs[side];
if (tab === "headers") return (payload.headers || []).map((item) => `${item.name}: ${item.value}`).join("\n");
if (tab === "raw") return payload.rawHex || "";
if (tab === "frames") return (payload.frames || []).map((frame) => frame.json || frame.rawHex || frame.error || "").join("\n\n");
return payload.decodedJson || "";
}
// formatHex 将十六进制载荷按行格式化为调试视图。
export function formatHex(value) {
const hex = String(value || "").replace(/[^0-9a-f]/gi, "");
const lines = [];
for (let index = 0; index < hex.length; index += 32) {
const chunk = hex.slice(index, index + 32);
const bytes = chunk.match(/.{1,2}/g) || [];
lines.push(`${(index / 2).toString(16).padStart(8, "0")} ${bytes.join(" ")}`);
}
return lines.join("\n");
}
// formatBytes 将字节数格式化为人类可读的单位。
export function formatBytes(value) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// formatDuration 将毫秒耗时格式化为人类可读的单位。
export function formatDuration(value) {
const milliseconds = Number(value || 0);
if (milliseconds < 1000) return `${milliseconds} ms`;
return `${(milliseconds / 1000).toFixed(1)} s`;
}
// escapeHTML 转义用户或网络输入,避免插入界面时形成 HTML。
export function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
+48
View File
@@ -0,0 +1,48 @@
# cursor-proto
`cursor-proto` 从已安装 Cursor 的 JavaScript bundle 中提取 Protobuf 定义。
## 目录
- `extractor/`Go 提取器。
- `scripts/extract.sh`:扫描 Cursor 安装目录并安全更新输出。
- `proto/`:提取结果,是项目内唯一的 Proto 输出目录;由脚本重新生成,不提交到 Git。
- `scripts/generate.sh`:根据提取结果生成可导入的 Go 消息包。
- `gen/`:供其他 Go module 使用的 Go 消息包;由脚本重新生成,不提交到 Git。
## 使用
默认从 `/Applications/Cursor.app` 提取:
```bash
./scripts/extract.sh
```
也可以指定 Cursor 应用、bundle 文件和输出目录:
```bash
./scripts/extract.sh /path/to/Cursor.app
./scripts/extract.sh /path/to/workbench.desktop.main.js /path/to/output
```
直接运行 Go 提取器时,可重复传入多个 bundle:
```bash
go run ./extractor \
-input /path/to/workbench.desktop.main.js \
-input /path/to/extensionHostProcess.js \
-output ./proto \
-strict
```
提取完成后重新生成 Go 消息包:
```bash
./scripts/generate.sh
```
## 验证
```bash
go test ./...
```
+192
View File
@@ -0,0 +1,192 @@
// extractor_test.go 验证压缩 bundle 的字段、别名、服务和合并提取行为。
package main
import "testing"
// TestParseFieldObjectSupportsShorthandType 验证字段类型简写可以解析。
func TestParseFieldObjectSupportsShorthandType(t *testing.T) {
field, err := parseFieldObject(`{no:4,name:"file_not_found",kind:"message",T,oneof:"result"}`)
if err != nil {
t.Fatalf("parse shorthand T: %v", err)
}
if field.T != "T" {
t.Fatalf("parsed shorthand T as %#v, want T", field.T)
}
}
// TestWebpackExportAliasResolvesServiceMessageType 验证 Webpack 导出别名可解析服务消息。
func TestWebpackExportAliasResolvesServiceMessageType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
n.d(t,{KS:()=>T,_B:()=>r});
var r;
class T {}
T.typeName="agent.v1.AgentClientMessage";
n.proto3.util.setEnumType(r,"agent.v1.DiagnosticSeverity",[]);
},
2:(e,t,n)=>{
var r=n(1);
const service={typeName:"agent.v1.AgentService",methods:{run:{name:"Run",I:r.KS,O:r.KS,kind:n.MethodKind.BiDiStreaming}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := []Message{{
TypeName: "agent.v1.AgentClientMessage",
VarName: "T",
InternalName: "T",
Package: "agent.v1",
Pos: 35,
ModuleStart: moduleStartForPos(moduleStarts, 35),
}}
enums := []Enum{{
TypeName: "agent.v1.DiagnosticSeverity",
VarName: "r",
Package: "agent.v1",
Pos: 100,
ModuleStart: moduleStartForPos(moduleStarts, 100),
}}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
resolver.moduleImports = buildModuleImportIndex(bundle, moduleStarts)
typeName, ok := resolver.ResolveTypeName("r.KS", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "agent.v1", "message")
if !ok {
t.Fatal("expected webpack export alias to resolve")
}
if typeName != "agent.v1.AgentClientMessage" {
t.Fatalf("resolved r.KS to %q, want agent.v1.AgentClientMessage", typeName)
}
}
// TestResolverPrefersExpectedKindOverCurrentPackage 验证类型类别优先于当前包候选。
func TestResolverPrefersExpectedKindOverCurrentPackage(t *testing.T) {
resolver := &TypeResolver{bySymbol: map[string][]symbolDef{
"nt": {
{TypeName: "git_forge.v1.GetTagResponse", Kind: "message", Pos: 10, ModuleStart: 1},
{TypeName: "origin.v1.TeamGroupKind", Kind: "enum", Pos: 20, ModuleStart: 1},
},
}}
typeName, ok := resolver.ResolveTypeName("nt", 30, 1, "origin.v1", "message")
if !ok {
t.Fatal("expected cross-package message type to resolve")
}
if typeName != "git_forge.v1.GetTagResponse" {
t.Fatalf("resolved nt to %q, want git_forge.v1.GetTagResponse", typeName)
}
}
// TestModernFactorySyntaxExtractsInAppAdServiceTypes 验证现代工厂语法提取完整服务类型。
func TestModernFactorySyntaxExtractsInAppAdServiceTypes(t *testing.T) {
const bundle = `
42:(e,t,n)=>{
var HasSeenAdRequest=n.makeMessageType("aiserver.v1.HasSeenAdRequest",()=>[{no:1,name:"ad_id",kind:"scalar",T:9}]),
HasSeenAdResponse=n.makeMessageType("aiserver.v1.HasSeenAdResponse",()=>[{no:1,name:"has_seen",kind:"scalar",T:8}]),
MarkAdAsSeenResponse=n.makeMessageType("aiserver.v1.MarkAdAsSeenResponse",[]),
Placement=n.makeEnum("aiserver.v1.InAppAdPlacement",[{no:0,name:"IN_APP_AD_PLACEMENT_UNSPECIFIED",localName:"UNSPECIFIED"}]),
InAppAdService={typeName:"aiserver.v1.InAppAdService",methods:{hasSeenAd:{name:"HasSeenAd",I:HasSeenAdRequest,O:HasSeenAdResponse,kind:n.MethodKind.Unary},markAdAsSeen:{name:"MarkAdAsSeen",I:HasSeenAdRequest,O:MarkAdAsSeenResponse,kind:n.MethodKind.Unary}}};
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
enums := extractEnums(bundle, moduleStarts)
services := extractServices(bundle, moduleStarts)
if len(messages) != 3 {
t.Fatalf("extracted %d messages, want 3", len(messages))
}
if len(messages[0].Fields) != 1 || messages[0].Fields[0].Name != "ad_id" {
t.Fatalf("unexpected request fields: %#v", messages[0].Fields)
}
if len(enums) != 1 || enums[0].TypeName != "aiserver.v1.InAppAdPlacement" {
t.Fatalf("unexpected enums: %#v", enums)
}
if len(services) != 1 || len(services[0].Methods) != 2 {
t.Fatalf("unexpected services: %#v", services)
}
resolver := newTypeResolver(messages, enums, buildAliasIndex(bundle, moduleStarts), buildWebpackExportAliasIndex(bundle, moduleStarts))
method := services[0].Methods[0]
input, inputOK := resolver.ResolveTypeName(method.InputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
output, outputOK := resolver.ResolveTypeName(method.OutputType, services[0].Pos, services[0].ModuleStart, services[0].Package, "message")
if !inputOK || input != "aiserver.v1.HasSeenAdRequest" {
t.Fatalf("resolved input to %q (ok=%v)", input, inputOK)
}
if !outputOK || output != "aiserver.v1.HasSeenAdResponse" {
t.Fatalf("resolved output to %q (ok=%v)", output, outputOK)
}
}
// TestAssignmentAliasResolvesStandardProtobufType 验证赋值别名解析标准协议类型。
func TestAssignmentAliasResolvesStandardProtobufType(t *testing.T) {
const bundle = `
1:(e,t,n)=>{
var Timestamp=class TimestampMessage extends Base{};
Timestamp.typeName="google.protobuf.Timestamp",Timestamp.fields=n.proto3.util.newFieldList(()=>[]),ua=Timestamp;
var Request=n.makeMessageType("aiserver.v1.Request",()=>[{no:1,name:"created_at",kind:"message",T:ua}]);
}`
moduleStarts := buildModuleStarts(bundle)
messages := extractMessages(bundle, moduleStarts)
resolver := newTypeResolver(messages, nil, buildAliasIndex(bundle, moduleStarts), nil)
typeName, ok := resolver.ResolveTypeName("ua", len(bundle)-1, moduleStartForPos(moduleStarts, len(bundle)-1), "aiserver.v1", "message")
if !ok || typeName != "google.protobuf.Timestamp" {
t.Fatalf("resolved ua to %q (ok=%v), want google.protobuf.Timestamp", typeName, ok)
}
}
// TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes 验证覆盖率忽略标准类型并报告遗漏。
func TestDeclarationCoverageReportsUnparsedTypesAndIgnoresGoogleTypes(t *testing.T) {
const bundle = `
var Request=n.makeMessageType("aiserver.v1.Request",()=>[]);
var Missing=n.makeMessageType("aiserver.v1.Missing",()=>[]);
var Timestamp=n.makeMessageType("google.protobuf.Timestamp",()=>[]);
var Service={typeName:"aiserver.v1.TestService",methods:{}};
`
messages := []Message{{TypeName: "aiserver.v1.Request"}}
services := []Service{{TypeName: "aiserver.v1.TestService"}}
declared, extracted, missing := declarationCoverage(bundle, messages, nil, services)
if declared != 3 || extracted != 2 {
t.Fatalf("coverage=%d/%d, want 2/3", extracted, declared)
}
if len(missing) != 1 || missing[0] != "aiserver.v1.Missing" {
t.Fatalf("unexpected missing declarations: %#v", missing)
}
}
// TestExtractServicesSupportsAnonymousDescriptors 验证匿名服务描述符可以提取。
func TestExtractServicesSupportsAnonymousDescriptors(t *testing.T) {
const bundle = `services.push({typeName:"aiserver.v1.FileSyncService",methods:{sync:{name:"Sync",I:Request,O:Response,kind:n.MethodKind.Unary}}})`
services := extractServices(bundle, nil)
if len(services) != 1 || services[0].TypeName != "aiserver.v1.FileSyncService" {
t.Fatalf("unexpected services: %#v", services)
}
if len(services[0].Methods) != 1 || services[0].Methods[0].Name != "Sync" {
t.Fatalf("unexpected methods: %#v", services[0].Methods)
}
}
// TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes 验证合并优先主 bundle 并保留补充类型。
func TestMergeMessagesPrefersPrimaryBundleAndKeepsSupplementalTypes(t *testing.T) {
primary := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "primary", Kind: "scalar", T: 9}},
}
supplemental := Message{
TypeName: "aiserver.v1.Shared",
Fields: []Field{{No: 1, Name: "supplemental", Kind: "scalar", T: 9}},
}
legacy := Message{TypeName: "aiserver.v1.LegacyOnly"}
merged := mergeMessagesByTypeName([]Message{primary, supplemental, legacy})
if len(merged) != 2 {
t.Fatalf("merged %d messages, want 2", len(merged))
}
if merged[0].Fields[0].Name != "primary" {
t.Fatalf("duplicate type did not preserve primary definition: %#v", merged[0])
}
if merged[1].TypeName != "aiserver.v1.LegacyOnly" {
t.Fatalf("supplemental-only type missing: %#v", merged)
}
}
+341
View File
@@ -0,0 +1,341 @@
// generator.go 计算跨包依赖并为各协议包准备完整声明集合。
package main
import (
"fmt"
"os"
)
// generateProtos 按协议包聚合声明并生成对应文件。
func generateProtos(messages []Message, enums []Enum, services []Service, resolver *TypeResolver, outputDir string) {
os.MkdirAll(outputDir, 0755)
// 按协议包聚合声明。
packages := make(map[string]struct {
messages []Message
enums []Enum
services []Service
})
for _, msg := range messages {
pkg := packages[msg.Package]
pkg.messages = append(pkg.messages, msg)
packages[msg.Package] = pkg
}
for _, enum := range enums {
pkg := packages[enum.Package]
pkg.enums = append(pkg.enums, enum)
packages[enum.Package] = pkg
}
for _, svc := range services {
pkg := packages[svc.Package]
pkg.services = append(pkg.services, svc)
packages[svc.Package] = pkg
}
// 建立跨包复制使用的全局类型索引。
allMessages := make(map[string]*Message)
allEnums := make(map[string]*Enum)
for pkgName, pkg := range packages {
if isGooglePkg(pkgName) {
continue
}
for i := range pkg.messages {
msg := &pkg.messages[i]
allMessages[msg.TypeName] = msg
}
for i := range pkg.enums {
enum := &pkg.enums[i]
allEnums[enum.TypeName] = enum
}
}
// 每轮生成前重置已复制类型索引。
copiedTypes = make(map[string]map[string]string)
for pkgName, pkg := range packages {
// Google 标准包直接使用官方协议文件。
if isGooglePkg(pkgName) {
fmt.Printf("跳过: %s (使用官方 proto 文件)\n", pkgName)
continue
}
// 把当前包引用的外部类型复制到本地。
augmentedPkg := copyAllExternalTypes(pkgName, pkg, resolver, allMessages, allEnums)
generateProtoFile(pkgName, augmentedPkg.messages, augmentedPkg.enums, pkg.services, resolver, outputDir)
}
}
// copyAllExternalTypes 递归复制当前包引用的全部外部类型。
func copyAllExternalTypes(pkgName string, pkg struct {
messages []Message
enums []Enum
services []Service
}, resolver *TypeResolver, allMessages map[string]*Message, allEnums map[string]*Enum) struct {
messages []Message
enums []Enum
services []Service
} {
if copiedTypes[pkgName] == nil {
copiedTypes[pkgName] = make(map[string]string)
}
// 建立当前包已有类型集合,并登记本地名称供字段解析使用。
localTypes := make(map[string]bool)
for _, msg := range pkg.messages {
localTypes[msg.ShortName] = true
// 空来源名表示该类型原本就在当前包。
if copiedTypes[pkgName][msg.ShortName] == "" {
copiedTypes[pkgName][msg.ShortName] = "local:" + msg.TypeName
}
}
for _, enum := range pkg.enums {
localTypes[enum.ShortName] = true
if copiedTypes[pkgName][enum.ShortName] == "" {
copiedTypes[pkgName][enum.ShortName] = "local:" + enum.TypeName
}
}
// 结果先保留当前包原始声明。
result := struct {
messages []Message
enums []Enum
services []Service
}{
messages: append([]Message{}, pkg.messages...),
enums: append([]Enum{}, pkg.enums...),
services: pkg.services,
}
totalCopied := 0
// 持续迭代,直到不再发现新的外部依赖。
for round := 1; ; round++ {
// 收集当前消息中的外部类型引用。
neededTypes := make(map[string]bool)
for _, msg := range result.messages {
preferredPkg, _ := parseTypeName(msg.TypeName)
for _, f := range msg.Fields {
collectFieldRefsSimple(f, pkgName, preferredPkg, msg.Pos, msg.ModuleStart, resolver, neededTypes, localTypes)
}
}
for _, svc := range result.services {
for _, m := range svc.Methods {
collectMethodRefsSimple(m.InputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
collectMethodRefsSimple(m.OutputType, pkgName, svc.Pos, svc.ModuleStart, resolver, neededTypes, localTypes)
}
}
// 复制本轮新增依赖类型。
copiedThisRound := 0
for typeName := range neededTypes {
refPkg, shortName := parseTypeName(typeName)
if refPkg == pkgName || isGooglePkg(refPkg) {
continue
}
// 已存在于本地时无需重复复制。
if localTypes[shortName] {
continue
}
// 复制消息声明。
if msg, ok := allMessages[typeName]; ok {
msgCopy := *msg
msgCopy.Package = pkgName
// 保留原始完整类型名,用于生成来源注释。
result.messages = append(result.messages, msgCopy)
copiedTypes[pkgName][shortName] = typeName // 保存原始完整类型名。
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制: %s\n", pkgName, round, typeName)
} else if enum, ok := allEnums[typeName]; ok {
// 复制枚举声明。
enumCopy := *enum
enumCopy.Package = pkgName
result.enums = append(result.enums, enumCopy)
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
copiedThisRound++
fmt.Printf(" [%s] 轮%d 复制枚举: %s\n", pkgName, round, typeName)
} else {
// 未找到声明时仍登记本地引用,兼容提取结果缺少但 bundle 实际存在的类型。
copiedTypes[pkgName][shortName] = typeName
localTypes[shortName] = true
fmt.Printf(" [%s] 轮%d 警告: 类型未找到 %s,标记为本地引用\n", pkgName, round, typeName)
}
}
totalCopied += copiedThisRound
if copiedThisRound == 0 {
break // 没有新增依赖时结束迭代。
}
if round > 20 {
fmt.Printf(" [%s] 警告: 复制轮次超过20,可能存在问题\n", pkgName)
break
}
}
if totalCopied > 0 {
fmt.Printf(" [%s] 共复制 %d 个外部类型\n", pkgName, totalCopied)
}
return result
}
// collectFieldRefsSimple 收集单个字段直接引用的外部类型。
func collectFieldRefsSimple(f Field, currentPkg string, preferredPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
type refWithKind struct {
ref string
kind string
}
var refs []refWithKind
if f.Kind == "message" || f.Kind == "enum" {
if v, ok := f.T.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.Kind})
}
}
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if v, ok := f.MapValueT.(string); ok {
refs = append(refs, refWithKind{ref: v, kind: f.MapValueKind})
}
}
for _, item := range refs {
typeName, ok := resolver.ResolveTypeName(item.ref, contextPos, contextModuleStart, preferredPkg, item.kind)
if !ok {
continue
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
continue
}
// 已在当前包中的类型无需收集。
if localTypes[shortName] {
continue
}
neededTypes[typeName] = true
}
}
// collectMethodRefsSimple 收集服务方法输入或输出引用的外部类型。
func collectMethodRefsSimple(ref string, currentPkg string, contextPos int, contextModuleStart int, resolver *TypeResolver,
neededTypes map[string]bool, localTypes map[string]bool) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, "message")
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
if refPkg == "" || refPkg == currentPkg || isGooglePkg(refPkg) {
return
}
if localTypes[shortName] {
return
}
neededTypes[typeName] = true
}
// copiedTypes 按目标包和短名称记录被复制类型的原始全限定名。
var copiedTypes = make(map[string]map[string]string)
// TypeNode 表示嵌套消息与枚举组成的类型树节点。
type TypeNode struct {
// Name 是当前嵌套层级的类型名。
Name string
// Message 保存当前节点的消息声明。
Message *Message
// Enum 保存当前节点的枚举声明。
Enum *Enum
// Children 保存下一层嵌套类型。
Children map[string]*TypeNode
}
// collectImports 只收集 Google 标准依赖,其余类型会复制到本地。
func collectImports(currentPkg string, messages []Message, services []Service, resolver *TypeResolver) map[string]bool {
imports := make(map[string]bool)
addImport := func(ref string, contextPos int, contextModuleStart int, expectedKind string) {
typeName, ok := resolver.ResolveTypeName(ref, contextPos, contextModuleStart, currentPkg, expectedKind)
if !ok {
return
}
refPkg, shortName := parseTypeName(typeName)
// 仅导入 Google 标准类型。
if refPkg == "google.protobuf" {
var importFile string
switch shortName {
case "Struct", "Value", "ListValue", "NullValue":
importFile = "google/protobuf/struct.proto"
case "Timestamp":
importFile = "google/protobuf/timestamp.proto"
case "Duration":
importFile = "google/protobuf/duration.proto"
case "Any":
importFile = "google/protobuf/any.proto"
case "Empty":
importFile = "google/protobuf/empty.proto"
case "FieldMask":
importFile = "google/protobuf/field_mask.proto"
case "BoolValue", "BytesValue", "DoubleValue", "FloatValue",
"Int32Value", "Int64Value", "StringValue", "UInt32Value", "UInt64Value":
importFile = "google/protobuf/wrappers.proto"
default:
importFile = "google/protobuf/descriptor.proto"
}
imports[importFile] = true
} else if refPkg == "google.rpc" {
var importFile string
switch shortName {
case "Status":
importFile = "google/rpc/status.proto"
case "Code":
importFile = "google/rpc/code.proto"
default:
importFile = "google/rpc/status.proto"
}
imports[importFile] = true
}
}
for _, msg := range messages {
for _, f := range msg.Fields {
if f.Kind == "message" || f.Kind == "enum" {
if ref, ok := f.T.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.Kind)
}
}
// map 值类型也可能引用标准包。
if f.Kind == "map" && (f.MapValueKind == "message" || f.MapValueKind == "enum") {
if ref, ok := f.MapValueT.(string); ok {
addImport(ref, msg.Pos, msg.ModuleStart, f.MapValueKind)
}
}
}
}
for _, svc := range services {
for _, m := range svc.Methods {
addImport(m.InputType, svc.Pos, svc.ModuleStart, "message")
addImport(m.OutputType, svc.Pos, svc.ModuleStart, "message")
}
}
return imports
}
+134
View File
@@ -0,0 +1,134 @@
// main.go 提供协议提取命令的参数解析、输入保护和输出调度。
package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
)
// inputPaths 支持命令行重复传入 bundle 路径。
type inputPaths []string
// String 返回已经登记的输入路径列表。
func (paths *inputPaths) String() string {
return fmt.Sprint([]string(*paths))
}
// Set 追加一个去除空白后的输入路径。
func (paths *inputPaths) Set(value string) error {
*paths = append(*paths, value)
return nil
}
// bailIf 在不可恢复错误时打印信息并退出。
func bailIf(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// findPrettier 定位可用的 prettier 命令。
func findPrettier() (string, error) {
// 尝试常见的 prettier 命令名
names := []string{"prettier", "prettier.cmd", "npx"}
for _, name := range names {
if path, err := exec.LookPath(name); err == nil {
return path, nil
}
}
return "", fmt.Errorf("prettier not found in PATH, please install: npm install -g prettier")
}
// main 解析参数、保护原始输入并执行协议提取。
func main() {
// 命令行参数
var inputs inputPaths
flag.Var(&inputs, "input", "Path to a JS bundle; repeat to merge multiple bundles")
outputDir := flag.String("output", "", "Output directory for proto files (default: ./cursor_proto)")
skipFormat := flag.Bool("skip-format", false, "Skip prettier formatting")
strict := flag.Bool("strict", true, "Fail when extraction validation detects unresolved/placeholder output")
flag.Parse()
// 如果没有 -input 参数,尝试从位置参数获取
if len(inputs) == 0 && flag.NArg() > 0 {
inputs = append(inputs, flag.Args()...)
}
if len(inputs) == 0 {
fmt.Fprintln(os.Stderr, "Usage: ext -input <path-to-js-file> [-input <another-js-file>] [-output <dir>] [-skip-format]")
fmt.Fprintln(os.Stderr, " ext <path-to-js-file>")
fmt.Fprintln(os.Stderr, "\nExample:")
fmt.Fprintln(os.Stderr, " ext -input /path/to/extensionHostProcess.js")
fmt.Fprintln(os.Stderr, " ext C:\\Users\\xxx\\AppData\\Local\\Programs\\cursor\\resources\\app\\out\\vs\\workbench\\api\\node\\extensionHostProcess.js")
os.Exit(1)
}
for _, inputPath := range inputs {
info, err := os.Stat(inputPath)
bailIf(err)
if info.IsDir() {
bailIf(fmt.Errorf("expected %s to be file, is dir", inputPath))
}
}
// 设置输出目录
if *outputDir == "" {
wd, err := os.Getwd()
bailIf(err)
*outputDir = filepath.Join(wd, "cursor_proto")
}
// 复制到临时文件后再格式化,避免修改 Cursor 安装目录。
fmt.Printf("Copying %d source bundle(s) to temp directory...\n", len(inputs))
tempFileNames := make([]string, 0, len(inputs))
for _, inputPath := range inputs {
originalFile, err := os.Open(inputPath)
bailIf(err)
tempFile, err := os.CreateTemp(os.TempDir(), "cursor-source-*.js")
bailIf(err)
_, err = io.Copy(tempFile, originalFile)
bailIf(err)
bailIf(originalFile.Close())
bailIf(tempFile.Close())
tempFileNames = append(tempFileNames, tempFile.Name())
fmt.Printf("Source: %s\n", inputPath)
}
if *skipFormat {
fmt.Println("Skipping formatting (--skip-format)")
} else if prettierBin, err := findPrettier(); err != nil {
fmt.Printf("Warning: %v\n", err)
fmt.Println("Skipping formatting, extraction may be less accurate...")
} else {
fmt.Println("Formatting source bundles (this may take a while)...")
for _, tempFileName := range tempFileNames {
var prettierCmd *exec.Cmd
if filepath.Base(prettierBin) == "npx" {
prettierCmd = exec.Command(prettierBin, "prettier", "--write", tempFileName)
} else {
prettierCmd = exec.Command(prettierBin, "--write", tempFileName)
}
out, formatErr := prettierCmd.CombinedOutput()
if formatErr != nil {
fmt.Printf("Prettier output: %s\n", string(out))
fmt.Println("Warning: formatting failed for one bundle, continuing anyway...")
}
}
}
// 运行提取器
fmt.Println("Extracting Proto definitions...")
SetStrictMode(*strict)
ExtractProtosFromFiles(tempFileNames, *outputDir)
for _, tempFileName := range tempFileNames {
_ = os.Remove(tempFileName)
}
fmt.Printf("\nOutput directory: %s\n", *outputDir)
}

Some files were not shown because too many files have changed in this diff Show More