From 90ebb1992160d22f4562f9205280214e05248211 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 30 Jun 2026 21:16:17 +0800 Subject: [PATCH 01/84] =?UTF-8?q?chore(release):=20dev=20=E5=88=87?= =?UTF-8?q?=E5=88=B0=200.9.0-dev=20=E5=BC=80=E5=8F=91=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.8.0 发版后按版本号规则切下一版 -dev 预发布号,标记开发态、与正式版区分(-dev 不打 tag、不发版)。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 976d3a59..a4480e3a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.8.0", + "version": "0.9.0-dev", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/package-lock.json b/package-lock.json index 16fad818..2e420e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.8.0", + "version": "0.9.0-dev", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", From 47ebd304ea850602687e68ee1718f0c283c00aa6 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 30 Jun 2026 21:51:39 +0800 Subject: [PATCH 02/84] =?UTF-8?q?docs(arch):=20=E6=96=B0=E5=A2=9E=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E9=9B=86=E6=88=90=E6=89=A9=E5=B1=95=E4=B8=8E=20CLI=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 0.9.0「外部集成扩展与 CLI」方向沉淀设计: - 服务监听与本地 API:主进程内置 HTTP API(IPC 之外第二前端),默认关闭、 强制 bearer token、loopback 默认 / 可选 0.0.0.0、固定默认端口 18765、只读边界 (写工具硬拒绝)、路由复用 service 层、端点表与 SV 错误码领域。 - CLI 工具:Go 技术栈与「同仓独立 cli/ 目录、不打进安装包」的内嵌评估、命令树、 本机零配置自动发现(读 ~/.code-meeseeks/config.yaml)、输出与退出码、跨平台分发与 CI。 - 同步 arch README / 00-overview 模块地图与 ROADMAP 交叉链接。 Co-Authored-By: Claude Opus 4.8 --- docs/ROADMAP.md | 3 +- docs/arch/00-overview.md | 3 + docs/arch/04-integration/01-service-api.md | 142 +++++++++++++++++++++ docs/arch/04-integration/02-cli.md | 113 ++++++++++++++++ docs/arch/README.md | 3 + 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 docs/arch/04-integration/01-service-api.md create mode 100644 docs/arch/04-integration/02-cli.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 88774b95..31e28935 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -78,7 +78,8 @@ ### 进行中 / 待办 ⏭️ - [ ] **可观测性扩展**:规则命中率、模型对比(token 用量已做)。 -- [ ] **外部集成扩展与 CLI**:下个版本方向——扩展与外部系统 / 工具的集成,并提供 CLI 能力。 +- [ ] **外部集成扩展与 CLI**:下个版本(0.9.0)方向——主进程内置本地 API + 独立 CLI,使外部 agent / 工具 + 可集成应用能力(设计见 [服务监听与本地 API](arch/04-integration/01-service-api.md) · [CLI 工具](arch/04-integration/02-cli.md))。 --- diff --git a/docs/arch/00-overview.md b/docs/arch/00-overview.md index d0d3d83a..17a41090 100644 --- a/docs/arch/00-overview.md +++ b/docs/arch/00-overview.md @@ -62,6 +62,9 @@ flowchart TB - [命令面板](03-gui/02-command-palette.md) —— 渲染层标题栏入口 + 分域命令注册表 - [消息通知](03-gui/03-notifications.md) —— `poller` 事件投影 + 主进程系统通知 / dock 角标 - [国际化](03-gui/04-i18n.md) —— react-i18next + 主 / 渲染双运行时 locale +- **`04-integration/`** —— 外部集成扩展与 CLI + - [服务监听与本地 API](04-integration/01-service-api.md) —— 主进程内置 HTTP API(IPC 之外的第二前端) + - [CLI 工具](04-integration/02-cli.md) —— Go 独立二进制,经本地 API 消费应用能力 - **`99-core/`** —— 基础设施 - [状态存储与数据模型](99-core/01-state-storage.md) —— `state-store` + `poller` 的 pr-state - [配置与凭据](99-core/02-config-and-secrets.md) —— `config` + 设置页 diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md new file mode 100644 index 00000000..333af30f --- /dev/null +++ b/docs/arch/04-integration/01-service-api.md @@ -0,0 +1,142 @@ +# 服务监听与本地 API + +## 职责与边界 + +在主进程内提供一个**本地 HTTP API**,把应用既有的 PR 发现 / 浏览 / Agent 操作能力,以语言无关的 +线协议暴露给**外部 agent / 工具 / 脚本**(经 [CLI](02-cli.md) 或直接 HTTP 调用)。是继渲染层 IPC +之后的**第二个前端**——同一套主进程 service 层,换一层入站协议。 + +负责:服务监听开关与生命周期、bearer token 鉴权、请求路由与响应封装、把内部能力映射成稳定的 HTTP 契约。 + +**不负责**: + +- **写操作**(发评论、审批、发布草稿等对远端有副作用的动作)—— API 一律不开放;有集成需求由调用方 + 自行用平台 API 实现(见下「只读边界」)。 +- **多用户 / 远端服务形态** —— 仍是单用户本地应用,API 只是本机(或可选局域网)的入站通道,不引入账户体系。 +- 业务逻辑本身 —— 复用 IPC controller 同源的 service 层,不在 HTTP 侧另起一套实现。 + +## 核心设计 + +### 默认关闭、强制鉴权 + +- **默认不启用**:`config.yaml` 新增 `service` 段,`enabled` 默认 `false`。不开启则主进程不监听任何端口, + 对外零暴露面。 +- **强制 bearer token**:开启监听即要求 token——所有请求须带 `Authorization: Bearer `,缺失 / 不匹配 + 直接拒绝(401 + 错误码)。**没有「关闭鉴权」选项**。开关首次打开时若 token 为空则**自动生成**一枚高强度 + 随机 token(`crypto.randomBytes` → base64url / hex),保证「启用」与「有 token」原子绑定。 +- **比对用常数时间**:token 校验走常数时间比较,避免计时侧信道。 +- **token 落盘策略同既有凭据**:token 明文存 `config.yaml`(与平台 token / LLM key / 代理密码一致,经 + `SecretStore` 抽象读写、绝不进日志 / 异常栈),属已知风险、文件权限收紧。将来切 keychain 时随既有凭据一并迁移。 + +### 监听地址与端口 + +- **默认仅 loopback**:`host` 默认 `127.0.0.1`,只本机可达。这是绝大多数「本机外部 agent 集成」场景的安全默认。 +- **可选 `0.0.0.0`**:允许配置为监听所有网卡(供同网段的远端 agent / CI 节点接入)。这是**显式高风险选项**—— + 设置页与文档须给安全警示(token 即唯一防线、建议配合防火墙 / 反代)。绑 `0.0.0.0` 时 token 强度与保密尤为关键。 +- **固定安全默认端口**:默认 `18765`(可在配置中改)。取 10000+ 既避开拥挤的 8xxx 开发 / 系统服务段 + (3000 / 5173 / 8000 / 8080 / 8888 等),又稳落在**临时端口范围(ephemeral,Windows 49152+ / Linux 32768+) + 之下**——固定监听端口若落进临时段可能与系统瞬时出站 socket 抢占,`18765` 处于已注册端口段、无此风险。 + 端口被占用导致监听失败时,记录错误并以非致命方式提示(不阻塞应用启动)。 + +### HTTP 实现:最小依赖 + +- 用 Node 内置 `http` 起服务 + **极简手写路由**(按 method + path 模式匹配),**不引入 express 等重型框架**—— + 与项目「优先复用、最小依赖」一致,端点数量有限、无需框架。 +- 统一中间环节:JSON body 解析(带最大 body 上限)、请求超时、鉴权校验、错误 → 响应封装、访问日志 + (记 method / path / status / 耗时,**不记** token 与敏感 body)。 + +### 路由复用 service 层 + +- HTTP route handler 经与 IPC controller **同一个进程级 `ControllerContext`**(`getContext()`)取用 service + (`ctx.pr` / `ctx.orchestrator` / `ctx.poller` / `ctx.connectionRuntime` 等),**不重复业务逻辑**。 +- 原则:核心能力沉在 service 层,IPC 与 HTTP 各自只做**薄封装 + 协议适配**。新增 API 端点前,先确保对应能力 + 在 service 层有可复用方法(必要时把 controller 内联逻辑下沉到 service)。 + +### 只读边界(写操作的硬拒绝) + +- API 暴露的 Agent 操作**仅限只读工具**(`/describe`·`/review`·`/ask`·`/improve` 一族)。修改类工具 + (`/approve`·`/needswork`·`/publish` 等,见工具注册表 `kind: 'mutating'`)**在 API 层即被硬拒绝**—— + 与 Agent 自身的 grant 授权闸**相互独立**:即便某 PR 的 AutoPilot grants 授予了写权限,经 API 发起的指令 + 仍不得触发写工具。 +- 由此「**不支持二次确认**」自然成立:API 无交互确认通道,只读指令直接执行、无需确认;需确认的写操作干脆不开放。 + +### 生命周期与热生效 + +- **启动时机**:在主进程完成连接 / IPC 初始化(`ControllerContext` 就绪)之后、轮询启动前后启动监听器; + 仅当 `service.enabled` 为真才实际 `listen`。 +- **优雅关闭**:应用退出(`before-quit`)时关闭监听、停止接收新连接、放行 in-flight 请求后退出。 +- **热生效**:`enabled` / `host` / `port` / token 变更 → 写盘 + 内存同步 + **停旧监听起新监听**(端口 / 地址变更 + 必然重建;token 变更即时生效,旧 token 立刻失效)。与既有「保存即热生效」一致,无需重启应用。 + +### 并发与资源 + +- 只读 `GET` 端点可并发处理。 +- Agent 写入型动作(触发 review / 指令 / 聊天)**复用既有 run 队列与 Orchestrator 的单工作者 + 并发上限**, + 不绕过调度——API 触发与 GUI 触发在同一队列里排队,互不抢占语义保持一致。 + +## 数据 / 接口契约 + +### 配置(`config.yaml` 顶层 `service`) + +```yaml +service: + enabled: false # 总开关;默认关 = 不监听、零暴露面 + host: 127.0.0.1 # 监听地址;可设 0.0.0.0(高风险,需安全警示) + port: 18765 # 固定安全默认端口(10000+,避开 8xxx 拥挤段且低于临时端口范围),可改 + token: '' # bearer token;启用且为空时自动生成;明文落盘(同既有凭据策略) +``` + +### 鉴权 + +- 请求头:`Authorization: Bearer `;缺失 / 不匹配 → `401` + 错误码。 +- token 经 `SecretStore` 读写,响应 / 日志中不回显。 + +### 统一响应封套 + +```jsonc +// 成功 +{ "ok": true, "data": } +// 失败(复用 AppError 的 code + 可序列化 meta;前端 / CLI 按码本地化) +{ "ok": false, "error": { "code": "ESV0001", "meta": { /* ... */ } } } +``` + +- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写操作不开放 / `404` 资源不存在 / + `409` 冲突 / `500` 内部错误。 +- 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)): + 如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。 + +### 端点(`/api/v1`,逐条对应 [CLI](02-cli.md) 命令) + +| Method & Path | 用途 | 复用的内部能力 | +| --- | --- | --- | +| `GET /api/v1/categories` | 当前启用平台下可用的分类标签:一级(`PrDiscoveryFilter`)+ 二级(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | +| `GET /api/v1/prs` | PR 列表(**不分页**、返回全部基础信息);query:`primary`(一级)/`secondary`(二级)/`q`(检索:标题 / 仓库 / 作者 / 编号) | `prs:list` 同源(`StoredPullRequest[]`) | +| `GET /api/v1/prs/{localId}` | 描述详情(标题 / 描述 / 作者 / 分支 / 时间 / 状态 / 合并态) | `StoredPullRequest` 概要 | +| `GET /api/v1/prs/{localId}/diff` | 变更文件列表;带 `?path=&side=base\|head` 时取单文件内容 | `diff:listChangedFiles` / `diff:getFileContent` 同源 | +| `GET /api/v1/prs/{localId}/activity` | 动态(评论 / 提交更新 / 评审决断归并的时间线) | `diff:listActivity` 同源 | +| `GET /api/v1/prs/{localId}/commits` | 提交列表(`PrCommit[]`) | `diff:listCommits` 同源 | +| `GET /api/v1/prs/{localId}/reviewers` | 评审人审批状态(`Reviewer[]`,含各人 `status`) | `StoredPullRequest.reviewers` | +| `GET /api/v1/prs/{localId}/agent` | Agent 当前执行状态(`AgentSession`:status / 进度 / 总结 / 建议) | `agent:getSession` 同源 | +| `GET /api/v1/prs/{localId}/agent/conversation` | 历史会话(`AgentMessage[]`) | `agent:getConversation` 同源 | +| `POST /api/v1/prs/{localId}/agent/review` | 执行 auto review(固定评审微流程 describe→review→[追问]→总结) | `agent:run` 同源 | +| `POST /api/v1/prs/{localId}/agent/instruct` | 发送 Agent 指令(**仅只读工具**:describe / review / ask / improve;写工具硬拒绝、无二次确认) | 只读工具派发(复用 run 队列) | +| `POST /api/v1/prs/{localId}/agent/chat` | 发送自然语言聊天(可触发 Agent 规划与任务执行) | `agent:ask` / `agent:enqueueMessage` 同源 | + +- `localId` 为跨平台稳定 PR 标识(内部哈希,非平台 `remoteId`);所有 PR 维度端点以它定位。 +- 过程步骤(transcript)暂不在初版 API 内开放,作为将来扩展位(见下)。 + +### 新增 IPC(设置页驱动) + +- `config:setService`:写 `service` 段 → 写盘 + 内存同步 + 重建监听器(热生效)。 +- `config:generateServiceToken`:重新生成 token → 写盘 + 即时失效旧 token,返回新 token 供 UI 展示 / 复制。 + +## 扩展与注意事项 + +- **加新端点先下沉 service**:HTTP 与 IPC 必须共用 service 方法,避免逻辑分叉;端点是 service 能力的薄投影。 +- **写操作边界是硬约束**:只读工具白名单在 API 层强校验,独立于 Agent grant 闸;新增工具时同步确认其 + `kind` 与是否纳入 API 白名单,默认排除一切 `mutating`。 +- **`0.0.0.0` 安全警示不可省**:设置页与使用文档须明确暴露范围与风险;token 是唯一防线。 +- **端口冲突**:监听失败以非致命方式提示,不拖垮应用启动;提示用户改端口。 +- **进度推送是将来扩展位**:初版以「轮询 `GET .../agent` 拉状态」为主;如需实时进度,可在同一监听器上加 + SSE / WebSocket 推送 Agent step 事件(复用现有 `agent:stepProgress` 广播),不改既有 REST 契约。 +- **契约稳定性**:`/api/v1` 前缀预留版本演进位;响应封套与错误码领域一旦发布需保持兼容(CLI 与第三方依赖它)。 diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md new file mode 100644 index 00000000..73113015 --- /dev/null +++ b/docs/arch/04-integration/02-cli.md @@ -0,0 +1,113 @@ +# CLI 工具(meebox) + +## 职责与边界 + +提供一个**独立分发的跨平台命令行客户端**,经[本地 API](01-service-api.md) 消费应用能力,供外部 +agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 + +负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 +约定退出码。 + +**不负责**: + +- 业务逻辑 —— CLI 是 API 的瘦客户端,不内置任何评审 / 平台逻辑。 +- **写操作**(发评论、审批、发布等)—— 不提供对应命令;API 本就不开放(见 [服务端的只读边界](01-service-api.md))。 +- 桌面应用本体 —— CLI **不内嵌进安装包**,是独立可分发物(见下「分发」)。 + +## 核心设计 + +### 技术栈与仓库形态决策(Go 评估) + +CLI 优先技术栈定为 **Go**,并就「是否内嵌当前项目」给出结论——**该问题分两层,结论不同**: + +1. **是否打进 Electron 安装包:否。** CLI 面向外部自动化、经 HTTP 与应用通信,无需随桌面包分发;打进去 + 只会无谓增大安装体积。二者是**相互独立的可分发物**。 +2. **源码是否放进本仓库(monorepo):是,但作为独立的顶层 `cli/` 目录、自带 `go.mod`,不纳入 npm + workspaces / Nx。** Go 有独立的模块系统与构建缓存,与 npm/Nx 的工程模型不兼容;强行包成 Nx project + (run-commands 壳)徒增复杂、且让 Go 工具链成为全仓开发的前置。CLI 与主工程的**唯一耦合是 HTTP/JSON + 线协议**(语言无关),无代码级共享,故「同仓、独立构建」最自然。 + +**为什么 Go 适合做这个 CLI**:静态链接小体积二进制、`GOOS`/`GOARCH` 一条命令交叉编译出全平台、启动快、 +无运行时依赖——正是分发型 CLI 的理想形态。相较把 Node/TS 用 pkg / SEA 打包(产物数十 MB、交叉编译脆弱、 +冷启动慢),Go 在分发体验上明显占优。 + +**代价与应对——类型契约同步**:Go 端无法编译期复用 `shared` 的 TS 类型。 + +- **初期**:API 端点少而稳,**手写 Go struct 对齐文档契约**即可(成本低)。 +- **将来**:若契约增长,引入 OpenAPI / JSON Schema 作单一事实源,生成 TS 侧校验 + Go 侧 client, + 消除手工漂移。 + +### 连接与鉴权 + +CLI 需 API base URL + token。来源优先级(高 → 低): + +1. 命令行 flag:`--api-url` / `--token`; +2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; +3. CLI 自身配置文件(如 `~/.config/meebox/cli.yaml`); +4. **本机自动发现**:同机同用户时,读用户主目录下的应用主配置 `~/.code-meeseeks/config.yaml` 的 `service` + 段,自动取 `host`/`port`/`token`——本机集成**零配置**开箱即用。 + +远端(服务端绑 `0.0.0.0`)场景无法自动发现,须显式给 `--api-url` + `--token`。token 缺失即报鉴权错误。 + +### 命令结构 + +```text +meebox [全局 flag] <组> <命令> [参数] + +全局 flag:--api-url · --token · --output (text|json) · --quiet +``` + +| 命令 | 用途 | 对应 API | +| --- | --- | --- | +| `meebox categories` | 列当前启用平台下可用的分类标签(一级 + 二级) | `GET /categories` | +| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页、全部基础信息) | `GET /prs` | +| `meebox pr show ` | 描述详情 | `GET /prs/{id}` | +| `meebox pr diff [--file ] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | `GET /prs/{id}/diff` | +| `meebox pr activity ` | 动态(时间线) | `GET /prs/{id}/activity` | +| `meebox pr commits ` | 提交列表 | `GET /prs/{id}/commits` | +| `meebox pr reviewers ` | 评审人审批状态 | `GET /prs/{id}/reviewers` | +| `meebox agent status ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | +| `meebox agent history ` | 历史会话 | `GET /prs/{id}/agent/conversation` | +| `meebox agent review ` | 执行 auto review | `POST /prs/{id}/agent/review` | +| `meebox agent instruct [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | +| `meebox agent chat ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | + +- `` 为 PR 的 `localId`(由 `pr list` 输出获得)。 +- 写工具不在 `instruct` 白名单内;传入即被服务端拒绝(CLI 也可前置友好报错)。 + +### 输出与退出码 + +- **`--output text`(默认)**:人类可读(表格 / 文本),便于交互式查看。 +- **`--output json`**:原样输出 API `data`,供外部 agent / 脚本机器消费——这是 CLI 服务自动化的主要形态。 +- **退出码约定**:`0` 成功;非 0 表错误并按类别区分(如 `2` 鉴权失败、`3` 资源不存在、`1` 通用错误); + 错误信息打 `stderr`,携带服务端返回的错误码(`ESV*` 等),便于脚本分支处理。 + +### 实现选型 + +- Go + 命令树库(如 cobra)+ 标准 `net/http` client,**最小依赖**。 +- 错误码 / 响应封套与服务端契约一一对齐(见 [服务端契约](01-service-api.md))。 + +## 数据 / 接口契约 + +- **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 > 本机 + `~/.code-meeseeks/config.yaml` 自动发现。 +- **输出模式**:`text`(人)/ `json`(机,输出 API `data`)。 +- **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 +- **二进制与压缩包命名**:`meebox-cli---.`(unix `.tar.gz`、windows `.zip`), + 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 + +## 分发与 CI + +- **覆盖平台**:Windows x64、macOS arm64、Linux x64 / arm64。 +- **随主工程一起发布**:在发布流程中增加一个 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译 + 矩阵;可选 GoReleaser 简化),产出四平台压缩包 + 校验和,与桌面安装包一并上传到**同一个 GitHub Release** + (由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 +- 版本号与应用同源(同 tag),确保 CLI 与服务端 API 契约版本可对应。 + +## 扩展与注意事项 + +- **只读边界**:写操作显式不提供;新增命令前先确认对应 API 端点已存在且为只读。 +- **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 +- **本机自动发现的边界**:仅同机同用户可读主目录下的 `~/.code-meeseeks/config.yaml`;远端 / 跨用户必须显式配 URL + token。 +- **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 +- **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 diff --git a/docs/arch/README.md b/docs/arch/README.md index 85b05123..31c9cac0 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -47,6 +47,9 @@ docs/arch/ │ ├── 02-command-palette.md 命令面板(标题栏入口 / 两级选择 / 按语言搜索 / 注册表 + 分域) │ ├── 03-notifications.md 消息通知(poll 事件投影 / 系统通知 toast / macOS dock 角标 / OS 权限降级) │ └── 04-i18n.md 国际化(react-i18next / 双运行时 / key 命名 / 翻译规范 / 模板翻译) +├── 04-integration/ 外部集成扩展与 CLI +│ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 只读边界 / 路由复用 service) +│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 本机自动发现 / 跨平台分发) └── 99-core/ 基础设施 ├── 01-state-storage.md 状态存储与数据模型(StateStore / per-PR 目录 / 存储模型 + 业务生命周期) ├── 02-config-and-secrets.md 配置与凭据(config.yaml / SecretStore / 设置页 / 首启向导) From 3fd604a0a06fd13b146bcfd89f13f1a24e0ac379 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 30 Jun 2026 22:18:45 +0800 Subject: [PATCH 03/84] =?UTF-8?q?feat(cli):=20=E6=90=AD=E5=BB=BA=20meebox?= =?UTF-8?q?=20CLI=20=E5=B7=A5=E7=A8=8B=E8=84=9A=E6=89=8B=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立 Go module(顶层 cli/,不纳入 npm/Nx),作为本地 API 的瘦客户端: - 命令树(cobra):categories、pr(list/show/diff/activity/commits/reviewers)、 agent(status/history/review/instruct/chat);全局 flag --api-url/--token/--output/--quiet。 - 连接与鉴权解析:flag > env > CLI 配置文件 > 本机 ~/.code-meeseeks/config.yaml 自动发现。 - HTTP client:bearer 鉴权 + { ok, data } / { ok:false, error } 响应封套解析。 - 输出与退出码:JSON 输出 + 0/1/2/3 退出码映射(text 表格化待后续)。 - agent instruct 前置只读白名单(describe|review|ask|improve),写工具拒绝。 按设计文档契约实现(server 端单独实现)。本地 go vet / build / test 均通过。 Co-Authored-By: Claude Opus 4.8 --- cli/.gitignore | 5 + cli/README.md | 66 +++++++++++ cli/cmd/agent.go | 122 ++++++++++++++++++++ cli/cmd/categories.go | 14 +++ cli/cmd/pr.go | 132 ++++++++++++++++++++++ cli/cmd/root.go | 91 +++++++++++++++ cli/go.mod | 13 +++ cli/go.sum | 12 ++ cli/internal/apiclient/client.go | 121 ++++++++++++++++++++ cli/internal/render/render.go | 79 +++++++++++++ cli/internal/settings/settings.go | 150 +++++++++++++++++++++++++ cli/internal/settings/settings_test.go | 44 ++++++++ cli/main.go | 13 +++ 13 files changed, 862 insertions(+) create mode 100644 cli/.gitignore create mode 100644 cli/README.md create mode 100644 cli/cmd/agent.go create mode 100644 cli/cmd/categories.go create mode 100644 cli/cmd/pr.go create mode 100644 cli/cmd/root.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/apiclient/client.go create mode 100644 cli/internal/render/render.go create mode 100644 cli/internal/settings/settings.go create mode 100644 cli/internal/settings/settings_test.go create mode 100644 cli/main.go diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..e2ab7836 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,5 @@ +# Build output +/bin/ +/dist/ +/meebox +/meebox.exe diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..d7a5e06d --- /dev/null +++ b/cli/README.md @@ -0,0 +1,66 @@ +# meebox CLI + +A standalone, cross-platform command-line client for **Code Meeseeks**. It is a +thin client over the desktop app's local HTTP API — see the design docs: + +- [Service listener & local API](../docs/arch/04-integration/01-service-api.md) +- [CLI tool](../docs/arch/04-integration/02-cli.md) + +All exposed capabilities are **read-only**; write operations (commenting, +approving, publishing) are intentionally not provided. + +## Status + +Project scaffold. The command tree, connection/auth resolution, HTTP client, +output formatting, and exit-code mapping are in place and built against the +documented API contract. The server-side API is implemented separately; until +it is available, commands will fail to connect. + +## Build & run + +This is an independent Go module (`go.mod`), not part of the npm/Nx workspace. + +```bash +cd cli +go build -o bin/meebox . # or: go install . +go vet ./... +go test ./... +``` + +Cross-compile (matches the release matrix): + +```bash +GOOS=windows GOARCH=amd64 go build -o dist/meebox.exe . +GOOS=darwin GOARCH=arm64 go build -o dist/meebox . +GOOS=linux GOARCH=amd64 go build -o dist/meebox . +GOOS=linux GOARCH=arm64 go build -o dist/meebox . +``` + +## Connection + +The CLI resolves the API base URL and bearer token in this order (highest first): + +1. flags — `--api-url`, `--token` +2. env — `MEEBOX_API_URL`, `MEEBOX_TOKEN` +3. CLI config — `/meebox/cli.yaml` (`api_url`, `token`) +4. local auto-discovery — the app's `~/.code-meeseeks/config.yaml` `service` + section (same machine, same user; zero-config) + +## Commands + +```text +meebox categories +meebox pr list [--primary ] [--secondary ] [--query ] +meebox pr show +meebox pr diff [--file ] [--side base|head] +meebox pr activity +meebox pr commits +meebox pr reviewers +meebox agent status +meebox agent history +meebox agent review +meebox agent instruct [args...] # read-only: describe|review|ask|improve +meebox agent chat +``` + +Global flags: `--api-url`, `--token`, `--output text|json`, `--quiet`. diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go new file mode 100644 index 00000000..0fd23da9 --- /dev/null +++ b/cli/cmd/agent.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" +) + +// readOnlyInstructions is the set of agent instructions the CLI may send. +// Write tools (approve / needswork / publish …) are intentionally excluded — +// the server also hard-refuses them, this is a friendly front-line check. +var readOnlyInstructions = map[string]bool{ + "describe": true, + "review": true, + "ask": true, + "improve": true, +} + +func newAgentCmd() *cobra.Command { + a := &cobra.Command{ + Use: "agent", + Short: "Operate the review agent on a PR", + } + a.AddCommand( + newAgentStatusCmd(), + newAgentHistoryCmd(), + newAgentReviewCmd(), + newAgentInstructCmd(), + newAgentChatCmd(), + ) + return a +} + +func newAgentStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show the agent's current execution status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent") + }, + } +} + +func newAgentHistoryCmd() *cobra.Command { + return &cobra.Command{ + Use: "history ", + Short: "Show the agent conversation history", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent/conversation") + }, + } +} + +func newAgentReviewCmd() *cobra.Command { + return &cobra.Command{ + Use: "review ", + Short: "Run auto review on a PR", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/review", nil) + if err != nil { + return err + } + return renderData(data) + }, + } +} + +func newAgentInstructCmd() *cobra.Command { + return &cobra.Command{ + Use: "instruct [args...]", + Short: "Send a read-only agent instruction (describe|review|ask|improve)", + Args: cobra.MinimumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + instruction := strings.TrimPrefix(args[1], "/") + if !readOnlyInstructions[instruction] { + return fmt.Errorf("instruction %q is not a read-only command; write operations are not supported via the CLI", args[1]) + } + c, err := resolveClient() + if err != nil { + return err + } + body := map[string]any{"command": instruction} + if len(args) > 2 { + body["args"] = strings.Join(args[2:], " ") + } + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/instruct", body) + if err != nil { + return err + } + return renderData(data) + }, + } +} + +func newAgentChatCmd() *cobra.Command { + return &cobra.Command{ + Use: "chat ", + Short: "Send a natural-language chat message (may trigger agent tasks)", + Args: cobra.MinimumNArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + body := map[string]any{"message": strings.Join(args[1:], " ")} + data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/chat", body) + if err != nil { + return err + } + return renderData(data) + }, + } +} diff --git a/cli/cmd/categories.go b/cli/cmd/categories.go new file mode 100644 index 00000000..d7e5d21b --- /dev/null +++ b/cli/cmd/categories.go @@ -0,0 +1,14 @@ +package cmd + +import "github.com/spf13/cobra" + +func newCategoriesCmd() *cobra.Command { + return &cobra.Command{ + Use: "categories", + Short: "List available PR classification labels for the enabled platforms", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/categories") + }, + } +} diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go new file mode 100644 index 00000000..10390280 --- /dev/null +++ b/cli/cmd/pr.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "net/url" + + "github.com/spf13/cobra" +) + +func newPrCmd() *cobra.Command { + pr := &cobra.Command{ + Use: "pr", + Short: "Browse pull requests", + } + pr.AddCommand( + newPrListCmd(), + newPrShowCmd(), + newPrDiffCmd(), + newPrActivityCmd(), + newPrCommitsCmd(), + newPrReviewersCmd(), + ) + return pr +} + +func newPrListCmd() *cobra.Command { + var primary, secondary, query string + cmd := &cobra.Command{ + Use: "list", + Short: "List PRs (no pagination) with optional category and search filters", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + c, err := resolveClient() + if err != nil { + return err + } + q := url.Values{} + if primary != "" { + q.Set("primary", primary) + } + if secondary != "" { + q.Set("secondary", secondary) + } + if query != "" { + q.Set("q", query) + } + data, err := c.Get("/api/v1/prs", q) + if err != nil { + return err + } + return renderData(data) + }, + } + f := cmd.Flags() + f.StringVar(&primary, "primary", "", "primary category (platform discovery filter)") + f.StringVar(&secondary, "secondary", "", "secondary filter (review status / merge state)") + f.StringVar(&query, "query", "", "search text (title / repo / author / number)") + return cmd +} + +func newPrShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show PR description detail", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0])) + }, + } +} + +func newPrDiffCmd() *cobra.Command { + var file, side string + cmd := &cobra.Command{ + Use: "diff ", + Short: "List changed files, or fetch one file's content with --file", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + c, err := resolveClient() + if err != nil { + return err + } + q := url.Values{} + if file != "" { + q.Set("path", file) + } + if side != "" { + q.Set("side", side) + } + data, err := c.Get("/api/v1/prs/"+url.PathEscape(args[0])+"/diff", q) + if err != nil { + return err + } + return renderData(data) + }, + } + cmd.Flags().StringVar(&file, "file", "", "fetch this file's content instead of the changed-file list") + cmd.Flags().StringVar(&side, "side", "", "file side when --file is set: base|head") + return cmd +} + +func newPrActivityCmd() *cobra.Command { + return &cobra.Command{ + Use: "activity ", + Short: "Show the PR activity timeline (comments / commits / review decisions)", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/activity") + }, + } +} + +func newPrCommitsCmd() *cobra.Command { + return &cobra.Command{ + Use: "commits ", + Short: "List the PR commits", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/commits") + }, + } +} + +func newPrReviewersCmd() *cobra.Command { + return &cobra.Command{ + Use: "reviewers ", + Short: "Show reviewer approval status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/reviewers") + }, + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 00000000..74fc945c --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,91 @@ +// Package cmd defines the meebox command tree built on cobra. +package cmd + +import ( + "encoding/json" + "os" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/render" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" + "github.com/spf13/cobra" +) + +// version is overridden at build time via -ldflags; "dev" for local builds. +var version = "dev" + +type globalFlags struct { + apiURL string + token string + output string + quiet bool +} + +var gflags globalFlags + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "meebox", + Short: "Code Meeseeks CLI — integrate PR review capabilities over the local API", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + } + pf := root.PersistentFlags() + pf.StringVar(&gflags.apiURL, "api-url", "", "API base URL (overrides env and local auto-discovery)") + pf.StringVar(&gflags.token, "token", "", "bearer token (overrides env and local auto-discovery)") + pf.StringVar(&gflags.output, "output", "text", "output format: text|json") + pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") + + root.AddCommand( + newCategoriesCmd(), + newPrCmd(), + newAgentCmd(), + ) + return root +} + +// Execute runs the root command, printing errors to stderr and mapping them +// to process exit codes per docs/arch/04-integration/02-cli.md. +func Execute() { + if err := newRootCmd().Execute(); err != nil { + render.Errorln(err) + os.Exit(render.ExitCodeFor(err)) + } +} + +// resolveClient builds an API client from the resolved connection settings. +func resolveClient() (*apiclient.Client, error) { + s, err := settings.Resolve(settings.Overrides{ + APIURL: gflags.apiURL, + Token: gflags.token, + }) + if err != nil { + return nil, err + } + return apiclient.New(s.APIURL, s.Token), nil +} + +func outputMode() render.Mode { + if gflags.output == "json" { + return render.ModeJSON + } + return render.ModeText +} + +func renderData(data json.RawMessage) error { + return render.Output(outputMode(), data) +} + +// getAndRender is the common GET-then-render path used by read-only commands. +func getAndRender(path string) error { + c, err := resolveClient() + if err != nil { + return err + } + data, err := c.Get(path, nil) + if err != nil { + return err + } + return renderData(data) +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 00000000..31a8aa5e --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,13 @@ +module github.com/huhamhire/code-meeseeks/cli + +go 1.24 + +require ( + github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 00000000..a01295bb --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/apiclient/client.go b/cli/internal/apiclient/client.go new file mode 100644 index 00000000..ccb417f0 --- /dev/null +++ b/cli/internal/apiclient/client.go @@ -0,0 +1,121 @@ +// Package apiclient is a thin HTTP client for the Code Meeseeks local API. +// It speaks the response envelope documented in +// docs/arch/04-integration/01-service-api.md: { ok, data } on success and +// { ok:false, error:{ code, meta } } on failure. +package apiclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client talks to the local API with a bearer token. +type Client struct { + baseURL string + token string + http *http.Client +} + +// New builds a client for the given base URL and bearer token. +func New(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +// APIError is the structured error decoded from a { ok:false, error } envelope +// (or a bare non-2xx status when the body is not an envelope). +type APIError struct { + Status int + Code string + Meta map[string]any +} + +func (e *APIError) Error() string { + if e.Code != "" { + return fmt.Sprintf("API error %s (HTTP %d)", e.Code, e.Status) + } + return fmt.Sprintf("API error (HTTP %d)", e.Status) +} + +type envelope struct { + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + Error *struct { + Code string `json:"code"` + Meta map[string]any `json:"meta"` + } `json:"error"` +} + +// Get issues an authenticated GET and returns the raw data payload. +func (c *Client) Get(path string, query url.Values) (json.RawMessage, error) { + u := c.baseURL + path + if len(query) > 0 { + u += "?" + query.Encode() + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + return c.do(req) +} + +// Post issues an authenticated POST with a JSON body and returns the raw data +// payload. A nil body sends no request entity. +func (c *Client) Post(path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(b) + } + req, err := http.NewRequest(http.MethodPost, c.baseURL+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req) +} + +func (c *Client) do(req *http.Request) (json.RawMessage, error) { + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var env envelope + if len(raw) > 0 { + if err := json.Unmarshal(raw, &env); err != nil { + // Body is not an API envelope (e.g. a proxy error page) — surface status. + return nil, &APIError{Status: resp.StatusCode} + } + } + if resp.StatusCode >= 400 || !env.OK { + ae := &APIError{Status: resp.StatusCode} + if env.Error != nil { + ae.Code = env.Error.Code + ae.Meta = env.Error.Meta + } + return nil, ae + } + return env.Data, nil +} diff --git a/cli/internal/render/render.go b/cli/internal/render/render.go new file mode 100644 index 00000000..06111bcb --- /dev/null +++ b/cli/internal/render/render.go @@ -0,0 +1,79 @@ +// Package render handles CLI output formatting (text vs JSON) and the mapping +// of errors to process exit codes per docs/arch/04-integration/02-cli.md. +package render + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" +) + +// Mode selects the output format. +type Mode int + +const ( + ModeText Mode = iota + ModeJSON +) + +// Process exit codes. +const ( + ExitOK = 0 + ExitGeneric = 1 + ExitAuth = 2 + ExitNotFound = 3 +) + +// Output writes API data to stdout per the selected mode. +// +// NOTE: text mode currently mirrors JSON pretty-printing. Per-command, +// human-friendly table rendering is a deliberate follow-up — the scaffold +// keeps a single code path so the wiring is verifiable end to end first. +func Output(_ Mode, data json.RawMessage) error { + return writeJSON(data) +} + +func writeJSON(data json.RawMessage) error { + if len(data) == 0 { + fmt.Println("null") + return nil + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + // Valid JSON we can't re-decode into `any` is unlikely; print verbatim. + fmt.Println(string(data)) + return nil + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// Errorln prints an error to stderr. +func Errorln(err error) { + fmt.Fprintln(os.Stderr, "error:", err) +} + +// ExitCodeFor maps an error to a process exit code. +func ExitCodeFor(err error) int { + if err == nil { + return ExitOK + } + if errors.Is(err, settings.ErrNoToken) { + return ExitAuth + } + var ae *apiclient.APIError + if errors.As(err, &ae) { + switch { + case ae.Status == 401 || ae.Status == 403: + return ExitAuth + case ae.Status == 404: + return ExitNotFound + } + } + return ExitGeneric +} diff --git a/cli/internal/settings/settings.go b/cli/internal/settings/settings.go new file mode 100644 index 00000000..efdc7134 --- /dev/null +++ b/cli/internal/settings/settings.go @@ -0,0 +1,150 @@ +// Package settings resolves the API base URL and bearer token used by the CLI, +// following the precedence documented in docs/arch/04-integration/02-cli.md: +// flag > env > CLI config file > local auto-discovery of the app config. +package settings + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Environment variable names for connection settings. +const ( + EnvAPIURL = "MEEBOX_API_URL" + EnvToken = "MEEBOX_TOKEN" +) + +const ( + defaultHost = "127.0.0.1" + defaultPort = 18765 +) + +// Overrides carries the highest-precedence values, typically from CLI flags. +type Overrides struct { + APIURL string + Token string +} + +// Settings is the resolved connection configuration. +type Settings struct { + APIURL string + Token string +} + +// ErrNoToken indicates no bearer token could be resolved from any source. +var ErrNoToken = errors.New("no API token: pass --token, set " + EnvToken + + ", or enable the service listener in the app") + +// Resolve applies the documented precedence (lowest first, overwritten by +// higher sources) and returns the final connection settings. +func Resolve(ov Overrides) (Settings, error) { + var s Settings + + // 4) lowest precedence: local auto-discovery from the app config. + if disc, ok := discoverFromAppConfig(); ok { + s = disc + } + // 3) CLI config file. + if cfg, ok := loadCLIConfig(); ok { + if cfg.APIURL != "" { + s.APIURL = cfg.APIURL + } + if cfg.Token != "" { + s.Token = cfg.Token + } + } + // 2) environment. + if v := os.Getenv(EnvAPIURL); v != "" { + s.APIURL = v + } + if v := os.Getenv(EnvToken); v != "" { + s.Token = v + } + // 1) highest precedence: flags. + if ov.APIURL != "" { + s.APIURL = ov.APIURL + } + if ov.Token != "" { + s.Token = ov.Token + } + + if s.APIURL == "" { + s.APIURL = fmt.Sprintf("http://%s:%d", defaultHost, defaultPort) + } + if s.Token == "" { + return Settings{}, ErrNoToken + } + return s, nil +} + +// appConfig is the slice of the app's main config we care about. +type appConfig struct { + Service struct { + Enabled bool `yaml:"enabled"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Token string `yaml:"token"` + } `yaml:"service"` +} + +// discoverFromAppConfig reads the app's main config at ~/.code-meeseeks/config.yaml +// and, when the service listener is enabled with a token, derives settings from +// it — giving same-machine, same-user integrations a zero-config experience. +func discoverFromAppConfig() (Settings, bool) { + home, err := os.UserHomeDir() + if err != nil { + return Settings{}, false + } + data, err := os.ReadFile(filepath.Join(home, ".code-meeseeks", "config.yaml")) + if err != nil { + return Settings{}, false + } + var cfg appConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Settings{}, false + } + svc := cfg.Service + if !svc.Enabled || svc.Token == "" { + return Settings{}, false + } + host := svc.Host + if host == "" || host == "0.0.0.0" { + // 0.0.0.0 is a bind address, not a dial target — assume loopback locally. + host = defaultHost + } + port := svc.Port + if port == 0 { + port = defaultPort + } + return Settings{ + APIURL: fmt.Sprintf("http://%s:%d", host, port), + Token: svc.Token, + }, true +} + +// cliConfig is the CLI's own optional config file. +type cliConfig struct { + APIURL string `yaml:"api_url"` + Token string `yaml:"token"` +} + +// loadCLIConfig reads the CLI config at /meebox/cli.yaml. +func loadCLIConfig() (cliConfig, bool) { + dir, err := os.UserConfigDir() + if err != nil { + return cliConfig{}, false + } + data, err := os.ReadFile(filepath.Join(dir, "meebox", "cli.yaml")) + if err != nil { + return cliConfig{}, false + } + var cfg cliConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cliConfig{}, false + } + return cfg, true +} diff --git a/cli/internal/settings/settings_test.go b/cli/internal/settings/settings_test.go new file mode 100644 index 00000000..e596a855 --- /dev/null +++ b/cli/internal/settings/settings_test.go @@ -0,0 +1,44 @@ +package settings + +import ( + "errors" + "testing" +) + +func TestResolveFlagsWinOverEnv(t *testing.T) { + t.Setenv(EnvAPIURL, "http://env:1") + t.Setenv(EnvToken, "env-token") + + got, err := Resolve(Overrides{APIURL: "http://flag:2", Token: "flag-token"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.APIURL != "http://flag:2" || got.Token != "flag-token" { + t.Fatalf("flags should win, got %+v", got) + } +} + +func TestResolveDefaultsURLWhenOnlyTokenGiven(t *testing.T) { + t.Setenv(EnvAPIURL, "") + t.Setenv(EnvToken, "env-token") + + got, err := Resolve(Overrides{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Token != "env-token" { + t.Fatalf("expected env token, got %q", got.Token) + } + if got.APIURL == "" { + t.Fatalf("expected a default API URL") + } +} + +func TestResolveNoTokenErrors(t *testing.T) { + t.Setenv(EnvAPIURL, "http://x:1") + t.Setenv(EnvToken, "") + + if _, err := Resolve(Overrides{}); !errors.Is(err, ErrNoToken) { + t.Fatalf("expected ErrNoToken, got %v", err) + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..9b078614 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,13 @@ +// Command meebox is the CLI client for the Code Meeseeks local API. +// +// It is a thin client over the desktop app's local HTTP API (see +// docs/arch/04-integration). All exposed capabilities are read-only; +// write operations (commenting, approving, publishing) are intentionally +// not provided — integrators implement those against the platform directly. +package main + +import "github.com/huhamhire/code-meeseeks/cli/cmd" + +func main() { + cmd.Execute() +} From 4f31eaf3e08d0bb34d58e0a8ed15eb95f2938b2d Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 30 Jun 2026 22:53:14 +0800 Subject: [PATCH 04/84] =?UTF-8?q?feat(desktop):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E9=9B=86=E6=88=90=E6=9C=AC=E5=9C=B0=20API=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=EF=BC=88=E7=9B=91=E5=90=AC=20+=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83=20+=20=E5=8F=AA=E8=AF=BB=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主进程内置 HTTP API(IPC 之外的第二前端),复用同一 ControllerContext 与 service 层把只读 PR / Agent 能力暴露给外部 CLI / 工具。默认关闭、开启即强制 bearer token 鉴权。 - 配置:config.yaml 新增 service 段(enabled / host / port=18765 / token,全默认); config:setService 热重建监听器、config:generateServiceToken 重新生成 token。 - 错误码:新增 SV(service)领域 + ESV0000~0004(经 HTTP 返回外部客户端,不走渲染层 i18n)。 - api-server:内置 http + 极简路由 + 常数时间 token 比对 + 统一响应封套 + 错误→状态码映射; 12 端点(categories / prs 列表 + 详情 diff·动态·提交·描述·评审人 / agent 状态·历史·review· instruct·chat)。生命周期随 app 启停、按 service.enabled 决定是否监听,监听失败非致命。 - 只读边界:instruct 仅放行 describe/review/ask/improve,写工具硬拒绝(403)。 - 薄连接层:PR 列表的一/二级过滤 + 检索谓词与分类标签下沉为 @meebox/shared 纯函数, 渲染层侧栏与 API 路由共用同一份语义(消除重复),api-server 仅做解析 + 委派。 - findPrOrThrow 改抛 AppError(PR_NOT_FOUND),API 可映射 404、GUI 亦得正确 i18n。 设计见 docs/arch/04-integration/01-service-api.md。本地 lint / typecheck / test / build 均通过。 设置页 UI 单列后续。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/main/controllers/config.ts | 31 ++++ apps/desktop/src/main/index.ts | 13 ++ apps/desktop/src/main/ipc.ts | 2 + .../src/main/services/api-server/http.ts | 84 +++++++++ .../src/main/services/api-server/index.ts | 1 + .../src/main/services/api-server/routes.ts | 168 ++++++++++++++++++ .../src/main/services/api-server/server.ts | 117 ++++++++++++ apps/desktop/src/main/services/context.ts | 2 + apps/desktop/src/main/services/pr-service.ts | 9 +- .../src/components/layout/Sidebar.tsx | 51 ++---- packages/ipc/src/config.ts | 7 + packages/shared/src/config.ts | 16 ++ packages/shared/src/error-code.ts | 17 +- packages/shared/src/index.ts | 1 + packages/shared/src/pr-filter.ts | 83 +++++++++ 15 files changed, 565 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/main/services/api-server/http.ts create mode 100644 apps/desktop/src/main/services/api-server/index.ts create mode 100644 apps/desktop/src/main/services/api-server/routes.ts create mode 100644 apps/desktop/src/main/services/api-server/server.ts create mode 100644 packages/shared/src/pr-filter.ts diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index fd400a97..a748c3fd 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { nativeTheme } from 'electron'; import { editorThemeNativeSource } from '@meebox/shared'; import { writeConfig } from '@meebox/config'; @@ -187,6 +188,36 @@ export const testConnection: IpcController<'config:testConnection'> = async (_ev } }; +/** + * 写本地 API 服务监听配置(开关 / host / port / token);内存同步后热重建监听器(停旧起新)。 + * token 由请求体携带(设置页保存当前值);单独「重新生成 token」走 generateServiceToken。 + */ +export const setService: IpcController<'config:setService'> = async (_event, req) => { + const { bootstrap, logger, reconfigureApiServer } = getContext(); + const next = { ...bootstrap.config, service: req.service }; + await writeConfig(bootstrap.paths.configFile, next); + bootstrap.config.service = req.service; + await reconfigureApiServer(); + logger.info( + { enabled: req.service.enabled, host: req.service.host, port: req.service.port }, + 'service listener config updated (hot-reloaded)', + ); +}; + +/** + * 重新生成 bearer token(高强度随机),写盘 + 内存同步。监听器每次请求实时读内存 token,故新 token + * 即时生效、旧 token 立刻失效,无需重启监听器。返回新 token 供设置页展示 / 复制。 + */ +export const generateServiceToken: IpcController<'config:generateServiceToken'> = async () => { + const { bootstrap, logger } = getContext(); + const token = randomBytes(32).toString('base64url'); + const service = { ...bootstrap.config.service, token }; + await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, service }); + bootstrap.config.service = service; + logger.info('service listener token regenerated'); + return { token }; +}; + /** * 配置过程中把连接 + LLM 草稿写盘防丢失,但不更新内存 config、不 reconfigure(不生效)。 */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cbb9c078..4d140825 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -21,6 +21,7 @@ import { } from './bootstrap/index.js'; import { initMainI18n } from './i18n/index.js'; import { registerIpcHandlers } from './ipc.js'; +import { ApiServer } from './services/api-server/index.js'; import { readConnectionStates } from './utils/connection-state.js'; // 进程(模块加载)起点:用于度量到主窗口首帧(ready-to-show)的启动耗时。 @@ -53,6 +54,8 @@ class App { private conns!: ConnectionRuntimeController; private windowManager!: WindowManager; private ipcControl?: IpcControl; + /** 本地 API 服务监听器(默认关闭;按 config.service 决定是否 listen)。 */ + private apiServer?: ApiServer; private quitCleanupDone = false; constructor(private readonly startMs: number) {} @@ -210,7 +213,15 @@ class App { connectionRuntime: this.conns.runtime, reconfigureConnections: () => this.conns.reconfigure(), repoMirror: this.repoMirror, + // 惰性引用:ApiServer 在 registerIpcHandlers 之后才构造(其请求处理依赖此刻才安装的 + // ControllerContext 单例);闭包在 config:setService 调用时才取最新实例。 + reconfigureApiServer: () => this.apiServer?.reconfigure() ?? Promise.resolve(), }); + + // 本地 API 服务监听器:ControllerContext 已由 registerIpcHandlers 安装,可安全处理请求。 + // 按 config.service 决定是否实际 listen(默认关闭);监听失败为非致命(内部已兜底记录)。 + this.apiServer = new ApiServer({ bootstrap: this.bootstrap, logger: this.logger }); + await this.apiServer.start(); } /** @@ -293,6 +304,8 @@ class App { // 不清理会留孤儿进程锁住安装目录 → 升级时 NSIS 报「应用无法关闭」。 app.on('before-quit', (event) => { if (this.poller) this.poller.stop(); + // 停本地 API 监听(停止接收新连接);fire-and-forget,关闭很快、不阻塞退出。 + void this.apiServer?.stop(); if (this.quitCleanupDone) return; const aborted = this.ipcControl?.abortAllActiveRuns() ?? 0; if (aborted === 0) return; // 无进行中 run,直接退出 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index c86338f1..9aaac7c9 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -114,6 +114,8 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('config:autosaveDraft', config.autosaveDraft); // 连接 / LLM 草稿存盘(不生效) ipcMain.handle('config:setPoller', config.setPoller); // 设轮询间隔(热替换定时器) ipcMain.handle('config:setMaxConcurrency', config.setMaxConcurrency); // 设评审并发数(热替换队列上限) + ipcMain.handle('config:setService', config.setService); // 设本地 API 服务监听(热重建监听器) + ipcMain.handle('config:generateServiceToken', config.generateServiceToken); // 重新生成 API bearer token /* * Agent 交互 diff --git a/apps/desktop/src/main/services/api-server/http.ts b/apps/desktop/src/main/services/api-server/http.ts new file mode 100644 index 00000000..c40737bb --- /dev/null +++ b/apps/desktop/src/main/services/api-server/http.ts @@ -0,0 +1,84 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { AppError, ERROR_CODES, type AppErrorMeta, type ErrorCode } from '@meebox/shared'; + +/** + * 本地 API 的 HTTP 工具:统一响应封套({ ok, data } / { ok:false, error })、请求体读取、 + * 错误 → HTTP 状态码映射。见 docs/arch/04-integration/01-service-api.md。 + */ + +const MAX_BODY_BYTES = 1024 * 1024; // 1 MiB 请求体上限 + +/** API 层错误(鉴权 / 路由 / 校验 / 写禁止等):自带 HTTP 状态码与错误码。 */ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: ErrorCode, + readonly meta?: AppErrorMeta, + ) { + super(code); + this.name = 'HttpError'; + } +} + +function writeJson(res: ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(body); +} + +/** 成功响应:200 + { ok:true, data }。 */ +export function sendOk(res: ServerResponse, data: unknown): void { + writeJson(res, 200, { ok: true, data: data ?? null }); +} + +/** 失败响应:按错误映射状态码 + { ok:false, error:{ code, meta } };返回所选状态码 / 码供日志。 */ +export function sendError(res: ServerResponse, err: unknown): { status: number; code: string } { + const mapped = mapError(err); + writeJson(res, mapped.status, { + ok: false, + error: { code: mapped.code, ...(mapped.meta ? { meta: mapped.meta } : {}) }, + }); + return { status: mapped.status, code: mapped.code }; +} + +function mapError(err: unknown): { status: number; code: ErrorCode; meta?: AppErrorMeta } { + if (err instanceof HttpError) return { status: err.status, code: err.code, meta: err.meta }; + if (err instanceof AppError) return { status: statusForAppCode(err.code), code: err.code, meta: err.meta }; + return { status: 500, code: ERROR_CODES.SV_UNCLASSIFIED }; +} + +/** 把控制器抛出的 AppError 业务码映射到合适的 HTTP 状态码(未覆盖者归 500)。 */ +function statusForAppCode(code: ErrorCode): number { + switch (code) { + case ERROR_CODES.PR_NOT_FOUND: + return 404; + case ERROR_CODES.PR_FORBIDDEN: + return 403; + case ERROR_CODES.PR_URL_INVALID: + case ERROR_CODES.AG_ASK_NEEDS_QUESTION: + return 400; + case ERROR_CODES.PR_NO_ACTIVE_CONNECTION: + return 409; + case ERROR_CODES.AG_PR_AGENT_NOT_READY: + return 503; + default: + return 500; + } +} + +/** 读取并解析 JSON 请求体(空体 → undefined);超限 413、非法 JSON 400,均归一为 SV 错误码。 */ +export async function readJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + total += (chunk as Buffer).length; + if (total > MAX_BODY_BYTES) throw new HttpError(413, ERROR_CODES.SV_BAD_REQUEST, { reason: 'body too large' }); + chunks.push(chunk as Buffer); + } + if (total === 0) return undefined; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'invalid json' }); + } +} diff --git a/apps/desktop/src/main/services/api-server/index.ts b/apps/desktop/src/main/services/api-server/index.ts new file mode 100644 index 00000000..fe586550 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/index.ts @@ -0,0 +1 @@ +export { ApiServer, type ApiServerDeps } from './server.js'; diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts new file mode 100644 index 00000000..1a47f81b --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -0,0 +1,168 @@ +import type { IpcMainInvokeEvent } from 'electron'; +import type { DiffSide } from '@meebox/ipc'; +import { + ERROR_CODES, + PR_SECONDARY_FILTERS, + filterPullRequests, + type PrDiscoveryFilter, + type PrSecondaryFilter, + type ReviewRunTool, +} from '@meebox/shared'; +import * as agentCtl from '../../controllers/agent.js'; +import * as prCtl from '../../controllers/pr.js'; +import { getContext } from '../context.js'; +import { HttpError } from './http.js'; + +/** + * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 + * `(event, req)` 且只读路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。 + * 只读边界:写工具一律不暴露(见 agent/instruct)。见 docs/arch/04-integration/01-service-api.md。 + */ + +// controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 +const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; + +/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ +const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'describe', + 'review', + 'ask', + 'improve', +]); + +export interface RouteContext { + params: Record; + query: URLSearchParams; + body: unknown; +} + +export type RouteHandler = (rc: RouteContext) => Promise | unknown; + +export interface Route { + method: 'GET' | 'POST'; + segments: string[]; + handler: RouteHandler; +} + +function seg(path: string): string[] { + return path.split('/').filter(Boolean); +} + +/** 当前启用平台下可用的分类标签:一级(平台发现分类)+ 二级(状态 / 合并态筛选)。 */ +const categories: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + const caps = built?.adapter.connection.capabilities(); + const primary: PrDiscoveryFilter[] = caps?.discoveryFilters + ? [...caps.discoveryFilters] + : ['review-requested']; + return { + platform: built?.adapter.kind ?? null, + primary, + secondary: [...PR_SECONDARY_FILTERS], + }; +}; + +/** + * PR 列表(不分页)+ 一级 / 二级分类过滤 + 检索。过滤语义复用 @meebox/shared 的纯谓词 + * (与渲染层侧栏同源),此处仅做查询参数解析 + 委派。 + */ +const listPrs: RouteHandler = async ({ query }) => { + const all = await prCtl.listPrs(NO_EVENT, undefined); + return filterPullRequests(all, { + primary: (query.get('primary') as PrDiscoveryFilter) || undefined, + secondary: (query.get('secondary') as PrSecondaryFilter) || undefined, + query: query.get('q') ?? undefined, + }); +}; + +const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(params.id); + +const reviewers: RouteHandler = async ({ params }) => + (await getContext().pr.findPrOrThrow(params.id)).reviewers; + +/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ +const diff: RouteHandler = ({ params, query }) => { + const path = query.get('path'); + if (path) { + const side: DiffSide = query.get('side') === 'base' ? 'base' : 'head'; + return prCtl.getFileContent(NO_EVENT, { localId: params.id, side, path }); + } + return prCtl.listChangedFiles(NO_EVENT, { localId: params.id }); +}; + +const activity: RouteHandler = ({ params }) => + prCtl.listActivity(NO_EVENT, { localId: params.id }); + +const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); + +const agentStatus: RouteHandler = ({ params }) => + agentCtl.getSession(NO_EVENT, { localId: params.id }); + +const agentHistory: RouteHandler = ({ params }) => + agentCtl.getConversation(NO_EVENT, { localId: params.id }); + +const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); + +/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ +const agentInstruct: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { command?: string; args?: string }; + const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; + if (!READ_ONLY_TOOLS.has(command)) { + throw new HttpError(403, ERROR_CODES.SV_WRITE_NOT_ALLOWED, { command: b.command ?? '' }); + } + if (command === 'ask' && !b.args?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'ask requires args' }); + } + return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); +}; + +/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ +const agentChat: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { message?: string }; + if (!b.message?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'message required' }); + } + return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); +}; + +export const routes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, + { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, + { method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr }, + { method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff }, + { method: 'GET', segments: seg('/api/v1/prs/:id/activity'), handler: activity }, + { method: 'GET', segments: seg('/api/v1/prs/:id/commits'), handler: commits }, + { method: 'GET', segments: seg('/api/v1/prs/:id/reviewers'), handler: reviewers }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent'), handler: agentStatus }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/conversation'), handler: agentHistory }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, +]; + +/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ +export function matchRoute( + method: string, + pathname: string, +): { route: Route; params: Record } | null { + const parts = seg(pathname); + for (const route of routes) { + if (route.method !== method || route.segments.length !== parts.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < route.segments.length; i++) { + const s = route.segments[i]; + if (s.startsWith(':')) params[s.slice(1)] = decodeURIComponent(parts[i]); + else if (s !== parts[i]) { + ok = false; + break; + } + } + if (ok) return { route, params }; + } + return null; +} diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts new file mode 100644 index 00000000..75e7a82f --- /dev/null +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -0,0 +1,117 @@ +import { timingSafeEqual } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { BootstrapResult } from '@meebox/config'; +import { ERROR_CODES } from '@meebox/shared'; +import type { Logger } from 'pino'; +import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; +import { matchRoute } from './routes.js'; + +/** + * 本地 API 服务监听器(见 docs/arch/04-integration/01-service-api.md)。 + * + * 主进程内置 HTTP listener,作为渲染层 IPC 之外的「第二前端」:复用同一 ControllerContext 与 service 层, + * 把只读 PR / Agent 能力暴露给外部 CLI / 工具。默认关闭;开启即强制 bearer token 鉴权。生命周期由 main 装配: + * start(按 config 决定是否 listen)/ stop(退出时优雅关闭)/ reconfigure(配置变更停旧起新)。 + */ +export interface ApiServerDeps { + bootstrap: BootstrapResult; + logger: Logger; +} + +export class ApiServer { + private server?: Server; + + constructor(private readonly deps: ApiServerDeps) {} + + /** 实时读内存 service 配置(token 变更无需重建即生效)。 */ + private get cfg() { + return this.deps.bootstrap.config.service; + } + + /** 按配置启动监听(未启用 / token 为空则不启动)。监听失败为非致命:记录后不抛,不拖垮应用启动。 */ + async start(): Promise { + if (this.server) return; + const cfg = this.cfg; + if (!cfg.enabled) return; + if (!cfg.token) { + this.deps.logger.warn('api server enabled but token is empty; not starting'); + return; + } + const server = createServer((req, res) => { + void this.handle(req, res); + }); + this.server = server; + await new Promise((resolve, reject) => { + const onError = (err: Error): void => { + this.server = undefined; + reject(err); + }; + server.once('error', onError); + server.listen(cfg.port, cfg.host, () => { + server.off('error', onError); + server.on('error', (err) => this.deps.logger.error({ err }, 'api server runtime error')); + this.deps.logger.info({ host: cfg.host, port: cfg.port }, 'local API server listening'); + resolve(); + }); + }).catch((err: unknown) => { + this.deps.logger.error({ err, port: cfg.port }, 'local API server failed to listen (non-fatal)'); + }); + } + + /** 优雅关闭:停止接收新连接、放行 in-flight 后落定。 */ + async stop(): Promise { + const server = this.server; + if (!server) return; + this.server = undefined; + await new Promise((resolve) => server.close(() => resolve())); + this.deps.logger.info('local API server stopped'); + } + + /** 配置(开关 / host / port)变更:停旧起新。 */ + async reconfigure(): Promise { + await this.stop(); + await this.start(); + } + + /** 常数时间比对 bearer token;缺 token 配置 / 非 Bearer 头 / 长度不符均判失败。 */ + private authorized(req: IncomingMessage): boolean { + const token = this.cfg.token; + if (!token) return false; + const header = req.headers['authorization']; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; + const provided = Buffer.from(header.slice('Bearer '.length)); + const expected = Buffer.from(token); + if (provided.length !== expected.length) return false; + return timingSafeEqual(provided, expected); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const started = Date.now(); + const method = req.method ?? 'GET'; + const rawUrl = req.url ?? '/'; + const qIdx = rawUrl.indexOf('?'); + const pathname = qIdx >= 0 ? rawUrl.slice(0, qIdx) : rawUrl; + const search = qIdx >= 0 ? rawUrl.slice(qIdx + 1) : ''; + + let outcome: { status: number; code?: string }; + try { + if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED); + const matched = matchRoute(method, pathname); + if (!matched) throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND); + const body = method === 'POST' ? await readJsonBody(req) : undefined; + const data = await matched.route.handler({ + params: matched.params, + query: new URLSearchParams(search), + body, + }); + sendOk(res, data); + outcome = { status: 200 }; + } catch (err) { + outcome = sendError(res, err); + } + this.deps.logger.debug( + { method, path: pathname, status: outcome.status, code: outcome.code, ms: Date.now() - started }, + 'api request', + ); + } +} diff --git a/apps/desktop/src/main/services/context.ts b/apps/desktop/src/main/services/context.ts index 8bc627a5..90b3c9e9 100644 --- a/apps/desktop/src/main/services/context.ts +++ b/apps/desktop/src/main/services/context.ts @@ -31,6 +31,8 @@ export interface RegisterDeps { /** 重建 adapters/poller 使连接变更热生效(config:setConnections 写盘后调用) */ reconfigureConnections: () => Promise; repoMirror: RepoMirrorManager; + /** 重建本地 API 监听器使 service 配置(开关 / host / port)变更热生效(config:setService 写盘后调用)。 */ + reconfigureApiServer: () => Promise; } /** diff --git a/apps/desktop/src/main/services/pr-service.ts b/apps/desktop/src/main/services/pr-service.ts index 8a2f2913..b1b35c53 100644 --- a/apps/desktop/src/main/services/pr-service.ts +++ b/apps/desktop/src/main/services/pr-service.ts @@ -8,7 +8,12 @@ import { writeDiffBaseCache, } from '@meebox/poller'; import type { RepoIdentity, RepoMirrorManager } from '@meebox/repo-mirror'; -import { pullRequestHeadRefspec, type StoredPullRequest } from '@meebox/shared'; +import { + AppError, + ERROR_CODES, + pullRequestHeadRefspec, + type StoredPullRequest, +} from '@meebox/shared'; import type { PlatformAdapter } from '@meebox/platform-core'; import type { JsonFileStateStore } from '@meebox/state-store'; import type { ConnectionRuntime } from '../adapters.js'; @@ -53,7 +58,7 @@ export class PrService { if (pr) return pr; const archived = await readPrMeta(this.deps.archiveStore, localId); if (archived) return archived.pr; - throw new Error(`PR not found in local state: ${localId}`); + throw new AppError(ERROR_CODES.PR_NOT_FOUND, { localId }, `PR not found in local state: ${localId}`); } /** diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index ada47140..294ae25e 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -1,18 +1,22 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { - AgentRecommendationVerdict, - LocalPrStatus, - PrDiscoveryFilter, - StoredPullRequest, +import { + matchesDiscoveryFilter, + matchesPrQuery, + matchesSecondaryFilter, + type AgentRecommendationVerdict, + type PrDiscoveryFilter, + type PrSecondaryFilter, + type StoredPullRequest, } from '@meebox/shared'; import { invoke, subscribe } from '../../api'; import { useChatRunStore } from '../../stores/chat-run-store'; import { HistoryIcon, PaneLoading } from '../common'; import { PrItem } from '../features/pr'; -// 'conflict' / 'mergeable' 是按远端 merge 状态跨 localStatus 横切的筛选;'all' 不限定 -export type FilterKey = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; +// 二级筛选键复用 @meebox/shared 的 PrSecondaryFilter(与本地 API 同源): +// 'conflict' / 'mergeable' 按远端 merge 状态跨 localStatus 横切;'all' 不限定。 +export type FilterKey = PrSecondaryFilter; /** PR 列表范围:进行中(活跃,按发现分类 + 状态细分)/ 已关闭(归档冷存储,扁平只读浏览)。 */ export type SidebarScope = 'active' | 'archived'; @@ -206,10 +210,7 @@ export function Sidebar({ // GitHub 发现分类:按 PR 上的 discoveryFilters 标记本地过滤(poller 已把四类都抓回来缓存), // 切标签纯本地、瞬时、零远端请求。非 GitHub(discoveryFilter 未设)时用全量。 const scopedPrs = useMemo( - () => - !isArchived && discoveryFilter - ? prs.filter((p) => p.discoveryFilters?.includes(discoveryFilter)) - : prs, + () => prs.filter((p) => matchesDiscoveryFilter(p, !isArchived ? discoveryFilter : undefined)), [prs, discoveryFilter, isArchived], ); @@ -231,30 +232,12 @@ export function Sidebar({ }, [scopedPrs]); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。 + // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。过滤 / 检索语义复用 + // @meebox/shared 纯谓词(与本地 API 同源)。 const effFilter: FilterKey = isArchived ? 'all' : filter; - return scopedPrs.filter((p) => { - if (effFilter === 'conflict') { - if (!p.hasConflict) return false; - } else if (effFilter === 'mergeable') { - if (!p.mergeStatus?.canMerge) return false; - } else if (effFilter !== 'all' && p.localStatus !== effFilter) { - return false; - } - if (!q) return true; - const hay = [ - p.title, - p.repo.projectKey, - p.repo.repoSlug, - p.author.displayName, - p.author.name, - p.remoteId, - ] - .join(' ') - .toLowerCase(); - return hay.includes(q); - }); + return scopedPrs.filter( + (p) => matchesSecondaryFilter(p, effFilter) && matchesPrQuery(p, query), + ); }, [scopedPrs, query, filter, isArchived]); const groups = useMemo(() => { diff --git a/packages/ipc/src/config.ts b/packages/ipc/src/config.ts index 2834f3f5..93e6ea1c 100644 --- a/packages/ipc/src/config.ts +++ b/packages/ipc/src/config.ts @@ -60,6 +60,13 @@ export interface ConfigChannels { request: { base_url: string; token: string; kind?: PlatformKind }; response: PingResult; }; + /** + * 写入本地 API 服务监听配置(开关 / host / port / token)到 config.yaml,并**热重建**监听器 + * (开关 / 地址 / 端口变更停旧起新;token 变更下次请求即生效)。见 docs/arch/04-integration/01-service-api.md。 + */ + 'config:setService': { request: { service: Config['service'] }; response: void }; + /** 重新生成 bearer token 并写盘(旧 token 即时失效),返回新 token 供设置页展示 / 复制。 */ + 'config:generateServiceToken': { request: void; response: { token: string } }; /** * 配置过程中自动把连接 + LLM 草稿写入 config.yaml(防丢失),但**不应用到运行时** * (不 reconfigure adapter/poller、不更新内存 config)——重启或点底栏「保存」才生效。 diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 7eeb1e26..34b6b1cd 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -157,6 +157,20 @@ export const ProxySchema = z.object({ }); export type ProxyConfig = z.infer; +/** + * 本地 API 服务监听(见 docs/arch/04-integration/01-service-api.md)。默认关闭、零暴露面;开启即**强制** + * bearer token 鉴权(token 为空时由主进程在启用时自动生成)。`host` 默认仅 loopback(127.0.0.1),可设 + * `0.0.0.0` 暴露到局域网——高风险、需安全警示。`port` 固定安全默认 18765(10000+,避开常见开发端口且低于 + * 临时端口范围)。token 明文落盘(同既有凭据策略,经 SecretStore 抽象、绝不进日志)。 + */ +export const ServiceSchema = z.object({ + enabled: z.boolean().default(false), + host: z.string().default('127.0.0.1'), + port: z.number().int().min(1).max(65535).default(18765), + token: z.string().default(''), +}); +export type ServiceConfig = z.infer; + /** * LLM 上下文长度(token):裁剪输入内容的全局上限,透传 pr-agent `CONFIG__MAX_MODEL_TOKENS` / * `CONFIG__CUSTOM_MODEL_MAX_TOKENS`。默认 128000(与现代主流模型上下文匹配);**对本地 CLI 模式 @@ -279,6 +293,8 @@ export const ConfigSchema = z.object({ check_enabled: z.boolean().default(true), }) .default({}), + /** 本地 API 服务监听(默认关闭)。见上 {@link ServiceSchema}。 */ + service: ServiceSchema.default({}), /** * 消息通知(见 docs/arch/03-gui/03-notifications.md)。enabled 为总开关;关闭后既不弹系统通知也不亮 dock 角标。 * new_pr / reply / mention 按事件类型分别控制系统通知(toast)是否弹出。macOS dock「待回应」计数角标无独立 diff --git a/packages/shared/src/error-code.ts b/packages/shared/src/error-code.ts index 52a5e9df..603df53f 100644 --- a/packages/shared/src/error-code.ts +++ b/packages/shared/src/error-code.ts @@ -4,7 +4,7 @@ */ /** 领域标签(两字母大写)。新增领域追加在末尾。 */ -export type ErrorDomain = 'AG' | 'UI' | 'CF' | 'NT' | 'PR'; +export type ErrorDomain = 'AG' | 'UI' | 'CF' | 'NT' | 'PR' | 'SV'; /** * 错误码注册表(唯一真相源):`E` + 两字母领域 + 四位数字。新增码在此登记,并在渲染层各 locale 补 @@ -45,6 +45,21 @@ export const ERROR_CODES = { PR_FORBIDDEN: 'EPR0006', /** 没有活动连接,无法按链接打开 PR。 */ PR_NO_ACTIVE_CONNECTION: 'EPR0007', + /** + * 本地 API 服务(service listener)域错误码。**经 HTTP 返回给外部 CLI / 客户端**,不经渲染层 i18n + * (故暂不在 renderer locale 登记;如未来在 GUI 展示再补 `errors.`,formatBackendError 已有兜底)。 + * 见 docs/arch/04-integration/01-service-api.md。 + */ + /** 未分类服务错误(兜底)。 */ + SV_UNCLASSIFIED: 'ESV0000', + /** 鉴权失败:缺失 / 不匹配 bearer token(→ HTTP 401)。 */ + SV_UNAUTHORIZED: 'ESV0001', + /** 写操作不经本地 API 开放(→ HTTP 403)。 */ + SV_WRITE_NOT_ALLOWED: 'ESV0002', + /** 路由 / 资源不存在(→ HTTP 404)。 */ + SV_NOT_FOUND: 'ESV0003', + /** 请求体校验失败(→ HTTP 400)。 */ + SV_BAD_REQUEST: 'ESV0004', } as const; /** 已登记的错误码字面量联合(抛错时只能用注册过的码,防笔误)。 */ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 91f4350e..794b0664 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './language.js'; export * from './platform.js'; export * from './poller-contract.js'; export * from './pr-agent-status.js'; +export * from './pr-filter.js'; export * from './sync-progress.js'; export * from './theme.js'; export * from './tool-registry.js'; diff --git a/packages/shared/src/pr-filter.ts b/packages/shared/src/pr-filter.ts new file mode 100644 index 00000000..40696902 --- /dev/null +++ b/packages/shared/src/pr-filter.ts @@ -0,0 +1,83 @@ +import type { LocalPrStatus, StoredPullRequest } from './poller-contract.js'; +import type { PrDiscoveryFilter } from './platform.js'; + +/** + * PR 列表筛选与检索的**纯谓词**(单一真相源)。渲染层侧栏与本地 API 的 PR 列表端点共用同一套语义, + * 避免两处各写一份过滤逻辑而漂移。仅做无副作用的判定 / 过滤,不含 UI(计数、可见性、分组属各自表现层)。 + * + * 二级筛选 `PrSecondaryFilter`:`'all'` 不限定;`LocalPrStatus`(本人评审决断 pending/approved/needs_work) + * 按 `localStatus` 匹配;`'conflict'` / `'mergeable'` 是跨 localStatus 横切的远端合并态筛选。 + */ +export type PrSecondaryFilter = 'all' | LocalPrStatus | 'conflict' | 'mergeable'; + +/** 二级筛选全集(与 {@link PrSecondaryFilter} 同步;本地 API 的分类标签据此列出)。 */ +export const PR_SECONDARY_FILTERS: readonly PrSecondaryFilter[] = [ + 'all', + 'pending', + 'approved', + 'needs_work', + 'conflict', + 'mergeable', +]; + +/** 一级(平台发现分类)匹配:未指定一级 = 不限定;否则按 PR 携带的 discoveryFilters 命中判定。 */ +export function matchesDiscoveryFilter( + pr: StoredPullRequest, + primary?: PrDiscoveryFilter, +): boolean { + return !primary || (pr.discoveryFilters?.includes(primary) ?? false); +} + +/** 二级筛选匹配(状态 / 合并态)。 */ +export function matchesSecondaryFilter( + pr: StoredPullRequest, + secondary: PrSecondaryFilter, +): boolean { + switch (secondary) { + case 'all': + return true; + case 'conflict': + return pr.hasConflict === true; + case 'mergeable': + return pr.mergeStatus?.canMerge === true; + default: + return pr.localStatus === secondary; + } +} + +/** 检索匹配:空查询恒真;否则在 标题 / 仓库 / 作者 / 编号 拼成的串里做大小写无关子串匹配。 */ +export function matchesPrQuery(pr: StoredPullRequest, query: string): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return [ + pr.title, + pr.repo.projectKey, + pr.repo.repoSlug, + pr.author.displayName, + pr.author.name, + pr.remoteId, + ] + .join(' ') + .toLowerCase() + .includes(q); +} + +/** 筛选条件(各项可省,省略即不限定)。 */ +export interface PrFilterCriteria { + primary?: PrDiscoveryFilter; + secondary?: PrSecondaryFilter; + query?: string; +} + +/** 按 一级 + 二级 + 检索 顺序过滤 PR 列表。 */ +export function filterPullRequests( + prs: StoredPullRequest[], + criteria: PrFilterCriteria, +): StoredPullRequest[] { + return prs.filter( + (p) => + matchesDiscoveryFilter(p, criteria.primary) && + matchesSecondaryFilter(p, criteria.secondary ?? 'all') && + matchesPrQuery(p, criteria.query ?? ''), + ); +} From 073b12572355283a0b071a7765a2634698e826db Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 00:02:53 +0800 Subject: [PATCH 05/84] =?UTF-8?q?feat(desktop):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=E3=80=8C=E9=9B=86=E6=88=90=E3=80=8D?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E9=85=8D=E7=BD=AE=E6=9C=AC=E5=9C=B0=20API=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页新增独立「集成」分区,可视化管理本地 API 服务监听(见上一提交的服务端): - 开关 + 监听地址 http://: 两输入框组合(host 自由输入,保存时做简单合法性 校验),开关仅决定是否监听、host/port/token 任何状态下均可编辑。 - bearer token:显示 / 隐藏 / 复制 / 重新生成(即时写盘生效);启用且无 token 时自动生成。 - 监听非 loopback 地址(0.0.0.0 / 局域网 IP)时给出安全警示。 - 四语 locale 同步(新增「集成」分区名与服务监听文案)。 - CHANGELOG 增「本地 API 服务」Unreleased 条目(CLI 待随发布产出后再补)。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 ++ .../features/settings/SettingsModal.tsx | 11 ++ .../settings/hooks/useSettingsDraft.ts | 37 +++++ .../settings/sections/ServiceSection.tsx | 144 ++++++++++++++++++ .../src/renderer/src/i18n/locales/de-DE.json | 15 ++ .../src/renderer/src/i18n/locales/en-US.json | 15 ++ .../src/renderer/src/i18n/locales/ja-JP.json | 15 ++ .../src/renderer/src/i18n/locales/zh-CN.json | 15 ++ 8 files changed, 266 insertions(+) create mode 100644 apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fac538..9ff39ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +> 本版重点: +> +> - **外部集成(本地 API 服务)**:开启本机 API,把 PR 浏览与评审 Agent 操作开放给外部 agent / 工具 / 脚本集成 + +### ✨ 新增 + +- **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 + - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 + - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 + - 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作。 + ## [0.8.0] - 2026-06-30 > 本版重点: @@ -371,6 +384,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 +[Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.8.0...HEAD [0.8.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.5.0...v0.6.0 diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index 9dbfc857..a7c9960e 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -10,6 +10,7 @@ import { QuestionIcon, RobotIcon, SettingsIcon, + ShareIcon, } from '../../common'; import { useSettingsDraft } from './hooks/useSettingsDraft'; import { useAppearanceDraft } from './hooks/useAppearanceDraft'; @@ -26,6 +27,7 @@ import { LlmContextSection } from './sections/LlmContextSection'; import { ConcurrencySection } from './sections/ConcurrencySection'; import { AgentStrategySection } from './sections/AgentStrategySection'; import { ProxySection } from './sections/ProxySection'; +import { ServiceSection } from './sections/ServiceSection'; import { AgentDirSection } from './sections/AgentDirSection'; import { WorkDirSection } from './sections/WorkDirSection'; import { CacheDirSection } from './sections/CacheDirSection'; @@ -38,6 +40,7 @@ export type SettingsCategory = | 'model' | 'agent' | 'notifications' + | 'integration' | 'about'; /** @@ -54,6 +57,7 @@ const SETTINGS_CATEGORIES: ReadonlyArray<{ { id: 'model', labelKey: 'settings.catModel', Icon: CpuIcon }, { id: 'agent', labelKey: 'settings.catAgent', Icon: RobotIcon }, { id: 'notifications', labelKey: 'settings.catNotifications', Icon: BellIcon }, + { id: 'integration', labelKey: 'settings.catIntegration', Icon: ShareIcon }, { id: 'about', labelKey: 'settings.catAbout', Icon: QuestionIcon }, ]; @@ -252,6 +256,13 @@ export function SettingsModal({ {category === 'notifications' && ( )} + {category === 'integration' && ( + void s.regenerateServiceToken()} + /> + )} {category === 'about' && ( <> diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts index 4c3467a9..d2aa478d 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -63,6 +63,9 @@ export function useSettingsDraft({ // 代理在独立模态框里编辑:null=关闭,非 null=正在编辑的草稿;保存回 proxy,底栏「保存」才写盘。 const [proxyEditor, setProxyEditor] = useState(null); + // 本地 API 服务监听(开关 / host / port 随整体保存;token 经 generateServiceToken 立即写盘)。 + const [service, setServiceState] = useState(config.service); + // 连接:多条可配置 + 单选启用;编辑只改本地 state,整体保存才写盘 + 热重建 const [connections, setConnections] = useState(config.connections); const [activeConnId, setActiveConnId] = useState(config.active_connection_id); @@ -83,6 +86,7 @@ export function useSettingsDraft({ llm: config.llm, proxy: config.proxy, notifications: config.notifications, + service: config.service, connections: config.connections, activeConnId: config.active_connection_id, })); @@ -243,6 +247,21 @@ export function useSettingsDraft({ setNotificationsState(next); setSaved(false); }; + const setService = (next: Config['service']): void => { + setServiceState(next); + setSaved(false); + }; + // token 重新生成是即时副作用(写盘 + 即时生效),不随整体保存:更新草稿 token 的同时同步基线, + // 使「重新生成」本身不被算作待保存改动(仅开关 / host / port 的未保存编辑才标脏)。 + const regenerateServiceToken = async (): Promise => { + try { + const { token } = await invoke('config:generateServiceToken', undefined); + setServiceState((prev) => ({ ...prev, token })); + setBase((b) => ({ ...b, service: { ...b.service, token } })); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } + }; const setAgentDir = (v: string): void => { setAgentDirInput(v); setSaved(false); @@ -291,6 +310,7 @@ export function useSettingsDraft({ const proxyChanged = JSON.stringify(proxy) !== JSON.stringify(base.proxy); const notificationsChanged = JSON.stringify(notifications) !== JSON.stringify(base.notifications); + const serviceChanged = JSON.stringify(service) !== JSON.stringify(base.service); const connectionsChanged = activeConnId !== base.activeConnId || JSON.stringify(connections) !== JSON.stringify(base.connections); @@ -302,6 +322,7 @@ export function useSettingsDraft({ llmChanged || proxyChanged || notificationsChanged || + serviceChanged || connectionsChanged; const saveAll = async (): Promise => { @@ -344,6 +365,17 @@ export function useSettingsDraft({ if (notificationsChanged) { await invoke('config:setNotifications', { notifications }); } + if (serviceChanged) { + const host = service.host.trim(); + // 简单合法性校验:非空、无空白 / 协议 / 斜杠(端口单列);放过 IPv4 / 主机名 / 0.0.0.0 / ::1。 + if (!host || !/^[A-Za-z0-9.:-]+$/.test(host)) { + throw new Error(t('settings.serviceHostInvalidError')); + } + if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) { + throw new Error(t('settings.servicePortRangeError')); + } + await invoke('config:setService', { service: { ...service, host } }); + } if (connectionsChanged) { await invoke('config:setConnections', { connections, active_connection_id: activeConnId }); await onConnectionsChange?.(); @@ -365,6 +397,7 @@ export function useSettingsDraft({ llm, proxy, notifications, + service, connections, activeConnId, }); @@ -410,6 +443,10 @@ export function useSettingsDraft({ // 通知 notifications, setNotifications, + // 本地 API 服务监听 + service, + setService, + regenerateServiceToken, // 轮询 / 并发 / 目录 pollerInput, setPoller, diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx new file mode 100644 index 00000000..4e711d41 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Config } from '@meebox/shared'; +import { CopyIcon, EyeIcon, EyeOffIcon, Switch } from '../../../common'; + +/** + * 本地 API 服务监听分区:开关 + 监听地址(仅本机 / 局域网)+ 端口 + bearer token(展示 / 显隐 / 复制 / + * 重新生成)。默认关闭;启用且无 token 时自动生成。监听 0.0.0.0 暴露到局域网时给安全警示。token 经 + * config:generateServiceToken 立即写盘生效(不随整体保存);开关 / 地址 / 端口随底栏「保存」生效。 + */ +export function ServiceSection({ + value, + onChange, + onRegenerateToken, +}: { + value: Config['service']; + onChange: (next: Config['service']) => void; + /** 立即重新生成 token(写盘 + 即时生效);启用且无 token 时也由此自动补一枚。 */ + onRegenerateToken: () => void; +}) { + const { t } = useTranslation(); + const [revealed, setRevealed] = useState(false); + const [copied, setCopied] = useState(false); + const on = value.enabled; + // 暴露判定:非 loopback 绑定(0.0.0.0 / 局域网 IP 等)即视为可被同网段访问,给安全警示。 + const host = value.host.trim(); + const exposed = host !== '' && !['127.0.0.1', 'localhost', '::1'].includes(host); + + const set = (patch: Partial): void => onChange({ ...value, ...patch }); + + const handleEnabled = (v: boolean): void => { + if (v && !value.token) onRegenerateToken(); // 启用且无 token → 自动生成一枚 + set({ enabled: v }); + }; + + const copyToken = async (): Promise => { + if (!value.token) return; + try { + await navigator.clipboard.writeText(value.token); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* 复制失败静默 */ + } + }; + + return ( +
+
+
+

{t('settings.serviceTitle')}

+
+ +
+

+ {t('settings.serviceHint')} +

+ + {/* 监听地址:http://: 两个输入框组合。host 默认 127.0.0.1(仅本机), + 可填 0.0.0.0 / 局域网 IP 开放到同网段。 */} +
+
+ {t('settings.serviceHostLabel')} +
+
+ http:// + set({ host: e.target.value })} + placeholder="127.0.0.1" + spellCheck={false} + /> + : + set({ port: Number.parseInt(e.target.value, 10) || 0 })} + /> +
+

+ {t('settings.serviceHostHint')} +

+
+ + {exposed && ( +

+ {t('settings.serviceExposeWarning')} +

+ )} + +
+ + + + +
+ {copied && {t('settings.serviceTokenCopied')}} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index f5fe935f..bf7f2adb 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -715,6 +715,7 @@ "catAgent": "Agent", "catConnection": "Verbindung", "catGeneral": "Allgemein", + "catIntegration": "Integration", "catModel": "Modell", "catNotifications": "Benachrichtigungen", "checkFailed": "Prüfung fehlgeschlagen: {{error}}", @@ -817,6 +818,20 @@ "runtimeTitle": "Laufzeitumgebung", "saved": "Gespeichert", "saving": "Speichere…", + "serviceEnableLabel": "Lokalen API-Dienst aktivieren", + "serviceExposeWarning": "Das Lauschen auf 0.0.0.0 macht die API im lokalen Netzwerk zugänglich; der Token ist der einzige Schutz – halten Sie ihn geheim und nutzen Sie eine Firewall.", + "serviceHint": "Stellt eine lokale HTTP-API für die Integration externer Tools / CLI bereit. Standardmäßig aus; bei Aktivierung ist Bearer-Token-Authentifizierung erforderlich.", + "serviceHostHint": "Standard http://127.0.0.1:18765 (nur dieses Gerät). Host auf 0.0.0.0 oder eine LAN-IP setzen, um Zugriff aus demselben Subnetz zu erlauben (hohes Risiko).", + "serviceHostInvalidError": "Ungültige Lausch-Adresse (nur IP oder Hostname; ohne Schema, Leerzeichen oder Port).", + "serviceHostLabel": "Lausch-Adresse", + "servicePortRangeError": "Der Port muss eine ganze Zahl zwischen 1 und 65535 sein.", + "serviceTitle": "Lokaler API-Dienst", + "serviceTokenCopied": "Kopiert", + "serviceTokenCopy": "Token kopieren", + "serviceTokenHide": "Token verbergen", + "serviceTokenPlaceholder": "Noch kein Token generiert", + "serviceTokenRegenerate": "Neu generieren", + "serviceTokenReveal": "Token anzeigen", "setActiveLlmAria": "Als aktiv festlegen", "show": "Anzeigen", "starOnGithub": "Auf GitHub favorisieren", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index e508951c..3b600cae 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -715,6 +715,7 @@ "catAgent": "Agent", "catConnection": "Connection", "catGeneral": "General", + "catIntegration": "Integration", "catModel": "Model", "catNotifications": "Notifications", "checkFailed": "Check failed: {{error}}", @@ -817,6 +818,20 @@ "runtimeTitle": "Runtime", "saved": "Saved", "saving": "Saving…", + "serviceEnableLabel": "Enable local API service", + "serviceExposeWarning": "Listening on 0.0.0.0 exposes the API to your local network; the token is the only safeguard—keep it secret and use a firewall.", + "serviceHint": "Expose a local HTTP API for external tools / CLI integration. Off by default; enabling enforces bearer token authentication.", + "serviceHostHint": "Default http://127.0.0.1:18765 (this machine only). Set host to 0.0.0.0 or a LAN IP to allow same-subnet access (high risk).", + "serviceHostInvalidError": "Invalid listen address (IP or hostname only; no scheme, spaces, or port).", + "serviceHostLabel": "Listen address", + "servicePortRangeError": "Port must be an integer between 1 and 65535.", + "serviceTitle": "Local API service", + "serviceTokenCopied": "Copied", + "serviceTokenCopy": "Copy token", + "serviceTokenHide": "Hide token", + "serviceTokenPlaceholder": "No token generated yet", + "serviceTokenRegenerate": "Regenerate", + "serviceTokenReveal": "Show token", "setActiveLlmAria": "Set as active", "show": "Show", "starOnGithub": "Star on GitHub", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 546aa837..e97ce3f7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -698,6 +698,7 @@ "catAgent": "エージェント", "catConnection": "接続", "catGeneral": "一般", + "catIntegration": "連携", "catModel": "モデル", "catNotifications": "通知", "checkFailed": "チェックに失敗しました:{{error}}", @@ -800,6 +801,20 @@ "runtimeTitle": "実行環境", "saved": "保存しました", "saving": "保存中…", + "serviceEnableLabel": "ローカル API サービスを有効化", + "serviceExposeWarning": "0.0.0.0 でのリッスンは API を LAN に公開します。トークンが唯一の防御策です——秘匿し、ファイアウォールを併用してください。", + "serviceHint": "外部ツール / CLI 連携用のローカル HTTP API を公開します。既定は無効。有効化すると bearer トークン認証が必須になります。", + "serviceHostHint": "既定は http://127.0.0.1:18765(本機のみ)。host に 0.0.0.0 や LAN の IP を指定すると同一サブネットから接続可能(高リスク)。", + "serviceHostInvalidError": "リッスンアドレスの形式が不正です(IP / ホスト名のみ。プロトコル・空白・ポートは不可)。", + "serviceHostLabel": "リッスンアドレス", + "servicePortRangeError": "ポートは 1〜65535 の整数で指定してください。", + "serviceTitle": "ローカル API サービス", + "serviceTokenCopied": "コピーしました", + "serviceTokenCopy": "トークンをコピー", + "serviceTokenHide": "トークンを隠す", + "serviceTokenPlaceholder": "トークン未生成", + "serviceTokenRegenerate": "再生成", + "serviceTokenReveal": "トークンを表示", "setActiveLlmAria": "アクティブに設定", "show": "表示", "starOnGithub": "GitHub でスター", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index d7ae37e0..794c7799 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -698,6 +698,7 @@ "catAgent": "智能体", "catConnection": "连接", "catGeneral": "常规", + "catIntegration": "集成", "catModel": "模型", "catNotifications": "通知", "checkFailed": "检查失败:{{error}}", @@ -800,6 +801,20 @@ "runtimeTitle": "运行环境", "saved": "已保存", "saving": "保存中…", + "serviceEnableLabel": "启用本地 API 服务", + "serviceExposeWarning": "监听 0.0.0.0 会把 API 暴露到局域网,token 是唯一防线——请确保 token 保密并配合防火墙。", + "serviceHint": "对外提供本地 HTTP API,供外部工具 / CLI 集成。默认关闭;启用即强制 bearer token 鉴权。", + "serviceHostHint": "默认 http://127.0.0.1:18765(仅本机可达)。host 填 0.0.0.0 或本机局域网 IP 可被同网段访问(高风险)。", + "serviceHostInvalidError": "监听地址格式不合法(仅允许 IP / 主机名,不含协议、空格或端口)。", + "serviceHostLabel": "监听地址", + "servicePortRangeError": "端口需为 1–65535 之间的整数。", + "serviceTitle": "本地 API 服务", + "serviceTokenCopied": "已复制", + "serviceTokenCopy": "复制 token", + "serviceTokenHide": "隐藏 token", + "serviceTokenPlaceholder": "尚未生成 token", + "serviceTokenRegenerate": "重新生成", + "serviceTokenReveal": "显示 token", "setActiveLlmAria": "设为活跃", "show": "显示", "starOnGithub": "GitHub 上 Star", From ff39addec3eb2532ad0a21131fc1b8a9aa84db49 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 06:55:07 +0800 Subject: [PATCH 06/84] =?UTF-8?q?ci:=20release=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=96=B0=E5=A2=9E=20meebox=20CLI=20=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BA=A4=E5=8F=89=E7=BC=96=E8=AF=91=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/ 为纯 Go、无 CGO,单 ubuntu runner 即可交叉编译全平台: - 矩阵出 Windows x64 / macOS arm64 / Linux x64·arm64 二进制,打 .zip(win)/ .tar.gz(unix) + .sha256,随桌面安装包挂到同一个 GitHub Release(CLI 不打进安装包,是独立可分发物)。 - -ldflags -X …/cmd.version 注入版本号;仅 tag 触发上传,workflow_dispatch 仅编译冒烟。 - 不设 Release 正文(由桌面 build job 注入),仅追加产物,避免覆盖。 - CHANGELOG 补「命令行工具 meebox」条目(CLI 现随发布产出),本版重点改为「外部集成与 CLI」。 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 64 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 3 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0529500b..f484d18b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,3 +137,67 @@ jobs: make_latest: ${{ !contains(github.ref_name, '-') }} # 正文 = RELEASE_NOTES(安装 / 首次打开 / 校验和)+ 注入的本版 CHANGELOG 段 body_path: RELEASE_BODY.md + + # meebox CLI(cli/,独立 Go module):纯 Go、无 CGO → 单 runner 交叉编译全平台。出四平台压缩包 + + # 校验和,随桌面安装包挂到同一个 GitHub Release(不打进安装包,是独立可分发物)。仅 tag 触发上传; + # workflow_dispatch 仍构建做编译冒烟、不上传。本 job 不设 Release 正文(由 build job 注入),仅追加产物。 + cli: + name: CLI (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { goos: windows, goarch: amd64, ext: '.exe', archive: zip } + - { goos: darwin, goarch: arm64, ext: '', archive: tar.gz } + - { goos: linux, goarch: amd64, ext: '', archive: tar.gz } + - { goos: linux, goarch: arm64, ext: '', archive: tar.gz } + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: 交叉编译 meebox CLI + shell: bash + working-directory: cli + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: | + VERSION="${GITHUB_REF_NAME#v}" # tag 去 v 前缀;非 tag(dispatch)取 ref 名,仅用于编译冒烟 + mkdir -p dist + go build -trimpath \ + -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ + -o "dist/meebox${{ matrix.ext }}" . + + - name: 打包压缩包 + 校验和 + if: startsWith(github.ref, 'refs/tags/') + shell: bash + working-directory: cli/dist + run: | + VERSION="${GITHUB_REF_NAME#v}" + BIN="meebox${{ matrix.ext }}" + ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" + cp ../../LICENSE . + if [ "${{ matrix.archive }}" = "zip" ]; then + zip -q "${ARCHIVE}.zip" "${BIN}" LICENSE + else + tar -czf "${ARCHIVE}.tar.gz" "${BIN}" LICENSE + fi + for f in "${ARCHIVE}".zip "${ARCHIVE}".tar.gz; do + [ -e "$f" ] && sha256sum "$f" > "$f.sha256" + done + + - name: 上传到 Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + # 本 matrix 项仅产出自身的压缩包 + .sha256(fresh runner);不设 body_path 以免覆盖 build job 注入的正文 + files: cli/dist/meebox-cli-* + fail_on_unmatched_files: true + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff39ccc..86aad51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ > 本版重点: > -> - **外部集成(本地 API 服务)**:开启本机 API,把 PR 浏览与评审 Agent 操作开放给外部 agent / 工具 / 脚本集成 +> - **外部集成与 CLI**:开启本机 API 把 PR 浏览与评审 Agent 操作开放给外部集成,并提供跨平台命令行工具 `meebox` ### ✨ 新增 @@ -15,6 +15,7 @@ - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 - 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作。 +- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR 与操作评审 Agent,便于脚本与外部 agent 集成;与本地 API 一致,只读取向、不含写操作。 ## [0.8.0] - 2026-06-30 From 650c2b1c42121d102c8bf0c14d3be80b061470ab Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 06:57:38 +0800 Subject: [PATCH 07/84] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20meebox=20CLI?= =?UTF-8?q?=20=E7=8B=AC=E7=AB=8B=E9=97=A8=E7=A6=81=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli/ 为独立 Go module,与 Node/Nx 的 CI 分开成独立 workflow:路径过滤只能加在 on 层 (不能按 job 过滤),独立成一条流水线才能做到仅当 cli/ 变更时才跑 go vet / test / build, 既隔离 Go 工具链、又省 CI 分钟。ci.yml 触发器保持不动,避免影响分支保护必检项。 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci-cli.yml | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/ci-cli.yml diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml new file mode 100644 index 00000000..2e03888d --- /dev/null +++ b/.github/workflows/ci-cli.yml @@ -0,0 +1,46 @@ +name: CLI + +# meebox CLI(cli/,独立 Go module)的门禁,与 Node/Nx 的 CI 分开: +# 路径过滤只能加在 workflow 的 on 层(不能按 job 过滤),故独立成一条流水线——仅当 cli/ 变更时才跑, +# 既隔离 Go 工具链、又省 CI 分钟。发布期的交叉编译 / 出包见 release.yml 的 cli job。 +on: + push: + branches: [master] + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/ci-cli.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + cli: + name: Vet + Test + Build + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: cli + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... + + - name: go build + run: go build ./... From 75c85cd359c7e06fe5130054acd20bae7c35ae80 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 09:16:07 +0800 Subject: [PATCH 08/84] =?UTF-8?q?feat(cli):=20=E9=BB=98=E8=AE=A4=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=94=B9=20YAML=EF=BC=88=E7=B1=BB=20k8s=20-o=20yaml?= =?UTF-8?q?=EF=BC=89=EF=BC=8C--output=20yaml|json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 主消费者是第三方 agent,但集成方传 flag 无门槛,故默认可面向人类优化体验: - 默认输出 YAML——结构化又比裸 JSON 可读(类 kubectl -o yaml);--output json 供机器/agent。 - YAML 与 JSON 皆为对响应数据的通用转换(json 解码 → 重新编码),不做逐命令表格 / formatter, 免去手写响应 struct 的契约同步负担。复用既有 yaml.v3 依赖,无新增。 - 同步 cli/README 与设计文档 02-cli 的输出模式说明。 Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 6 ++++- cli/cmd/root.go | 4 +-- cli/internal/render/render.go | 42 ++++++++++++++++++++++++------ docs/arch/04-integration/02-cli.md | 10 ++++--- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/cli/README.md b/cli/README.md index d7a5e06d..41e36244 100644 --- a/cli/README.md +++ b/cli/README.md @@ -63,4 +63,8 @@ meebox agent instruct [args...] # read-only: describe|review|as meebox agent chat ``` -Global flags: `--api-url`, `--token`, `--output text|json`, `--quiet`. +Global flags: `--api-url`, `--token`, `--output yaml|json`, `--quiet`. + +Output defaults to **YAML** (human-friendly, k8s `-o yaml` style); pass +`--output json` for the machine-readable form used by third-party integrations. +Both are generic transforms of the response — no per-command formatting. diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 74fc945c..8707981f 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -34,7 +34,7 @@ func newRootCmd() *cobra.Command { pf := root.PersistentFlags() pf.StringVar(&gflags.apiURL, "api-url", "", "API base URL (overrides env and local auto-discovery)") pf.StringVar(&gflags.token, "token", "", "bearer token (overrides env and local auto-discovery)") - pf.StringVar(&gflags.output, "output", "text", "output format: text|json") + pf.StringVar(&gflags.output, "output", "yaml", "output format: yaml|json") pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") root.AddCommand( @@ -70,7 +70,7 @@ func outputMode() render.Mode { if gflags.output == "json" { return render.ModeJSON } - return render.ModeText + return render.ModeYAML } func renderData(data json.RawMessage) error { diff --git a/cli/internal/render/render.go b/cli/internal/render/render.go index 06111bcb..2f91d947 100644 --- a/cli/internal/render/render.go +++ b/cli/internal/render/render.go @@ -10,13 +10,19 @@ import ( "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" "github.com/huhamhire/code-meeseeks/cli/internal/settings" + "gopkg.in/yaml.v3" ) // Mode selects the output format. type Mode int const ( - ModeText Mode = iota + // ModeYAML renders responses as YAML — the default, human-friendly view + // (k8s `-o yaml` style): structured yet readable, and derived generically + // from any response without per-command formatters. + ModeYAML Mode = iota + // ModeJSON renders responses as JSON — the stable machine contract for + // third-party agents / tooling. ModeJSON ) @@ -28,13 +34,14 @@ const ( ExitNotFound = 3 ) -// Output writes API data to stdout per the selected mode. -// -// NOTE: text mode currently mirrors JSON pretty-printing. Per-command, -// human-friendly table rendering is a deliberate follow-up — the scaffold -// keeps a single code path so the wiring is verifiable end to end first. -func Output(_ Mode, data json.RawMessage) error { - return writeJSON(data) +// Output writes API data to stdout per the selected mode: YAML (default, +// human-friendly) or JSON (machine contract). Both are generic transforms of +// the response data — no per-command formatting. +func Output(mode Mode, data json.RawMessage) error { + if mode == ModeJSON { + return writeJSON(data) + } + return writeYAML(data) } func writeJSON(data json.RawMessage) error { @@ -53,6 +60,25 @@ func writeJSON(data json.RawMessage) error { return enc.Encode(v) } +func writeYAML(data json.RawMessage) error { + if len(data) == 0 { + fmt.Println("null") + return nil + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + // Not decodable as JSON — fall back to the raw payload. + fmt.Println(string(data)) + return nil + } + out, err := yaml.Marshal(v) + if err != nil { + return err + } + _, err = os.Stdout.Write(out) + return err +} + // Errorln prints an error to stderr. func Errorln(err error) { fmt.Fprintln(os.Stderr, "error:", err) diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 73113015..8749be33 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -54,7 +54,7 @@ CLI 需 API base URL + token。来源优先级(高 → 低): ```text meebox [全局 flag] <组> <命令> [参数] -全局 flag:--api-url · --token · --output (text|json) · --quiet +全局 flag:--api-url · --token · --output (yaml|json) · --quiet ``` | 命令 | 用途 | 对应 API | @@ -77,8 +77,10 @@ meebox [全局 flag] <组> <命令> [参数] ### 输出与退出码 -- **`--output text`(默认)**:人类可读(表格 / 文本),便于交互式查看。 -- **`--output json`**:原样输出 API `data`,供外部 agent / 脚本机器消费——这是 CLI 服务自动化的主要形态。 +- **`--output yaml`(默认)**:把响应渲染为 YAML(类 k8s `-o yaml`)——结构化又可读,便于人交互式查看。 + 与 JSON 一样是对响应数据的**通用转换**,不做逐命令表格 / formatter(省去手写 struct 的契约同步负担)。 +- **`--output json`**:原样输出 API `data`,供外部 agent / 脚本机器消费。agent 传参无门槛,故默认面向人优化(YAML)、 + 机器集成显式取 `json`;两者字段形状同源、皆稳定。 - **退出码约定**:`0` 成功;非 0 表错误并按类别区分(如 `2` 鉴权失败、`3` 资源不存在、`1` 通用错误); 错误信息打 `stderr`,携带服务端返回的错误码(`ESV*` 等),便于脚本分支处理。 @@ -91,7 +93,7 @@ meebox [全局 flag] <组> <命令> [参数] - **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 > 本机 `~/.code-meeseeks/config.yaml` 自动发现。 -- **输出模式**:`text`(人)/ `json`(机,输出 API `data`)。 +- **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 - **二进制与压缩包命名**:`meebox-cli---.`(unix `.tar.gz`、windows `.zip`), 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 From d624f798b1b266d2823e007148ecfa3835e99879 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 09:16:07 +0800 Subject: [PATCH 09/84] =?UTF-8?q?ci:=20release=20=E7=9A=84=20GUI=20?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=20job=20=E7=94=B1=20build=20=E6=9B=B4?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20gui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与并列的 cli job 对称,明确 release 内 GUI / CLI 两条产物线的划分。release 保持单 workflow 双 job(按 tag 原子发版、发到同一 Release),不拆分——路径触发那套只适用于按变更跑的 CI。 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f484d18b..649f65d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ concurrency: cancel-in-progress: false jobs: - build: + gui: strategy: fail-fast: false matrix: From aa902dfa1fa005644444e0f4acf9dd01386c71cd Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 09:39:55 +0800 Subject: [PATCH 10/84] =?UTF-8?q?feat(cli):=20CLI=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=94=B9=E7=94=A8=20~/.code-meeseeks/cli.yam?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先读 /meebox/cli.yaml 会另起一个配置根,与产品单一数据目录约定 (~/.code-meeseeks/)不一致。改为 ~/.code-meeseeks/cli.yaml:与 GUI 的 config.yaml 同目录、 独立文件,隔离二者配置且单一可发现。抽 appHome() 复用同一 home 解析。 Co-Authored-By: Claude Opus 4.8 --- cli/README.md | 2 +- cli/internal/settings/settings.go | 23 +++++++++++++++++------ docs/arch/04-integration/02-cli.md | 6 +++--- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cli/README.md b/cli/README.md index 41e36244..b13bc7f5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -42,7 +42,7 @@ The CLI resolves the API base URL and bearer token in this order (highest first) 1. flags — `--api-url`, `--token` 2. env — `MEEBOX_API_URL`, `MEEBOX_TOKEN` -3. CLI config — `/meebox/cli.yaml` (`api_url`, `token`) +3. CLI config — `~/.code-meeseeks/cli.yaml` (`api_url`, `token`) 4. local auto-discovery — the app's `~/.code-meeseeks/config.yaml` `service` section (same machine, same user; zero-config) diff --git a/cli/internal/settings/settings.go b/cli/internal/settings/settings.go index efdc7134..6bb0fe49 100644 --- a/cli/internal/settings/settings.go +++ b/cli/internal/settings/settings.go @@ -94,12 +94,22 @@ type appConfig struct { // discoverFromAppConfig reads the app's main config at ~/.code-meeseeks/config.yaml // and, when the service listener is enabled with a token, derives settings from // it — giving same-machine, same-user integrations a zero-config experience. -func discoverFromAppConfig() (Settings, bool) { +// appHome returns the app's fixed data directory (~/.code-meeseeks), shared by the GUI +// and CLI. Both meebox configs live here (GUI: config.yaml, CLI: cli.yaml). +func appHome() (string, bool) { home, err := os.UserHomeDir() if err != nil { + return "", false + } + return filepath.Join(home, ".code-meeseeks"), true +} + +func discoverFromAppConfig() (Settings, bool) { + home, ok := appHome() + if !ok { return Settings{}, false } - data, err := os.ReadFile(filepath.Join(home, ".code-meeseeks", "config.yaml")) + data, err := os.ReadFile(filepath.Join(home, "config.yaml")) if err != nil { return Settings{}, false } @@ -132,13 +142,14 @@ type cliConfig struct { Token string `yaml:"token"` } -// loadCLIConfig reads the CLI config at /meebox/cli.yaml. +// loadCLIConfig reads the CLI config at ~/.code-meeseeks/cli.yaml — co-located with the +// GUI config but a separate file, isolating CLI settings from the GUI's config.yaml. func loadCLIConfig() (cliConfig, bool) { - dir, err := os.UserConfigDir() - if err != nil { + home, ok := appHome() + if !ok { return cliConfig{}, false } - data, err := os.ReadFile(filepath.Join(dir, "meebox", "cli.yaml")) + data, err := os.ReadFile(filepath.Join(home, "cli.yaml")) if err != nil { return cliConfig{}, false } diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 8749be33..475bdcfe 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -43,7 +43,7 @@ CLI 需 API base URL + token。来源优先级(高 → 低): 1. 命令行 flag:`--api-url` / `--token`; 2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; -3. CLI 自身配置文件(如 `~/.config/meebox/cli.yaml`); +3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置); 4. **本机自动发现**:同机同用户时,读用户主目录下的应用主配置 `~/.code-meeseeks/config.yaml` 的 `service` 段,自动取 `host`/`port`/`token`——本机集成**零配置**开箱即用。 @@ -91,8 +91,8 @@ meebox [全局 flag] <组> <命令> [参数] ## 数据 / 接口契约 -- **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 > 本机 - `~/.code-meeseeks/config.yaml` 自动发现。 +- **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 + (`~/.code-meeseeks/cli.yaml`)> 本机 `~/.code-meeseeks/config.yaml` 自动发现。 - **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 - **二进制与压缩包命名**:`meebox-cli---.`(unix `.tar.gz`、windows `.zip`), From 1e458452d8b47df49702a3c61f5a44af200af8ed Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 09:40:11 +0800 Subject: [PATCH 11/84] =?UTF-8?q?docs(guide):=20=E6=96=B0=E5=A2=9E=20CLI?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 docs/guide/06-cli.md(面向用户):开启本地 API 服务 → 获取 CLI → 连接方式(含本机零配置 自动发现)→ 命令表 → 输出格式与退出码 → 安全须知;更新 guide README 索引,并与设计文档 02-cli 互链。 Co-Authored-By: Claude Opus 4.8 --- docs/arch/04-integration/02-cli.md | 2 + docs/guide/06-cli.md | 89 ++++++++++++++++++++++++++++++ docs/guide/README.md | 1 + 3 files changed, 92 insertions(+) create mode 100644 docs/guide/06-cli.md diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 475bdcfe..67215130 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -5,6 +5,8 @@ 提供一个**独立分发的跨平台命令行客户端**,经[本地 API](01-service-api.md) 消费应用能力,供外部 agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 +> 面向用户的使用说明见 [docs/guide/06-cli.md](../../guide/06-cli.md)。 + 负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 约定退出码。 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md new file mode 100644 index 00000000..25f94b74 --- /dev/null +++ b/docs/guide/06-cli.md @@ -0,0 +1,89 @@ +# CLI 命令行工具(meebox) + +`meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 +评审 Agent 操作接入脚本、CI 或外部 agent。命令行只做**浏览与评审操作**,不含评论发送等写操作。 + +> 面向开发者的接口 / 架构细节见 [../arch/04-integration/](../arch/04-integration/01-service-api.md)。 + +## 1. 开启本地 API 服务 + +CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 集成** 开启: + +- 打开「本地 API 服务」开关(首次开启会自动生成一枚访问令牌)。 +- **监听地址**:默认 `http://127.0.0.1:18765`(仅本机可达)。如需被同网段的其他机器 / CI 访问,可把 + host 改为 `0.0.0.0` 或本机局域网 IP——此时**令牌是唯一防线**,请妥善保密并配合防火墙。 +- **访问令牌**:可显示 / 复制 / 重新生成;重新生成后旧令牌立即失效。 + +## 2. 获取 CLI + +从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) 下载对应平台的压缩包 +(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 可执行文件放到 `PATH` 中。 + +覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。 + +## 3. 连接方式 + +`meebox` 按以下优先级解析 API 地址与令牌(高 → 低): + +1. 命令行参数:`--api-url` / `--token` +2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` +3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) +4. **本机自动发现**:同机同用户时,自动读应用配置 `~/.code-meeseeks/config.yaml` 的服务监听设置 + +因此**在开启服务的本机上零配置即可用**——直接运行命令,自动读取本机地址与令牌: + +```bash +meebox pr list +``` + +远端访问(服务监听 `0.0.0.0`)需显式提供地址与令牌: + +```bash +meebox --api-url http://<主机>:18765 --token <令牌> pr list +# 或经环境变量 +export MEEBOX_API_URL=http://<主机>:18765 +export MEEBOX_TOKEN=<令牌> +meebox pr list +``` + +## 4. 命令 + +```text +meebox [全局参数] <组> <命令> [参数] +``` + +| 命令 | 用途 | +| --- | --- | +| `meebox categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选) | +| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页),支持按分类与关键字过滤 | +| `meebox pr show ` | PR 描述详情 | +| `meebox pr diff [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | +| `meebox pr activity ` | 活动时间线(评论 / 提交 / 评审决断) | +| `meebox pr commits ` | 提交列表 | +| `meebox pr reviewers ` | 评审人审批状态 | +| `meebox agent status ` | 评审 Agent 当前执行状态 | +| `meebox agent history ` | 历史会话 | +| `meebox agent review ` | 执行一次自动评审 | +| `meebox agent instruct <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | +| `meebox agent chat <消息>` | 发送自然语言消息(可触发 Agent 任务) | + +其中 `` 为 PR 的本地标识,由 `meebox pr list` 输出获得。 + +## 5. 输出格式 + +全局参数 `--output`: + +- **`yaml`(默认)**:结构化又易读(类 kubectl `-o yaml`),适合人在终端查看。 +- **`json`**:适合脚本 / 外部 agent 机器解析。 + +```bash +meebox pr list --output json | jq '.[].title' +``` + +**退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 + +## 注意事项 + +- **只读取向**:CLI 不提供评论发送、审批、合并等写操作;有此需求请自行对接代码平台。 +- **令牌安全**:令牌明文存于 `~/.code-meeseeks/config.yaml`(同其他凭据);监听 `0.0.0.0` 暴露到局域网时尤需保密, + 并及时通过「重新生成」吊销泄露的令牌。 diff --git a/docs/guide/README.md b/docs/guide/README.md index 1b4afb27..445da964 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -14,6 +14,7 @@ Code Meeseeks 是本地运行的 PR 评审客户端:连上你的代码平台 | [03 · 网络代理配置](03-proxy.md) | 内网 / 受限网络下统一走 HTTP 代理出网 | | [04 · 配置文件参考](04-config-reference.md) | `config.yaml` 完整结构与各配置项功能说明(含高级参数) | | [05 · 自定义评审规则](05-rules.md) | 编写规则 `.md` 文件:frontmatter 命中条件 + 正文注入 AI 的评审指令 | +| [06 · CLI 命令行工具](06-cli.md) | 开启本地 API 服务 + 用 `meebox` 命令行浏览 PR / 操作评审 Agent(供脚本 / 外部 agent 集成) | ## 通用须知 From 9777cfed2a10ef37ce00d7d354be58e52282fc75 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 11:25:44 +0800 Subject: [PATCH 12/84] =?UTF-8?q?test:=20=E8=A1=A5=20CLI=E2=86=94=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E4=B8=8E=20shared?= =?UTF-8?q?=20PR=20=E8=BF=87=E6=BB=A4=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI(Go):httptest 契约 mock 起在进程内跑命令,断言各命令的 method/path/query/鉴权头/ 请求体、yaml/json 渲染、退出码映射(401/403→2、404→3),以及写工具客户端硬拒绝。 - render:可注入 Stdout/Stderr 输出 seam + ExitCodeFor / 输出格式单测。 - settings:加 isolateHome,隔离本机 ~/.code-meeseeks 使解析测试本地 / CI 都 hermetic。 - @meebox/shared:新增 test target + pr-filter 纯谓词单测(渲染层侧栏与 API 共用同一份语义)。 - 全部零外部依赖、不触第三方模型,本地与 GitHub 均可跑。 Co-Authored-By: Claude Opus 4.8 --- cli/cmd/integration_test.go | 238 ++++++++++++++++++++++++ cli/internal/render/render.go | 22 ++- cli/internal/render/render_test.go | 77 ++++++++ cli/internal/settings/settings_test.go | 13 ++ packages/shared/package.json | 4 +- packages/shared/tests/pr-filter.test.ts | 153 +++++++++++++++ 6 files changed, 499 insertions(+), 8 deletions(-) create mode 100644 cli/cmd/integration_test.go create mode 100644 cli/internal/render/render_test.go create mode 100644 packages/shared/tests/pr-filter.test.ts diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go new file mode 100644 index 00000000..e6104e18 --- /dev/null +++ b/cli/cmd/integration_test.go @@ -0,0 +1,238 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/huhamhire/code-meeseeks/cli/internal/render" +) + +// capturedReq records what the mock server received, for request-shape assertions. +type capturedReq struct { + called bool + method string + path string + query string + auth string + body string +} + +// mockServer stands in for the local API: it records the request and returns the +// documented envelope ({ok:true,data} on 2xx, {ok:false,error} on >=400). +func mockServer(rec *capturedReq, status int, dataJSON string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rec.called = true + rec.method = r.Method + rec.path = r.URL.Path + rec.query = r.URL.RawQuery + rec.auth = r.Header.Get("Authorization") + rec.body = string(body) + + w.Header().Set("Content-Type", "application/json") + code := status + if code == 0 { + code = http.StatusOK + } + w.WriteHeader(code) + if code >= 400 { + _, _ = io.WriteString(w, `{"ok":false,"error":{"code":"ESV0001"}}`) + return + } + _, _ = io.WriteString(w, `{"ok":true,"data":`+dataJSON+`}`) + })) +} + +// runCmd runs the root command with captured output, returning stdout + the error +// (Execute()'s os.Exit wrapper is bypassed so tests can assert on the error). +func runCmd(args ...string) (string, error) { + var buf bytes.Buffer + origOut, origErr := render.Stdout, render.Stderr + render.Stdout, render.Stderr = &buf, io.Discard + defer func() { render.Stdout, render.Stderr = origOut, origErr }() + + root := newRootCmd() + root.SetArgs(args) + err := root.Execute() + return buf.String(), err +} + +// base flags force explicit connection settings so tests are hermetic (no env / +// local config auto-discovery interference). +func base(srvURL string, rest ...string) []string { + return append([]string{"--api-url", srvURL, "--token", "tk"}, rest...) +} + +func TestCategories(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"platform":"github","primary":["review-requested"],"secondary":["all"]}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "categories")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/categories" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if rec.auth != "Bearer tk" { + t.Errorf("wrong auth header: %q", rec.auth) + } + if !strings.Contains(out, "platform: github") { + t.Errorf("output missing rendered field: %q", out) + } +} + +func TestPrListFilters(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `[]`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "list", "--primary", "created", "--secondary", "approved", "--query", "foo")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.path != "/api/v1/prs" { + t.Errorf("wrong path: %s", rec.path) + } + for _, want := range []string{"primary=created", "secondary=approved", "q=foo"} { + if !strings.Contains(rec.query, want) { + t.Errorf("query %q missing %q", rec.query, want) + } + } +} + +func TestPrShow(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"localId":"abc123","title":"t"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "show", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/prs/abc123" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestPrDiffFile(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"binary":false,"content":"x"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "diff", "abc123", "--file", "src/a.go", "--side", "head")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.path != "/api/v1/prs/abc123/diff" { + t.Errorf("wrong path: %s", rec.path) + } + for _, want := range []string{"path=src", "side=head"} { + if !strings.Contains(rec.query, want) { + t.Errorf("query %q missing %q", rec.query, want) + } + } +} + +func TestAgentReviewPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"status":"succeeded"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "review", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/review" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestAgentInstructBody(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"status":"queued"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "describe", "extra", "ctx")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/instruct" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.body, "describe") || !strings.Contains(rec.body, "extra ctx") { + t.Errorf("body missing command/args: %q", rec.body) + } +} + +func TestAgentInstructWriteToolRejected(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `null`) + defer srv.Close() + + _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "approve")...) + if err == nil { + t.Fatal("expected write tool to be rejected") + } + if rec.called { + t.Error("server must not be called for a rejected write tool") + } +} + +func TestAgentChatPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"queued":true}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "agent", "chat", "abc123", "hello", "world")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/chat" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.body, "hello world") { + t.Errorf("body missing message: %q", rec.body) + } +} + +func TestAuthFailureExitCode(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 401, "") + defer srv.Close() + + _, err := runCmd(base(srv.URL, "categories")...) + if err == nil { + t.Fatal("expected auth error") + } + if got := render.ExitCodeFor(err); got != render.ExitAuth { + t.Errorf("auth failure exit code = %d, want %d", got, render.ExitAuth) + } +} + +func TestNotFoundExitCode(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 404, "") + defer srv.Close() + + _, err := runCmd(base(srv.URL, "pr", "show", "missing")...) + if err == nil { + t.Fatal("expected not-found error") + } + if got := render.ExitCodeFor(err); got != render.ExitNotFound { + t.Errorf("not-found exit code = %d, want %d", got, render.ExitNotFound) + } +} + +func TestOutputJSON(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"platform":"github"}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "--output", "json", "categories")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, `"platform": "github"`) { + t.Errorf("json output not indented JSON: %q", out) + } +} diff --git a/cli/internal/render/render.go b/cli/internal/render/render.go index 2f91d947..8f529bfc 100644 --- a/cli/internal/render/render.go +++ b/cli/internal/render/render.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" @@ -13,6 +14,13 @@ import ( "gopkg.in/yaml.v3" ) +// Stdout / Stderr are the sinks for rendered output and errors. They default to +// the process streams and are overridable in tests to capture output. +var ( + Stdout io.Writer = os.Stdout + Stderr io.Writer = os.Stderr +) + // Mode selects the output format. type Mode int @@ -46,42 +54,42 @@ func Output(mode Mode, data json.RawMessage) error { func writeJSON(data json.RawMessage) error { if len(data) == 0 { - fmt.Println("null") + fmt.Fprintln(Stdout, "null") return nil } var v any if err := json.Unmarshal(data, &v); err != nil { // Valid JSON we can't re-decode into `any` is unlikely; print verbatim. - fmt.Println(string(data)) + fmt.Fprintln(Stdout, string(data)) return nil } - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(Stdout) enc.SetIndent("", " ") return enc.Encode(v) } func writeYAML(data json.RawMessage) error { if len(data) == 0 { - fmt.Println("null") + fmt.Fprintln(Stdout, "null") return nil } var v any if err := json.Unmarshal(data, &v); err != nil { // Not decodable as JSON — fall back to the raw payload. - fmt.Println(string(data)) + fmt.Fprintln(Stdout, string(data)) return nil } out, err := yaml.Marshal(v) if err != nil { return err } - _, err = os.Stdout.Write(out) + _, err = Stdout.Write(out) return err } // Errorln prints an error to stderr. func Errorln(err error) { - fmt.Fprintln(os.Stderr, "error:", err) + fmt.Fprintln(Stderr, "error:", err) } // ExitCodeFor maps an error to a process exit code. diff --git a/cli/internal/render/render_test.go b/cli/internal/render/render_test.go new file mode 100644 index 00000000..860a88e5 --- /dev/null +++ b/cli/internal/render/render_test.go @@ -0,0 +1,77 @@ +package render + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" +) + +func TestExitCodeFor(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"nil", nil, ExitOK}, + {"no token", settings.ErrNoToken, ExitAuth}, + {"401 unauthorized", &apiclient.APIError{Status: 401}, ExitAuth}, + {"403 forbidden", &apiclient.APIError{Status: 403}, ExitAuth}, + {"404 not found", &apiclient.APIError{Status: 404}, ExitNotFound}, + {"500 server", &apiclient.APIError{Status: 500}, ExitGeneric}, + {"generic", errors.New("boom"), ExitGeneric}, + } + for _, c := range cases { + if got := ExitCodeFor(c.err); got != c.want { + t.Errorf("%s: got %d, want %d", c.name, got, c.want) + } + } +} + +func TestOutputYAMLAndJSON(t *testing.T) { + orig := Stdout + defer func() { Stdout = orig }() + var buf bytes.Buffer + Stdout = &buf + + data := json.RawMessage(`{"platform":"github","primary":["review-requested"]}`) + + // YAML (default, human-friendly) + buf.Reset() + if err := Output(ModeYAML, data); err != nil { + t.Fatalf("yaml output: %v", err) + } + if got := buf.String(); !strings.Contains(got, "platform: github") { + t.Errorf("yaml output missing key: %q", got) + } + + // JSON (machine contract) — must be valid, re-parseable JSON + buf.Reset() + if err := Output(ModeJSON, data); err != nil { + t.Fatalf("json output: %v", err) + } + var v map[string]any + if err := json.Unmarshal(buf.Bytes(), &v); err != nil { + t.Fatalf("json output not valid JSON: %v (%q)", err, buf.String()) + } + if v["platform"] != "github" { + t.Errorf("json output wrong platform: %v", v["platform"]) + } +} + +func TestOutputEmptyData(t *testing.T) { + orig := Stdout + defer func() { Stdout = orig }() + var buf bytes.Buffer + Stdout = &buf + if err := Output(ModeYAML, nil); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(buf.String()) != "null" { + t.Errorf("empty data should print null, got %q", buf.String()) + } +} diff --git a/cli/internal/settings/settings_test.go b/cli/internal/settings/settings_test.go index e596a855..09c0d562 100644 --- a/cli/internal/settings/settings_test.go +++ b/cli/internal/settings/settings_test.go @@ -5,7 +5,18 @@ import ( "testing" ) +// isolateHome points HOME / USERPROFILE at an empty temp dir so os.UserHomeDir +// resolves there — keeping Resolve's local auto-discovery (~/.code-meeseeks/*) from +// reading the developer's real app config and making these tests non-hermetic. +func isolateHome(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) +} + func TestResolveFlagsWinOverEnv(t *testing.T) { + isolateHome(t) t.Setenv(EnvAPIURL, "http://env:1") t.Setenv(EnvToken, "env-token") @@ -19,6 +30,7 @@ func TestResolveFlagsWinOverEnv(t *testing.T) { } func TestResolveDefaultsURLWhenOnlyTokenGiven(t *testing.T) { + isolateHome(t) t.Setenv(EnvAPIURL, "") t.Setenv(EnvToken, "env-token") @@ -35,6 +47,7 @@ func TestResolveDefaultsURLWhenOnlyTokenGiven(t *testing.T) { } func TestResolveNoTokenErrors(t *testing.T) { + isolateHome(t) t.Setenv(EnvAPIURL, "http://x:1") t.Setenv(EnvToken, "") diff --git a/packages/shared/package.json b/packages/shared/package.json index bd68cf72..67a90044 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,11 +11,13 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint src --max-warnings=0" + "test": "vitest run", + "lint": "eslint src tests --max-warnings=0" }, "nx": { "targets": { "typecheck": { "cache": true }, + "test": { "cache": true }, "lint": { "cache": true } } } diff --git a/packages/shared/tests/pr-filter.test.ts b/packages/shared/tests/pr-filter.test.ts new file mode 100644 index 00000000..9bf461ce --- /dev/null +++ b/packages/shared/tests/pr-filter.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredPullRequest } from '../src/poller-contract.js'; +import { + PR_SECONDARY_FILTERS, + filterPullRequests, + matchesDiscoveryFilter, + matchesPrQuery, + matchesSecondaryFilter, +} from '../src/pr-filter.js'; + +/** 最小 StoredPullRequest 构造:只填谓词用到的字段,其余以 double-cast 略过。 */ +function mkPr(over: Partial): StoredPullRequest { + return { + title: 'Fix login bug', + repo: { projectKey: 'PROJ', repoSlug: 'web-app' }, + author: { displayName: 'Alice Zhang', name: 'alice' }, + remoteId: '42', + localStatus: 'pending', + hasConflict: false, + mergeStatus: { canMerge: false, conflicted: false, vetoes: [] }, + discoveryFilters: ['review-requested'], + ...over, + } as unknown as StoredPullRequest; +} + +describe('matchesDiscoveryFilter', () => { + it('无一级 = 不限定,恒真', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: [] }), undefined)).toBe(true); + }); + it('命中 discoveryFilters 为真', () => { + expect( + matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested', 'created'] }), 'created'), + ).toBe(true); + }); + it('未命中为假', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: ['review-requested'] }), 'assigned')).toBe( + false, + ); + }); + it('PR 无 discoveryFilters 且指定了一级 → 假', () => { + expect(matchesDiscoveryFilter(mkPr({ discoveryFilters: undefined }), 'review-requested')).toBe( + false, + ); + }); +}); + +describe('matchesSecondaryFilter', () => { + it("'all' 恒真", () => { + expect(matchesSecondaryFilter(mkPr({ localStatus: 'needs_work' }), 'all')).toBe(true); + }); + it('按 localStatus 匹配', () => { + expect(matchesSecondaryFilter(mkPr({ localStatus: 'approved' }), 'approved')).toBe(true); + expect(matchesSecondaryFilter(mkPr({ localStatus: 'pending' }), 'approved')).toBe(false); + }); + it("'conflict' 看 hasConflict", () => { + expect(matchesSecondaryFilter(mkPr({ hasConflict: true }), 'conflict')).toBe(true); + expect(matchesSecondaryFilter(mkPr({ hasConflict: false }), 'conflict')).toBe(false); + }); + it("'mergeable' 看 mergeStatus.canMerge", () => { + expect( + matchesSecondaryFilter( + mkPr({ mergeStatus: { canMerge: true, conflicted: false, vetoes: [] } }), + 'mergeable', + ), + ).toBe(true); + expect( + matchesSecondaryFilter( + mkPr({ mergeStatus: { canMerge: false, conflicted: false, vetoes: [] } }), + 'mergeable', + ), + ).toBe(false); + }); +}); + +describe('matchesPrQuery', () => { + const pr = mkPr({ + title: 'Fix login bug', + repo: { projectKey: 'PROJ', repoSlug: 'web-app' }, + author: { displayName: 'Alice Zhang', name: 'alice' } as StoredPullRequest['author'], + remoteId: '42', + }); + it('空查询恒真', () => { + expect(matchesPrQuery(pr, '')).toBe(true); + expect(matchesPrQuery(pr, ' ')).toBe(true); + }); + it('大小写无关匹配标题 / 仓库 / 作者 / 编号', () => { + expect(matchesPrQuery(pr, 'LOGIN')).toBe(true); // 标题 + expect(matchesPrQuery(pr, 'web-app')).toBe(true); // repoSlug + expect(matchesPrQuery(pr, 'proj')).toBe(true); // projectKey + expect(matchesPrQuery(pr, 'alice')).toBe(true); // author.name + expect(matchesPrQuery(pr, 'Alice Zhang')).toBe(true); // author.displayName + expect(matchesPrQuery(pr, '42')).toBe(true); // remoteId + }); + it('未命中为假', () => { + expect(matchesPrQuery(pr, 'nonexistent')).toBe(false); + }); +}); + +describe('filterPullRequests', () => { + const prs = [ + mkPr({ + remoteId: '1', + title: 'alpha', + localStatus: 'pending', + discoveryFilters: ['review-requested'], + }), + mkPr({ + remoteId: '2', + title: 'beta', + localStatus: 'approved', + discoveryFilters: ['created'], + }), + mkPr({ + remoteId: '3', + title: 'gamma', + localStatus: 'approved', + discoveryFilters: ['review-requested'], + hasConflict: true, + }), + ]; + + it('空条件返回全部', () => { + expect(filterPullRequests(prs, {})).toHaveLength(3); + }); + it('一级过滤', () => { + const out = filterPullRequests(prs, { primary: 'review-requested' }); + expect(out.map((p) => p.remoteId)).toEqual(['1', '3']); + }); + it('一级 + 二级 AND', () => { + const out = filterPullRequests(prs, { primary: 'review-requested', secondary: 'approved' }); + expect(out.map((p) => p.remoteId)).toEqual(['3']); + }); + it('二级 + 检索 AND', () => { + const out = filterPullRequests(prs, { secondary: 'approved', query: 'beta' }); + expect(out.map((p) => p.remoteId)).toEqual(['2']); + }); + it('conflict 横切筛选', () => { + expect(filterPullRequests(prs, { secondary: 'conflict' }).map((p) => p.remoteId)).toEqual(['3']); + }); +}); + +describe('PR_SECONDARY_FILTERS', () => { + it('含全部二级筛选键', () => { + expect(PR_SECONDARY_FILTERS).toEqual([ + 'all', + 'pending', + 'approved', + 'needs_work', + 'conflict', + 'mergeable', + ]); + }); +}); From 7ff84d29ade2ea023bb04f59f0921172562ae24a Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 11:25:55 +0800 Subject: [PATCH 13/84] =?UTF-8?q?chore(cli):=20=E8=A1=A5=E5=85=A8=20Go=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补 Go 专有、根 .gitignore 未覆盖的忽略项:测试二进制(*.test)、覆盖率 / 剖析输出 (*.out / *.prof)、go workspace 文件(go.work / go.work.sum)。exe 与 dist/ 依赖根规则全局忽略。 Co-Authored-By: Claude Opus 4.8 --- cli/.gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli/.gitignore b/cli/.gitignore index e2ab7836..65330843 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -3,3 +3,12 @@ /dist/ /meebox /meebox.exe + +# Test binaries (go test -c), coverage / profiling output +*.test +*.out +*.prof + +# Go workspace files (local dev only) +go.work +go.work.sum From 9bafd791f69d16d6586ec3e66413498ece347973 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 11:25:56 +0800 Subject: [PATCH 14/84] =?UTF-8?q?ci:=20CLI=20=E7=9A=84=20macOS=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=BA=A7=E7=89=A9=E6=94=B9=E7=94=A8=20zip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 与 Windows 统一用 .zip(Finder 原生一步解压、贴合 Apple 工具链,Linux 仍用 .tar.gz)。 Linux runner 上 zip 保留 darwin 二进制的可执行位,解压即可运行。 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 649f65d9..22d96cd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,7 +149,7 @@ jobs: matrix: include: - { goos: windows, goarch: amd64, ext: '.exe', archive: zip } - - { goos: darwin, goarch: arm64, ext: '', archive: tar.gz } + - { goos: darwin, goarch: arm64, ext: '', archive: zip } - { goos: linux, goarch: amd64, ext: '', archive: tar.gz } - { goos: linux, goarch: arm64, ext: '', archive: tar.gz } steps: From dc41d7badc9a509e4169352b69caf597bc673d91 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 11:26:11 +0800 Subject: [PATCH 15/84] =?UTF-8?q?docs:=20=E8=A1=A5=20CLI=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=AF=B4=E6=98=8E=E4=B8=8E=20AGENTS=20CLI=20=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guide 06-cli:新增「网络代理」段(CLI 遵循标准 HTTP(S)_PROXY / NO_PROXY,loopback 直连)。 - arch 02-cli:补代理行为说明;发布产物命名更新为 Windows/macOS .zip、Linux .tar.gz。 - AGENTS.md:新增「CLI 工程(cli/)」段(独立 Go module / 本地命令 / 双 CI / 只读边界 / 契约同步); 仓库结构对齐(cli/ 树、services/api-server、shared 的 pr-filter、docs/guide); CHANGELOG 撰写风格与 i18n 要点由密集单行改为列表排版。 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 41 +++++++++++++++++++++++++----- docs/arch/04-integration/02-cli.md | 4 ++- docs/guide/06-cli.md | 7 +++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index db4339ea..d346e8e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,12 @@ ## 仓库结构 -Electron 桌面应用 + npm workspaces + Nx 单仓多包。关键路径(`apps/desktop` 是主战场): +Electron 桌面应用(npm workspaces + Nx 单仓多包)外加一个独立 Go CLI 子工程。关键路径(`apps/desktop` 是主战场): ``` apps/desktop/ ├── src/ -│ ├── main/ # 主进程:index.ts(启动/单例锁) · ipc.ts(IPC handlers) · adapters.ts · utils/ +│ ├── main/ # 主进程:index.ts(启动/单例锁) · ipc.ts(IPC handlers) · adapters.ts · services/(pr-service · agent 编排 · api-server 本地 API) · utils/ │ ├── preload/ # contextBridge 暴露泛型 invoke() │ └── renderer/src/ # React 渲染层(components/ 等) ├── scripts/ # assemble-pragent-runtime.mjs · pragent-shim/(shim) · pragent-runtime.json @@ -17,12 +17,18 @@ apps/desktop/ ├── electron-builder.yml └── vendor/pragent/ # 嵌入式运行时(gitignored,由 prepare:pragent 生成) -packages//src/index.ts # 各库入口;shared 还含 ipc.ts(IPC 契约) · config.ts · poller-contract.ts +packages//src/index.ts # 各库入口;shared 还含 ipc.ts(IPC 契约) · config.ts · poller-contract.ts · pr-filter.ts + +cli/ # 独立 Go module:跨平台 CLI `meebox`(经本地 API 集成,不入 npm/Nx) +├── cmd/ # 命令树(cobra):categories · pr · agent +├── internal/ # apiclient(HTTP+鉴权) · settings(连接解析) · render(yaml/json + 退出码) +└── go.mod ``` - `apps/desktop` —— Electron 应用(main + preload + renderer/React)。唯一有 `build`/`dist` 的项目。 - `packages/*` —— 内部库(`@meebox/*`),按职责拆分;其中 `shared` 含共享类型与 IPC 契约。 -- `docs/arch/` 各模块设计文档(首选入口);`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 +- `cli/` —— 独立分发的 Go 命令行工具 `meebox`(外部集成用,不属 npm/Nx;详见下「CLI 工程(cli/)」段)。 +- `docs/arch/` 各模块设计文档(首选入口);`docs/guide/` 使用说明;`docs/ROADMAP.md` 路线图;`tools/` 杂项脚本。 **命名约定**:代码内部统一用中性代号 `meebox`(npm 作用域 `@meebox/*`);对外品牌名 `Code Meeseeks`;用户数据目录 `~/.code-meeseeks/`。`pr-agent` 为第三方依赖,不在重命名范围内。 @@ -77,7 +83,26 @@ tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag( **版本号规则**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,并 `npm install` 同步 lockfile),标记开发态、与正式版区分。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面三步前置把它改成目标号(预发布 `0.7.0-alpha.N` 或正式 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range,故无「预发布不满足范围」陷阱)与构建。 -**CHANGELOG 撰写风格**(面向用户、求简):① 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**;② 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止;③ 重构类任务**前后端合并**为一条总结、不展开实现细节;④ 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响;⑤ 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良;⑥ **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险;⑦ **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security)。外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 +**CHANGELOG 撰写风格**(面向用户、求简): + +- 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**; +- 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止; +- 重构类任务**前后端合并**为一条总结、不展开实现细节; +- 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响; +- 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良; +- **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险; +- **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security); +- 外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 + +## CLI 工程(cli/) + +`cli/` 是独立分发的跨平台命令行客户端 `meebox`(供外部 agent / 脚本经[本地 API 服务](docs/arch/04-integration/01-service-api.md)集成)。设计见 [docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md),用法见 [docs/guide/06-cli.md](docs/guide/06-cli.md)。 + +- **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 +- **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 +- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 +- **只读边界**:CLI 只做浏览与评审操作,**不提供评论发送等写操作**;写工具(approve/needswork/publish)在 CLI 与服务端双重硬拒绝。新增命令先确认对应 API 端点已存在且只读——CLI 不得绕过 API 直连应用内部。 +- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向),`--output json` 供机器;配置走环境变量 / flag / `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 隔离),代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 ## 约定 @@ -96,7 +121,11 @@ tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag( ## 国际化 (i18n) -GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**,无源/译层级;UI 语言由 `config.language` 经 `resolveLanguage` 决定,空则按 OS 回落英语)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文):渲染层 en-US 静态打包进入口 + 其余懒加载、`fallbackLng: 'en-US'`,主进程各持一份 locale、同样兜底 en-US。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩的:①新增文本须在**各语言 locale 都加**并保持**递归字典序**(日语复数同中文仅 `_other`、德语同英语需 `_one`/`_other`);②i18next **只有 `count`** 触发复数,普通计数插值要换别的变量名;③**不要开 `nonExplicitSupportedLngs`**——它把 `zh-CN` 按基码 `zh` 查找、与按 `zh-CN` 注册的 bundle 错位 → 整页裸 key。 +GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**,无源/译层级;UI 语言由 `config.language` 经 `resolveLanguage` 决定,空则按 OS 回落英语)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文):渲染层 en-US 静态打包进入口 + 其余懒加载、`fallbackLng: 'en-US'`,主进程各持一份 locale、同样兜底 en-US。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩的: + +1. 新增文本须在**各语言 locale 都加**并保持**递归字典序**(日语复数同中文仅 `_other`、德语同英语需 `_one`/`_other`); +2. i18next **只有 `count`** 触发复数,普通计数插值要换别的变量名; +3. **不要开 `nonExplicitSupportedLngs`**——它把 `zh-CN` 按基码 `zh` 查找、与按 `zh-CN` 注册的 bundle 错位 → 整页裸 key。 ## 文档约定 diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 67215130..1d58b33e 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -97,7 +97,7 @@ meebox [全局 flag] <组> <命令> [参数] (`~/.code-meeseeks/cli.yaml`)> 本机 `~/.code-meeseeks/config.yaml` 自动发现。 - **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 -- **二进制与压缩包命名**:`meebox-cli---.`(unix `.tar.gz`、windows `.zip`), +- **二进制与压缩包命名**:`meebox-cli---.`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 ## 分发与 CI @@ -115,3 +115,5 @@ meebox [全局 flag] <组> <命令> [参数] - **本机自动发现的边界**:仅同机同用户可读主目录下的 `~/.code-meeseeks/config.yaml`;远端 / 跨用户必须显式配 URL + token。 - **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 - **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 +- **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` / + `NO_PROXY`;loopback(`127.0.0.1` / `localhost`)默认直连不走代理——无需自实现代理逻辑。 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 25f94b74..71da6394 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -82,6 +82,13 @@ meebox pr list --output json | jq '.[].title' **退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 +## 网络代理 + +`meebox` 遵循标准的 HTTP 代理环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,大小写均可),无需额外配置: + +- 访问**本机**服务(`127.0.0.1` / `localhost`)自动直连、不走代理。 +- 访问**远端**服务(如经 `0.0.0.0` 暴露的机器)时,若设了 `HTTP_PROXY` 则经其出网;可用 `NO_PROXY` 排除特定主机。 + ## 注意事项 - **只读取向**:CLI 不提供评论发送、审批、合并等写操作;有此需求请自行对接代码平台。 From 62b05f07f50d3fd255126c54ffe14b3368f2b984 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 11:40:11 +0800 Subject: [PATCH 16/84] =?UTF-8?q?docs:=20=E7=98=A6=E8=BA=AB=20AGENTS.md?= =?UTF-8?q?=EF=BC=8C=E5=8F=91=E5=B8=83/CHANGELOG=20=E7=BB=86=E8=8A=82?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=20packaging-release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md 每会话整篇进上下文,精简其固定开销: - 发布前置清单(版本号/CHANGELOG/校对)+ `-dev` 版本号规则 + CHANGELOG 撰写风格下沉到 docs/development/packaging-release.md;AGENTS 只留 ⚠️ 前置警示 + 指针。 - pr-agent 运行时 / shim 深坑收成一条指针(机制与铁律已全在 02-agent/05)。 - 仓库结构树改用途导向(去具体文件名,各目录留一句职责);i18n 三条、内部包两步登记改列表排版。 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 66 +++++++++------------------ docs/development/packaging-release.md | 23 ++++++++++ 2 files changed, 44 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d346e8e6..37d6b1e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,22 +7,17 @@ Electron 桌面应用(npm workspaces + Nx 单仓多包)外加一个独立 Go CLI 子工程。关键路径(`apps/desktop` 是主战场): ``` -apps/desktop/ +apps/desktop/ # Electron 应用(唯一有 build/dist 的项目) ├── src/ -│ ├── main/ # 主进程:index.ts(启动/单例锁) · ipc.ts(IPC handlers) · adapters.ts · services/(pr-service · agent 编排 · api-server 本地 API) · utils/ +│ ├── main/ # 主进程:业务与 IO 唯一所在(启动/单例锁 · IPC handlers · 平台适配 · 服务层:pr · agent 编排 · 本地 API) │ ├── preload/ # contextBridge 暴露泛型 invoke() -│ └── renderer/src/ # React 渲染层(components/ 等) -├── scripts/ # assemble-pragent-runtime.mjs · pragent-shim/(shim) · pragent-runtime.json -├── build-resources/ # after-pack.cjs(ad-hoc 签名) · entitlements.mac.plist -├── electron-builder.yml -└── vendor/pragent/ # 嵌入式运行时(gitignored,由 prepare:pragent 生成) - -packages//src/index.ts # 各库入口;shared 还含 ipc.ts(IPC 契约) · config.ts · poller-contract.ts · pr-filter.ts - -cli/ # 独立 Go module:跨平台 CLI `meebox`(经本地 API 集成,不入 npm/Nx) -├── cmd/ # 命令树(cobra):categories · pr · agent -├── internal/ # apiclient(HTTP+鉴权) · settings(连接解析) · render(yaml/json + 退出码) -└── go.mod +│ └── renderer/src/ # React 渲染层(UI / 交互) +├── scripts/ # 嵌入式 pr-agent 运行时组装 + monkeypatch shim +├── build-resources/ # electron-builder 打包资源(签名钩子 · entitlements) +└── vendor/pragent/ # 嵌入式运行时(gitignored,prepare:pragent 生成) + +packages// # 内部库 @meebox/*(各 src/index.ts 为入口);shared 含共享类型 + IPC 契约 +cli/ # 独立 Go module:跨平台 CLI meebox(命令树 + HTTP client;经本地 API 集成,不入 npm/Nx) ``` - `apps/desktop` —— Electron 应用(main + preload + renderer/React)。唯一有 `build`/`dist` 的项目。 @@ -73,26 +68,9 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 ## 发布流程 -发版从 `dev` 汇入 `master` 后,在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + GitHub Release)。**打 tag 前必须在同一批改动里完成三步前置,且随发版改动一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: - -1. **版本号** —— 把 [apps/desktop/package.json](apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀,如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直接取此值——不改则安装包文件名缺 `-alpha.N`、与 tag 不符。改完跑一次 `npm install` 同步 lockfile。 -2. **CHANGELOG** —— 把 [CHANGELOG.md](CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,并在文件底部补 `[<版本>]: …/compare/…` 链接引用(仿现有行)。**发布即移除 Unreleased、不再另起空段**——`## [Unreleased]` 仅**开发期**存在以累积变更,发布时被改名消费掉;下一笔开发期 changelog 改动时再新建一个 `## [Unreleased]`(见「版本号规则」的 `-dev` 开发态)。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文的「版本变更」区——缺这段则正文回退、无任何变更说明。**若本次是正式版(无 `-` 后缀)、且其内容来自此前的 alpha/预发布**:此时开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应的 `[-alpha.N]:` 链接引用即可(内容并入正式版段,不再保留空壳 stub);尚无对应正式版的其它预发布段保留。 -3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 - -tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag(名含 `-`,如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 +发版:`dev` 汇入 `master` → 在 `master` 打 `v*` tag 触发 [release.yml](.github/workflows/release.yml)(出 Windows / macOS 安装包 + CLI 二进制 + GitHub Release)。 -**版本号规则**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,并 `npm install` 同步 lockfile),标记开发态、与正式版区分。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面三步前置把它改成目标号(预发布 `0.7.0-alpha.N` 或正式 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range,故无「预发布不满足范围」陷阱)与构建。 - -**CHANGELOG 撰写风格**(面向用户、求简): - -- 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**; -- 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止; -- 重构类任务**前后端合并**为一条总结、不展开实现细节; -- 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响; -- 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良; -- **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险; -- **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security); -- 外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 +⚠️ **打 tag 前必须在同一批改动里完成三步前置(版本号 / CHANGELOG / 校对),随发版一并经 `dev` → `master`**——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release。**完整前置清单、`-dev` 版本号规则、CHANGELOG 撰写风格见 [打包与发布](docs/development/packaging-release.md)**。tag 名须等于 package.json 版本(`v<版本>`);名含 `-` 的预发布 tag 自动标 prerelease、不抢占 Latest。 ## CLI 工程(cli/) @@ -121,11 +99,11 @@ tag 名与 package.json 版本必须一致(`v<版本>`)。预发布 tag( ## 国际化 (i18n) -GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**,无源/译层级;UI 语言由 `config.language` 经 `resolveLanguage` 决定,空则按 OS 回落英语)。**默认 / 兜底语言取 `en-US`**(国际化标准:缺 key 回退英文而非中文):渲染层 en-US 静态打包进入口 + 其余懒加载、`fallbackLng: 'en-US'`,主进程各持一份 locale、同样兜底 en-US。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩的: +GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / `ja-JP` / `de-DE` 为**对等译文集**;**默认 / 兜底 `en-US`**,缺 key 回退英文)。设计、key 命名、翻译规范见 [docs/arch/03-gui/04-i18n](docs/arch/03-gui/04-i18n.md)。三条易踩(详见该篇): -1. 新增文本须在**各语言 locale 都加**并保持**递归字典序**(日语复数同中文仅 `_other`、德语同英语需 `_one`/`_other`); -2. i18next **只有 `count`** 触发复数,普通计数插值要换别的变量名; -3. **不要开 `nonExplicitSupportedLngs`**——它把 `zh-CN` 按基码 `zh` 查找、与按 `zh-CN` 注册的 bundle 错位 → 整页裸 key。 +1. 新增文本各语言 locale 都加且保持**递归字典序**; +2. 复数只认 `count`(普通计数插值换别名); +3. 勿开 `nonExplicitSupportedLngs`(按基码 `zh` 错位 → 整页裸 key)。 ## 文档约定 @@ -136,14 +114,12 @@ GUI 文本走 **react-i18next**(key 为中立标识符,`zh-CN` / `en-US` / ` ## 工程维护坑 -- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外,**必须**:① 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`;② 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。漏 ② 时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 -- **嵌入式 pr-agent 运行时**(见 [docs/arch/02-agent/03-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)):`apps/desktop/scripts/assemble-pragent-runtime.mjs` 按 `pragent-runtime.json` 把可重定位 CPython + pinned pr-agent 装到 `apps/desktop/vendor/pragent/`(gitignored)。 -- **monkeypatch shim** `apps/desktop/scripts/pragent-shim/`(薄加载器 `sitecustomize.py` + 领域拆分包 `meebox_pragent_shim/`:`patches/` 各 patch + `cli/` 本地 CLI provider + `runtime.py`/`usage.py`。对 pr-agent 的无侵入补丁): - - 改了它,跑一次 `npm --prefix apps/desktop run prepare:pragent` 即重新同步进 vendor(幂等跳过分支也会同步 shim),**无需 `--force` 全量重建**。 - - 受版本守卫:`meebox_pragent_shim/runtime.py` 的 `_EXPECTED_PRAGENT_VERSION` 必须等于 `pragent-runtime.json` 的 `prAgent.version`(assemble 构建期强校验,运行期不符则跳过补丁 + stderr WARNING)。升级 pr-agent 要同步两处并重新验证。 - - **拆分铁律**:各 patch 对 `pr_agent` 的 import 一律放在 patch 函数体内(惰性);模块顶层只 import 同包内的 runtime/usage 等,**绝不在顶层 import pr_agent**(否则 sitecustomize 阶段即 eager 加载,拖慢每次 python 启动)。 - - 调试:`MEEBOX_SHIM_DEBUG=1` 让 shim 打 stderr 调试。 +- **新增内部 `@meebox/*` 包必做两步登记**(漏则报 `Cannot find module …/src/.js`):内部包源码是 `.ts`、相对 import 带 `.js` 扩展(NodeNext 约定),Node 运行期不能直接读。新建一个被 desktop 主/preload 引用的内部包后,除 `npm install`(建 workspace 软链)外**必须**: + 1. 在 `apps/desktop/package.json` 依赖加 `"@meebox/": "*"`; + 2. 在 [apps/desktop/electron.vite.config.ts](apps/desktop/electron.vite.config.ts) 的 `internalPackages` 数组加该名——让 electron-vite 把它 **bundle**(转译 TS、解析 `.js`→`.ts`)而非 externalize。 + + 漏第 2 步时 Node 把它当外部包按 `main: src/index.ts` 加载,撞到 `export … from './x.js'` 而文件是 `.ts` → 运行期崩。 +- **pr-agent 运行时 / shim**:嵌入式 CPython + pinned pr-agent 由 `assemble-pragent-runtime.mjs` 装到 `vendor/pragent`(gitignored);对 pr-agent 的无侵入补丁在 `scripts/pragent-shim/`。机制与铁律(惰性 import 拆分 · 版本守卫 · 改后跑 `prepare:pragent` 同步 · 调试 `MEEBOX_SHIM_DEBUG=1`)见 [02-agent/05-pragent-runtime](docs/arch/02-agent/05-pragent-runtime.md)。弱网 pip 超时加 `PIP_DEFAULT_TIMEOUT=120`。 - **二进制资源走 Git LFS**(`*.png/.ico/.icns` 等):本地没装 git-lfs 时拿到的是指针文件,electron-builder 转图标会崩 → `brew install git-lfs && git lfs pull`。 - **dev 起不来**:若 `npm run dev` 报 `electron does not provide an export named …`,是环境里有 `ELECTRON_RUN_AS_NODE=1`(VSCode 扩展宿主会注入)→ `unset ELECTRON_RUN_AS_NODE` 再跑。 - **grep 个别文件无输出**:如 `repo-mirror-manager.ts` 被 `file` 判为 `data`(含非 UTF-8 字节),普通 grep 静默 → 用 `grep -a`。 -- **prepare:pragent 网络**:pip 默认 15s 超时,弱网下加 `PIP_DEFAULT_TIMEOUT=120` 或配国内镜像(`~/.config/pip/pip.conf`)。 diff --git a/docs/development/packaging-release.md b/docs/development/packaging-release.md index 9a8b64bd..092ce693 100644 --- a/docs/development/packaging-release.md +++ b/docs/development/packaging-release.md @@ -42,3 +42,26 @@ 或提供 Homebrew Cask。 - **升级公证**:见上;同时 mac 段需加回 hardenedRuntime + entitlements + notarize(entitlements 已备好 `disable-library-validation` 让嵌入式 python 在 hardened runtime 下能加载第三方 dylib)。 + +## 发布前置清单(打 tag 前必做) + +在**同一批改动**里完成,随发版经 `dev` → `master`——漏任一步 CI 不报错(仅 `::warning::`)但会产出错误的 Release: + +1. **版本号** —— 把 [apps/desktop/package.json](../../apps/desktop/package.json) 的 `version` 改成目标版本(去 `v` 前缀,预发布带后缀如 `0.5.0-alpha.1`)。electron-builder 的 `artifactName: code-meeseeks-${version}-...` 直取此值——不改则安装包文件名与 tag 不符。改完 `npm install` 同步 lockfile。 +2. **CHANGELOG** —— 把 [CHANGELOG.md](../../CHANGELOG.md) 的 `## [Unreleased]` 改名为 `## [<版本>] - `,并在文件底部补 `[<版本>]: …/compare/…` 链接引用。**发布即消费掉 Unreleased、不留空段**;下一笔开发期 changelog 改动时再新建。release.yml 按 `## [<版本>]` 字面抽段注入 Release 正文——缺段则正文回退、无变更说明。**若正式版内容来自此前的 alpha/预发布**:开发期通常无独立 Unreleased(内容已在预发布段),直接把该预发布段改名为正式版段、删去对应 `[-alpha.N]:` 链接引用(内容并入正式版段,不留空壳 stub);尚无对应正式版的其它预发布段保留。 +3. **校对** —— 确认 `## [<版本>]` 段已覆盖自上版本以来合入 `dev` 的全部要点(新增 / 变更 / 修复)。 + +tag 名与 package.json 版本必须一致(`v<版本>`)。名含 `-` 的预发布 tag(如 `-alpha.N`)由 release.yml 自动标 prerelease 且不抢占 Latest。 + +**版本号规则(`-dev`)**:每次正式发版后,`dev` 立即把 [apps/desktop/package.json](../../apps/desktop/package.json) 切到**下一版的 `-dev` 预发布号**(如发完 `0.6.0` 即切 `0.7.0-dev`,`npm install` 同步 lockfile),标记开发态。`-dev` 仅作开发标记——**不打 tag、不发版**;发版时按上面改成目标号(`0.7.0-alpha.N` 或 `0.7.0`)。`-dev` 是合法 semver(`0.6.0` < `0.7.0-dev` < `0.7.0`),不影响更新检测([update-check.ts](../../apps/desktop/src/main/utils/update-check.ts) 用 `semver.gt` 比对、不用 range)与构建。 + +## CHANGELOG 撰写风格(面向用户、求简) + +- 版本引言 `>` 区直接进入「本版重点」、要点用**无序列表**排版,不堆成长句,**不写「首个 / 第 N 个正式版」之类的版本序数引言**; +- 新增 按**功能场景**分类、用缩进的二级列表表达,每个小点一句话点到即止; +- 重构类任务**前后端合并**为一条总结、不展开实现细节; +- 修复 **不写「怎么修的」机制**,每条一句话只述修复的现象/影响; +- 通篇不写 IPC 通道名、函数名、文件路径、字段名等实现细节,优先突出新增特性与改良; +- **安装 / 升级注意事项**(版本引言里的 ⚠️ 警示,如先卸载旧版、per-machine 提权等)属安全关键信息,**保留完整、不参与精简**——这些会随 release.yml 注入 GitHub Release 正文,删减会让用户漏看升级风险; +- **分段标题用中文 + emoji**:`### ✨ 新增 / ♻️ 变更 / 🔧 修复 / 🗑️ 移除 / 🔒 安全`(对应 Keep a Changelog 的 Added / Changed / Deprecated / Removed / Fixed / Security); +- 外部贡献者的 PR 习惯性致谢(仿 `(#65,感谢 @user)`)。 From 8231fd8d85010d44e59f7454ad4b9e43c00b7b8d Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 13:48:03 +0800 Subject: [PATCH 17/84] =?UTF-8?q?fix(desktop):=20=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=BC=B9=E5=B1=82=20i18n=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=20+=20=E7=9B=B8=E5=AF=B9=E8=B7=AF=E5=BE=84=20+=20?= =?UTF-8?q?=E6=89=93=E5=BC=80=E7=9B=AE=E5=BD=95=E6=8C=89=E9=92=AE=E5=85=A5?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - priority / tools 标签补 i18n(优先级 / 工具,四语)。 - 文件路径由机器绝对路径改为相对 Agent 目录展示(`rules/`)。 - 「打开 Agent 目录」按钮从每条规则行移到标题栏、全局仅一个(通用 Modal 新增可复用 headerActions 槽;.modal-header-right 用 align-items:stretch 让图标按钮与关闭键等高)。 - 单条规则标题去掉 id(改「规则详情」),不再与下方路径重复。 Co-Authored-By: Claude Opus 4.8 --- .../renderer/src/components/common/Modal.tsx | 38 +++++++++++-------- .../chat/components/RulePreviewModal.tsx | 25 +++++++++--- .../src/renderer/src/i18n/locales/de-DE.json | 5 ++- .../src/renderer/src/i18n/locales/en-US.json | 5 ++- .../src/renderer/src/i18n/locales/ja-JP.json | 5 ++- .../src/renderer/src/i18n/locales/zh-CN.json | 5 ++- .../src/renderer/src/styles/common/modal.scss | 8 ++++ 7 files changed, 67 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/common/Modal.tsx b/apps/desktop/src/renderer/src/components/common/Modal.tsx index a09ce85d..5819e5ff 100644 --- a/apps/desktop/src/renderer/src/components/common/Modal.tsx +++ b/apps/desktop/src/renderer/src/components/common/Modal.tsx @@ -28,6 +28,8 @@ interface ModalProps { titleId?: string; /** header 右侧关闭按钮样式:图标按钮 / 文案按钮(不传则无) */ headerClose?: 'icon' | 'text'; + /** header 右侧、关闭键左侧的自定义动作(如「打开目录」按钮);与关闭键同组右对齐。 */ + headerActions?: ReactNode; /** modal-body 追加类名(如 confirm-body) */ bodyClassName?: string; /** 底部 footer 区内容(作为 modal-body 的兄弟渲染);不传则无 footer 区 */ @@ -54,6 +56,7 @@ export function Modal({ title, titleId, headerClose, + headerActions, bodyClassName, footer, footerClassName = 'modal-footer-bar', @@ -86,21 +89,26 @@ export function Modal({ {title !== undefined && (

{title}

- {headerClose === 'icon' && ( - - )} - {headerClose === 'text' && ( - + {(headerActions !== undefined || headerClose) && ( +
+ {headerActions} + {headerClose === 'icon' && ( + + )} + {headerClose === 'text' && ( + + )} +
)}
)} diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx index 75337498..aa1d63c8 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/RulePreviewModal.tsx @@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkBreaks from 'remark-breaks'; import remarkGfm from 'remark-gfm'; -import { Modal, mermaidComponents } from '../../../common'; +import { FolderIcon, Modal, mermaidComponents } from '../../../common'; import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown'; +import { invoke } from '../../../../api'; import type { MatchedRules } from '../types'; /** @@ -27,9 +28,20 @@ export function RulePreviewModal({ title={ multi ? t('chatPane.rulePreviewTitleMulti', { n: rules.length }) - : t('chatPane.rulePreviewTitle', { id: rules[0]?.id ?? '' }) + : t('chatPane.rulePreviewTitle') } headerClose="text" + headerActions={ + + } ariaLabel={t('chatPane.rulePreviewAria')} > {rules.map((rule, i) => ( @@ -41,10 +53,13 @@ export function RulePreviewModal({ )}
{t('chatPane.ruleFilePath')}
-
{rule.filePath}
-
priority
+ {/* 相对 Agent 目录展示(`rules/`),避免暴露冗长的机器绝对路径;打开目录按钮在标题栏统一提供。 */} +
+ rules/{rule.id} +
+
{t('chatPane.rulePriority')}
{rule.priority}
-
tools
+
{t('chatPane.ruleTools')}
{rule.tools.join(', ')}
diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index bf7f2adb..8607f7c8 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -138,9 +138,12 @@ "ruleChipLabel": "Regel", "ruleChipTitle": "Klicken, um den Regelinhalt anzuzeigen", "ruleFilePath": "Dateipfad", + "ruleOpenAgentDir": "Agent-Verzeichnis öffnen", "rulePreviewAria": "Regelvorschau", - "rulePreviewTitle": "Regel: {{id}}", + "rulePreviewTitle": "Regeldetails", "rulePreviewTitleMulti": "Übereinstimmende Regeln ({{n}})", + "rulePriority": "Priorität", + "ruleTools": "Tools", "runCancelled": "Abgebrochen", "runFailed": "Ausführung fehlgeschlagen", "runFailedReason": "Ausführung fehlgeschlagen ({{reason}})", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 3b600cae..4738483d 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -138,9 +138,12 @@ "ruleChipLabel": "Rule", "ruleChipTitle": "Click to view rule content", "ruleFilePath": "File path", + "ruleOpenAgentDir": "Open agent directory", "rulePreviewAria": "Rule preview", - "rulePreviewTitle": "Rule: {{id}}", + "rulePreviewTitle": "Rule details", "rulePreviewTitleMulti": "Matched rules ({{n}})", + "rulePriority": "Priority", + "ruleTools": "Tools", "runCancelled": "Cancelled", "runFailed": "Run failed", "runFailedReason": "Run failed ({{reason}})", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index e97ce3f7..4881aef9 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -138,9 +138,12 @@ "ruleChipLabel": "ルール", "ruleChipTitle": "クリックしてルール本文を表示", "ruleFilePath": "ファイルパス", + "ruleOpenAgentDir": "Agent ディレクトリを開く", "rulePreviewAria": "ルールのプレビュー", - "rulePreviewTitle": "ルール: {{id}}", + "rulePreviewTitle": "ルール詳細", "rulePreviewTitleMulti": "一致したルール({{n}} 件)", + "rulePriority": "優先度", + "ruleTools": "ツール", "runCancelled": "キャンセル済み", "runFailed": "実行に失敗しました", "runFailedReason": "実行に失敗しました ({{reason}})", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index 794c7799..ab62814e 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -138,9 +138,12 @@ "ruleChipLabel": "规则", "ruleChipTitle": "点击查看规则正文", "ruleFilePath": "文件路径", + "ruleOpenAgentDir": "打开 Agent 目录", "rulePreviewAria": "规则预览", - "rulePreviewTitle": "规则: {{id}}", + "rulePreviewTitle": "规则详情", "rulePreviewTitleMulti": "命中规则({{n}} 条)", + "rulePriority": "优先级", + "ruleTools": "工具", "runCancelled": "已取消", "runFailed": "run 失败", "runFailedReason": "run 失败 ({{reason}})", diff --git a/apps/desktop/src/renderer/src/styles/common/modal.scss b/apps/desktop/src/renderer/src/styles/common/modal.scss index e9f7a2e5..f9c0d6f1 100644 --- a/apps/desktop/src/renderer/src/styles/common/modal.scss +++ b/apps/desktop/src/renderer/src/styles/common/modal.scss @@ -40,6 +40,14 @@ } } +// header 右侧组:自定义动作(headerActions)+ 关闭键,右对齐并排。 +// align-items: stretch 让图标动作按钮拉伸到与关闭键等高(图标内容比文字矮,否则会短一截)。 +.modal-header-right { + display: flex; + align-items: stretch; + gap: $space-3; +} + // 右上角关闭按钮:方形 X 图标(免国际化),复用 .icon-btn 的透明无边框, // 这里只调方形 padding + hover 浅底 .modal-close { From 4950e9444131c42483cccde99653303384a028e1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 13:58:01 +0800 Subject: [PATCH 18/84] =?UTF-8?q?fix(desktop):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E7=A6=81=E7=94=A8=E8=83=8C=E6=99=AF=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=85=B3=E9=97=AD=EF=BC=8C=E9=98=B2=E6=9C=AA=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E9=85=8D=E7=BD=AE=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回归修复:有未保存草稿时点击面板外部背景会直接关闭并丢失配置。设置页改为 closeOnBackdrop={false},仅右上角关闭键(或保存成功)退出。子编辑器(连接 / LLM) 的 dirty→确认放弃逻辑保留不变。 Co-Authored-By: Claude Opus 4.8 --- .../renderer/src/components/features/settings/SettingsModal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index a7c9960e..ffeb7b54 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -133,6 +133,8 @@ export function SettingsModal({ Date: Wed, 1 Jul 2026 14:26:06 +0800 Subject: [PATCH 19/84] =?UTF-8?q?fix(desktop):=20=E9=9B=86=E6=88=90?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=86=E5=8C=BA=E6=94=B9=E8=89=AF=EF=BC=88?= =?UTF-8?q?=E5=88=86=E7=BA=A7=E5=B8=83=E5=B1=80=20/=20=E6=96=87=E6=A1=88?= =?UTF-8?q?=20/=20=E5=9B=BE=E6=A0=87=20/=20token=20=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分级交互样式:整分区改用 settings-sublist(圆点 + 标签/说明 + 右控件),与「策略」「通知」统一; 监听地址、访问令牌各一行,两行右侧控件等宽(320px)左右边缘对齐、组内输入框自适应填充。 - 文案:serviceHint 弱化为「使用 token 鉴权」;监听提示精简为单句「配置为 0.0.0.0 可被本机局域网访问」; 访问令牌新增标签 + 一句用途说明。 - 图标:分区导航 ShareIcon → PuzzleIcon(拼图/扩展);「重新生成」文字按钮 → 刷新图标 SyncIcon, 与显隐 / 复制图标按钮并排一致。 - token 草稿制:generateServiceToken 改为纯生成随机 token(32B→base64url,不落盘),前端只置入草稿并 标脏 → host / port / token 一致「保存才生效、不保存则丢弃」。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/main/controllers/config.ts | 15 +- .../renderer/src/components/common/icons.tsx | 9 + .../features/settings/SettingsModal.tsx | 4 +- .../settings/hooks/useSettingsDraft.ts | 6 +- .../settings/sections/ServiceSection.tsx | 181 ++++++++++-------- .../src/renderer/src/i18n/locales/de-DE.json | 6 +- .../src/renderer/src/i18n/locales/en-US.json | 6 +- .../src/renderer/src/i18n/locales/ja-JP.json | 6 +- .../src/renderer/src/i18n/locales/zh-CN.json | 6 +- 9 files changed, 134 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index a748c3fd..5d555803 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -205,17 +205,12 @@ export const setService: IpcController<'config:setService'> = async (_event, req }; /** - * 重新生成 bearer token(高强度随机),写盘 + 内存同步。监听器每次请求实时读内存 token,故新 token - * 即时生效、旧 token 立刻失效,无需重启监听器。返回新 token 供设置页展示 / 复制。 + * 生成一枚高强度随机 bearer token(32 字节 → base64url,43 字符,字符集 [A-Za-z0-9-_],URL / 请求头安全) + * 并返回,**不落盘**——由前端置入设置草稿,随底栏「保存」经 config:setService 生效;不保存则丢弃 + * (与 host / port 同为草稿制)。 */ -export const generateServiceToken: IpcController<'config:generateServiceToken'> = async () => { - const { bootstrap, logger } = getContext(); - const token = randomBytes(32).toString('base64url'); - const service = { ...bootstrap.config.service, token }; - await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, service }); - bootstrap.config.service = service; - logger.info('service listener token regenerated'); - return { token }; +export const generateServiceToken: IpcController<'config:generateServiceToken'> = () => { + return { token: randomBytes(32).toString('base64url') }; }; /** diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx index f4ce5c7e..7fa94b40 100644 --- a/apps/desktop/src/renderer/src/components/common/icons.tsx +++ b/apps/desktop/src/renderer/src/components/common/icons.tsx @@ -256,6 +256,15 @@ export function ShareIcon({ size = 14 }: IconProps) { ); } +/** 拼图块(extension / plugin):集成 / 扩展 的通用隐喻。设置「集成」分区导航用。 */ +export function PuzzleIcon({ size = 14 }: IconProps) { + return ( + + ); +} + /** 评论:带文字行的对话气泡。finding 卡「编辑成评论草稿」动作用(与 ChatIcon 区分:内含文字行)。 */ export function CommentIcon({ size = 14 }: IconProps) { return ( diff --git a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx index ffeb7b54..e52a22a9 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/SettingsModal.tsx @@ -7,10 +7,10 @@ import { CpuIcon, GlobeIcon, Modal, + PuzzleIcon, QuestionIcon, RobotIcon, SettingsIcon, - ShareIcon, } from '../../common'; import { useSettingsDraft } from './hooks/useSettingsDraft'; import { useAppearanceDraft } from './hooks/useAppearanceDraft'; @@ -57,7 +57,7 @@ const SETTINGS_CATEGORIES: ReadonlyArray<{ { id: 'model', labelKey: 'settings.catModel', Icon: CpuIcon }, { id: 'agent', labelKey: 'settings.catAgent', Icon: RobotIcon }, { id: 'notifications', labelKey: 'settings.catNotifications', Icon: BellIcon }, - { id: 'integration', labelKey: 'settings.catIntegration', Icon: ShareIcon }, + { id: 'integration', labelKey: 'settings.catIntegration', Icon: PuzzleIcon }, { id: 'about', labelKey: 'settings.catAbout', Icon: QuestionIcon }, ]; diff --git a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts index d2aa478d..32d8e1f4 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts +++ b/apps/desktop/src/renderer/src/components/features/settings/hooks/useSettingsDraft.ts @@ -251,13 +251,13 @@ export function useSettingsDraft({ setServiceState(next); setSaved(false); }; - // token 重新生成是即时副作用(写盘 + 即时生效),不随整体保存:更新草稿 token 的同时同步基线, - // 使「重新生成」本身不被算作待保存改动(仅开关 / host / port 的未保存编辑才标脏)。 + // token 重新生成只更新草稿并标脏(不落盘、不同步基线):与 host / port 一致走草稿制,随底栏「保存」 + // 经 config:setService 生效;不保存则丢弃、保留原 token。functional update 避开与开关切换的竞态。 const regenerateServiceToken = async (): Promise => { try { const { token } = await invoke('config:generateServiceToken', undefined); setServiceState((prev) => ({ ...prev, token })); - setBase((b) => ({ ...b, service: { ...b.service, token } })); + setSaved(false); } catch (e) { setSaveError(e instanceof Error ? e.message : String(e)); } diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx index 4e711d41..057f8f8a 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/ServiceSection.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { Config } from '@meebox/shared'; -import { CopyIcon, EyeIcon, EyeOffIcon, Switch } from '../../../common'; +import { CopyIcon, EyeIcon, EyeOffIcon, Switch, SyncIcon } from '../../../common'; /** - * 本地 API 服务监听分区:开关 + 监听地址(仅本机 / 局域网)+ 端口 + bearer token(展示 / 显隐 / 复制 / - * 重新生成)。默认关闭;启用且无 token 时自动生成。监听 0.0.0.0 暴露到局域网时给安全警示。token 经 - * config:generateServiceToken 立即写盘生效(不随整体保存);开关 / 地址 / 端口随底栏「保存」生效。 + * 本地 API 服务监听分区:总开关(分区头)+ 缩进「功能列表」逐行展示监听地址 / 访问令牌(行首圆点 + + * 标签 + 说明,右侧控件),与「策略」「通知」等分区风格统一。地址为 http://: 组合,token + * 可显示 / 复制 / 重新生成;任何开关状态下均可编辑。启用且无 token 时自动生成;非 loopback 绑定给安全 + * 警示。监听地址 / 端口 / token 均为**草稿制**——随底栏「保存」经 config:setService 生效,不保存则丢弃。 */ export function ServiceSection({ value, @@ -28,6 +29,9 @@ export function ServiceSection({ const set = (patch: Partial): void => onChange({ ...value, ...patch }); + // 监听地址组与令牌组共用同一固定宽度,使两行右侧控件左右边缘对齐;组内输入框自适应填充剩余。 + const CONTROL_W = 320; + const handleEnabled = (v: boolean): void => { if (v && !value.token) onRegenerateToken(); // 启用且无 token → 自动生成一枚 set({ enabled: v }); @@ -56,89 +60,102 @@ export function ServiceSection({ {t('settings.serviceHint')}

- {/* 监听地址:http://: 两个输入框组合。host 默认 127.0.0.1(仅本机), - 可填 0.0.0.0 / 局域网 IP 开放到同网段。 */} -
-
- {t('settings.serviceHostLabel')} -
-
- http:// - set({ host: e.target.value })} - placeholder="127.0.0.1" - spellCheck={false} - /> - : - set({ port: Number.parseInt(e.target.value, 10) || 0 })} - /> -
-

- {t('settings.serviceHostHint')} -

-
+
    +
  • +
    + {t('settings.serviceHostLabel')} + {t('settings.serviceHostHint')} +
    +
    + http:// + set({ host: e.target.value })} + placeholder="127.0.0.1" + spellCheck={false} + /> + : + set({ port: Number.parseInt(e.target.value, 10) || 0 })} + /> +
    +
  • +
  • +
    + {t('settings.serviceTokenLabel')} + {t('settings.serviceTokenDesc')} +
    +
    + + + + +
    +
  • +
{exposed && ( -

+

{t('settings.serviceExposeWarning')}

)} - -
- - - - -
- {copied && {t('settings.serviceTokenCopied')}} + {copied && ( +

+ {t('settings.serviceTokenCopied')} +

+ )} ); } diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index 8607f7c8..cdcaace0 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -823,15 +823,17 @@ "saving": "Speichere…", "serviceEnableLabel": "Lokalen API-Dienst aktivieren", "serviceExposeWarning": "Das Lauschen auf 0.0.0.0 macht die API im lokalen Netzwerk zugänglich; der Token ist der einzige Schutz – halten Sie ihn geheim und nutzen Sie eine Firewall.", - "serviceHint": "Stellt eine lokale HTTP-API für die Integration externer Tools / CLI bereit. Standardmäßig aus; bei Aktivierung ist Bearer-Token-Authentifizierung erforderlich.", - "serviceHostHint": "Standard http://127.0.0.1:18765 (nur dieses Gerät). Host auf 0.0.0.0 oder eine LAN-IP setzen, um Zugriff aus demselben Subnetz zu erlauben (hohes Risiko).", + "serviceHint": "Stellt eine lokale HTTP-API für die Integration externer Tools / CLI bereit. Standardmäßig aus; verwendet Token-Authentifizierung.", + "serviceHostHint": "Auf 0.0.0.0 setzen, um Zugriff aus dem lokalen Netzwerk zu erlauben.", "serviceHostInvalidError": "Ungültige Lausch-Adresse (nur IP oder Hostname; ohne Schema, Leerzeichen oder Port).", "serviceHostLabel": "Lausch-Adresse", "servicePortRangeError": "Der Port muss eine ganze Zahl zwischen 1 und 65535 sein.", "serviceTitle": "Lokaler API-Dienst", "serviceTokenCopied": "Kopiert", "serviceTokenCopy": "Token kopieren", + "serviceTokenDesc": "Auth-Token für externen Zugriff.", "serviceTokenHide": "Token verbergen", + "serviceTokenLabel": "Token", "serviceTokenPlaceholder": "Noch kein Token generiert", "serviceTokenRegenerate": "Neu generieren", "serviceTokenReveal": "Token anzeigen", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 4738483d..dc075776 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -823,15 +823,17 @@ "saving": "Saving…", "serviceEnableLabel": "Enable local API service", "serviceExposeWarning": "Listening on 0.0.0.0 exposes the API to your local network; the token is the only safeguard—keep it secret and use a firewall.", - "serviceHint": "Expose a local HTTP API for external tools / CLI integration. Off by default; enabling enforces bearer token authentication.", - "serviceHostHint": "Default http://127.0.0.1:18765 (this machine only). Set host to 0.0.0.0 or a LAN IP to allow same-subnet access (high risk).", + "serviceHint": "Expose a local HTTP API for external tools / CLI integration. Off by default; uses token authentication.", + "serviceHostHint": "Set to 0.0.0.0 to allow access from your local network.", "serviceHostInvalidError": "Invalid listen address (IP or hostname only; no scheme, spaces, or port).", "serviceHostLabel": "Listen address", "servicePortRangeError": "Port must be an integer between 1 and 65535.", "serviceTitle": "Local API service", "serviceTokenCopied": "Copied", "serviceTokenCopy": "Copy token", + "serviceTokenDesc": "Auth token for external access.", "serviceTokenHide": "Hide token", + "serviceTokenLabel": "Token", "serviceTokenPlaceholder": "No token generated yet", "serviceTokenRegenerate": "Regenerate", "serviceTokenReveal": "Show token", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 4881aef9..52e69275 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -806,15 +806,17 @@ "saving": "保存中…", "serviceEnableLabel": "ローカル API サービスを有効化", "serviceExposeWarning": "0.0.0.0 でのリッスンは API を LAN に公開します。トークンが唯一の防御策です——秘匿し、ファイアウォールを併用してください。", - "serviceHint": "外部ツール / CLI 連携用のローカル HTTP API を公開します。既定は無効。有効化すると bearer トークン認証が必須になります。", - "serviceHostHint": "既定は http://127.0.0.1:18765(本機のみ)。host に 0.0.0.0 や LAN の IP を指定すると同一サブネットから接続可能(高リスク)。", + "serviceHint": "外部ツール / CLI 連携用のローカル HTTP API を公開します。既定は無効。トークン認証を使用します。", + "serviceHostHint": "0.0.0.0 に設定すると LAN から接続可能。", "serviceHostInvalidError": "リッスンアドレスの形式が不正です(IP / ホスト名のみ。プロトコル・空白・ポートは不可)。", "serviceHostLabel": "リッスンアドレス", "servicePortRangeError": "ポートは 1〜65535 の整数で指定してください。", "serviceTitle": "ローカル API サービス", "serviceTokenCopied": "コピーしました", "serviceTokenCopy": "トークンをコピー", + "serviceTokenDesc": "外部アクセス用の認証トークン。", "serviceTokenHide": "トークンを隠す", + "serviceTokenLabel": "トークン", "serviceTokenPlaceholder": "トークン未生成", "serviceTokenRegenerate": "再生成", "serviceTokenReveal": "トークンを表示", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index ab62814e..05891eff 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -806,15 +806,17 @@ "saving": "保存中…", "serviceEnableLabel": "启用本地 API 服务", "serviceExposeWarning": "监听 0.0.0.0 会把 API 暴露到局域网,token 是唯一防线——请确保 token 保密并配合防火墙。", - "serviceHint": "对外提供本地 HTTP API,供外部工具 / CLI 集成。默认关闭;启用即强制 bearer token 鉴权。", - "serviceHostHint": "默认 http://127.0.0.1:18765(仅本机可达)。host 填 0.0.0.0 或本机局域网 IP 可被同网段访问(高风险)。", + "serviceHint": "对外提供本地 HTTP API,供外部工具 / CLI 集成。默认关闭,使用 token 鉴权。", + "serviceHostHint": "配置为 0.0.0.0 可被本机局域网访问。", "serviceHostInvalidError": "监听地址格式不合法(仅允许 IP / 主机名,不含协议、空格或端口)。", "serviceHostLabel": "监听地址", "servicePortRangeError": "端口需为 1–65535 之间的整数。", "serviceTitle": "本地 API 服务", "serviceTokenCopied": "已复制", "serviceTokenCopy": "复制 token", + "serviceTokenDesc": "外部访问的鉴权令牌。", "serviceTokenHide": "隐藏 token", + "serviceTokenLabel": "访问令牌", "serviceTokenPlaceholder": "尚未生成 token", "serviceTokenRegenerate": "重新生成", "serviceTokenReveal": "显示 token", From dfd3f88f7789086b1acbb1491e599fdf48cad994 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 14:37:01 +0800 Subject: [PATCH 20/84] =?UTF-8?q?feat(desktop):=20PR=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=B8=80=E7=BA=A7=E5=88=86=E7=B1=BB=E6=A0=87=E7=AD=BE=E6=9C=AA?= =?UTF-8?q?=E8=AF=BB=E5=9C=86=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发现分类标签(待我评审 / 我创建 / 指派 / 提及)下有未读 PR 时,标签文字后加亮蓝未读圆点, 提示该分类有新的待处理;无发现分类平台的单一「进行中」锚点任一活动 PR 未读即标点。 未读点始终基于活跃 PR(即便处在「已关闭」视图,标签仍反映活跃分类的未读),复用既有 pr.unread 语义,随 PR 被查看清除。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/renderer/src/App.tsx | 1 + .../src/components/layout/Sidebar.tsx | 24 +++++++++++++++++++ .../renderer/src/styles/layout/sidebar.scss | 11 +++++++++ 3 files changed, 36 insertions(+) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 3ca14390..f8e37404 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -203,6 +203,7 @@ export default function App() { {!sidebarCollapsed && ( { setSelectedId(pr.localId); diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index 294ae25e..e7c4d851 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -23,6 +23,11 @@ export type SidebarScope = 'active' | 'archived'; interface SidebarProps { prs: StoredPullRequest[]; + /** + * 活跃范围 PR(始终传入,与当前 scope 无关):供一级发现分类标签的未读圆点计算——即便处在 + * 「已关闭」视图,标签仍反映活跃分类的未读。缺省回退到 prs。 + */ + activePrs?: StoredPullRequest[]; selectedId: string | null; onSelect: (pr: StoredPullRequest) => void; width: number; @@ -78,6 +83,7 @@ interface PrGroup { export function Sidebar({ prs, + activePrs, selectedId, onSelect, width, @@ -214,6 +220,20 @@ export function Sidebar({ [prs, discoveryFilter, isArchived], ); + // 未读圆点始终基于**活跃** PR(缺省回退 prs):即便当前在「已关闭」视图,一级标签仍反映活跃分类的未读。 + const unreadSourcePrs = activePrs ?? prs; + // 各一级发现分类下是否有未读 PR → 在标签文字后加未读圆点,提示该分类有新的待处理。 + const unreadFilters = useMemo(() => { + const s = new Set(); + for (const p of unreadSourcePrs) { + if (!p.unread) continue; + for (const f of p.discoveryFilters ?? []) s.add(f); + } + return s; + }, [unreadSourcePrs]); + // 无发现分类平台的单一「进行中」锚点:任一活动 PR 未读即标圆点。 + const anyUnread = useMemo(() => unreadSourcePrs.some((p) => p.unread), [unreadSourcePrs]); + const counts = useMemo(() => { const out: Record = { all: scopedPrs.length, @@ -292,6 +312,9 @@ export function Sidebar({ type="button" > {t(DISCOVERY_LABEL_KEYS[f])} + {unreadFilters.has(f) && ( + + )} )) ) : ( @@ -303,6 +326,7 @@ export function Sidebar({ type="button" > {t('sidebar.scopeActive')} + {anyUnread && } )}
diff --git a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss index fa02c819..0ae2cfc4 100644 --- a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss @@ -142,6 +142,17 @@ } } +// 一级发现分类标签的未读圆点:该分类下有未读 PR 时,跟在标签文字后(复用 PR 项未读点的亮蓝)。 +.sidebar-discovery-tab-dot { + display: inline-block; + width: 6px; + height: 6px; + margin-left: $space-2; + border-radius: 50%; + background: $color-info; + flex: 0 0 auto; +} + .sidebar-list { flex: 1; overflow-y: auto; From fff5fb3453e3f7b9a6981a44efa53eef6c285fee Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 14:51:44 +0800 Subject: [PATCH 21/84] =?UTF-8?q?fix(desktop):=20PR=20=E5=88=86=E7=BB=84?= =?UTF-8?q?=E5=A4=B4=E8=83=8C=E6=99=AF=E9=9A=8F=E4=B8=BB=E9=A2=98=E6=B4=BE?= =?UTF-8?q?=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --bg-group-header 之前不在 editor-chrome-sync 的派生集里,主题切换时停在 _theme.scss 静态浅/深值、 与派生出来的 --bg-app/--bg-panel 不匹配。现纳入派生:从当前主题 base 色向黑轻微下沉(比 bg-app 略暗的 凹陷标题带,hover 走 --bg-panel 上浮),随主题一致变化。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts index 16a48553..eccc5442 100644 --- a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts +++ b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts @@ -30,6 +30,7 @@ const OVERRIDDEN_VARS = [ '--border-default-fade', '--border-muted', '--bg-selected', + '--bg-group-header', ] as const; interface Rgb { @@ -147,6 +148,8 @@ export function applyChromeFromEditorTheme(editorThemeId: string, resolvedGuiThe const borderDefault = mix(fg, bg, 0.8); const borderMuted = mix(fg, bg, 0.88); const borderFade = { ...borderDefault, a: 0.5 }; + // 分组头:向黑轻微下沉(比 bg-app 略暗的「凹陷」标题带,hover 走 --bg-panel 上浮),随主题派生。 + const bgGroupHeader = mix(bg, BLACK, 0.12); const set = (name: string, c: Rgb): void => document.documentElement.style.setProperty(name, toRgbString(c)); set('--bg-app', bg); @@ -164,6 +167,7 @@ export function applyChromeFromEditorTheme(editorThemeId: string, resolvedGuiThe set('--border-muted', borderMuted); set('--border-default-fade', borderFade); set('--bg-selected', sel); + set('--bg-group-header', bgGroupHeader); // 可读性体检:muted 文字 / 弱边框对背景的对比度(AA 正文≥4.5、次要文本/非文本≥3) const mutedCr = contrastRatio(textMuted, bg); From b054644ac969ac4cf5cbe5027a03366523c24745 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 14:51:45 +0800 Subject: [PATCH 22/84] =?UTF-8?q?docs:=20CLI=20=E8=AE=BE=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=96=87=E6=A1=A3=E7=A7=BB=E9=99=A4=E4=BA=92?= =?UTF-8?q?=E7=9B=B8=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按约定,CLI 的设计文档(arch 02-cli)与使用文档(guide 06-cli)无须互相引用,双向移除引导链接。 Co-Authored-By: Claude Opus 4.8 --- docs/arch/04-integration/02-cli.md | 2 -- docs/guide/06-cli.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 1d58b33e..fa8589dd 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -5,8 +5,6 @@ 提供一个**独立分发的跨平台命令行客户端**,经[本地 API](01-service-api.md) 消费应用能力,供外部 agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 -> 面向用户的使用说明见 [docs/guide/06-cli.md](../../guide/06-cli.md)。 - 负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 约定退出码。 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 71da6394..f811edd4 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -3,8 +3,6 @@ `meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 评审 Agent 操作接入脚本、CI 或外部 agent。命令行只做**浏览与评审操作**,不含评论发送等写操作。 -> 面向开发者的接口 / 架构细节见 [../arch/04-integration/](../arch/04-integration/01-service-api.md)。 - ## 1. 开启本地 API 服务 CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 集成** 开启: From add0223a449dacf27e5767fc8c2f98072658d3a6 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 14:59:27 +0800 Subject: [PATCH 23/84] =?UTF-8?q?fix(desktop):=20Windows=20=E7=AA=97?= =?UTF-8?q?=E6=8E=A7=E6=8C=89=E9=92=AE=E9=9A=8F=E4=B8=BB=E9=A2=98=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 窗控按钮(titleBarOverlay)此前在主进程用硬编码通用深/浅色,只按 nativeTheme 深浅二选一, 非标准主题下与标题栏派生底色对不上、右上角有接缝。现由渲染层在主题应用后把派生的 --bg-app / --text-primary(hex)经新 IPC window:setControlColors 推给主进程 setTitleBarOverlay, 与具体主题精确同色;渲染层未推送时按 nativeTheme 深浅回退通用色(新窗口 / 兜底)。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/bootstrap/window-manager.ts | 38 +++++++++++++++---- apps/desktop/src/main/controllers/app.ts | 8 ++++ apps/desktop/src/main/ipc.ts | 1 + .../src/renderer/src/hooks/useTheme.ts | 14 +++++-- .../renderer/src/theme/editor-chrome-sync.ts | 15 +++++++- packages/ipc/src/app.ts | 8 ++++ 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/bootstrap/window-manager.ts b/apps/desktop/src/main/bootstrap/window-manager.ts index 75671fa2..1d6d2210 100644 --- a/apps/desktop/src/main/bootstrap/window-manager.ts +++ b/apps/desktop/src/main/bootstrap/window-manager.ts @@ -24,10 +24,31 @@ const TITLE_BAR_OVERLAY = { dark: { color: '#1e1e1e', symbolColor: '#cccccc' }, light: { color: '#f8f8f8', symbolColor: '#1f1f20' }, }; -/** 按当前有效主题取窗控按钮配色(含高度,供建窗与 setTitleBarOverlay 共用)。 */ -function overlayOptions(): { color: string; symbolColor: string; height: number } { - const c = nativeTheme.shouldUseDarkColors ? TITLE_BAR_OVERLAY.dark : TITLE_BAR_OVERLAY.light; - return { ...c, height: 36 }; +// 渲染层派生的窗控配色(跟随具体主题的 --bg-app/--text-primary,hex);null 时按 nativeTheme 深浅取通用色兜底。 +let overlayColors: { color: string; symbolColor: string } | null = null; +/** 通用深/浅窗控配色(无渲染层派生色时的兜底,按 nativeTheme 有效深浅)。 */ +function genericOverlayColors(): { color: string; symbolColor: string } { + return nativeTheme.shouldUseDarkColors ? TITLE_BAR_OVERLAY.dark : TITLE_BAR_OVERLAY.light; +} +/** 当前窗控 overlay(渲染层派生色优先,否则通用色)+ 高度(与 .app-titlebar 一致 36px)。 */ +function currentOverlay(): { color: string; symbolColor: string; height: number } { + return { ...(overlayColors ?? genericOverlayColors()), height: 36 }; +} +/** + * 由渲染层在主题应用后经 IPC 调用:把当前主题派生的窗控配色(color=--bg-app、symbolColor=--text-primary) + * 设给所有窗口;传 null 回退通用深/浅色。使窗控按钮与具体主题的标题栏底色精确同色,而非仅通用深/浅。 + */ +export function setWindowControlColors(colors: { color: string; symbolColor: string } | null): void { + overlayColors = colors; + if (process.platform === 'darwin') return; // macOS 无 titleBarOverlay + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.setTitleBarOverlay(currentOverlay()); + } catch { + /* 平台不支持 setTitleBarOverlay → 忽略 */ + } + } } /** @@ -89,7 +110,7 @@ export class WindowManager { titleBarStyle: 'hidden', ...(process.platform === 'darwin' ? { trafficLightPosition: { x: 12, y: 11 } } - : { titleBarOverlay: overlayOptions() }), + : { titleBarOverlay: currentOverlay() }), // dev 下显式给窗口图标;打包态窗口/任务栏图标走 exe 内嵌(electron-builder),故仅 dev 设置。 icon: app.isPackaged ? undefined @@ -149,13 +170,14 @@ export class WindowManager { ); }); - // 全局主题切换(config:setEditorAppearance 据主题改 nativeTheme.themeSource)或 'auto' 主题下 OS - // 深浅变化时,nativeTheme 发 'updated':重置 Windows 窗控按钮配色跟随主题(macOS 无 titleBarOverlay,不注册)。 + // 'auto' 主题下 OS 深浅变化时 nativeTheme 发 'updated':按当前 overlay(渲染层派生色优先,否则通用色) + // 重置窗控配色兜底(macOS 无 titleBarOverlay,不注册)。具体主题的精确配色由渲染层经 setWindowControlColors + // 主动推送(见 useGlobalTheme),此处仅在渲染层未推送时按 nativeTheme 深浅回退。 if (process.platform !== 'darwin') { const onThemeUpdated = (): void => { if (win.isDestroyed()) return; try { - win.setTitleBarOverlay(overlayOptions()); + win.setTitleBarOverlay(currentOverlay()); } catch { /* 平台不支持 setTitleBarOverlay → 忽略 */ } diff --git a/apps/desktop/src/main/controllers/app.ts b/apps/desktop/src/main/controllers/app.ts index 59938dab..7ef49dc9 100644 --- a/apps/desktop/src/main/controllers/app.ts +++ b/apps/desktop/src/main/controllers/app.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { app, BrowserWindow, dialog, shell } from 'electron'; import type { Logger } from 'pino'; +import { setWindowControlColors as applyWindowControlColors } from '../bootstrap/window-manager.js'; import { buildAppInfo, buildConnectionSummaries } from '../services/app.js'; import { getContext } from '../services/context.js'; import { applyBadgeCount } from '../services/notifications.js'; @@ -25,6 +26,13 @@ export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getCont */ export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths; +/** + * 渲染层在主题应用后推送窗控按钮配色(跟随具体主题的 --bg-app/--text-primary);null 回退通用深/浅。 + */ +export const setWindowControlColors: IpcController<'window:setControlColors'> = (_event, req) => { + applyWindowControlColors(req); +}; + /** * pr-agent 探测状态(是否就绪)。 */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 9aaac7c9..25cb790a 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -44,6 +44,7 @@ export function registerIpcHandlers(deps: RegisterDeps): { ipcMain.handle('app:paths', app.readAppPaths); // 关键目录路径(config / agent / 日志) ipcMain.handle('app:prAgentStatus', app.readPrAgentStatus); // pr-agent 探测状态(是否就绪) ipcMain.handle('log:write', app.writeRendererLog); // 渲染层日志回传落盘 + ipcMain.handle('window:setControlColors', app.setWindowControlColors); // 渲染层推送主题派生窗控配色 ipcMain.handle('app:connections', app.listConnections); // 当前活动连接摘要(Header / 状态栏) ipcMain.handle('app:userAvatar', app.getUserAvatar); // 用户头像(内存 + 磁盘两级缓存) ipcMain.handle('app:openConfigFile', app.openConfigFile); // 打开 config.yaml diff --git a/apps/desktop/src/renderer/src/hooks/useTheme.ts b/apps/desktop/src/renderer/src/hooks/useTheme.ts index 0db3fad4..fab2ee6b 100644 --- a/apps/desktop/src/renderer/src/hooks/useTheme.ts +++ b/apps/desktop/src/renderer/src/hooks/useTheme.ts @@ -9,6 +9,14 @@ import { } from '../theme'; import { applyChromeFromEditorTheme } from '../theme/editor-chrome-sync'; import { setEditorAppearance, useEditorAppearance } from '../stores/editor-appearance-store'; +import { invoke } from '../api'; + +/** 主题应用后把派生的窗控配色(Windows titleBarOverlay)推给主进程;null 回退通用深/浅。失败静默。 */ +function syncWindowControls(colors: { color: string; symbolColor: string } | null): void { + void invoke('window:setControlColors', colors).catch(() => { + /* 平台不支持 / 主进程未就绪 → 忽略 */ + }); +} /** * 全局主题生效:主题变化时把它反推浅 / 深写到 documentElement.data-theme(驱动语义色板)+ 派生结构性 @@ -23,10 +31,10 @@ export function useGlobalTheme(): void { useEffect(() => { applyGlobalTheme(editorTheme); persistEditorTheme(editorTheme); - applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme)); - // 'auto' 主题:OS 深浅切换时重写 data-theme(watch 内部已做)并重派生 chrome + syncWindowControls(applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme))); + // 'auto' 主题:OS 深浅切换时重写 data-theme(watch 内部已做)并重派生 chrome + 同步窗控配色 return watchSystemThemeForAuto(editorTheme, () => { - applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme)); + syncWindowControls(applyChromeFromEditorTheme(editorTheme, resolveGlobalTheme(editorTheme))); }); }, [editorTheme]); } diff --git a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts index eccc5442..e47007f0 100644 --- a/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts +++ b/apps/desktop/src/renderer/src/theme/editor-chrome-sync.ts @@ -74,6 +74,12 @@ function toRgbString({ r, g, b, a }: Rgb): string { return a >= 1 ? `rgb(${r}, ${g}, ${b})` : `rgb(${r} ${g} ${b} / ${a.toFixed(3)})`; } +/** 转 #rrggbb(忽略 alpha);供 Windows titleBarOverlay 用(其 color 取 hex)。 */ +function toHex({ r, g, b }: Rgb): string { + const h = (v: number): string => v.toString(16).padStart(2, '0'); + return `#${h(r)}${h(g)}${h(b)}`; +} + /** 相对亮度(WCAG)。 */ function luminance({ r, g, b }: Rgb): number { const ch = (v: number): number => { @@ -118,7 +124,10 @@ function clearChromeOverrides(): void { * 把当前全局主题的 base 色派生为 GUI chrome 的结构性 token,写到 documentElement(覆盖 _theme.scss)。 * 取不到色 / 缺 bg·fg 时清空覆盖、回退语义色板(仍随 data-theme 浅 / 深正常显示)。 */ -export function applyChromeFromEditorTheme(editorThemeId: string, resolvedGuiTheme: 'light' | 'dark'): void { +export function applyChromeFromEditorTheme( + editorThemeId: string, + resolvedGuiTheme: 'light' | 'dark', +): { color: string; symbolColor: string } | null { // 'auto' 跟随解析主题 → 取默认 2026 主题(dark-2026 / light-2026)的 base 色 const effectiveId = editorThemeId === 'auto' ? (resolvedGuiTheme === 'dark' ? 'dark-2026' : 'light-2026') : editorThemeId; @@ -128,7 +137,7 @@ export function applyChromeFromEditorTheme(editorThemeId: string, resolvedGuiThe if (!bg || !fg) { clearChromeOverrides(); console.warn('[chrome-sync] no usable bg/fg for theme, fell back to semantic palette:', effectiveId); - return; + return null; } const isDark = luminance(bg) < 0.5; const edge = isDark ? WHITE : BLACK; // 提升层(背景越「浮」越靠该边)/ 边框混合方向 @@ -175,4 +184,6 @@ export function applyChromeFromEditorTheme(editorThemeId: string, resolvedGuiThe console.info( `[chrome-sync] "${effectiveId}" (${isDark ? 'dark' : 'light'}) → muted/bg contrast ${mutedCr.toFixed(2)} (AA≥4.5), border/bg ${borderCr.toFixed(2)} (≥3)`, ); + // 窗控按钮同色:把主题 base 背景 / 前景(hex)交回主进程更新 Windows titleBarOverlay(见 useGlobalTheme)。 + return { color: toHex(bg), symbolColor: toHex(fg) }; } diff --git a/packages/ipc/src/app.ts b/packages/ipc/src/app.ts index fcb5ae83..cbb0aff3 100644 --- a/packages/ipc/src/app.ts +++ b/packages/ipc/src/app.ts @@ -55,6 +55,14 @@ export interface AppChannels { request: { defaultPath?: string; title: string }; response: { path: string | null }; }; + /** + * 由渲染层在主题应用后推送当前主题派生的窗控按钮配色(Windows titleBarOverlay:color=--bg-app、 + * symbolColor=--text-primary),使系统窗控按钮与具体主题的标题栏底色精确同色;null 回退通用深 / 浅色。 + */ + 'window:setControlColors': { + request: { color: string; symbolColor: string } | null; + response: void; + }; /** 各连接的 ping 后缓存:当前用户 + display_name,Header 用 */ 'app:connections': { request: void; response: ConnectionSummary[] }; /** From 7e4e856c7ff7f0bc9798374d1c70431056aa72ff Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:18:28 +0800 Subject: [PATCH 24/84] =?UTF-8?q?feat(desktop):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=E7=A7=BB=E9=99=A4=20pr-agent=20=E7=89=88=E6=9C=AC=20c?= =?UTF-8?q?hip=EF=BC=8C=E5=BC=B1=E5=8C=96=E4=B8=8B=E6=B2=89=E5=88=B0?= =?UTF-8?q?=E5=85=B3=E4=BA=8E=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 状态栏常态不再显示 pr-agent 版本号(减少噪声),仅在不可用时保留红色告警 chip(可操作信息)。版本与运行策略改在「关于」页运行环境信息中弱化展示, 打开时按需拉取。 Co-Authored-By: Claude Opus 4.8 --- .../settings/sections/RuntimeSection.tsx | 19 +++++++++++++++++-- .../src/components/layout/StatusBar.tsx | 19 +++++-------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx index 27cf0970..cfea4c70 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { AppInfo } from '@meebox/shared'; +import type { AppInfo, PrAgentStatus } from '@meebox/shared'; import { CheckGlyphIcon, CopyIcon, GitHubMarkIcon, IssueIcon, TagIcon } from '../../../common'; import { invoke } from '../../../../api'; import { UpdateCheckButton } from '../elements/UpdateCheckButton'; @@ -9,6 +9,18 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE const { t } = useTranslation(); // 复制后短暂切到「打勾 + 已复制」绿色态作反馈(无 toast 体系,按钮内联反馈)。 const [copied, setCopied] = useState(false); + // pr-agent 运行时状态(版本 / 策略):从状态栏弱化下沉到此处按需展示,打开关于页时拉取。 + const [prAgent, setPrAgent] = useState(null); + useEffect(() => { + void invoke('app:prAgentStatus', undefined) + .then(setPrAgent) + .catch(() => setPrAgent(null)); + }, []); + const prAgentText = prAgent + ? prAgent.available + ? `${prAgent.strategy} · ${prAgent.version}` + : t('statusBar.prAgentUnavailable') + : '…'; // 操作系统:平台代号 + 系统版本合并展示(如「darwin 15.5」)。 const osText = `${info.platform} ${info.osVersion}`.trim(); @@ -20,6 +32,7 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE `Node: ${info.nodeVersion}`, `${t('settings.operatingSystem')}: ${osText}`, `${t('settings.architecture')}: ${info.arch}`, + `pr-agent: ${prAgentText}`, ].join('\n'); const copyInfo = (): void => { @@ -56,6 +69,8 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE
{osText}
{t('settings.architecture')}
{info.arch}
+
pr-agent
+
{prAgentText}
diff --git a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx index 15b10dd3..db8ade7a 100644 --- a/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/StatusBar.tsx @@ -39,22 +39,13 @@ interface StatusBarProps { onToggleAutopilot: () => void; } -/** pr-agent 运行时 chip:只显示版本(可用)/「不可用」(错误态),属应用运行时级,留在 layout。 */ +/** + * pr-agent 运行时 chip:仅在**不可用**(错误态)时显示红色「不可用」告警(可操作信息)。可用时的版本 + * 号已从状态栏弱化下沉到「关于」页展示(见 RuntimeSection),此处不再渲染,减少常态噪声。 + */ function PrAgentRuntimeChip({ status }: { status: PrAgentStatus }) { const { t } = useTranslation(); - if (status.available) { - // chip 只显示 pr-agent 版本,不显示 strategy(embedded/local-cli 对用户无意义); - // embedded → `pr-agent 0.36.0` → 取 `0.36.0`;local-cli → help 首行截到首个空白前。 - const ver = - status.strategy === 'embedded' - ? status.version.replace(/^pr-agent\s+/, '') - : status.version.split(/\s+/)[0] || status.version; - return ( - - {t('statusBar.prAgentVersion', { ver })} - - ); - } + if (status.available) return null; return ( a.error).join('\n')}> {t('statusBar.prAgentUnavailable')} From 78b665d8ef618d20de446190e435eeaf8e7526ce Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:18:37 +0800 Subject: [PATCH 25/84] =?UTF-8?q?feat(desktop):=20=E3=80=8C=E6=88=91?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=9A=84=E3=80=8D=E5=88=86=E7=B1=BB=E4=B8=8B?= =?UTF-8?q?=E5=BE=85=E5=A4=84=E7=90=86=20PR=20=E5=B9=B6=E5=85=A5=E6=9C=89?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E7=9A=84=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 作者视角下有合并冲突的 PR 需其跟进解决,即便评审已通过。「我创建的」发现分类 的「待处理」二级筛选除本人评审决断 pending 外,并入存在冲突的 PR;筛选与计数 均复用 @meebox/shared 同源谓词(与本地 API 一致),避免与冲突计数重复叠加。 Co-Authored-By: Claude Opus 4.8 --- .../src/components/layout/Sidebar.tsx | 13 ++++-- packages/shared/src/pr-filter.ts | 12 ++++- packages/shared/tests/pr-filter.test.ts | 46 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx index e7c4d851..0b5e9071 100644 --- a/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx +++ b/apps/desktop/src/renderer/src/components/layout/Sidebar.tsx @@ -248,17 +248,22 @@ export function Sidebar({ if (p.hasConflict) out.conflict += 1; if (p.mergeStatus?.canMerge) out.mergeable += 1; } + // 「我创建的」下「待处理」并入冲突 PR(作者需跟进),复用同源谓词重算、避免与冲突计数重复叠加。 + if (!isArchived && discoveryFilter === 'created') { + out.pending = scopedPrs.filter((p) => matchesSecondaryFilter(p, 'pending', 'created')).length; + } return out; - }, [scopedPrs]); + }, [scopedPrs, isArchived, discoveryFilter]); const filtered = useMemo(() => { // 已关闭范围强制「全部」(不应用状态筛选);进行中范围按当前状态筛选。过滤 / 检索语义复用 - // @meebox/shared 纯谓词(与本地 API 同源)。 + // @meebox/shared 纯谓词(与本地 API 同源),并传入一级发现分类以启用分类相关的语义细化。 const effFilter: FilterKey = isArchived ? 'all' : filter; + const effPrimary = !isArchived ? discoveryFilter : undefined; return scopedPrs.filter( - (p) => matchesSecondaryFilter(p, effFilter) && matchesPrQuery(p, query), + (p) => matchesSecondaryFilter(p, effFilter, effPrimary) && matchesPrQuery(p, query), ); - }, [scopedPrs, query, filter, isArchived]); + }, [scopedPrs, query, filter, isArchived, discoveryFilter]); const groups = useMemo(() => { const m = new Map(); diff --git a/packages/shared/src/pr-filter.ts b/packages/shared/src/pr-filter.ts index 40696902..1074a7c9 100644 --- a/packages/shared/src/pr-filter.ts +++ b/packages/shared/src/pr-filter.ts @@ -28,10 +28,15 @@ export function matchesDiscoveryFilter( return !primary || (pr.discoveryFilters?.includes(primary) ?? false); } -/** 二级筛选匹配(状态 / 合并态)。 */ +/** + * 二级筛选匹配(状态 / 合并态)。`primary` 为当前一级发现分类(可空),用于分类相关的语义细化: + * 「我创建的」(`created`)下「待处理」= 需作者跟进 —— 除本人评审决断 pending 外,还并入存在合并冲突的 + * PR(作者需解决冲突方能推进,即便评审已通过)。 + */ export function matchesSecondaryFilter( pr: StoredPullRequest, secondary: PrSecondaryFilter, + primary?: PrDiscoveryFilter, ): boolean { switch (secondary) { case 'all': @@ -40,6 +45,9 @@ export function matchesSecondaryFilter( return pr.hasConflict === true; case 'mergeable': return pr.mergeStatus?.canMerge === true; + case 'pending': + if (primary === 'created') return pr.localStatus === 'pending' || pr.hasConflict === true; + return pr.localStatus === 'pending'; default: return pr.localStatus === secondary; } @@ -77,7 +85,7 @@ export function filterPullRequests( return prs.filter( (p) => matchesDiscoveryFilter(p, criteria.primary) && - matchesSecondaryFilter(p, criteria.secondary ?? 'all') && + matchesSecondaryFilter(p, criteria.secondary ?? 'all', criteria.primary) && matchesPrQuery(p, criteria.query ?? ''), ); } diff --git a/packages/shared/tests/pr-filter.test.ts b/packages/shared/tests/pr-filter.test.ts index 9bf461ce..beadc579 100644 --- a/packages/shared/tests/pr-filter.test.ts +++ b/packages/shared/tests/pr-filter.test.ts @@ -70,6 +70,38 @@ describe('matchesSecondaryFilter', () => { ), ).toBe(false); }); + it("'pending' 默认按 localStatus,不含冲突", () => { + expect(matchesSecondaryFilter(mkPr({ localStatus: 'pending' }), 'pending')).toBe(true); + expect( + matchesSecondaryFilter(mkPr({ localStatus: 'approved', hasConflict: true }), 'pending'), + ).toBe(false); + }); + it("'created' 分类下 'pending' 并入冲突 PR(作者需跟进)", () => { + // 评审已通过但存在冲突 → created 下计入待处理 + expect( + matchesSecondaryFilter( + mkPr({ localStatus: 'approved', hasConflict: true }), + 'pending', + 'created', + ), + ).toBe(true); + // 无冲突且非 pending → 仍不计入 + expect( + matchesSecondaryFilter( + mkPr({ localStatus: 'approved', hasConflict: false }), + 'pending', + 'created', + ), + ).toBe(false); + // localStatus pending 本就计入 + expect( + matchesSecondaryFilter( + mkPr({ localStatus: 'pending', hasConflict: false }), + 'pending', + 'created', + ), + ).toBe(true); + }); }); describe('matchesPrQuery', () => { @@ -137,6 +169,20 @@ describe('filterPullRequests', () => { it('conflict 横切筛选', () => { expect(filterPullRequests(prs, { secondary: 'conflict' }).map((p) => p.remoteId)).toEqual(['3']); }); + it("'created' + 'pending' 并入冲突的已通过 PR", () => { + const createdPrs = [ + mkPr({ remoteId: '10', localStatus: 'pending', discoveryFilters: ['created'] }), + mkPr({ + remoteId: '11', + localStatus: 'approved', + hasConflict: true, + discoveryFilters: ['created'], + }), + mkPr({ remoteId: '12', localStatus: 'approved', discoveryFilters: ['created'] }), + ]; + const out = filterPullRequests(createdPrs, { primary: 'created', secondary: 'pending' }); + expect(out.map((p) => p.remoteId)).toEqual(['10', '11']); + }); }); describe('PR_SECONDARY_FILTERS', () => { From b7949dd02559718b32e37d1b01769867c9dc3e15 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:37:49 +0800 Subject: [PATCH 26/84] =?UTF-8?q?fix(desktop):=20=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E9=A1=B5=20PR-Agent=20=E4=BB=85=E6=98=BE=E7=A4=BA=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E5=B9=B6=E5=AF=B9=E9=BD=90=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=A4=A7=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运行环境信息中 pr-agent 一行去除 embedded/local-cli 运行策略前缀(对用户无 意义),仅展示版本号;键名对齐官方品牌名 PR-Agent 的大小写。 Co-Authored-By: Claude Opus 4.8 --- .../settings/sections/RuntimeSection.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx index cfea4c70..2620a002 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/RuntimeSection.tsx @@ -9,18 +9,21 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE const { t } = useTranslation(); // 复制后短暂切到「打勾 + 已复制」绿色态作反馈(无 toast 体系,按钮内联反馈)。 const [copied, setCopied] = useState(false); - // pr-agent 运行时状态(版本 / 策略):从状态栏弱化下沉到此处按需展示,打开关于页时拉取。 + // pr-agent 运行时状态:从状态栏弱化下沉到此处按需展示,打开关于页时拉取。 const [prAgent, setPrAgent] = useState(null); useEffect(() => { void invoke('app:prAgentStatus', undefined) .then(setPrAgent) .catch(() => setPrAgent(null)); }, []); - const prAgentText = prAgent - ? prAgent.available - ? `${prAgent.strategy} · ${prAgent.version}` - : t('statusBar.prAgentUnavailable') - : '…'; + // 仅展示版本号(不显示 embedded/local-cli 运行策略——对用户无意义):embedded 的 version 形如 + // `pr-agent 0.36.0` → 取 `0.36.0`;local-cli 为 help 首行 → 截到首个空白前。 + const prAgentVer = prAgent?.available + ? prAgent.strategy === 'embedded' + ? prAgent.version.replace(/^pr-agent\s+/, '') + : prAgent.version.split(/\s+/)[0] || prAgent.version + : null; + const prAgentText = prAgent ? (prAgentVer ?? t('statusBar.prAgentUnavailable')) : '…'; // 操作系统:平台代号 + 系统版本合并展示(如「darwin 15.5」)。 const osText = `${info.platform} ${info.osVersion}`.trim(); @@ -32,7 +35,7 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE `Node: ${info.nodeVersion}`, `${t('settings.operatingSystem')}: ${osText}`, `${t('settings.architecture')}: ${info.arch}`, - `pr-agent: ${prAgentText}`, + `PR-Agent: ${prAgentText}`, ].join('\n'); const copyInfo = (): void => { @@ -69,7 +72,7 @@ export function RuntimeSection({ info, updateEnabled }: { info: AppInfo; updateE
{osText}
{t('settings.architecture')}
{info.arch}
-
pr-agent
+
PR-Agent
{prAgentText}
From 05b9464d39c38a6b8add6edb63e0a9061ba7a21e Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:45:29 +0800 Subject: [PATCH 27/84] =?UTF-8?q?fix(desktop):=20PR=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=BA=8C=E7=BA=A7=E7=AD=9B=E9=80=89=E8=83=B6=E5=9B=8A=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E8=87=AA=E9=80=82=E5=BA=94=EF=BC=8C=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E5=AE=B9=E5=BE=97=E4=B8=8B=E5=8D=B3=E4=B8=8D=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 二级状态筛选按钮改为宽度自适应胶囊:basis 取内容宽,一行挤得下就用一行;容纳不下 时按内容宽换行(窄侧栏自然落为每行两个),flex-grow 使每行按项数均分撑满整行,消除 定宽按钮换行后右侧留白参差;内容居中读作对称胶囊。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/renderer/src/styles/layout/sidebar.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss index 0ae2cfc4..0776a879 100644 --- a/apps/desktop/src/renderer/src/styles/layout/sidebar.scss +++ b/apps/desktop/src/renderer/src/styles/layout/sidebar.scss @@ -45,6 +45,14 @@ .sidebar-filters { flex-wrap: wrap; + + // 胶囊宽度自适应:basis 取内容宽(auto)→ 一行挤得下就用一行;容纳不下时按内容宽换行(窄侧栏下 + // 自然落为每行两个)。flex-grow 让每行按项数均分撑满整行,消除定宽按钮换行后右侧留白参差。 + // 内容居中,读作对称胶囊。 + .btn { + flex: 1 1 auto; + justify-content: center; + } } .sidebar-search { From 386abc8ba38f3298776fe5bb92a3e3280059595c Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:49:42 +0800 Subject: [PATCH 28/84] =?UTF-8?q?docs(changelog):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=9C=AC=E5=88=86=E6=94=AF=20PR=20=E5=88=97=E8=A1=A8=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=B8=8E=E4=B8=BB=E9=A2=98=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E6=94=B9=E8=89=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 Unreleased:新增发现分类未读圆点;变更状态栏 pr-agent 版本弱化、「我创建的」 待处理并入冲突、二级筛选胶囊宽度自适应;修复分组头背景与 Windows 窗控随主题。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86aad51d..8ef17e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 - 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作。 - **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR 与操作评审 Agent,便于脚本与外部 agent 集成;与本地 API 一致,只读取向、不含写操作。 +- **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 + +### ♻️ 变更 + +- 状态栏不再常态显示 pr-agent 版本号(减少常态噪声),仅在其不可用时保留告警提示;版本号改在设置「关于」页的运行环境信息中展示。 +- 「我创建的」分类下的「待处理」筛选并入存在合并冲突的 PR:作者视角下有冲突的 PR 需其跟进解决(即便评审已通过),故一并计入待处理。 +- PR 列表的状态二级筛选改为宽度自适应胶囊:一行容得下即不换行,容纳不下时换行并按每行项数均分撑满整行,消除换行后右侧留白参差。 + +### 🔧 修复 + +- 修复 PR 列表分组标题背景色、Windows 窗口右上角控制按钮此前不随主题(编辑器配色主题)变化的问题;现二者均跟随当前主题派生配色,深浅 / 主题切换实时生效。 ## [0.8.0] - 2026-06-30 From 1dd32ecb099d0f255c3942ef30806700e945e50a Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 15:58:46 +0800 Subject: [PATCH 29/84] =?UTF-8?q?fix(desktop):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=8F=E5=BE=85=E8=AF=84=E5=AE=A1=20PR=20=E8=AE=A1=E6=95=B0?= =?UTF-8?q?=20chip=20=E6=94=B9=E7=94=A8=E4=B8=BB=E9=A2=98=E5=BC=BA?= =?UTF-8?q?=E8=B0=83=E8=93=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实色绿 chip(tone=ok)改为与 pr-agent 活动 / repo sync 同一蓝调(chip-tone accent),与主题强调色一致。 Co-Authored-By: Claude Opus 4.8 --- .../src/components/features/pr/statusbar/PrsCountChip.tsx | 1 - .../src/renderer/src/styles/features/pr/statusbar.scss | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx index 563d6731..cf68d2ff 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/statusbar/PrsCountChip.tsx @@ -6,7 +6,6 @@ export function PrsCountChip({ count }: { count: number }) { const { t } = useTranslation(); return ( Date: Wed, 1 Jul 2026 16:16:21 +0800 Subject: [PATCH 30/84] =?UTF-8?q?refactor(cli):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=20GUI=20=E4=B8=BB=E9=85=8D=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E8=87=AA=E5=8A=A8=E5=8F=91=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E4=BF=A1=E6=81=AF=E9=A1=BB=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=8F=90=E4=BE=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 不再读应用主配置 config.yaml 的 service 段自动取 host/port/token:该文件承载 各代码平台访问令牌等连接层机密,静默取服务令牌等于让 CLI 越权触达预期外凭据。连接 改为仅 flag > 环境变量(MEEBOX_API_URL / MEEBOX_TOKEN)> cli.yaml,token 缺失即报 鉴权错误;环境变量为本机免逐次传参的推荐方式。同步更新 CLI 设计 / 使用文档与 arch 索引。 Co-Authored-By: Claude Opus 4.8 --- cli/cmd/root.go | 4 +- cli/internal/settings/settings.go | 63 ++++---------------------- cli/internal/settings/settings_test.go | 4 +- docs/arch/04-integration/02-cli.md | 14 +++--- docs/arch/README.md | 2 +- docs/guide/06-cli.md | 14 +++--- 6 files changed, 30 insertions(+), 71 deletions(-) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 8707981f..af9035db 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -32,8 +32,8 @@ func newRootCmd() *cobra.Command { Version: version, } pf := root.PersistentFlags() - pf.StringVar(&gflags.apiURL, "api-url", "", "API base URL (overrides env and local auto-discovery)") - pf.StringVar(&gflags.token, "token", "", "bearer token (overrides env and local auto-discovery)") + pf.StringVar(&gflags.apiURL, "api-url", "", "API base URL (overrides "+settings.EnvAPIURL+" and cli.yaml)") + pf.StringVar(&gflags.token, "token", "", "bearer token (overrides "+settings.EnvToken+" and cli.yaml)") pf.StringVar(&gflags.output, "output", "yaml", "output format: yaml|json") pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") diff --git a/cli/internal/settings/settings.go b/cli/internal/settings/settings.go index 6bb0fe49..2fe70a0a 100644 --- a/cli/internal/settings/settings.go +++ b/cli/internal/settings/settings.go @@ -1,6 +1,11 @@ // Package settings resolves the API base URL and bearer token used by the CLI, // following the precedence documented in docs/arch/04-integration/02-cli.md: -// flag > env > CLI config file > local auto-discovery of the app config. +// flag > env > CLI config file (~/.code-meeseeks/cli.yaml). +// +// The CLI deliberately does NOT read the GUI's config.yaml: that file holds +// connection-layer secrets (platform tokens etc.), and silently sourcing the +// service token from it would let the CLI reach into credentials it has no +// business touching. Connection details must be provided explicitly. package settings import ( @@ -37,18 +42,14 @@ type Settings struct { // ErrNoToken indicates no bearer token could be resolved from any source. var ErrNoToken = errors.New("no API token: pass --token, set " + EnvToken + - ", or enable the service listener in the app") + ", or add `token` to ~/.code-meeseeks/cli.yaml") // Resolve applies the documented precedence (lowest first, overwritten by // higher sources) and returns the final connection settings. func Resolve(ov Overrides) (Settings, error) { var s Settings - // 4) lowest precedence: local auto-discovery from the app config. - if disc, ok := discoverFromAppConfig(); ok { - s = disc - } - // 3) CLI config file. + // 3) lowest precedence: CLI config file. if cfg, ok := loadCLIConfig(); ok { if cfg.APIURL != "" { s.APIURL = cfg.APIURL @@ -81,21 +82,9 @@ func Resolve(ov Overrides) (Settings, error) { return s, nil } -// appConfig is the slice of the app's main config we care about. -type appConfig struct { - Service struct { - Enabled bool `yaml:"enabled"` - Host string `yaml:"host"` - Port int `yaml:"port"` - Token string `yaml:"token"` - } `yaml:"service"` -} - -// discoverFromAppConfig reads the app's main config at ~/.code-meeseeks/config.yaml -// and, when the service listener is enabled with a token, derives settings from -// it — giving same-machine, same-user integrations a zero-config experience. // appHome returns the app's fixed data directory (~/.code-meeseeks), shared by the GUI -// and CLI. Both meebox configs live here (GUI: config.yaml, CLI: cli.yaml). +// and CLI. The CLI's own config (cli.yaml) lives here; the GUI's config.yaml also lives +// here but the CLI never reads it (see package doc). func appHome() (string, bool) { home, err := os.UserHomeDir() if err != nil { @@ -104,38 +93,6 @@ func appHome() (string, bool) { return filepath.Join(home, ".code-meeseeks"), true } -func discoverFromAppConfig() (Settings, bool) { - home, ok := appHome() - if !ok { - return Settings{}, false - } - data, err := os.ReadFile(filepath.Join(home, "config.yaml")) - if err != nil { - return Settings{}, false - } - var cfg appConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - return Settings{}, false - } - svc := cfg.Service - if !svc.Enabled || svc.Token == "" { - return Settings{}, false - } - host := svc.Host - if host == "" || host == "0.0.0.0" { - // 0.0.0.0 is a bind address, not a dial target — assume loopback locally. - host = defaultHost - } - port := svc.Port - if port == 0 { - port = defaultPort - } - return Settings{ - APIURL: fmt.Sprintf("http://%s:%d", host, port), - Token: svc.Token, - }, true -} - // cliConfig is the CLI's own optional config file. type cliConfig struct { APIURL string `yaml:"api_url"` diff --git a/cli/internal/settings/settings_test.go b/cli/internal/settings/settings_test.go index 09c0d562..0f39b2fa 100644 --- a/cli/internal/settings/settings_test.go +++ b/cli/internal/settings/settings_test.go @@ -6,8 +6,8 @@ import ( ) // isolateHome points HOME / USERPROFILE at an empty temp dir so os.UserHomeDir -// resolves there — keeping Resolve's local auto-discovery (~/.code-meeseeks/*) from -// reading the developer's real app config and making these tests non-hermetic. +// resolves there — keeping Resolve's CLI config lookup (~/.code-meeseeks/cli.yaml) +// from reading the developer's real file and making these tests non-hermetic. func isolateHome(t *testing.T) { t.Helper() dir := t.TempDir() diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index fa8589dd..aee20de1 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -43,11 +43,13 @@ CLI 需 API base URL + token。来源优先级(高 → 低): 1. 命令行 flag:`--api-url` / `--token`; 2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; -3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置); -4. **本机自动发现**:同机同用户时,读用户主目录下的应用主配置 `~/.code-meeseeks/config.yaml` 的 `service` - 段,自动取 `host`/`port`/`token`——本机集成**零配置**开箱即用。 +3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置)。 -远端(服务端绑 `0.0.0.0`)场景无法自动发现,须显式给 `--api-url` + `--token`。token 缺失即报鉴权错误。 +连接信息须**显式提供**(flag / 环境变量 / `cli.yaml` 三者之一),token 缺失即报鉴权错误。 + +**不读取 GUI 主配置**:CLI 刻意**不**读应用主配置 `~/.code-meeseeks/config.yaml`。该文件承载连接层机密 +(各代码平台的访问令牌等),若从中静默取服务令牌,等于让 CLI 触达其本不应接触的凭据——属预期外的越权访问, +故移除此前的「本机自动发现」设计。环境变量 `MEEBOX_TOKEN` 是本机免逐次传参的推荐方式(配合 shell / CI 环境注入)。 ### 命令结构 @@ -92,7 +94,7 @@ meebox [全局 flag] <组> <命令> [参数] ## 数据 / 接口契约 - **配置来源优先级**:flag > env(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)> CLI 配置文件 - (`~/.code-meeseeks/cli.yaml`)> 本机 `~/.code-meeseeks/config.yaml` 自动发现。 + (`~/.code-meeseeks/cli.yaml`)。连接信息须显式提供;CLI 不读 GUI 主配置 `config.yaml`(含连接层机密)。 - **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 - **二进制与压缩包命名**:`meebox-cli---.`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), @@ -110,7 +112,7 @@ meebox [全局 flag] <组> <命令> [参数] - **只读边界**:写操作显式不提供;新增命令前先确认对应 API 端点已存在且为只读。 - **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 -- **本机自动发现的边界**:仅同机同用户可读主目录下的 `~/.code-meeseeks/config.yaml`;远端 / 跨用户必须显式配 URL + token。 +- **不触碰 GUI 机密**:CLI 不读应用主配置 `~/.code-meeseeks/config.yaml`(含各平台访问令牌等连接层机密);服务令牌须经 flag / 环境变量 / `cli.yaml` 显式提供,避免越权触达预期外凭据。 - **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 - **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 - **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` / diff --git a/docs/arch/README.md b/docs/arch/README.md index 31c9cac0..5064f3ed 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -49,7 +49,7 @@ docs/arch/ │ └── 04-i18n.md 国际化(react-i18next / 双运行时 / key 命名 / 翻译规范 / 模板翻译) ├── 04-integration/ 外部集成扩展与 CLI │ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 只读边界 / 路由复用 service) -│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 本机自动发现 / 跨平台分发) +│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 显式连接配置 / 跨平台分发) └── 99-core/ 基础设施 ├── 01-state-storage.md 状态存储与数据模型(StateStore / per-PR 目录 / 存储模型 + 业务生命周期) ├── 02-config-and-secrets.md 配置与凭据(config.yaml / SecretStore / 设置页 / 首启向导) diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index f811edd4..1b8212b0 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -26,24 +26,24 @@ CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 1. 命令行参数:`--api-url` / `--token` 2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` 3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) -4. **本机自动发现**:同机同用户时,自动读应用配置 `~/.code-meeseeks/config.yaml` 的服务监听设置 -因此**在开启服务的本机上零配置即可用**——直接运行命令,自动读取本机地址与令牌: +连接信息须**显式提供**其一。令牌在设置页「集成」分区查看 / 复制。本机免逐次传参推荐用环境变量: ```bash +export MEEBOX_API_URL=http://127.0.0.1:18765 +export MEEBOX_TOKEN=<令牌> meebox pr list ``` -远端访问(服务监听 `0.0.0.0`)需显式提供地址与令牌: +远端访问(服务监听 `0.0.0.0`)同样显式提供地址与令牌: ```bash meebox --api-url http://<主机>:18765 --token <令牌> pr list -# 或经环境变量 -export MEEBOX_API_URL=http://<主机>:18765 -export MEEBOX_TOKEN=<令牌> -meebox pr list ``` +> CLI **不读取** GUI 主配置 `~/.code-meeseeks/config.yaml`:该文件含代码平台访问令牌等连接层机密, +> 不从中取服务令牌,避免越权触达预期外的凭据。API 地址默认 `http://127.0.0.1:18765`(未显式指定时)。 + ## 4. 命令 ```text From d2829052255716315404441f333fc58c223c8417 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:23:49 +0800 Subject: [PATCH 31/84] =?UTF-8?q?fix(desktop):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=8F=91=E5=B8=83=E8=AF=84=E8=AE=BA=E3=80=8C?= =?UTF-8?q?=E5=85=A8=E9=80=89=20/=20=E5=8F=96=E6=B6=88=E5=85=A8=E9=80=89?= =?UTF-8?q?=E3=80=8D=E6=8C=89=E9=92=AE=E7=BC=BA=E5=A4=B1=E7=9A=84=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishReviewModal 工具栏的切换按钮用了未定义的 .btn-link 类,回退到浏览器默认按钮 外观。补一个文本型按钮样式(无底无边框、强调蓝、hover 下划线),读作行内链接动作。 Co-Authored-By: Claude Opus 4.8 --- .../desktop/src/renderer/src/styles/base.scss | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/src/renderer/src/styles/base.scss b/apps/desktop/src/renderer/src/styles/base.scss index b191aa16..6b6cf0bf 100644 --- a/apps/desktop/src/renderer/src/styles/base.scss +++ b/apps/desktop/src/renderer/src/styles/base.scss @@ -157,6 +157,25 @@ textarea { } } +// 文本型按钮:无底 / 无边框的链接态动作(工具栏里的轻量操作,如「全选 / 取消全选」), +// 强调蓝、hover 加下划线;不占按钮实底,读作行内链接。 +.btn-link { + background: none; + border: none; + padding: 0; + color: $color-accent; + font: inherit; + cursor: pointer; + + &:hover:not(:disabled) { + text-decoration: underline; + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + .count-pill { background: $bg-white-fade; padding: 0 $space-3; From f49e928e3cc62c50ad197822859ae621619c255a Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:23:49 +0800 Subject: [PATCH 32/84] =?UTF-8?q?chore(cli):=20editorconfig=20=E8=A1=A5=20?= =?UTF-8?q?Go=20=E7=BC=A9=E8=BF=9B=E8=A7=84=E5=88=99=EF=BC=88tab=EF=BC=8C?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=AE=BD=E5=BA=A6=204=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前无 [*.go] 段,Go 文件落到 [*] 默认的 space/2,与 gofmt 恒用 tab 冲突。补 [*.go] 与 [go.mod] 段:缩进用 tab(gofmt 强制、非空格),tab 显示宽度约定为 4。 Co-Authored-By: Claude Opus 4.8 --- .editorconfig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.editorconfig b/.editorconfig index 3026ff88..c3bba3c4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,6 +27,15 @@ indent_size = 2 indent_style = space indent_size = 4 +# Go 由 gofmt 统一格式化:缩进恒用 tab(非空格),此处仅约定 tab 的显示宽度为 4。 +[*.go] +indent_style = tab +tab_width = 4 + +[go.mod] +indent_style = tab +tab_width = 4 + [{Makefile,makefile,GNUmakefile,*.mk}] indent_style = tab tab_width = 4 From fc48ffbf6b6ec14a8910bd657145caf6cfeb740f Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:34:26 +0800 Subject: [PATCH 33/84] =?UTF-8?q?feat(cli):=20PR=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=8A=95=E5=BD=B1=20+=20skip/limit=20?= =?UTF-8?q?=E5=88=86=E9=A1=B5=20+=20category/status=20=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端 GET /prs 改为返回精简列表投影 PrListItem(新增 api-server/views.ts 作视图层 树结构约束单一投影):去 description 明细、人员仅 slug(reviewer 附 status)、字段序 以 id/title/author/createdAt 优先;localId 对外命名为 id。新增 skip/limit 分页(默认 limit 100)。一二级分类改用语义化命名:query 参数 category(原 primary)/ status(原 secondary),/categories 响应键随之改为 categories/statuses。 CLI:pr list 新增 --category/--status/--skip/--limit;渲染层改为保序输出(JSON 走 json.Indent、YAML 走 json 令牌流构建 yaml.Node),使服务端字段序透传到视图,不再被 map 排序打乱。补保序单测。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/services/api-server/routes.ts | 27 ++++-- .../src/main/services/api-server/views.ts | 68 +++++++++++++ cli/cmd/integration_test.go | 6 +- cli/cmd/pr.go | 26 +++-- cli/internal/render/render.go | 97 +++++++++++++++++-- cli/internal/render/render_test.go | 42 ++++++++ 6 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/main/services/api-server/views.ts diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index 1a47f81b..9debd872 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -12,6 +12,7 @@ import * as agentCtl from '../../controllers/agent.js'; import * as prCtl from '../../controllers/pr.js'; import { getContext } from '../context.js'; import { HttpError } from './http.js'; +import { toPrListItem } from './views.js'; /** * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 @@ -48,7 +49,10 @@ function seg(path: string): string[] { return path.split('/').filter(Boolean); } -/** 当前启用平台下可用的分类标签:一级(平台发现分类)+ 二级(状态 / 合并态筛选)。 */ +/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */ +const DEFAULT_LIMIT = 100; + +/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */ const categories: RouteHandler = () => { const ctx = getContext(); const activeId = ctx.bootstrap.config.active_connection_id; @@ -56,27 +60,32 @@ const categories: RouteHandler = () => { ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) : undefined; const caps = built?.adapter.connection.capabilities(); - const primary: PrDiscoveryFilter[] = caps?.discoveryFilters + const categoryList: PrDiscoveryFilter[] = caps?.discoveryFilters ? [...caps.discoveryFilters] : ['review-requested']; return { platform: built?.adapter.kind ?? null, - primary, - secondary: [...PR_SECONDARY_FILTERS], + categories: categoryList, + statuses: [...PR_SECONDARY_FILTERS], }; }; /** - * PR 列表(不分页)+ 一级 / 二级分类过滤 + 检索。过滤语义复用 @meebox/shared 的纯谓词 - * (与渲染层侧栏同源),此处仅做查询参数解析 + 委派。 + * PR 列表:`category`(一级发现分类)+ `status`(二级状态 / 合并态)过滤 + `q` 检索 + + * `skip`/`limit` 分页(默认 limit 100)。过滤语义复用 @meebox/shared 的纯谓词(与渲染层侧栏同源); + * 返回**精简列表投影**({@link toPrListItem},去 description 明细、人员仅 slug),此处仅解析参数 + 委派。 */ const listPrs: RouteHandler = async ({ query }) => { const all = await prCtl.listPrs(NO_EVENT, undefined); - return filterPullRequests(all, { - primary: (query.get('primary') as PrDiscoveryFilter) || undefined, - secondary: (query.get('secondary') as PrSecondaryFilter) || undefined, + const filtered = filterPullRequests(all, { + primary: (query.get('category') as PrDiscoveryFilter) || undefined, + secondary: (query.get('status') as PrSecondaryFilter) || undefined, query: query.get('q') ?? undefined, }); + const skip = Math.max(0, Number.parseInt(query.get('skip') ?? '', 10) || 0); + const limitRaw = Number.parseInt(query.get('limit') ?? '', 10); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : DEFAULT_LIMIT; + return filtered.slice(skip, skip + limit).map(toPrListItem); }; const showPr: RouteHandler = ({ params }) => getContext().pr.findPrOrThrow(params.id); diff --git a/apps/desktop/src/main/services/api-server/views.ts b/apps/desktop/src/main/services/api-server/views.ts new file mode 100644 index 00000000..c23e8ba7 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/views.ts @@ -0,0 +1,68 @@ +import type { + LocalPrStatus, + PlatformKind, + PrDiscoveryFilter, + ReviewerStatus, + StoredPullRequest, +} from '@meebox/shared'; + +/** + * PR 列表视图项:`GET /prs` 对外暴露的**精简投影**。这是「请求接口视图层的树结构约束方法」—— + * 单一投影函数 {@link toPrListItem} 定义列表返回的字段集合与次序,避免直接把整条 + * StoredPullRequest(含 description 明细、完整人员对象等)泄给列表消费方。 + * + * 收窄原则: + * - 只给标识与概览,**去掉 description 明细**(详情走 `GET /prs/{id}`); + * - **人员信息只留 slug**(reviewer 另带 status);头像 / 展示名等留给详情; + * - **字段顺序即输出顺序**:id / title / author / createdAt 优先,再给其余概览字段。 + */ +export interface PrListItem { + /** PR 的本地稳定标识(== StoredPullRequest.localId);写操作与详情端点均按此定位。 */ + id: string; + title: string; + /** 作者 slug(缺失时回退 name);不含展示名 / 头像。 */ + author: string; + createdAt: string; + /** 本人评审决断(pending / approved / needs_work)。 */ + status: LocalPrStatus; + state: 'open' | 'merged' | 'declined'; + draft: boolean; + platform: PlatformKind; + /** `projectKey/repoSlug`。 */ + repo: string; + /** 远端平台 PR 编号。 */ + remoteId: string; + updatedAt: string; + hasConflict: boolean; + /** 远端判定可直接合并(== mergeStatus.canMerge)。 */ + mergeable: boolean; + /** 命中的发现分类(一级 category)。 */ + categories: PrDiscoveryFilter[]; + /** 评审人:仅 slug + status。 */ + reviewers: Array<{ slug: string; status: ReviewerStatus }>; + unread: boolean; + unreadMentionCount: number; +} + +/** 把存储态 PR 投影为列表视图项。对象字面量的键序即 JSON 输出顺序(CLI 视图层据此渲染)。 */ +export function toPrListItem(pr: StoredPullRequest): PrListItem { + return { + id: pr.localId, + title: pr.title, + author: pr.author.slug ?? pr.author.name, + createdAt: pr.createdAt, + status: pr.localStatus, + state: pr.state, + draft: pr.draft, + platform: pr.platform, + repo: `${pr.repo.projectKey}/${pr.repo.repoSlug}`, + remoteId: pr.remoteId, + updatedAt: pr.updatedAt, + hasConflict: pr.hasConflict, + mergeable: pr.mergeStatus?.canMerge === true, + categories: pr.discoveryFilters, + reviewers: pr.reviewers.map((r) => ({ slug: r.slug ?? r.name, status: r.status })), + unread: pr.unread ?? false, + unreadMentionCount: pr.unreadMentionCount ?? 0, + }; +} diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index e6104e18..1b4f0a6c 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -69,7 +69,7 @@ func base(srvURL string, rest ...string) []string { func TestCategories(t *testing.T) { var rec capturedReq - srv := mockServer(&rec, 200, `{"platform":"github","primary":["review-requested"],"secondary":["all"]}`) + srv := mockServer(&rec, 200, `{"platform":"github","categories":["review-requested"],"statuses":["all"]}`) defer srv.Close() out, err := runCmd(base(srv.URL, "categories")...) @@ -92,13 +92,13 @@ func TestPrListFilters(t *testing.T) { srv := mockServer(&rec, 200, `[]`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "list", "--primary", "created", "--secondary", "approved", "--query", "foo")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "list", "--category", "created", "--status", "approved", "--query", "foo", "--skip", "5", "--limit", "20")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.path != "/api/v1/prs" { t.Errorf("wrong path: %s", rec.path) } - for _, want := range []string{"primary=created", "secondary=approved", "q=foo"} { + for _, want := range []string{"category=created", "status=approved", "q=foo", "skip=5", "limit=20"} { if !strings.Contains(rec.query, want) { t.Errorf("query %q missing %q", rec.query, want) } diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go index 10390280..237806b6 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -2,6 +2,7 @@ package cmd import ( "net/url" + "strconv" "github.com/spf13/cobra" ) @@ -23,10 +24,11 @@ func newPrCmd() *cobra.Command { } func newPrListCmd() *cobra.Command { - var primary, secondary, query string + var category, status, query string + var skip, limit int cmd := &cobra.Command{ Use: "list", - Short: "List PRs (no pagination) with optional category and search filters", + Short: "List PRs with category / status filters and skip+limit pagination", Args: cobra.NoArgs, RunE: func(_ *cobra.Command, _ []string) error { c, err := resolveClient() @@ -34,15 +36,21 @@ func newPrListCmd() *cobra.Command { return err } q := url.Values{} - if primary != "" { - q.Set("primary", primary) + if category != "" { + q.Set("category", category) } - if secondary != "" { - q.Set("secondary", secondary) + if status != "" { + q.Set("status", status) } if query != "" { q.Set("q", query) } + if skip > 0 { + q.Set("skip", strconv.Itoa(skip)) + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } data, err := c.Get("/api/v1/prs", q) if err != nil { return err @@ -51,9 +59,11 @@ func newPrListCmd() *cobra.Command { }, } f := cmd.Flags() - f.StringVar(&primary, "primary", "", "primary category (platform discovery filter)") - f.StringVar(&secondary, "secondary", "", "secondary filter (review status / merge state)") + f.StringVar(&category, "category", "", "discovery category (review-requested|created|assigned|mentioned)") + f.StringVar(&status, "status", "", "status filter (pending|approved|needs_work|conflict|mergeable)") f.StringVar(&query, "query", "", "search text (title / repo / author / number)") + f.IntVar(&skip, "skip", 0, "skip the first N results (pagination offset)") + f.IntVar(&limit, "limit", 0, "max results to return (default 100 when unset)") return cmd } diff --git a/cli/internal/render/render.go b/cli/internal/render/render.go index 8f529bfc..4ede5095 100644 --- a/cli/internal/render/render.go +++ b/cli/internal/render/render.go @@ -3,11 +3,13 @@ package render import ( + "bytes" "encoding/json" "errors" "fmt" "io" "os" + "strings" "github.com/huhamhire/code-meeseeks/cli/internal/apiclient" "github.com/huhamhire/code-meeseeks/cli/internal/settings" @@ -57,15 +59,17 @@ func writeJSON(data json.RawMessage) error { fmt.Fprintln(Stdout, "null") return nil } - var v any - if err := json.Unmarshal(data, &v); err != nil { - // Valid JSON we can't re-decode into `any` is unlikely; print verbatim. + // Indent the raw bytes rather than unmarshal→marshal: json.Indent preserves the + // server's object key order (the view-layer field order), which decoding into a + // Go map would lose. + var buf bytes.Buffer + if err := json.Indent(&buf, data, "", " "); err != nil { fmt.Fprintln(Stdout, string(data)) return nil } - enc := json.NewEncoder(Stdout) - enc.SetIndent("", " ") - return enc.Encode(v) + buf.WriteByte('\n') + _, err := Stdout.Write(buf.Bytes()) + return err } func writeYAML(data json.RawMessage) error { @@ -73,13 +77,15 @@ func writeYAML(data json.RawMessage) error { fmt.Fprintln(Stdout, "null") return nil } - var v any - if err := json.Unmarshal(data, &v); err != nil { + // Build the YAML tree from the JSON token stream so object key order is preserved + // (a Go map would sort keys and drop the server's intended field order). + node, err := jsonToYAMLNode(data) + if err != nil { // Not decodable as JSON — fall back to the raw payload. fmt.Fprintln(Stdout, string(data)) return nil } - out, err := yaml.Marshal(v) + out, err := yaml.Marshal(node) if err != nil { return err } @@ -87,6 +93,79 @@ func writeYAML(data json.RawMessage) error { return err } +// jsonToYAMLNode decodes JSON into a *yaml.Node tree, preserving object key order +// (unlike decoding into map[string]any, which loses insertion order). +func jsonToYAMLNode(data []byte) (*yaml.Node, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + return buildYAMLValue(dec) +} + +func buildYAMLValue(dec *json.Decoder) (*yaml.Node, error) { + tok, err := dec.Token() + if err != nil { + return nil, err + } + if delim, ok := tok.(json.Delim); ok { + switch delim { + case '{': + m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, err + } + key, _ := keyTok.(string) + val, err := buildYAMLValue(dec) + if err != nil { + return nil, err + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, val) + } + if _, err := dec.Token(); err != nil { // consume '}' + return nil, err + } + return m, nil + case '[': + s := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for dec.More() { + val, err := buildYAMLValue(dec) + if err != nil { + return nil, err + } + s.Content = append(s.Content, val) + } + if _, err := dec.Token(); err != nil { // consume ']' + return nil, err + } + return s, nil + } + } + return scalarYAMLNode(tok), nil +} + +func scalarYAMLNode(tok json.Token) *yaml.Node { + switch t := tok.(type) { + case string: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: t} + case json.Number: + tag := "!!int" + if strings.ContainsAny(t.String(), ".eE") { + tag = "!!float" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: t.String()} + case bool: + v := "false" + if t { + v = "true" + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: v} + default: // nil + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"} + } +} + // Errorln prints an error to stderr. func Errorln(err error) { fmt.Fprintln(Stderr, "error:", err) diff --git a/cli/internal/render/render_test.go b/cli/internal/render/render_test.go index 860a88e5..226414ab 100644 --- a/cli/internal/render/render_test.go +++ b/cli/internal/render/render_test.go @@ -63,6 +63,48 @@ func TestOutputYAMLAndJSON(t *testing.T) { } } +// TestOutputPreservesKeyOrder locks in that both renderers keep the server's object +// key order (the view-layer field order) rather than sorting keys alphabetically. +func TestOutputPreservesKeyOrder(t *testing.T) { + orig := Stdout + defer func() { Stdout = orig }() + var buf bytes.Buffer + Stdout = &buf + + // Keys deliberately out of alphabetical order; also exercises int / bool / array scalars. + data := json.RawMessage( + `{"id":"abc","title":"fix","author":"alice","createdAt":"2026-01-01",` + + `"unreadMentionCount":0,"mergeable":true,"categories":["created"]}`) + + assertKeyOrder := func(mode Mode, keys []string) { + buf.Reset() + if err := Output(mode, data); err != nil { + t.Fatalf("output: %v", err) + } + s := buf.String() + last := -1 + for _, k := range keys { + idx := strings.Index(s, k) + if idx < 0 { + t.Fatalf("missing %q in %q", k, s) + } + if idx < last { + t.Fatalf("key %q out of order in %q", k, s) + } + last = idx + } + } + assertKeyOrder(ModeYAML, []string{"id:", "title:", "author:", "createdAt:"}) + assertKeyOrder(ModeJSON, []string{`"id"`, `"title"`, `"author"`, `"createdAt"`}) + + // int / bool scalars render unquoted (not as strings) + buf.Reset() + _ = Output(ModeYAML, data) + if y := buf.String(); !strings.Contains(y, "unreadMentionCount: 0") || !strings.Contains(y, "mergeable: true") { + t.Errorf("scalar typing lost in yaml: %q", y) + } +} + func TestOutputEmptyData(t *testing.T) { orig := Stdout defer func() { Stdout = orig }() From 8606aed57789a58bd9bda998228525a9654f4ac0 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:39:34 +0800 Subject: [PATCH 34/84] =?UTF-8?q?refactor(cli):=20PR=20id=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20--pr=20=E4=BC=A0=E5=8F=82=EF=BC=8Cagent=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E5=BD=92=E5=85=A5=20pr=20=E7=BB=84=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 命令树调整: - PR 标识改用显式命名 flag `--pr `(原位置参数),各 PR 关联命令调用更自描述; 新增 prIDFlag 辅助统一注册为必填。 - agent 命令与 PR 强关联、且必带 PR id,故整组下沉到 `pr agent ...`(原顶层 `agent`)。 - 每个命令构造器补 Go doc 注释(用途 + 对应 API 端点)。 Co-Authored-By: Claude Opus 4.8 --- cli/cmd/agent.go | 91 ++++++++++++++++++++++++------------- cli/cmd/categories.go | 2 + cli/cmd/integration_test.go | 14 +++--- cli/cmd/pr.go | 87 ++++++++++++++++++++++++----------- cli/cmd/root.go | 1 - 5 files changed, 130 insertions(+), 65 deletions(-) diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go index 0fd23da9..9fabca70 100644 --- a/cli/cmd/agent.go +++ b/cli/cmd/agent.go @@ -8,9 +8,10 @@ import ( "github.com/spf13/cobra" ) -// readOnlyInstructions is the set of agent instructions the CLI may send. -// Write tools (approve / needswork / publish …) are intentionally excluded — -// the server also hard-refuses them, this is a friendly front-line check. +// readOnlyInstructions is the set of agent instructions the CLI may send via +// `pr agent instruct`. These are the read-only pr-agent tools; write review actions +// have dedicated commands (`pr approve` / `pr needswork` / `pr comment`) and are not +// routed through instruct. The server independently enforces the same whitelist. var readOnlyInstructions = map[string]bool{ "describe": true, "review": true, @@ -18,6 +19,8 @@ var readOnlyInstructions = map[string]bool{ "improve": true, } +// newAgentCmd builds the `pr agent` subgroup: review-agent operations, all PR-scoped +// (each requires `--pr `). Wiring only; nested under `pr`. func newAgentCmd() *cobra.Command { a := &cobra.Command{ Use: "agent", @@ -33,90 +36,116 @@ func newAgentCmd() *cobra.Command { return a } +// newAgentStatusCmd builds `pr agent status --pr `: the agent's current run state +// snapshot (GET /prs/{id}/agent). func newAgentStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status ", + var pr string + cmd := &cobra.Command{ + Use: "status", Short: "Show the agent's current execution status", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent") + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/agent") }, } + prIDFlag(cmd, &pr) + return cmd } +// newAgentHistoryCmd builds `pr agent history --pr `: the multi-turn conversation +// history (GET /prs/{id}/agent/conversation). func newAgentHistoryCmd() *cobra.Command { - return &cobra.Command{ - Use: "history ", + var pr string + cmd := &cobra.Command{ + Use: "history", Short: "Show the agent conversation history", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/agent/conversation") + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/agent/conversation") }, } + prIDFlag(cmd, &pr) + return cmd } +// newAgentReviewCmd builds `pr agent review --pr `: kicks off the review micro-flow +// (describe→review→ask→summary) (POST /prs/{id}/agent/review). func newAgentReviewCmd() *cobra.Command { - return &cobra.Command{ - Use: "review ", + var pr string + cmd := &cobra.Command{ + Use: "review", Short: "Run auto review on a PR", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { c, err := resolveClient() if err != nil { return err } - data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/review", nil) + data, err := c.Post("/api/v1/prs/"+url.PathEscape(pr)+"/agent/review", nil) if err != nil { return err } return renderData(data) }, } + prIDFlag(cmd, &pr) + return cmd } +// newAgentInstructCmd builds `pr agent instruct --pr [args...]`: sends a +// single read-only pr-agent instruction (POST /prs/{id}/agent/instruct). Write tools are +// rejected up front (and again by the server). func newAgentInstructCmd() *cobra.Command { - return &cobra.Command{ - Use: "instruct [args...]", + var pr string + cmd := &cobra.Command{ + Use: "instruct [args...]", Short: "Send a read-only agent instruction (describe|review|ask|improve)", - Args: cobra.MinimumNArgs(2), + Args: cobra.MinimumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - instruction := strings.TrimPrefix(args[1], "/") + instruction := strings.TrimPrefix(args[0], "/") if !readOnlyInstructions[instruction] { - return fmt.Errorf("instruction %q is not a read-only command; write operations are not supported via the CLI", args[1]) + return fmt.Errorf("instruction %q is not a read-only command; use `pr approve` / `pr needswork` / `pr comment` for write actions", args[0]) } c, err := resolveClient() if err != nil { return err } body := map[string]any{"command": instruction} - if len(args) > 2 { - body["args"] = strings.Join(args[2:], " ") + if len(args) > 1 { + body["args"] = strings.Join(args[1:], " ") } - data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/instruct", body) + data, err := c.Post("/api/v1/prs/"+url.PathEscape(pr)+"/agent/instruct", body) if err != nil { return err } return renderData(data) }, } + prIDFlag(cmd, &pr) + return cmd } +// newAgentChatCmd builds `pr agent chat --pr `: sends a natural-language +// message that may trigger agent tasks (POST /prs/{id}/agent/chat). func newAgentChatCmd() *cobra.Command { - return &cobra.Command{ - Use: "chat ", + var pr string + cmd := &cobra.Command{ + Use: "chat ", Short: "Send a natural-language chat message (may trigger agent tasks)", - Args: cobra.MinimumNArgs(2), + Args: cobra.MinimumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { c, err := resolveClient() if err != nil { return err } - body := map[string]any{"message": strings.Join(args[1:], " ")} - data, err := c.Post("/api/v1/prs/"+url.PathEscape(args[0])+"/agent/chat", body) + body := map[string]any{"message": strings.Join(args, " ")} + data, err := c.Post("/api/v1/prs/"+url.PathEscape(pr)+"/agent/chat", body) if err != nil { return err } return renderData(data) }, } + prIDFlag(cmd, &pr) + return cmd } diff --git a/cli/cmd/categories.go b/cli/cmd/categories.go index d7e5d21b..e3ee2a71 100644 --- a/cli/cmd/categories.go +++ b/cli/cmd/categories.go @@ -2,6 +2,8 @@ package cmd import "github.com/spf13/cobra" +// newCategoriesCmd builds `meebox categories`: lists the enabled platform's available +// filter labels — `categories` (discovery) and `statuses` (review/merge) (GET /categories). func newCategoriesCmd() *cobra.Command { return &cobra.Command{ Use: "categories", diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index 1b4f0a6c..f2f6c0d6 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -110,7 +110,7 @@ func TestPrShow(t *testing.T) { srv := mockServer(&rec, 200, `{"localId":"abc123","title":"t"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "show", "abc123")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "show", "--pr", "abc123")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodGet || rec.path != "/api/v1/prs/abc123" { @@ -123,7 +123,7 @@ func TestPrDiffFile(t *testing.T) { srv := mockServer(&rec, 200, `{"binary":false,"content":"x"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "diff", "abc123", "--file", "src/a.go", "--side", "head")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "diff", "--pr", "abc123", "--file", "src/a.go", "--side", "head")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.path != "/api/v1/prs/abc123/diff" { @@ -141,7 +141,7 @@ func TestAgentReviewPost(t *testing.T) { srv := mockServer(&rec, 200, `{"status":"succeeded"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "agent", "review", "abc123")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "agent", "review", "--pr", "abc123")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/review" { @@ -154,7 +154,7 @@ func TestAgentInstructBody(t *testing.T) { srv := mockServer(&rec, 200, `{"status":"queued"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "describe", "extra", "ctx")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "agent", "instruct", "--pr", "abc123", "describe", "extra", "ctx")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/instruct" { @@ -170,7 +170,7 @@ func TestAgentInstructWriteToolRejected(t *testing.T) { srv := mockServer(&rec, 200, `null`) defer srv.Close() - _, err := runCmd(base(srv.URL, "agent", "instruct", "abc123", "approve")...) + _, err := runCmd(base(srv.URL, "pr", "agent", "instruct", "--pr", "abc123", "approve")...) if err == nil { t.Fatal("expected write tool to be rejected") } @@ -184,7 +184,7 @@ func TestAgentChatPost(t *testing.T) { srv := mockServer(&rec, 200, `{"queued":true}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "agent", "chat", "abc123", "hello", "world")...); err != nil { + if _, err := runCmd(base(srv.URL, "pr", "agent", "chat", "--pr", "abc123", "hello", "world")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/chat" { @@ -214,7 +214,7 @@ func TestNotFoundExitCode(t *testing.T) { srv := mockServer(&rec, 404, "") defer srv.Close() - _, err := runCmd(base(srv.URL, "pr", "show", "missing")...) + _, err := runCmd(base(srv.URL, "pr", "show", "--pr", "missing")...) if err == nil { t.Fatal("expected not-found error") } diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go index 237806b6..3fa21148 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -7,10 +7,20 @@ import ( "github.com/spf13/cobra" ) +// prIDFlag registers the required `--pr ` flag (the `id` field from `pr list`) +// on cmd and binds it to target. Making the PR id an explicit named flag (rather than +// a positional arg) keeps every PR-scoped command's invocation self-describing. +func prIDFlag(cmd *cobra.Command, target *string) { + cmd.Flags().StringVar(target, "pr", "", "PR id (the `id` field from `pr list`)") + _ = cmd.MarkFlagRequired("pr") +} + +// newPrCmd builds the `pr` command group: PR browsing / write actions plus the +// PR-scoped `agent` subgroup. It carries no logic itself, only wiring subcommands. func newPrCmd() *cobra.Command { pr := &cobra.Command{ Use: "pr", - Short: "Browse pull requests", + Short: "Browse and act on pull requests", } pr.AddCommand( newPrListCmd(), @@ -19,10 +29,15 @@ func newPrCmd() *cobra.Command { newPrActivityCmd(), newPrCommitsCmd(), newPrReviewersCmd(), + // Agent is PR-scoped (every agent op requires a PR id), so it nests under `pr`. + newAgentCmd(), ) return pr } +// newPrListCmd builds `pr list`: the paginated, filtered PR list (GET /prs). Returns +// the slim list projection (id / title / author / createdAt first); category/status +// map to the discovery + review/merge filters, skip/limit drive pagination. func newPrListCmd() *cobra.Command { var category, status, query string var skip, limit int @@ -67,24 +82,30 @@ func newPrListCmd() *cobra.Command { return cmd } +// newPrShowCmd builds `pr show --pr `: the full PR detail incl. description (GET /prs/{id}). func newPrShowCmd() *cobra.Command { - return &cobra.Command{ - Use: "show ", + var pr string + cmd := &cobra.Command{ + Use: "show", Short: "Show PR description detail", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0])) + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr)) }, } + prIDFlag(cmd, &pr) + return cmd } +// newPrDiffCmd builds `pr diff --pr `: the changed-file list, or (with --file) one +// file's content on the given --side (GET /prs/{id}/diff[?path=&side=]). func newPrDiffCmd() *cobra.Command { - var file, side string + var pr, file, side string cmd := &cobra.Command{ - Use: "diff ", + Use: "diff", Short: "List changed files, or fetch one file's content with --file", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { c, err := resolveClient() if err != nil { return err @@ -96,47 +117,61 @@ func newPrDiffCmd() *cobra.Command { if side != "" { q.Set("side", side) } - data, err := c.Get("/api/v1/prs/"+url.PathEscape(args[0])+"/diff", q) + data, err := c.Get("/api/v1/prs/"+url.PathEscape(pr)+"/diff", q) if err != nil { return err } return renderData(data) }, } + prIDFlag(cmd, &pr) cmd.Flags().StringVar(&file, "file", "", "fetch this file's content instead of the changed-file list") cmd.Flags().StringVar(&side, "side", "", "file side when --file is set: base|head") return cmd } +// newPrActivityCmd builds `pr activity --pr `: the merged activity timeline +// (comments / commits / review decisions) (GET /prs/{id}/activity). func newPrActivityCmd() *cobra.Command { - return &cobra.Command{ - Use: "activity ", + var pr string + cmd := &cobra.Command{ + Use: "activity", Short: "Show the PR activity timeline (comments / commits / review decisions)", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/activity") + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/activity") }, } + prIDFlag(cmd, &pr) + return cmd } +// newPrCommitsCmd builds `pr commits --pr `: the PR's own commits (GET /prs/{id}/commits). func newPrCommitsCmd() *cobra.Command { - return &cobra.Command{ - Use: "commits ", + var pr string + cmd := &cobra.Command{ + Use: "commits", Short: "List the PR commits", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/commits") + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/commits") }, } + prIDFlag(cmd, &pr) + return cmd } +// newPrReviewersCmd builds `pr reviewers --pr `: reviewer approval status (GET /prs/{id}/reviewers). func newPrReviewersCmd() *cobra.Command { - return &cobra.Command{ - Use: "reviewers ", + var pr string + cmd := &cobra.Command{ + Use: "reviewers", Short: "Show reviewer approval status", - Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - return getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/reviewers") + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/reviewers") }, } + prIDFlag(cmd, &pr) + return cmd } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index af9035db..9cc581bb 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -40,7 +40,6 @@ func newRootCmd() *cobra.Command { root.AddCommand( newCategoriesCmd(), newPrCmd(), - newAgentCmd(), ) return root } From 9511195a7dfa26a8c747fdc4d5b6cf49128bfdc1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:44:15 +0800 Subject: [PATCH 35/84] =?UTF-8?q?feat(cli):=20=E5=BC=80=E6=94=BE=20PR=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E5=86=99=E6=93=8D=E4=BD=9C=20approve=20/=20n?= =?UTF-8?q?eedswork=20/=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打破此前「纯只读」边界,提供评审写能力(复用 GUI 同源 controller,均为真实远端写): - 服务端新增 POST /prs/{id}/approve、/needswork(setPrStatus 写远端评审决断)与 /comment(createComment 发顶层评论,空正文 400)。 - CLI 新增 pr approve / pr needswork / pr comment ;抽 postAndRender 复用。 仍不暴露:merge(合并)与 pr-agent 变更类工具(publish 等,instruct 只读白名单不变)。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/services/api-server/routes.ts | 27 +++++++++- cli/cmd/integration_test.go | 42 +++++++++++++++ cli/cmd/pr.go | 53 +++++++++++++++++++ cli/cmd/root.go | 14 +++++ 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index 9debd872..7bbb56d4 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -16,8 +16,11 @@ import { toPrListItem } from './views.js'; /** * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 - * `(event, req)` 且只读路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。 - * 只读边界:写工具一律不暴露(见 agent/instruct)。见 docs/arch/04-integration/01-service-api.md。 + * `(event, req)` 且这些路径不触碰 event,故以 NO_EVENT 占位调用,避免在 HTTP 侧另起一套实现。 + * + * 写边界:开放**评审写操作**——approve / needswork(远端评审决断)与顶层 comment(发评论), + * 均复用 GUI 同源 controller。仍**不**暴露:merge(合并)、pr-agent 的变更类工具(publish 等, + * 见 agent/instruct 的只读白名单)。见 docs/arch/04-integration/01-service-api.md。 */ // controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 @@ -138,6 +141,23 @@ const agentChat: RouteHandler = ({ params, body }) => { return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); }; +/** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */ +const approve: RouteHandler = ({ params }) => + prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' }); + +/** 评审决断「需修改」:先写远端评审状态、再落本地。 */ +const needswork: RouteHandler = ({ params }) => + prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'needs_work' }); + +/** 发一条顶层(不锚文件)评论到远端 PR。body.body 为评论正文,空则 400。 */ +const comment: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { body?: string }; + if (!b.body?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'comment body required' }); + } + return prCtl.createComment(NO_EVENT, { localId: params.id, body: b.body }); +}; + export const routes: Route[] = [ { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, @@ -151,6 +171,9 @@ export const routes: Route[] = [ { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, + { method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve }, + { method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork }, + { method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment }, ]; /** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index f2f6c0d6..8027f1da 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -195,6 +195,48 @@ func TestAgentChatPost(t *testing.T) { } } +func TestPrApprovePost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"localStatus":"approved"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "approve", "--pr", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/approve" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestPrNeedsworkPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"localStatus":"needs_work"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "needswork", "--pr", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/needswork" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestPrCommentPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"remoteId":"c1"}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "comment", "--pr", "abc123", "please", "fix")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/comment" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(rec.body, "please fix") { + t.Errorf("body missing comment text: %q", rec.body) + } +} + func TestAuthFailureExitCode(t *testing.T) { var rec capturedReq srv := mockServer(&rec, 401, "") diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go index 3fa21148..2b557dca 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -3,6 +3,7 @@ package cmd import ( "net/url" "strconv" + "strings" "github.com/spf13/cobra" ) @@ -29,6 +30,9 @@ func newPrCmd() *cobra.Command { newPrActivityCmd(), newPrCommitsCmd(), newPrReviewersCmd(), + newPrApproveCmd(), + newPrNeedsworkCmd(), + newPrCommentCmd(), // Agent is PR-scoped (every agent op requires a PR id), so it nests under `pr`. newAgentCmd(), ) @@ -175,3 +179,52 @@ func newPrReviewersCmd() *cobra.Command { prIDFlag(cmd, &pr) return cmd } + +// newPrApproveCmd builds `pr approve --pr `: records an Approve review decision on +// the platform, i.e. a real remote write (POST /prs/{id}/approve). +func newPrApproveCmd() *cobra.Command { + var pr string + cmd := &cobra.Command{ + Use: "approve", + Short: "Approve the PR (posts a real review decision to the platform)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return postAndRender("/api/v1/prs/"+url.PathEscape(pr)+"/approve", nil) + }, + } + prIDFlag(cmd, &pr) + return cmd +} + +// newPrNeedsworkCmd builds `pr needswork --pr `: records a Needs-Work review decision +// on the platform, i.e. a real remote write (POST /prs/{id}/needswork). +func newPrNeedsworkCmd() *cobra.Command { + var pr string + cmd := &cobra.Command{ + Use: "needswork", + Short: "Mark the PR as needs-work (posts a real review decision to the platform)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return postAndRender("/api/v1/prs/"+url.PathEscape(pr)+"/needswork", nil) + }, + } + prIDFlag(cmd, &pr) + return cmd +} + +// newPrCommentCmd builds `pr comment --pr `: posts a top-level comment +// to the PR on the platform (POST /prs/{id}/comment). +func newPrCommentCmd() *cobra.Command { + var pr string + cmd := &cobra.Command{ + Use: "comment ", + Short: "Post a top-level comment on the PR", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return postAndRender("/api/v1/prs/"+url.PathEscape(pr)+"/comment", + map[string]any{"body": strings.Join(args, " ")}) + }, + } + prIDFlag(cmd, &pr) + return cmd +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 9cc581bb..85944018 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -88,3 +88,17 @@ func getAndRender(path string) error { } return renderData(data) } + +// postAndRender is the common POST-then-render path used by action commands +// (agent triggers, review write actions). body may be nil for parameterless POSTs. +func postAndRender(path string, body any) error { + c, err := resolveClient() + if err != nil { + return err + } + data, err := c.Post(path, body) + if err != nil { + return err + } + return renderData(data) +} From 06db332f846f768731bcfd9a08c6044efe7781e7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:46:21 +0800 Subject: [PATCH 36/84] =?UTF-8?q?feat(cli):=20=E6=96=B0=E5=A2=9E=20agent?= =?UTF-8?q?=20stop=20=E4=B8=AD=E6=96=AD=20PR=20=E8=BF=90=E8=A1=8C=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E8=AF=84=E5=AE=A1=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端新增 POST /prs/{id}/agent/stop(复用 agent:stop / orchestrator.stop),CLI 新增 pr agent stop --pr 。粒度取 PR 级——中断该 PR 的 Agent 于思考 / 执行任意阶段即时停; 不做按单个工具 run 的中断(runId 用户侧不易获取,PR 级更贴合 CLI 使用)。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/services/api-server/routes.ts | 5 +++++ cli/cmd/agent.go | 18 ++++++++++++++++++ cli/cmd/integration_test.go | 13 +++++++++++++ 3 files changed, 36 insertions(+) diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index 7bbb56d4..db6f97d6 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -141,6 +141,10 @@ const agentChat: RouteHandler = ({ params, body }) => { return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); }; +/** 中断该 PR 正在运行的 Agent(思考 / 执行任意阶段即时停)。PR 级停,非按单个工具 run。 */ +const agentStop: RouteHandler = ({ params }) => + agentCtl.stopAgent(NO_EVENT, { localId: params.id }); + /** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */ const approve: RouteHandler = ({ params }) => prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' }); @@ -171,6 +175,7 @@ export const routes: Route[] = [ { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/stop'), handler: agentStop }, { method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve }, { method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork }, { method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment }, diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go index 9fabca70..92db4392 100644 --- a/cli/cmd/agent.go +++ b/cli/cmd/agent.go @@ -32,6 +32,7 @@ func newAgentCmd() *cobra.Command { newAgentReviewCmd(), newAgentInstructCmd(), newAgentChatCmd(), + newAgentStopCmd(), ) return a } @@ -149,3 +150,20 @@ func newAgentChatCmd() *cobra.Command { prIDFlag(cmd, &pr) return cmd } + +// newAgentStopCmd builds `pr agent stop --pr `: interrupts the PR's running agent in +// any phase (thinking / executing) (POST /prs/{id}/agent/stop). PR-level stop — it halts +// the whole agent for that PR, not one specific tool run. +func newAgentStopCmd() *cobra.Command { + var pr string + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the PR's running agent (interrupts any in-progress phase)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return postAndRender("/api/v1/prs/"+url.PathEscape(pr)+"/agent/stop", nil) + }, + } + prIDFlag(cmd, &pr) + return cmd +} diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index 8027f1da..8d3a1022 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -237,6 +237,19 @@ func TestPrCommentPost(t *testing.T) { } } +func TestAgentStopPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"ok":true}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "agent", "stop", "--pr", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/stop" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + func TestAuthFailureExitCode(t *testing.T) { var rec capturedReq srv := mockServer(&rec, 401, "") From a256d65ad306de761957033f5cf391ec44347380 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 16:53:30 +0800 Subject: [PATCH 37/84] =?UTF-8?q?feat(cli):=20=E6=96=B0=E5=A2=9E=20whoami?= =?UTF-8?q?=20=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=9F=A5=E7=9C=8B=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E8=BA=AB=E4=BB=BD=E4=B8=8E=E9=9B=86=E6=88=90=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端新增 GET /whoami:返回活动连接的 PAT 所属用户(name/displayName/slug)、平台种类 与连接显示名(收窄,不带 capabilities 大对象);无活动连接时各项为 null。CLI 新增顶层 meebox whoami,便于确认令牌解析到的账号 / 平台是否符合预期。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/services/api-server/routes.ts | 24 +++++++++++++++++++ cli/cmd/integration_test.go | 17 +++++++++++++ cli/cmd/root.go | 1 + cli/cmd/whoami.go | 17 +++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 cli/cmd/whoami.go diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index db6f97d6..d9b0d1b5 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -73,6 +73,29 @@ const categories: RouteHandler = () => { }; }; +/** + * 当前身份与集成平台:活动连接的 PAT 所属用户(name / displayName / slug)+ 平台种类 + + * 连接显示名。无活动连接时各项为 null。刻意收窄——不带 capabilities(那是 GUI 降级用的大对象)。 + */ +const whoami: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + if (!activeId || !built) { + return { platform: null, connectionId: null, displayName: null, user: null }; + } + const conn = ctx.bootstrap.config.connections.find((c) => c.id === activeId); + const user = built.adapter.connection.getCurrentUser(); + return { + platform: built.adapter.kind, + connectionId: activeId, + displayName: conn?.display_name ?? activeId, + user: user ? { name: user.name, displayName: user.displayName, slug: user.slug ?? null } : null, + }; +}; + /** * PR 列表:`category`(一级发现分类)+ `status`(二级状态 / 合并态)过滤 + `q` 检索 + * `skip`/`limit` 分页(默认 limit 100)。过滤语义复用 @meebox/shared 的纯谓词(与渲染层侧栏同源); @@ -164,6 +187,7 @@ const comment: RouteHandler = ({ params, body }) => { export const routes: Route[] = [ { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, + { method: 'GET', segments: seg('/api/v1/whoami'), handler: whoami }, { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, { method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr }, { method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff }, diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index 8d3a1022..29870624 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -87,6 +87,23 @@ func TestCategories(t *testing.T) { } } +func TestWhoami(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"platform":"github","user":{"slug":"alice"}}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "whoami")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/whoami" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(out, "platform: github") { + t.Errorf("output missing rendered field: %q", out) + } +} + func TestPrListFilters(t *testing.T) { var rec capturedReq srv := mockServer(&rec, 200, `[]`) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 85944018..fa43e794 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -38,6 +38,7 @@ func newRootCmd() *cobra.Command { pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") root.AddCommand( + newWhoamiCmd(), newCategoriesCmd(), newPrCmd(), ) diff --git a/cli/cmd/whoami.go b/cli/cmd/whoami.go new file mode 100644 index 00000000..2342c0e2 --- /dev/null +++ b/cli/cmd/whoami.go @@ -0,0 +1,17 @@ +package cmd + +import "github.com/spf13/cobra" + +// newWhoamiCmd builds `meebox whoami`: the current authenticated user (from the active +// connection's PAT) plus the integrated platform and connection display name (GET /whoami). +// Handy first call to confirm the token resolves to the expected account / platform. +func newWhoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the current user identity and integrated platform", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/whoami") + }, + } +} From 1e137663e7efe341b9a1b1e1c7d4e3c98602454b Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 17:00:16 +0800 Subject: [PATCH 38/84] =?UTF-8?q?docs(cli):=20=E5=AF=B9=E9=BD=90=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=20CLI/API=20=E6=94=B9=E5=8A=A8=E7=9A=84=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E4=B8=8E=E4=BD=BF=E7=94=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 CLI 优化的全部对外契约变更: - 写边界改述:开放评审写动作(approve / needswork / comment),合并与 pr-agent 变更类工具(publish 等)仍不开放;instruct 只读白名单不变。 - 端点表 / 命令表重写:新增 whoami、approve/needswork/comment、agent/stop;PR 列表 改精简投影 + skip/limit 分页 + category/status 命名;PR 标识对外为 id、命令用 --pr。 - 连接改述:移除本机自动发现,连接须显式提供、不读 GUI config.yaml。 - 覆盖 arch service-api / arch cli / arch README / guide 06-cli / AGENTS.md / CHANGELOG。 Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 +- CHANGELOG.md | 4 +- docs/arch/04-integration/01-service-api.md | 65 +++++++++++++--------- docs/arch/04-integration/02-cli.md | 46 +++++++++------ docs/arch/README.md | 2 +- docs/guide/06-cli.md | 43 ++++++++------ 6 files changed, 99 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37d6b1e2..01658b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,8 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 - **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 - **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 - **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 -- **只读边界**:CLI 只做浏览与评审操作,**不提供评论发送等写操作**;写工具(approve/needswork/publish)在 CLI 与服务端双重硬拒绝。新增命令先确认对应 API 端点已存在且只读——CLI 不得绕过 API 直连应用内部。 -- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向),`--output json` 供机器;配置走环境变量 / flag / `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 隔离),代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 +- **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。 +- **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 ## 约定 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef17e77..d4e44be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ - **外部集成 · 本地 API 服务**:设置新增「集成」分区,可开启一个本机 API 服务,将 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 工具 / 脚本集成。 - 默认关闭;开启即强制访问令牌鉴权,令牌可一键生成 / 显示 / 复制 / 重新生成。 - 监听地址可自定义:默认仅本机可达,按需可开放到局域网(开放时给出安全提示)。 - - 仅开放浏览与评审操作(PR 列表 / 详情 / diff / 动态 / 提交 / 评审人审批,以及评审 Agent 的状态 / 历史 / 自动评审 / 指令 / 对话),不提供评论发送等写操作。 -- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR 与操作评审 Agent,便于脚本与外部 agent 集成;与本地 API 一致,只读取向、不含写操作。 + - 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)。 +- **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr `;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 - **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 ### ♻️ 变更 diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md index 333af30f..cd860893 100644 --- a/docs/arch/04-integration/01-service-api.md +++ b/docs/arch/04-integration/01-service-api.md @@ -8,10 +8,13 @@ 负责:服务监听开关与生命周期、bearer token 鉴权、请求路由与响应封装、把内部能力映射成稳定的 HTTP 契约。 +开放的**写操作**限定为评审动作:approve / needswork(远端评审决断)与顶层 comment(发评论),复用 +GUI 同源 controller(见下「写边界」)。 + **不负责**: -- **写操作**(发评论、审批、发布草稿等对远端有副作用的动作)—— API 一律不开放;有集成需求由调用方 - 自行用平台 API 实现(见下「只读边界」)。 +- **合并与 pr-agent 变更类工具** —— merge(合并 PR)、pr-agent 的 publish 等变更工具不开放;有此需求由 + 调用方自行用平台 API 实现(见下「写边界」)。 - **多用户 / 远端服务形态** —— 仍是单用户本地应用,API 只是本机(或可选局域网)的入站通道,不引入账户体系。 - 业务逻辑本身 —— 复用 IPC controller 同源的 service 层,不在 HTTP 侧另起一套实现。 @@ -52,13 +55,17 @@ - 原则:核心能力沉在 service 层,IPC 与 HTTP 各自只做**薄封装 + 协议适配**。新增 API 端点前,先确保对应能力 在 service 层有可复用方法(必要时把 controller 内联逻辑下沉到 service)。 -### 只读边界(写操作的硬拒绝) +### 写边界(放开评审动作,拒绝合并与变更类 Agent 工具) -- API 暴露的 Agent 操作**仅限只读工具**(`/describe`·`/review`·`/ask`·`/improve` 一族)。修改类工具 - (`/approve`·`/needswork`·`/publish` 等,见工具注册表 `kind: 'mutating'`)**在 API 层即被硬拒绝**—— - 与 Agent 自身的 grant 授权闸**相互独立**:即便某 PR 的 AutoPilot grants 授予了写权限,经 API 发起的指令 - 仍不得触发写工具。 -- 由此「**不支持二次确认**」自然成立:API 无交互确认通道,只读指令直接执行、无需确认;需确认的写操作干脆不开放。 +- 开放的写操作**限定评审动作**:`POST …/approve`·`…/needswork`(远端评审决断,复用 `prs:setLocalStatus`—— + 先写远端评审状态、再落本地)与 `POST …/comment`(发顶层评论,复用 `comments:create`)。均为真实远端写。 +- Agent 指令(`…/agent/instruct`)**仍限只读工具**(`/describe`·`/review`·`/ask`·`/improve`);变更类工具 + (`/publish` 等,见工具注册表 `kind: 'mutating'`)**在 API 层即被硬拒绝**——与 Agent 自身 grant 授权闸 + **相互独立**:即便某 PR 的 AutoPilot grants 授予了写权限,经 API 的 instruct 仍不得触发写工具。评审写动作 + 改走上面的 approve / needswork / comment 专用端点,不经 instruct。 +- **不开放合并(merge)**:对远端影响大且不可逆,暂不纳入 API。 +- **无二次确认**:API 无交互确认通道;已开放的写端点直接执行(调用方自负授权),需交互确认的动作(如合并) + 干脆不开放。 ### 生命周期与热生效 @@ -100,29 +107,36 @@ service: { "ok": false, "error": { "code": "ESV0001", "meta": { /* ... */ } } } ``` -- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写操作不开放 / `404` 资源不存在 / +- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写工具被拒(未开放的写操作)/ `404` 资源不存在 / `409` 冲突 / `500` 内部错误。 - 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)): 如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。 ### 端点(`/api/v1`,逐条对应 [CLI](02-cli.md) 命令) +读端点用 `GET`、写端点用 `POST`。列表返回**精简投影**,其余读端点返回同源结构。 + | Method & Path | 用途 | 复用的内部能力 | | --- | --- | --- | -| `GET /api/v1/categories` | 当前启用平台下可用的分类标签:一级(`PrDiscoveryFilter`)+ 二级(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | -| `GET /api/v1/prs` | PR 列表(**不分页**、返回全部基础信息);query:`primary`(一级)/`secondary`(二级)/`q`(检索:标题 / 仓库 / 作者 / 编号) | `prs:list` 同源(`StoredPullRequest[]`) | -| `GET /api/v1/prs/{localId}` | 描述详情(标题 / 描述 / 作者 / 分支 / 时间 / 状态 / 合并态) | `StoredPullRequest` 概要 | -| `GET /api/v1/prs/{localId}/diff` | 变更文件列表;带 `?path=&side=base\|head` 时取单文件内容 | `diff:listChangedFiles` / `diff:getFileContent` 同源 | -| `GET /api/v1/prs/{localId}/activity` | 动态(评论 / 提交更新 / 评审决断归并的时间线) | `diff:listActivity` 同源 | -| `GET /api/v1/prs/{localId}/commits` | 提交列表(`PrCommit[]`) | `diff:listCommits` 同源 | -| `GET /api/v1/prs/{localId}/reviewers` | 评审人审批状态(`Reviewer[]`,含各人 `status`) | `StoredPullRequest.reviewers` | -| `GET /api/v1/prs/{localId}/agent` | Agent 当前执行状态(`AgentSession`:status / 进度 / 总结 / 建议) | `agent:getSession` 同源 | -| `GET /api/v1/prs/{localId}/agent/conversation` | 历史会话(`AgentMessage[]`) | `agent:getConversation` 同源 | -| `POST /api/v1/prs/{localId}/agent/review` | 执行 auto review(固定评审微流程 describe→review→[追问]→总结) | `agent:run` 同源 | -| `POST /api/v1/prs/{localId}/agent/instruct` | 发送 Agent 指令(**仅只读工具**:describe / review / ask / improve;写工具硬拒绝、无二次确认) | 只读工具派发(复用 run 队列) | -| `POST /api/v1/prs/{localId}/agent/chat` | 发送自然语言聊天(可触发 Agent 规划与任务执行) | `agent:ask` / `agent:enqueueMessage` 同源 | - -- `localId` 为跨平台稳定 PR 标识(内部哈希,非平台 `remoteId`);所有 PR 维度端点以它定位。 +| `GET /api/v1/whoami` | 当前身份:活动连接 PAT 所属用户(`name`/`displayName`/`slug`)+ 集成平台 + 连接显示名;无活动连接各项 null | 连接摘要(当前用户 + 平台) | +| `GET /api/v1/categories` | 当前启用平台下可用的分类标签:`categories`(`PrDiscoveryFilter`)+ `statuses`(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | +| `GET /api/v1/prs` | PR 列表(**精简投影** `PrListItem`:字段序 id/title/author/createdAt 优先,去 description、人员仅 slug);query:`category`(一级)/`status`(二级)/`q`(检索)/`skip`+`limit`(分页,默认 limit 100) | `prs:list` + 列表筛选谓词 + 视图投影 | +| `GET /api/v1/prs/{id}` | 描述详情(完整 `StoredPullRequest`:标题 / 描述 / 作者 / 分支 / 时间 / 状态 / 合并态) | `StoredPullRequest` | +| `GET /api/v1/prs/{id}/diff` | 变更文件列表;带 `?path=&side=base\|head` 时取单文件内容 | `diff:listChangedFiles` / `diff:getFileContent` 同源 | +| `GET /api/v1/prs/{id}/activity` | 动态(评论 / 提交更新 / 评审决断归并的时间线) | `diff:listActivity` 同源 | +| `GET /api/v1/prs/{id}/commits` | 提交列表(`PrCommit[]`) | `diff:listCommits` 同源 | +| `GET /api/v1/prs/{id}/reviewers` | 评审人审批状态(`Reviewer[]`,含各人 `status`) | `StoredPullRequest.reviewers` | +| `GET /api/v1/prs/{id}/agent` | Agent 当前执行状态(`AgentSession`:status / 进度 / 总结 / 建议) | `agent:getSession` 同源 | +| `GET /api/v1/prs/{id}/agent/conversation` | 历史会话(`AgentMessage[]`) | `agent:getConversation` 同源 | +| `POST /api/v1/prs/{id}/agent/review` | 执行 auto review(固定评审微流程 describe→review→[追问]→总结) | `agent:run` 同源 | +| `POST /api/v1/prs/{id}/agent/instruct` | 发送 Agent 指令(**仅只读工具**:describe / review / ask / improve;写工具硬拒绝) | 只读工具派发(复用 run 队列) | +| `POST /api/v1/prs/{id}/agent/chat` | 发送自然语言聊天(可触发 Agent 规划与任务执行) | `agent:ask` / `agent:enqueueMessage` 同源 | +| `POST /api/v1/prs/{id}/agent/stop` | 中断该 PR 运行中的 Agent(思考 / 执行任意阶段即时停;PR 级,非按单个 run) | `agent:stop` 同源 | +| `POST /api/v1/prs/{id}/approve` | 评审决断「通过」(写远端评审状态 + 落本地) | `prs:setLocalStatus` 同源 | +| `POST /api/v1/prs/{id}/needswork` | 评审决断「需修改」(写远端评审状态 + 落本地) | `prs:setLocalStatus` 同源 | +| `POST /api/v1/prs/{id}/comment` | 发一条顶层评论(body 为正文,空则 400) | `comments:create` 同源 | + +- `{id}` 即 PR 的 `localId`——跨平台稳定 PR 标识(内部哈希,非平台 `remoteId`);列表投影里对外命名为 `id`,所有 PR 维度端点以它定位。 - 过程步骤(transcript)暂不在初版 API 内开放,作为将来扩展位(见下)。 ### 新增 IPC(设置页驱动) @@ -133,8 +147,9 @@ service: ## 扩展与注意事项 - **加新端点先下沉 service**:HTTP 与 IPC 必须共用 service 方法,避免逻辑分叉;端点是 service 能力的薄投影。 -- **写操作边界是硬约束**:只读工具白名单在 API 层强校验,独立于 Agent grant 闸;新增工具时同步确认其 - `kind` 与是否纳入 API 白名单,默认排除一切 `mutating`。 +- **写边界是硬约束**:评审写动作仅经 approve / needswork / comment 专用端点;Agent `instruct` 的只读工具 + 白名单在 API 层强校验、独立于 Agent grant 闸——新增 Agent 工具时同步确认其 `kind` 与是否纳入 instruct + 白名单,默认排除一切 `mutating`。放开新的写端点须显式评估远端副作用(合并等高影响动作暂不开放)。 - **`0.0.0.0` 安全警示不可省**:设置页与使用文档须明确暴露范围与风险;token 是唯一防线。 - **端口冲突**:监听失败以非致命方式提示,不拖垮应用启动;提示用户改端口。 - **进度推送是将来扩展位**:初版以「轮询 `GET .../agent` 拉状态」为主;如需实时进度,可在同一监听器上加 diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index aee20de1..42ca89c3 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -6,12 +6,12 @@ agent / 脚本 / CI 把 meebox 的 PR 发现、浏览与 Agent 操作纳入自动化流程。命令名 **`meebox`**。 负责:把 API 端点封装成顺手的命令树、解析连接 / 鉴权配置、按人 / 机两种消费方式输出(文本 / JSON)、 -约定退出码。 +约定退出码。提供浏览与**评审写动作**(approve / needswork / comment)——与服务端写边界一致。 **不负责**: - 业务逻辑 —— CLI 是 API 的瘦客户端,不内置任何评审 / 平台逻辑。 -- **写操作**(发评论、审批、发布等)—— 不提供对应命令;API 本就不开放(见 [服务端的只读边界](01-service-api.md))。 +- **合并与变更类 Agent 工具**(merge / publish 等)—— 不提供对应命令;API 本就不开放(见 [服务端写边界](01-service-api.md))。 - 桌面应用本体 —— CLI **不内嵌进安装包**,是独立可分发物(见下「分发」)。 ## 核心设计 @@ -59,23 +59,32 @@ meebox [全局 flag] <组> <命令> [参数] 全局 flag:--api-url · --token · --output (yaml|json) · --quiet ``` +PR 关联命令统一用**必填 flag `--pr `** 传 PR 标识(`id` 由 `pr list` 输出获得);agent 命令与 PR +强绑定、必带 id,故整组归入 `pr agent …`。 + | 命令 | 用途 | 对应 API | | --- | --- | --- | -| `meebox categories` | 列当前启用平台下可用的分类标签(一级 + 二级) | `GET /categories` | -| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页、全部基础信息) | `GET /prs` | -| `meebox pr show ` | 描述详情 | `GET /prs/{id}` | -| `meebox pr diff [--file ] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | `GET /prs/{id}/diff` | -| `meebox pr activity ` | 动态(时间线) | `GET /prs/{id}/activity` | -| `meebox pr commits ` | 提交列表 | `GET /prs/{id}/commits` | -| `meebox pr reviewers ` | 评审人审批状态 | `GET /prs/{id}/reviewers` | -| `meebox agent status ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | -| `meebox agent history ` | 历史会话 | `GET /prs/{id}/agent/conversation` | -| `meebox agent review ` | 执行 auto review | `POST /prs/{id}/agent/review` | -| `meebox agent instruct [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | -| `meebox agent chat ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | - -- `` 为 PR 的 `localId`(由 `pr list` 输出获得)。 -- 写工具不在 `instruct` 白名单内;传入即被服务端拒绝(CLI 也可前置友好报错)。 +| `meebox whoami` | 当前身份(用户 + 平台 + 连接名) | `GET /whoami` | +| `meebox categories` | 列当前启用平台的分类标签(`categories` 一级 + `statuses` 二级) | `GET /categories` | +| `meebox pr list [--category <一级>] [--status <二级>] [--query <检索>] [--skip N] [--limit N]` | PR 列表(精简投影 + 分页,默认 limit 100) | `GET /prs` | +| `meebox pr show --pr ` | 描述详情 | `GET /prs/{id}` | +| `meebox pr diff --pr [--file ] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | `GET /prs/{id}/diff` | +| `meebox pr activity --pr ` | 动态(时间线) | `GET /prs/{id}/activity` | +| `meebox pr commits --pr ` | 提交列表 | `GET /prs/{id}/commits` | +| `meebox pr reviewers --pr ` | 评审人审批状态 | `GET /prs/{id}/reviewers` | +| `meebox pr approve --pr ` | 评审决断「通过」(真实远端写) | `POST /prs/{id}/approve` | +| `meebox pr needswork --pr ` | 评审决断「需修改」(真实远端写) | `POST /prs/{id}/needswork` | +| `meebox pr comment --pr ` | 发一条顶层评论(真实远端写) | `POST /prs/{id}/comment` | +| `meebox pr agent status --pr ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | +| `meebox pr agent history --pr ` | 历史会话 | `GET /prs/{id}/agent/conversation` | +| `meebox pr agent review --pr ` | 执行 auto review | `POST /prs/{id}/agent/review` | +| `meebox pr agent instruct --pr [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | +| `meebox pr agent chat --pr ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | +| `meebox pr agent stop --pr ` | 中断该 PR 运行中的 Agent(PR 级) | `POST /prs/{id}/agent/stop` | + +- `` 为 PR 的 `localId`(列表投影里对外命名为 `id`,由 `pr list` 输出获得)。 +- 评审写动作走 `pr approve` / `pr needswork` / `pr comment` 专用命令;变更类工具(publish 等)不在 `instruct` + 白名单内,传入即被服务端拒绝(CLI 亦前置友好报错)。merge(合并)不提供。 ### 输出与退出码 @@ -110,7 +119,8 @@ meebox [全局 flag] <组> <命令> [参数] ## 扩展与注意事项 -- **只读边界**:写操作显式不提供;新增命令前先确认对应 API 端点已存在且为只读。 +- **写边界与服务端一致**:仅提供评审写动作(approve / needswork / comment);合并与变更类 Agent 工具不提供。 + 新增命令前先确认对应 API 端点已存在,写端点须与服务端写边界对齐。 - **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 - **不触碰 GUI 机密**:CLI 不读应用主配置 `~/.code-meeseeks/config.yaml`(含各平台访问令牌等连接层机密);服务令牌须经 flag / 环境变量 / `cli.yaml` 显式提供,避免越权触达预期外凭据。 - **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 diff --git a/docs/arch/README.md b/docs/arch/README.md index 5064f3ed..1f0e487c 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -48,7 +48,7 @@ docs/arch/ │ ├── 03-notifications.md 消息通知(poll 事件投影 / 系统通知 toast / macOS dock 角标 / OS 权限降级) │ └── 04-i18n.md 国际化(react-i18next / 双运行时 / key 命名 / 翻译规范 / 模板翻译) ├── 04-integration/ 外部集成扩展与 CLI -│ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 只读边界 / 路由复用 service) +│ ├── 01-service-api.md 服务监听与本地 API(loopback 默认 / 强制 token / 读+评审写边界 / 路由复用 service) │ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 显式连接配置 / 跨平台分发) └── 99-core/ 基础设施 ├── 01-state-storage.md 状态存储与数据模型(StateStore / per-PR 目录 / 存储模型 + 业务生命周期) diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 1b8212b0..00a57e73 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -1,7 +1,8 @@ # CLI 命令行工具(meebox) `meebox` 是随发布提供的跨平台命令行工具,经本机的「本地 API 服务」访问应用能力,便于把 PR 浏览与 -评审 Agent 操作接入脚本、CI 或外部 agent。命令行只做**浏览与评审操作**,不含评论发送等写操作。 +评审 Agent 操作接入脚本、CI 或外部 agent。命令行提供**浏览与评审操作**,含评审决断(approve / needswork) +与发评论;不含合并(merge)等高影响写操作。 ## 1. 开启本地 API 服务 @@ -50,22 +51,29 @@ meebox --api-url http://<主机>:18765 --token <令牌> pr list meebox [全局参数] <组> <命令> [参数] ``` +PR 关联命令统一用**必填参数 `--pr `** 指定 PR(`id` 由 `meebox pr list` 输出获得);agent 命令归在 `pr agent` 下。 + | 命令 | 用途 | | --- | --- | +| `meebox whoami` | 当前登录身份与集成平台(用户 + 平台 + 连接名) | | `meebox categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选) | -| `meebox pr list [--primary <一级>] [--secondary <二级>] [--query <检索>]` | PR 列表(不分页),支持按分类与关键字过滤 | -| `meebox pr show ` | PR 描述详情 | -| `meebox pr diff [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | -| `meebox pr activity ` | 活动时间线(评论 / 提交 / 评审决断) | -| `meebox pr commits ` | 提交列表 | -| `meebox pr reviewers ` | 评审人审批状态 | -| `meebox agent status ` | 评审 Agent 当前执行状态 | -| `meebox agent history ` | 历史会话 | -| `meebox agent review ` | 执行一次自动评审 | -| `meebox agent instruct <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | -| `meebox agent chat <消息>` | 发送自然语言消息(可触发 Agent 任务) | - -其中 `` 为 PR 的本地标识,由 `meebox pr list` 输出获得。 +| `meebox pr list [--category <一级>] [--status <二级>] [--query <检索>] [--skip N] [--limit N]` | PR 列表(精简字段 + 分页,默认 limit 100) | +| `meebox pr show --pr ` | PR 描述详情 | +| `meebox pr diff --pr [--file <路径>] [--side base\|head]` | 无 `--file` 列变更文件;有则取该文件内容 | +| `meebox pr activity --pr ` | 活动时间线(评论 / 提交 / 评审决断) | +| `meebox pr commits --pr ` | 提交列表 | +| `meebox pr reviewers --pr ` | 评审人审批状态 | +| `meebox pr approve --pr ` | 将 PR 标记为「通过」(发送真实评审决断到平台) | +| `meebox pr needswork --pr ` | 将 PR 标记为「需修改」(发送真实评审决断到平台) | +| `meebox pr comment --pr <消息>` | 发一条顶层评论到平台 | +| `meebox pr agent status --pr ` | 评审 Agent 当前执行状态 | +| `meebox pr agent history --pr ` | 历史会话 | +| `meebox pr agent review --pr ` | 执行一次自动评审 | +| `meebox pr agent instruct --pr <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | +| `meebox pr agent chat --pr <消息>` | 发送自然语言消息(可触发 Agent 任务) | +| `meebox pr agent stop --pr ` | 中断该 PR 运行中的评审 Agent | + +其中 `` 为 PR 的本地标识(列表里的 `id` 字段),由 `meebox pr list` 输出获得。 ## 5. 输出格式 @@ -89,6 +97,7 @@ meebox pr list --output json | jq '.[].title' ## 注意事项 -- **只读取向**:CLI 不提供评论发送、审批、合并等写操作;有此需求请自行对接代码平台。 -- **令牌安全**:令牌明文存于 `~/.code-meeseeks/config.yaml`(同其他凭据);监听 `0.0.0.0` 暴露到局域网时尤需保密, - 并及时通过「重新生成」吊销泄露的令牌。 +- **写能力范围**:CLI 提供评审写动作——`pr approve` / `pr needswork`(发送真实评审决断)与 `pr comment` + (发顶层评论);但**不提供合并(merge)与变更类 Agent 工具(publish 等)**,有此需求请自行对接代码平台。 +- **令牌安全**:服务令牌在 GUI 的 `~/.code-meeseeks/config.yaml` 明文存储;若写入 CLI 的 `~/.code-meeseeks/cli.yaml` + 同为明文。监听 `0.0.0.0` 暴露到局域网时尤需保密,并及时通过「重新生成」吊销泄露的令牌。 From 85ad799c0b0975998ff41963334edf2c2e2138ab Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 17:11:15 +0800 Subject: [PATCH 39/84] =?UTF-8?q?feat(cli):=20=E6=96=B0=E5=A2=9E=20agent?= =?UTF-8?q?=20run=20list=20/=20cancel=EF=BC=8C=E6=8C=89=20run=20=E5=8F=96?= =?UTF-8?q?=E6=B6=88=20pr-agent=20=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 比 PR 级 agent stop 更细的粒度:可查看并取消单个 pr-agent 工具调用 run。 - 服务端 GET /prs/{id}/agent/runs(复用 pragent:queue 快照、筛该 PR 的 active+waiting, 投影 PrAgentRunItem:runId/tool/state/时间)与 POST /prs/{id}/agent/runs/{runId}/cancel (校验 run 归属该 PR,复用 pragent:cancel;不属则 404 SV_NOT_FOUND)。 - CLI 新增 run 子组:pr agent run list --pr / pr agent run cancel --pr --run 。 Co-Authored-By: Claude Opus 4.8 --- .../src/main/services/api-server/routes.ts | 22 ++++++++- .../src/main/services/api-server/views.ts | 36 ++++++++++++++ cli/cmd/agent.go | 49 +++++++++++++++++++ cli/cmd/integration_test.go | 26 ++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index d9b0d1b5..854e760b 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -12,7 +12,7 @@ import * as agentCtl from '../../controllers/agent.js'; import * as prCtl from '../../controllers/pr.js'; import { getContext } from '../context.js'; import { HttpError } from './http.js'; -import { toPrListItem } from './views.js'; +import { toPrAgentRuns, toPrListItem } from './views.js'; /** * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 @@ -168,6 +168,24 @@ const agentChat: RouteHandler = ({ params, body }) => { const agentStop: RouteHandler = ({ params }) => agentCtl.stopAgent(NO_EVENT, { localId: params.id }); +/** 该 PR 在运行队列里的 pr-agent runs(active + waiting),供按 run 取消前的发现。 */ +const agentRuns: RouteHandler = async ({ params }) => { + const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); + return toPrAgentRuns(snapshot, params.id); +}; + +/** 取消该 PR 的某个 pr-agent run(active SIGKILL / waiting 出队)。先校验 run 归属该 PR。 */ +const agentRunCancel: RouteHandler = async ({ params }) => { + const snapshot = await agentCtl.getQueue(NO_EVENT, undefined); + const belongs = [...snapshot.active, ...snapshot.waiting].some( + (r) => r.runId === params.runId && r.prLocalId === params.id, + ); + if (!belongs) { + throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND, { runId: params.runId, localId: params.id }); + } + return agentCtl.cancelPragent(NO_EVENT, { runId: params.runId }); +}; + /** 评审决断「通过」:先写远端评审状态、再落本地(复用 GUI 同源 setPrStatus)。 */ const approve: RouteHandler = ({ params }) => prCtl.setPrStatus(NO_EVENT, { localId: params.id, status: 'approved' }); @@ -200,6 +218,8 @@ export const routes: Route[] = [ { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, { method: 'POST', segments: seg('/api/v1/prs/:id/agent/stop'), handler: agentStop }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/runs'), handler: agentRuns }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/runs/:runId/cancel'), handler: agentRunCancel }, { method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve }, { method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork }, { method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment }, diff --git a/apps/desktop/src/main/services/api-server/views.ts b/apps/desktop/src/main/services/api-server/views.ts index c23e8ba7..4f9677e3 100644 --- a/apps/desktop/src/main/services/api-server/views.ts +++ b/apps/desktop/src/main/services/api-server/views.ts @@ -1,7 +1,9 @@ +import type { PragentRunInfo } from '@meebox/ipc'; import type { LocalPrStatus, PlatformKind, PrDiscoveryFilter, + ReviewRunTool, ReviewerStatus, StoredPullRequest, } from '@meebox/shared'; @@ -44,6 +46,40 @@ export interface PrListItem { unreadMentionCount: number; } +/** + * 某 PR 在运行队列里的一个 pr-agent run 视图项:`GET /prs/{id}/agent/runs` 的投影。用于让调用方 + * 发现可取消的 run(runId + tool + 运行 / 排队态),配合 `…/runs/{runId}/cancel` 做按 run 取消。 + */ +export interface PrAgentRunItem { + runId: string; + tool: ReviewRunTool; + /** active = 正在执行;waiting = 排队中。 */ + state: 'active' | 'waiting'; + /** 开始执行时间(ISO);waiting 为 null。 */ + startedAt: string | null; + enqueuedAt: string; + question?: string; +} + +/** 从队列快照筛出属于该 PR 的 run(active 在前、waiting 在后),投影为精简项。 */ +export function toPrAgentRuns( + queue: { active: PragentRunInfo[]; waiting: PragentRunInfo[] }, + prId: string, +): PrAgentRunItem[] { + const pick = (r: PragentRunInfo, state: 'active' | 'waiting'): PrAgentRunItem => ({ + runId: r.runId, + tool: r.tool, + state, + startedAt: r.startedAt, + enqueuedAt: r.enqueuedAt, + ...(r.question ? { question: r.question } : {}), + }); + return [ + ...queue.active.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'active')), + ...queue.waiting.filter((r) => r.prLocalId === prId).map((r) => pick(r, 'waiting')), + ]; +} + /** 把存储态 PR 投影为列表视图项。对象字面量的键序即 JSON 输出顺序(CLI 视图层据此渲染)。 */ export function toPrListItem(pr: StoredPullRequest): PrListItem { return { diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go index 92db4392..3841f6ac 100644 --- a/cli/cmd/agent.go +++ b/cli/cmd/agent.go @@ -33,10 +33,59 @@ func newAgentCmd() *cobra.Command { newAgentInstructCmd(), newAgentChatCmd(), newAgentStopCmd(), + newAgentRunCmd(), ) return a } +// newAgentRunCmd builds the `pr agent run` subgroup: inspect / cancel individual pr-agent +// tool-call runs in the queue — finer-grained than the PR-level `agent stop`. +func newAgentRunCmd() *cobra.Command { + r := &cobra.Command{ + Use: "run", + Short: "Inspect or cancel individual pr-agent runs", + } + r.AddCommand(newAgentRunListCmd(), newAgentRunCancelCmd()) + return r +} + +// newAgentRunListCmd builds `pr agent run list --pr `: the PR's active + waiting +// pr-agent runs (runId / tool / state), the source of run ids to cancel +// (GET /prs/{id}/agent/runs). +func newAgentRunListCmd() *cobra.Command { + var pr string + cmd := &cobra.Command{ + Use: "list", + Short: "List the PR's active and queued pr-agent runs", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/agent/runs") + }, + } + prIDFlag(cmd, &pr) + return cmd +} + +// newAgentRunCancelCmd builds `pr agent run cancel --pr --run `: cancels one +// pr-agent run (active SIGKILL / waiting dequeue) (POST /prs/{id}/agent/runs/{runId}/cancel). +// Unlike `agent stop` (halts the whole PR agent), this targets a single tool-call run. +func newAgentRunCancelCmd() *cobra.Command { + var pr, run string + cmd := &cobra.Command{ + Use: "cancel", + Short: "Cancel one pr-agent run by id (see `run list`)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return postAndRender( + "/api/v1/prs/"+url.PathEscape(pr)+"/agent/runs/"+url.PathEscape(run)+"/cancel", nil) + }, + } + prIDFlag(cmd, &pr) + cmd.Flags().StringVar(&run, "run", "", "run id to cancel (from `pr agent run list`)") + _ = cmd.MarkFlagRequired("run") + return cmd +} + // newAgentStatusCmd builds `pr agent status --pr `: the agent's current run state // snapshot (GET /prs/{id}/agent). func newAgentStatusCmd() *cobra.Command { diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index 29870624..5316e732 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -267,6 +267,32 @@ func TestAgentStopPost(t *testing.T) { } } +func TestAgentRunList(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `[{"runId":"r1","tool":"review","state":"active"}]`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "agent", "run", "list", "--pr", "abc123")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/prs/abc123/agent/runs" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +func TestAgentRunCancel(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"ok":true}`) + defer srv.Close() + + if _, err := runCmd(base(srv.URL, "pr", "agent", "run", "cancel", "--pr", "abc123", "--run", "r1")...); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/runs/r1/cancel" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + func TestAuthFailureExitCode(t *testing.T) { var rec capturedReq srv := mockServer(&rec, 401, "") From 8aa6a335dfaa038b6cb7af96d2beb318e01b36b1 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 17:16:44 +0800 Subject: [PATCH 40/84] =?UTF-8?q?refactor(cli):=20agent=20=E6=8F=90?= =?UTF-8?q?=E4=B8=BA=E9=A1=B6=E5=B1=82=E9=A2=86=E5=9F=9F=E7=BB=84=EF=BC=8C?= =?UTF-8?q?pr=20=E7=BB=84=E4=BB=85=E7=95=99=20PR=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meebox 只管理 PR,且 --pr 已是所有 PR 命令的统一必填 flag,nest 在 pr 下会让 agent 命令出现 `pr agent … --pr` 的 pr 重复。改为 `pr`(PR 实体:浏览 + approve/ needswork/comment)与 `agent`(评审 Agent:status/history/review/instruct/chat/stop/ run list|cancel)两个平级领域组。仅命令树分组调整,API 路径与服务端不变。同步文档命令表。 Co-Authored-By: Claude Opus 4.8 --- cli/cmd/agent.go | 21 +++++++++++---------- cli/cmd/integration_test.go | 14 +++++++------- cli/cmd/pr.go | 8 ++++---- cli/cmd/root.go | 1 + docs/arch/04-integration/02-cli.md | 20 ++++++++++++-------- docs/guide/06-cli.md | 17 ++++++++++------- 6 files changed, 45 insertions(+), 36 deletions(-) diff --git a/cli/cmd/agent.go b/cli/cmd/agent.go index 3841f6ac..97a8207b 100644 --- a/cli/cmd/agent.go +++ b/cli/cmd/agent.go @@ -19,8 +19,9 @@ var readOnlyInstructions = map[string]bool{ "improve": true, } -// newAgentCmd builds the `pr agent` subgroup: review-agent operations, all PR-scoped -// (each requires `--pr `). Wiring only; nested under `pr`. +// newAgentCmd builds the top-level `agent` command group: review-agent operations, all +// PR-scoped (each requires `--pr `). A sibling of `pr` rather than nested under it — +// the uniform --pr flag already carries the PR id, so nesting would only repeat `pr`. func newAgentCmd() *cobra.Command { a := &cobra.Command{ Use: "agent", @@ -49,7 +50,7 @@ func newAgentRunCmd() *cobra.Command { return r } -// newAgentRunListCmd builds `pr agent run list --pr `: the PR's active + waiting +// newAgentRunListCmd builds `agent run list --pr `: the PR's active + waiting // pr-agent runs (runId / tool / state), the source of run ids to cancel // (GET /prs/{id}/agent/runs). func newAgentRunListCmd() *cobra.Command { @@ -66,7 +67,7 @@ func newAgentRunListCmd() *cobra.Command { return cmd } -// newAgentRunCancelCmd builds `pr agent run cancel --pr --run `: cancels one +// newAgentRunCancelCmd builds `agent run cancel --pr --run `: cancels one // pr-agent run (active SIGKILL / waiting dequeue) (POST /prs/{id}/agent/runs/{runId}/cancel). // Unlike `agent stop` (halts the whole PR agent), this targets a single tool-call run. func newAgentRunCancelCmd() *cobra.Command { @@ -86,7 +87,7 @@ func newAgentRunCancelCmd() *cobra.Command { return cmd } -// newAgentStatusCmd builds `pr agent status --pr `: the agent's current run state +// newAgentStatusCmd builds `agent status --pr `: the agent's current run state // snapshot (GET /prs/{id}/agent). func newAgentStatusCmd() *cobra.Command { var pr string @@ -102,7 +103,7 @@ func newAgentStatusCmd() *cobra.Command { return cmd } -// newAgentHistoryCmd builds `pr agent history --pr `: the multi-turn conversation +// newAgentHistoryCmd builds `agent history --pr `: the multi-turn conversation // history (GET /prs/{id}/agent/conversation). func newAgentHistoryCmd() *cobra.Command { var pr string @@ -118,7 +119,7 @@ func newAgentHistoryCmd() *cobra.Command { return cmd } -// newAgentReviewCmd builds `pr agent review --pr `: kicks off the review micro-flow +// newAgentReviewCmd builds `agent review --pr `: kicks off the review micro-flow // (describe→review→ask→summary) (POST /prs/{id}/agent/review). func newAgentReviewCmd() *cobra.Command { var pr string @@ -142,7 +143,7 @@ func newAgentReviewCmd() *cobra.Command { return cmd } -// newAgentInstructCmd builds `pr agent instruct --pr [args...]`: sends a +// newAgentInstructCmd builds `agent instruct --pr [args...]`: sends a // single read-only pr-agent instruction (POST /prs/{id}/agent/instruct). Write tools are // rejected up front (and again by the server). func newAgentInstructCmd() *cobra.Command { @@ -175,7 +176,7 @@ func newAgentInstructCmd() *cobra.Command { return cmd } -// newAgentChatCmd builds `pr agent chat --pr `: sends a natural-language +// newAgentChatCmd builds `agent chat --pr `: sends a natural-language // message that may trigger agent tasks (POST /prs/{id}/agent/chat). func newAgentChatCmd() *cobra.Command { var pr string @@ -200,7 +201,7 @@ func newAgentChatCmd() *cobra.Command { return cmd } -// newAgentStopCmd builds `pr agent stop --pr `: interrupts the PR's running agent in +// newAgentStopCmd builds `agent stop --pr `: interrupts the PR's running agent in // any phase (thinking / executing) (POST /prs/{id}/agent/stop). PR-level stop — it halts // the whole agent for that PR, not one specific tool run. func newAgentStopCmd() *cobra.Command { diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index 5316e732..a7bdda07 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -158,7 +158,7 @@ func TestAgentReviewPost(t *testing.T) { srv := mockServer(&rec, 200, `{"status":"succeeded"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "review", "--pr", "abc123")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "review", "--pr", "abc123")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/review" { @@ -171,7 +171,7 @@ func TestAgentInstructBody(t *testing.T) { srv := mockServer(&rec, 200, `{"status":"queued"}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "instruct", "--pr", "abc123", "describe", "extra", "ctx")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "instruct", "--pr", "abc123", "describe", "extra", "ctx")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/instruct" { @@ -187,7 +187,7 @@ func TestAgentInstructWriteToolRejected(t *testing.T) { srv := mockServer(&rec, 200, `null`) defer srv.Close() - _, err := runCmd(base(srv.URL, "pr", "agent", "instruct", "--pr", "abc123", "approve")...) + _, err := runCmd(base(srv.URL, "agent", "instruct", "--pr", "abc123", "approve")...) if err == nil { t.Fatal("expected write tool to be rejected") } @@ -201,7 +201,7 @@ func TestAgentChatPost(t *testing.T) { srv := mockServer(&rec, 200, `{"queued":true}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "chat", "--pr", "abc123", "hello", "world")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "chat", "--pr", "abc123", "hello", "world")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/chat" { @@ -259,7 +259,7 @@ func TestAgentStopPost(t *testing.T) { srv := mockServer(&rec, 200, `{"ok":true}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "stop", "--pr", "abc123")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "stop", "--pr", "abc123")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/stop" { @@ -272,7 +272,7 @@ func TestAgentRunList(t *testing.T) { srv := mockServer(&rec, 200, `[{"runId":"r1","tool":"review","state":"active"}]`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "run", "list", "--pr", "abc123")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "run", "list", "--pr", "abc123")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodGet || rec.path != "/api/v1/prs/abc123/agent/runs" { @@ -285,7 +285,7 @@ func TestAgentRunCancel(t *testing.T) { srv := mockServer(&rec, 200, `{"ok":true}`) defer srv.Close() - if _, err := runCmd(base(srv.URL, "pr", "agent", "run", "cancel", "--pr", "abc123", "--run", "r1")...); err != nil { + if _, err := runCmd(base(srv.URL, "agent", "run", "cancel", "--pr", "abc123", "--run", "r1")...); err != nil { t.Fatalf("unexpected error: %v", err) } if rec.method != http.MethodPost || rec.path != "/api/v1/prs/abc123/agent/runs/r1/cancel" { diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go index 2b557dca..deba26dc 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -16,8 +16,10 @@ func prIDFlag(cmd *cobra.Command, target *string) { _ = cmd.MarkFlagRequired("pr") } -// newPrCmd builds the `pr` command group: PR browsing / write actions plus the -// PR-scoped `agent` subgroup. It carries no logic itself, only wiring subcommands. +// newPrCmd builds the `pr` command group: direct PR-entity operations — browsing plus +// review write actions (approve / needswork / comment). The review agent is its own +// top-level `agent` group (see newAgentCmd), not nested here: since every command is +// PR-scoped via --pr, nesting agent under pr would only add a redundant `pr` segment. func newPrCmd() *cobra.Command { pr := &cobra.Command{ Use: "pr", @@ -33,8 +35,6 @@ func newPrCmd() *cobra.Command { newPrApproveCmd(), newPrNeedsworkCmd(), newPrCommentCmd(), - // Agent is PR-scoped (every agent op requires a PR id), so it nests under `pr`. - newAgentCmd(), ) return pr } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index fa43e794..15925c50 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -41,6 +41,7 @@ func newRootCmd() *cobra.Command { newWhoamiCmd(), newCategoriesCmd(), newPrCmd(), + newAgentCmd(), ) return root } diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 42ca89c3..0830c76e 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -59,8 +59,9 @@ meebox [全局 flag] <组> <命令> [参数] 全局 flag:--api-url · --token · --output (yaml|json) · --quiet ``` -PR 关联命令统一用**必填 flag `--pr `** 传 PR 标识(`id` 由 `pr list` 输出获得);agent 命令与 PR -强绑定、必带 id,故整组归入 `pr agent …`。 +命令分两个领域组:`pr`(直接的 PR 实体操作)与 `agent`(评审 Agent 操作)。二者都用**必填 flag +`--pr `** 传 PR 标识(`id` 由 `pr list` 输出获得)——meebox 只管理 PR,故 agent **不再嵌进 `pr`** +(避免 `pr agent … --pr` 里 `pr` 重复),而与 `pr` 平级。 | 命令 | 用途 | 对应 API | | --- | --- | --- | @@ -75,16 +76,19 @@ PR 关联命令统一用**必填 flag `--pr `** 传 PR 标识(`id` 由 `pr | `meebox pr approve --pr ` | 评审决断「通过」(真实远端写) | `POST /prs/{id}/approve` | | `meebox pr needswork --pr ` | 评审决断「需修改」(真实远端写) | `POST /prs/{id}/needswork` | | `meebox pr comment --pr ` | 发一条顶层评论(真实远端写) | `POST /prs/{id}/comment` | -| `meebox pr agent status --pr ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | -| `meebox pr agent history --pr ` | 历史会话 | `GET /prs/{id}/agent/conversation` | -| `meebox pr agent review --pr ` | 执行 auto review | `POST /prs/{id}/agent/review` | -| `meebox pr agent instruct --pr [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | -| `meebox pr agent chat --pr ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | -| `meebox pr agent stop --pr ` | 中断该 PR 运行中的 Agent(PR 级) | `POST /prs/{id}/agent/stop` | +| `meebox agent status --pr ` | Agent 当前执行状态 | `GET /prs/{id}/agent` | +| `meebox agent history --pr ` | 历史会话 | `GET /prs/{id}/agent/conversation` | +| `meebox agent review --pr ` | 执行 auto review | `POST /prs/{id}/agent/review` | +| `meebox agent instruct --pr [args]` | 发送 Agent 指令(仅只读:describe / review / ask / improve) | `POST /prs/{id}/agent/instruct` | +| `meebox agent chat --pr ` | 自然语言聊天(可触发任务执行) | `POST /prs/{id}/agent/chat` | +| `meebox agent stop --pr ` | 中断该 PR 运行中的 Agent(PR 级) | `POST /prs/{id}/agent/stop` | +| `meebox agent run list --pr ` | 该 PR 运行队列中的 pr-agent runs(active + waiting) | `GET /prs/{id}/agent/runs` | +| `meebox agent run cancel --pr --run ` | 按 run 取消一个 pr-agent 工具调用 | `POST /prs/{id}/agent/runs/{runId}/cancel` | - `` 为 PR 的 `localId`(列表投影里对外命名为 `id`,由 `pr list` 输出获得)。 - 评审写动作走 `pr approve` / `pr needswork` / `pr comment` 专用命令;变更类工具(publish 等)不在 `instruct` 白名单内,传入即被服务端拒绝(CLI 亦前置友好报错)。merge(合并)不提供。 +- 中断粒度:`agent stop` 停整个 PR 的 Agent;`agent run cancel` 只取消指定的单个 pr-agent run。 ### 输出与退出码 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 00a57e73..4ec560af 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -51,7 +51,8 @@ meebox --api-url http://<主机>:18765 --token <令牌> pr list meebox [全局参数] <组> <命令> [参数] ``` -PR 关联命令统一用**必填参数 `--pr `** 指定 PR(`id` 由 `meebox pr list` 输出获得);agent 命令归在 `pr agent` 下。 +命令分 `pr`(直接的 PR 操作)与 `agent`(评审 Agent 操作)两个领域组,均用**必填参数 `--pr `** 指定 PR +(`id` 由 `meebox pr list` 输出获得)。 | 命令 | 用途 | | --- | --- | @@ -66,12 +67,14 @@ PR 关联命令统一用**必填参数 `--pr `** 指定 PR(`id` 由 `meebo | `meebox pr approve --pr ` | 将 PR 标记为「通过」(发送真实评审决断到平台) | | `meebox pr needswork --pr ` | 将 PR 标记为「需修改」(发送真实评审决断到平台) | | `meebox pr comment --pr <消息>` | 发一条顶层评论到平台 | -| `meebox pr agent status --pr ` | 评审 Agent 当前执行状态 | -| `meebox pr agent history --pr ` | 历史会话 | -| `meebox pr agent review --pr ` | 执行一次自动评审 | -| `meebox pr agent instruct --pr <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | -| `meebox pr agent chat --pr <消息>` | 发送自然语言消息(可触发 Agent 任务) | -| `meebox pr agent stop --pr ` | 中断该 PR 运行中的评审 Agent | +| `meebox agent status --pr ` | 评审 Agent 当前执行状态 | +| `meebox agent history --pr ` | 历史会话 | +| `meebox agent review --pr ` | 执行一次自动评审 | +| `meebox agent instruct --pr <指令> [参数]` | 发送评审指令(`describe` / `review` / `ask` / `improve`) | +| `meebox agent chat --pr <消息>` | 发送自然语言消息(可触发 Agent 任务) | +| `meebox agent stop --pr ` | 中断该 PR 运行中的评审 Agent(整体停) | +| `meebox agent run list --pr ` | 列出该 PR 运行中 / 排队中的 pr-agent runs | +| `meebox agent run cancel --pr --run ` | 按 run id 取消单个 pr-agent 工具调用 | 其中 `` 为 PR 的本地标识(列表里的 `id` 字段),由 `meebox pr list` 输出获得。 From 0b022dcdb26bf649b814809a8f0ec6f612886a2d Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 17:25:33 +0800 Subject: [PATCH 41/84] =?UTF-8?q?feat(cli):=20=E4=BA=A4=E4=BB=98=20SKILL.m?= =?UTF-8?q?d=EF=BC=8C=E5=8E=8B=E7=BC=A9=E5=8C=85=E6=89=93=E6=88=90?= =?UTF-8?q?=E5=8F=AF=E7=9B=B4=E6=8E=A5=E6=8A=95=E6=94=BE=E7=9A=84=20agent?= =?UTF-8?q?=20skill=20=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 面向 agent 集成,主交付形态为 skill: - 新增 cli/SKILL.md(frontmatter name: meebox)——连接方式、pr / agent 命令图、典型 评审闭环、写边界与退出码,教 agent 驱动 meebox。 - release.yml 的 cli job 压缩包除二进制外一并打包 LICENSE + README.md + SKILL.md; 解压到 agent 的 skills 目录即得可用 skill(SKILL.md 紧邻其驱动的二进制)。 - 重写严重过期的 cli/README.md(原称「只读 / scaffold / 自动发现 / --primary」均已不符)。 - 同步 arch cli / guide / AGENTS.md 说明 skill 交付。 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 9 ++-- AGENTS.md | 1 + cli/README.md | 54 ++++++++++++++---------- cli/SKILL.md | 66 ++++++++++++++++++++++++++++++ docs/arch/04-integration/02-cli.md | 9 ++-- docs/guide/06-cli.md | 3 ++ 6 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 cli/SKILL.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22d96cd8..44b49609 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,11 +182,14 @@ jobs: VERSION="${GITHUB_REF_NAME#v}" BIN="meebox${{ matrix.ext }}" ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" - cp ../../LICENSE . + # Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill + # directory (unzip into a skills dir → SKILL.md beside the binary it drives). + cp ../../LICENSE ../README.md ../SKILL.md . + FILES=("${BIN}" LICENSE README.md SKILL.md) if [ "${{ matrix.archive }}" = "zip" ]; then - zip -q "${ARCHIVE}.zip" "${BIN}" LICENSE + zip -q "${ARCHIVE}.zip" "${FILES[@]}" else - tar -czf "${ARCHIVE}.tar.gz" "${BIN}" LICENSE + tar -czf "${ARCHIVE}.tar.gz" "${FILES[@]}" fi for f in "${ARCHIVE}".zip "${ARCHIVE}".tar.gz; do [ -e "$f" ] && sha256sum "$f" > "$f.sha256" diff --git a/AGENTS.md b/AGENTS.md index 01658b1b..b8552b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,7 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 - **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 - **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 - **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 +- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。改命令树 / 边界时同步更新 `SKILL.md` 与 `README.md`。 - **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。 - **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 diff --git a/cli/README.md b/cli/README.md index b13bc7f5..edfc7e3f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -5,16 +5,14 @@ thin client over the desktop app's local HTTP API — see the design docs: - [Service listener & local API](../docs/arch/04-integration/01-service-api.md) - [CLI tool](../docs/arch/04-integration/02-cli.md) +- Usage guide: [docs/guide/06-cli.md](../docs/guide/06-cli.md) -All exposed capabilities are **read-only**; write operations (commenting, -approving, publishing) are intentionally not provided. +It provides PR browsing plus review write actions (approve / needs-work / comment); +merging and the agent's publish/mutating tools are intentionally not exposed. -## Status - -Project scaffold. The command tree, connection/auth resolution, HTTP client, -output formatting, and exit-code mapping are in place and built against the -documented API contract. The server-side API is implemented separately; until -it is available, commands will fail to connect. +`meebox` is also shipped as a drop-in agent **skill** — each release archive bundles +[`SKILL.md`](SKILL.md) beside the binary, so unzipping it into an agent's skills +directory yields a working skill. ## Build & run @@ -43,28 +41,40 @@ The CLI resolves the API base URL and bearer token in this order (highest first) 1. flags — `--api-url`, `--token` 2. env — `MEEBOX_API_URL`, `MEEBOX_TOKEN` 3. CLI config — `~/.code-meeseeks/cli.yaml` (`api_url`, `token`) -4. local auto-discovery — the app's `~/.code-meeseeks/config.yaml` `service` - section (same machine, same user; zero-config) + +Connection details must be provided explicitly; the CLI does **not** read the app's +`~/.code-meeseeks/config.yaml` (which holds connection-layer secrets). The API URL +defaults to `http://127.0.0.1:18765` when unset. ## Commands +Two domains, `pr` and `agent`, both PR-scoped via the required `--pr ` flag +(`id` comes from `pr list`): + ```text +meebox whoami meebox categories -meebox pr list [--primary ] [--secondary ] [--query ] -meebox pr show -meebox pr diff [--file ] [--side base|head] -meebox pr activity -meebox pr commits -meebox pr reviewers -meebox agent status -meebox agent history -meebox agent review -meebox agent instruct [args...] # read-only: describe|review|ask|improve -meebox agent chat +meebox pr list [--category ] [--status ] [--query ] [--skip N] [--limit N] +meebox pr show --pr +meebox pr diff --pr [--file ] [--side base|head] +meebox pr activity --pr +meebox pr commits --pr +meebox pr reviewers --pr +meebox pr approve --pr # real remote review decision +meebox pr needswork --pr # real remote review decision +meebox pr comment --pr # real remote comment +meebox agent status --pr +meebox agent history --pr +meebox agent review --pr +meebox agent instruct --pr [args...] # read-only: describe|review|ask|improve +meebox agent chat --pr +meebox agent stop --pr # stop the whole PR agent +meebox agent run list --pr +meebox agent run cancel --pr --run # cancel one pr-agent run ``` Global flags: `--api-url`, `--token`, `--output yaml|json`, `--quiet`. Output defaults to **YAML** (human-friendly, k8s `-o yaml` style); pass `--output json` for the machine-readable form used by third-party integrations. -Both are generic transforms of the response — no per-command formatting. +Both preserve the server's field order (no per-command formatting). diff --git a/cli/SKILL.md b/cli/SKILL.md new file mode 100644 index 00000000..82aab248 --- /dev/null +++ b/cli/SKILL.md @@ -0,0 +1,66 @@ +--- +name: meebox +description: Review and act on pull requests through the Code Meeseeks "meebox" CLI — list and inspect PRs, run the AI review agent, and record review outcomes (approve / needs-work / comment). Use when the user wants to triage, review, or act on pull requests via a running Code Meeseeks desktop app. +--- + +# meebox — pull-request review over the local API + +`meebox` is a thin cross-platform CLI over a running **Code Meeseeks** desktop app's +local HTTP API. Use it to browse pull requests, drive the review agent, and record +review outcomes. Output defaults to YAML; pass `--output json` when parsing results. + +## Prerequisites + +- The Code Meeseeks desktop app is running with the local API service **enabled** + (Settings → Integration). +- The `meebox` binary is available — it ships in this skill directory; put it on `PATH` + or invoke it by path (`./meebox`). +- Connection is configured (below). Verify with `meebox whoami`. + +## Connect + +Provide the API base URL + token explicitly — the CLI never reads the app's `config.yaml`: + +```bash +export MEEBOX_API_URL=http://127.0.0.1:18765 # default port; override for remote hosts +export MEEBOX_TOKEN= # from Settings → Integration +meebox whoami # confirm the resolved user + platform +meebox --output json pr list | jq '.[].id' # JSON for scripting +``` + +## Command map + +Two domains, both PR-scoped via the **required `--pr `** flag (`id` comes from `pr list`): + +**Browse / inspect — `pr`** +- `meebox pr list [--category review-requested|created|assigned|mentioned] [--status pending|approved|needs_work|conflict|mergeable] [--query ] [--skip N] [--limit N]` — paginated (default limit 100), slim fields (id / title / author / createdAt first). +- `meebox pr show --pr ` — full detail incl. description. +- `meebox pr diff --pr [--file --side base|head]` — changed files, or one file's content. +- `meebox pr activity --pr ` · `meebox pr commits --pr ` · `meebox pr reviewers --pr `. + +**Review agent — `agent`** +- `meebox agent review --pr ` — run the auto-review micro-flow (describe→review→[ask]→summary). +- `meebox agent status --pr ` · `meebox agent history --pr ` — progress + conversation. +- `meebox agent instruct --pr [text]` — one read-only tool call. +- `meebox agent chat --pr ` — natural-language message (may trigger tasks). +- `meebox agent run list --pr ` · `meebox agent run cancel --pr --run ` — inspect / cancel a single agent run. +- `meebox agent stop --pr ` — stop the whole agent for the PR. + +**Record outcomes — `pr` (real remote writes)** +- `meebox pr approve --pr ` · `meebox pr needswork --pr ` — post a review decision. +- `meebox pr comment --pr ` — post a top-level comment. + +Filter vocabulary: `meebox categories` lists the active platform's available `categories` / `statuses`. + +## Typical loop + +1. `meebox --output json pr list --status pending` → choose a PR `id`. +2. `meebox pr show --pr ` and/or `meebox agent review --pr `; poll `meebox agent status --pr `. +3. Inspect changes: `meebox pr diff --pr `. +4. Record the outcome: `meebox pr approve --pr ` / `meebox pr needswork --pr ` / `meebox pr comment --pr "…"`. + +## Boundaries + +- **Not available**: merging PRs, and the agent's publish / mutating tools — `instruct` accepts + only the read-only tools `describe` / `review` / `ask` / `improve`. +- Exit codes: `0` ok · `2` auth failure · `3` not found · `1` other; errors print to stderr with the API error code. diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index 0830c76e..a21d6cdf 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -112,13 +112,16 @@ meebox [全局 flag] <组> <命令> [参数] - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 - **二进制与压缩包命名**:`meebox-cli---.`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 +- **压缩包内容 = 可直接投放的 skill 目录**:除二进制外一并打包 `LICENSE` + `README.md` + `SKILL.md`。解压到 + agent 的 skills 目录即得一个可用 skill——`SKILL.md`(frontmatter `name: meebox`)教 agent 用法,紧邻其驱动 + 的二进制。这是 CLI「面向 agent 交付」的主形态。 ## 分发与 CI - **覆盖平台**:Windows x64、macOS arm64、Linux x64 / arm64。 -- **随主工程一起发布**:在发布流程中增加一个 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译 - 矩阵;可选 GoReleaser 简化),产出四平台压缩包 + 校验和,与桌面安装包一并上传到**同一个 GitHub Release** - (由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 +- **随主工程一起发布**:发布流程的 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译矩阵)产出 + 四平台压缩包(含二进制 + `LICENSE` + `README.md` + `SKILL.md`)+ 校验和,与桌面安装包一并上传到**同一个 + GitHub Release**(由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 - 版本号与应用同源(同 tag),确保 CLI 与服务端 API 契约版本可对应。 ## 扩展与注意事项 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 4ec560af..be17d071 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -20,6 +20,9 @@ CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。 +压缩包内含 `meebox` 二进制、`LICENSE`、`README.md` 与 `SKILL.md`。**作为 agent skill 使用**:把解压目录直接 +放入 agent 的 skills 目录(如 `~/.claude/skills/meebox/`)即可——`SKILL.md` 会告诉 agent 如何驱动其旁的 `meebox`。 + ## 3. 连接方式 `meebox` 按以下优先级解析 API 地址与令牌(高 → 低): From 6c287b0b0148603c4e51858096d92af61b28dffa Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 19:44:03 +0800 Subject: [PATCH 42/84] =?UTF-8?q?feat(poller):=20=E3=80=8C=E6=88=91?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E7=9A=84=E3=80=8DPR=20=E7=9A=84=20needsWork?= =?UTF-8?q?=20/=20=E6=96=B0=E8=AF=84=E8=AE=BA=20/=20=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 poll 的 per-PR delta 循环中,对作者为本人(pr.author == 当前用户)的 PR 额外产出三类 通知事件: - authored_needs_work:新出现的「需修改」评审人(本轮在 needsWork、上一轮不在)。 - authored_conflict:hasConflict false→true。 - authored_comment:他人新评论(collectCommentsFromOthers 收全部非本人评论,取晚于独立 游标 lastCommentAt 的;自己的评论不计,避免自评误报)。 PrIndexEntry 增补上一轮快照 hasConflict / needsWorkReviewers / lastCommentAt(可选字段, 向后兼容);字段缺失(升级前旧索引)按基线只播种、不补发,避免升级后一次性涌入。新增 PollNotificationKind 三值 + 三条 poller 通知测试。 Co-Authored-By: Claude Opus 4.8 --- packages/poller/src/poller.ts | 78 +++++++++++++++- packages/poller/src/pr-state.ts | 10 ++ packages/poller/src/unread.ts | 40 ++++++++ packages/poller/tests/poller.test.ts | 121 +++++++++++++++++++++++++ packages/shared/src/config.ts | 11 ++- packages/shared/src/poller-contract.ts | 29 ++++-- 6 files changed, 279 insertions(+), 10 deletions(-) diff --git a/packages/poller/src/poller.ts b/packages/poller/src/poller.ts index f2afb418..74c5afaa 100644 --- a/packages/poller/src/poller.ts +++ b/packages/poller/src/poller.ts @@ -11,7 +11,7 @@ import type { import type { PlatformAdapter } from '@meebox/platform-core'; import { relocateTree, type StateStore } from '@meebox/state-store'; import { prHashId } from './pr-hash-id.js'; -import { collectMentionsToMe } from './unread.js'; +import { collectCommentsFromOthers, collectMentionsToMe } from './unread.js'; import { MENTION_ATS_CAP, PURGE_GRACE_MS, @@ -333,6 +333,44 @@ export class Poller { }); } + // 「我创建的」PR(作者为本人)通知:被标记需修改 / 出现冲突。仅在已有基线 + 已知 PR(prev)时探测; + // 各自的上一轮快照字段缺失(升级前旧索引)时按「基线」处理——只在下方索引写入处播种、不补发历史事件。 + const authoredByMe = !!me && pr.author.name === me.name; + const needsWorkReviewers = pr.reviewers + .filter((r) => r.status === 'needsWork') + .map((r) => r.name); + if (authoredByMe && hadBaseline && prev) { + // 新出现的「需修改」评审人(本轮在 needsWork、上一轮不在)→ authored_needs_work。 + const prevNW = prev.needsWorkReviewers; + if (prevNW !== undefined) { + const fresh = needsWorkReviewers.filter((n) => !prevNW.includes(n)); + if (fresh.length > 0) { + const reviewer = pr.reviewers.find((r) => r.name === fresh[0]) ?? pr.author; + notifyEvents.push({ + kind: 'authored_needs_work', + localId, + connectionId, + remoteId: pr.remoteId, + title: pr.title, + repo: pr.repo, + actor: reviewer, + }); + } + } + // 合并冲突 false→true → authored_conflict(无具体发起人,actor 取 PR 作者本人)。 + if (prev.hasConflict === false && pr.hasConflict === true) { + notifyEvents.push({ + kind: 'authored_conflict', + localId, + connectionId, + remoteId: pr.remoteId, + title: pr.title, + repo: pr.repo, + actor: pr.author, + }); + } + } + // 复活:上一轮处于归档态(数据已搬入 archived/)→ 先把整树搬回活跃存储,再写 meta, // 让 runs / 评论 / 已读水位等历史与新 meta 同处活跃目录(搬回先于 writePrMeta,避免 split)。 if (prev?.archivedAt) { @@ -372,6 +410,7 @@ export class Poller { : true; let lastMentionAt = prev?.lastMentionAt; let mentionAts = prev?.mentionAts; + let lastCommentAt = prev?.lastCommentAt; if (notifiable && me && shouldScanComments) { try { const comments = await adapter.comments.listPullRequestComments( @@ -417,6 +456,40 @@ export class Poller { project('mention'); } } + // 「我创建的」PR:他人新评论(不限是否 @我 / 回复我,自己的评论不计)→ authored_comment。 + // 独立游标 lastCommentAt:晚于它的他人评论计为新;游标缺失(升级前)时仅播种、不补发历史评论。 + if (authoredByMe) { + const others = collectCommentsFromOthers(comments, me); + if (others.length) { + const newest = others.reduce((a, b) => + Date.parse(b.at) > Date.parse(a.at) ? b : a, + ); + const prevCursor = prev?.lastCommentAt; + if (prevCursor !== undefined) { + const sinceMs = Date.parse(prevCursor); + const fresh = others.filter((o) => Date.parse(o.at) > sinceMs); + if (fresh.length > 0) { + const latest = fresh.reduce((a, b) => + Date.parse(b.at) > Date.parse(a.at) ? b : a, + ); + notifyEvents.push({ + kind: 'authored_comment', + localId, + connectionId, + remoteId: pr.remoteId, + title: pr.title, + repo: pr.repo, + actor: latest.author, + count: fresh.length, + comment: { remoteId: latest.commentRemoteId, anchor: latest.anchor }, + }); + } + } + if (!lastCommentAt || Date.parse(newest.at) > Date.parse(lastCommentAt)) { + lastCommentAt = newest.at; + } + } + } } catch (err) { this.opts.logger.warn( { err, connectionId, localId }, @@ -435,6 +508,9 @@ export class Poller { archivedAt: null, lastMentionAt, mentionAts, + hasConflict: pr.hasConflict, + needsWorkReviewers, + lastCommentAt, }); } } catch (err) { diff --git a/packages/poller/src/pr-state.ts b/packages/poller/src/pr-state.ts index a17460ac..382d2925 100644 --- a/packages/poller/src/pr-state.ts +++ b/packages/poller/src/pr-state.ts @@ -42,6 +42,16 @@ export interface PrIndexEntry { * 并存、互不替代。同 `lastMentionAt` 由 poll 独占维护,与已读水位解耦。 */ mentionAts?: string[]; + /** + * 「我创建的」PR 通知用的上一轮快照(poll 独占维护)。仅当 PR 作者为本人时才据此产出 authored_* 通知; + * 字段缺失(升级前的旧索引)时对应事件按「基线」处理——只播种、不补发,避免升级后一次性涌入历史事件。 + */ + /** 上一轮的合并冲突态(== PullRequest.hasConflict);用于探测 false→true 的新增冲突。 */ + hasConflict?: boolean; + /** 上一轮处于「需修改」状态的评审人 name 列表;用于探测新出现的 needs-work 评审人。 */ + needsWorkReviewers?: string[]; + /** 上一轮已知的最新「他人评论」createdAt(ISO)游标;晚于它的他人评论计为新评论。 */ + lastCommentAt?: string; } /** mentionAts 保留上限:仅留最近 10 条。未读计数据此封顶,UI 满额显示「10+」。 */ diff --git a/packages/poller/src/unread.ts b/packages/poller/src/unread.ts index f6fa0dbf..de1fded2 100644 --- a/packages/poller/src/unread.ts +++ b/packages/poller/src/unread.ts @@ -76,6 +76,46 @@ export function collectMentionsToMe( return hits; } +/** 评论树里一条**他人**评论(不限是否 @我 / 回复我):时间 + 作者 + 定位。用于「我创建的」PR 的新评论通知。 */ +export interface CommentHit { + at: string; + author: PlatformUser; + commentRemoteId: string; + anchor: PrCommentAnchor | null; +} + +/** + * 评论树里**所有他人评论**(作者非当前用户)的命中,深度优先、自然到达顺序(未排序)。与 + * {@link collectMentionsToMe} 不同:不筛 @我 / 回复我,收全部他人评论——供「我创建的」PR 的「收到新评论」通知 + * 用(作者本人的评论不计,故不会因自己评论而误报)。 + */ +export function collectCommentsFromOthers( + comments: readonly PrComment[], + me: PlatformUser, +): CommentHit[] { + const handles = [me.name, me.slug].filter((x): x is string => !!x); + const lowered = new Set(handles.map((h) => h.toLowerCase())); + const isMe = (u: PlatformUser): boolean => + lowered.has(u.name.toLowerCase()) || (u.slug ? lowered.has(u.slug.toLowerCase()) : false); + + const hits: CommentHit[] = []; + const walk = (list: readonly PrComment[]): void => { + for (const c of list) { + if (!isMe(c.author)) { + hits.push({ + at: c.createdAt, + author: c.author, + commentRemoteId: c.remoteId, + anchor: c.anchor, + }); + } + if (c.replies?.length) walk(c.replies); + } + }; + walk(comments); + return hits; +} + /** * 评论树里所有「@我 / 回复我」他人评论的 createdAt(ISO)列表。基于 {@link collectMentionsToMe}。 */ diff --git a/packages/poller/tests/poller.test.ts b/packages/poller/tests/poller.test.ts index 36cb5548..73204470 100644 --- a/packages/poller/tests/poller.test.ts +++ b/packages/poller/tests/poller.test.ts @@ -170,6 +170,13 @@ function makePr(id: string, updatedAt: string, title = `PR ${id}`): PullRequest }; } +/** 「我创建的」PR:作者即当前用户(默认 alice)。用于 authored_* 通知测试。 */ +function makeAuthoredPr(id: string, updatedAt: string, author = 'alice'): PullRequest { + const pr = makePr(id, updatedAt); + pr.author = { name: author, displayName: author }; + return pr; +} + let tmpDir: string; let store: JsonFileStateStore; // 归档冷存储:与 store 物理分离(store 根 = tmpDir,archived 根 = tmpDir/archived)。 @@ -1027,4 +1034,118 @@ describe('Poller onNotify (system notification projection)', () => { expect(adapter.commentCalls).toBe(1); // 未变化 → 未扫 expect(events.length).toBe(eventsLen); // 无新事件 }); + + it('authored PR: fires authored_needs_work when a reviewer newly marks needs-work', async () => { + const adapter = new FakeAdapter([makeAuthoredPr('1', '2026-05-28T01:00:00.000Z')]); + adapter.seedUser('alice'); + const events: PollNotificationEvent[] = []; + let now = new Date('2026-06-01T00:00:00.000Z'); + const poller = new Poller({ + connections: [{ connectionId: 'bb1', adapter }], + stateStore: store, + archiveStore, + intervalSeconds: 60, + logger: noopLogger, + now: () => now, + onNotify: (e) => events.push(...e), + }); + + await poller.tick(); // 基线:无 needsWork 评审人 + expect(events).toEqual([]); + + now = new Date('2026-06-02T00:00:00.000Z'); + const changed = makeAuthoredPr('1', '2026-05-29T01:00:00.000Z'); + changed.reviewers = [{ name: 'bob', displayName: 'Bob', status: 'needsWork' as const }]; + adapter.setPrs([changed]); + await poller.tick(); + + const e = events.find((x) => x.kind === 'authored_needs_work'); + expect(e).toBeDefined(); + expect(e!.remoteId).toBe('1'); + expect(e!.actor.name).toBe('bob'); // 标记需修改的评审人 + }); + + it('authored PR: fires authored_conflict on a false→true merge-conflict transition', async () => { + const adapter = new FakeAdapter([makeAuthoredPr('1', '2026-05-28T01:00:00.000Z')]); + adapter.seedUser('alice'); + const events: PollNotificationEvent[] = []; + let now = new Date('2026-06-01T00:00:00.000Z'); + const poller = new Poller({ + connections: [{ connectionId: 'bb1', adapter }], + stateStore: store, + archiveStore, + intervalSeconds: 60, + logger: noopLogger, + now: () => now, + onNotify: (e) => events.push(...e), + }); + + await poller.tick(); // 基线:无冲突 + expect(events).toEqual([]); + + now = new Date('2026-06-02T00:00:00.000Z'); + const conflicted = makeAuthoredPr('1', '2026-05-29T01:00:00.000Z'); + conflicted.hasConflict = true; + conflicted.mergeStatus = { canMerge: false, conflicted: true, vetoes: [] }; + adapter.setPrs([conflicted]); + await poller.tick(); + + const e = events.find((x) => x.kind === 'authored_conflict'); + expect(e).toBeDefined(); + expect(e!.remoteId).toBe('1'); + }); + + it('authored PR: seeds the comment cursor silently, then fires authored_comment on a later new comment', async () => { + const adapter = new FakeAdapter([makeAuthoredPr('1', '2026-05-28T01:00:00.000Z')]); + adapter.seedUser('alice'); + const events: PollNotificationEvent[] = []; + let now = new Date('2026-06-01T00:00:00.000Z'); + const poller = new Poller({ + connections: [{ connectionId: 'bb1', adapter }], + stateStore: store, + archiveStore, + intervalSeconds: 60, + logger: noopLogger, + now: () => now, + onNotify: (e) => events.push(...e), + }); + + await poller.tick(); // 基线:首轮不 notifiable、不扫评论 + + // 第二轮:出现一条他人评论 → 仅播种游标、不补发历史评论。 + now = new Date('2026-06-02T00:00:00.000Z'); + adapter.setPrs([makeAuthoredPr('1', '2026-05-29T01:00:00.000Z')]); + adapter.seedComments([ + makeComment({ + author: { name: 'bob', displayName: 'Bob' }, + body: 'looks good', + createdAt: '2026-06-02T00:00:00.000Z', + }), + ]); + await poller.tick(); + expect(events.some((e) => e.kind === 'authored_comment')).toBe(false); + + // 第三轮:又来一条更晚的他人评论(晚于游标)→ 触发 authored_comment。 + now = new Date('2026-06-03T00:00:00.000Z'); + adapter.setPrs([makeAuthoredPr('1', '2026-05-30T01:00:00.000Z')]); + adapter.seedComments([ + makeComment({ + author: { name: 'bob', displayName: 'Bob' }, + body: 'looks good', + createdAt: '2026-06-02T00:00:00.000Z', + }), + makeComment({ + author: { name: 'bob', displayName: 'Bob' }, + body: 'one more thing', + createdAt: '2026-06-03T00:00:00.000Z', + }), + ]); + await poller.tick(); + + const e = events.find((x) => x.kind === 'authored_comment'); + expect(e).toBeDefined(); + expect(e!.remoteId).toBe('1'); + expect(e!.count).toBe(1); + expect(e!.actor.name).toBe('bob'); + }); }); diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 34b6b1cd..2bb368e9 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -297,8 +297,9 @@ export const ConfigSchema = z.object({ service: ServiceSchema.default({}), /** * 消息通知(见 docs/arch/03-gui/03-notifications.md)。enabled 为总开关;关闭后既不弹系统通知也不亮 dock 角标。 - * new_pr / reply / mention 按事件类型分别控制系统通知(toast)是否弹出。macOS dock「待回应」计数角标无独立 - * 开关——随总开关默认启用。系统通知受 OS 权限约束——用户在系统设置关闭后应用静默降级。 + * 其余各项按事件类型分别控制系统通知(toast)是否弹出:new_pr / reply / mention 面向「待我评审」等场景; + * authored_* 面向「我创建的」PR(作者为本人)——新评论 / 被标记需修改 / 出现冲突。macOS dock「待回应」计数 + * 角标无独立开关——随总开关默认启用。系统通知受 OS 权限约束——用户在系统设置关闭后应用静默降级。 */ notifications: z .object({ @@ -309,6 +310,12 @@ export const ConfigSchema = z.object({ reply: z.boolean().default(true), /** 评论中被 @ 提及时弹系统通知 */ mention: z.boolean().default(true), + /** 我创建的 PR 收到他人新评论时弹系统通知 */ + authored_comment: z.boolean().default(true), + /** 我创建的 PR 被评审标记「需修改」时弹系统通知 */ + authored_needs_work: z.boolean().default(true), + /** 我创建的 PR 出现合并冲突时弹系统通知 */ + authored_conflict: z.boolean().default(true), }) .default({}), /** diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index 76806d18..78601adf 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -401,12 +401,24 @@ export interface PollResult { errors: number; } -/** 系统通知事件类型:新 PR / 被 @ / 被回复(与设置页三个开关一一对应)。 */ -export type PollNotificationKind = 'new_pr' | 'mention' | 'reply'; +/** + * 系统通知事件类型(与设置页开关一一对应): + * - `new_pr` / `mention` / `reply`:面向「待我评审」等——新 PR / 被 @ / 被回复。 + * - `authored_comment` / `authored_needs_work` / `authored_conflict`:面向「我创建的」PR(作者为本人)—— + * 收到他人新评论 / 被评审标记需修改 / 出现合并冲突。 + */ +export type PollNotificationKind = + | 'new_pr' + | 'mention' + | 'reply' + | 'authored_comment' + | 'authored_needs_work' + | 'authored_conflict'; /** * Poll 本轮新发生的「值得提醒」事件,由 poller 经 onNotify 投影给主进程(用于弹系统通知)。仅在**已有基线** - * (非首轮 / PR 此前已知)时产出,避免首启 / 批量涌入时通知风暴;mention/reply 仅当评论时间晚于历史游标才计。 + * (非首轮 / PR 此前已知)时产出,避免首启 / 批量涌入时通知风暴;带游标的事件(mention/reply/authored_comment) + * 仅当评论时间晚于历史游标才计;authored_needs_work / authored_conflict 仅在对应状态发生新迁移时才产出。 */ export interface PollNotificationEvent { kind: PollNotificationKind; @@ -420,13 +432,16 @@ export interface PollNotificationEvent { title: string; /** PR 所在仓库,用于通知正文展示「项目 / 仓库」 */ repo: RepoRef; - /** 发起人:new_pr=PR 作者;mention/reply=触发本轮该类事件的最新一条评论作者。用于通知头像。 */ + /** + * 发起人(通知头像):new_pr=PR 作者;mention/reply/authored_comment=触发本轮该类事件的最新一条评论作者; + * authored_needs_work=新标记需修改的评审人;authored_conflict=PR 作者(无具体发起人)。 + */ actor: PlatformUser; - /** mention / reply:本轮新增条数;new_pr 省略 */ + /** mention / reply / authored_comment:本轮新增条数;其余省略 */ count?: number; /** - * mention / reply:触发事件的最新一条评论的定位信息(通知点击跳转用)。`anchor` 非空=inline 评论(可跳 diff 行), - * 为 null=summary 评论(打开「活动」对话标签)。new_pr 无此字段。 + * 触发事件的最新一条评论的定位信息(通知点击跳转用)。`anchor` 非空=inline 评论(可跳 diff 行), + * 为 null=summary 评论(打开「活动」对话标签)。仅 mention / reply / authored_comment 带此字段。 */ comment?: { remoteId: string; anchor: PrCommentAnchor | null }; } From c9eab95d2772b1ecdcf8cc9597601c43839a13ec Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 19:44:15 +0800 Subject: [PATCH 43/84] =?UTF-8?q?feat(desktop):=20=E4=B8=BB=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E6=8A=95=E5=BD=B1=E3=80=8C=E6=88=91=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=E3=80=8DPR=20=E4=B8=89=E7=B1=BB=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=9A=84=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifications.ts 的类型开关映射 / i18n 分组 / emoji 补齐 authored_comment(💬)/ authored_needs_work(📝)/ authored_conflict(⚠️);四语言主进程 toast 文案(标题 + 正文) 一并补全。事件的 config 开关过滤在此层生效。 Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/main/i18n/locales/de-DE.json | 12 ++++++++++++ apps/desktop/src/main/i18n/locales/en-US.json | 12 ++++++++++++ apps/desktop/src/main/i18n/locales/ja-JP.json | 12 ++++++++++++ apps/desktop/src/main/i18n/locales/zh-CN.json | 12 ++++++++++++ apps/desktop/src/main/services/notifications.ts | 11 ++++++++++- 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/i18n/locales/de-DE.json b/apps/desktop/src/main/i18n/locales/de-DE.json index 13c6cb02..c1d260e2 100644 --- a/apps/desktop/src/main/i18n/locales/de-DE.json +++ b/apps/desktop/src/main/i18n/locales/de-DE.json @@ -21,6 +21,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "Neuer Kommentar zu deinem PR" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "Dein PR hat einen Merge-Konflikt" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "Dein PR wurde als überarbeitungsbedürftig markiert" + }, "mention": { "body": "#{{id}} {{title}}", "title": "Du wurdest erwähnt" diff --git a/apps/desktop/src/main/i18n/locales/en-US.json b/apps/desktop/src/main/i18n/locales/en-US.json index 98f5cfbb..4c682615 100644 --- a/apps/desktop/src/main/i18n/locales/en-US.json +++ b/apps/desktop/src/main/i18n/locales/en-US.json @@ -21,6 +21,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "New comment on your PR" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "Your PR has a merge conflict" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "Your PR was marked needs-work" + }, "mention": { "body": "#{{id}} {{title}}", "title": "You were mentioned" diff --git a/apps/desktop/src/main/i18n/locales/ja-JP.json b/apps/desktop/src/main/i18n/locales/ja-JP.json index 5a9caca4..f6c867ff 100644 --- a/apps/desktop/src/main/i18n/locales/ja-JP.json +++ b/apps/desktop/src/main/i18n/locales/ja-JP.json @@ -20,6 +20,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR に新しいコメント" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR にマージ競合が発生" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "あなたの PR が要修正に設定されました" + }, "mention": { "body": "#{{id}} {{title}}", "title": "あなたへのメンションがあります" diff --git a/apps/desktop/src/main/i18n/locales/zh-CN.json b/apps/desktop/src/main/i18n/locales/zh-CN.json index 09308a59..dc1c905f 100644 --- a/apps/desktop/src/main/i18n/locales/zh-CN.json +++ b/apps/desktop/src/main/i18n/locales/zh-CN.json @@ -20,6 +20,18 @@ } }, "notifications": { + "authoredComment": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 有新评论" + }, + "authoredConflict": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 出现合并冲突" + }, + "authoredNeedsWork": { + "body": "#{{id}} {{title}}", + "title": "你的 PR 被标记需修改" + }, "mention": { "body": "#{{id}} {{title}}", "title": "有人提到了你" diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index c40c9902..09e999da 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -19,11 +19,14 @@ import { ensureAvatarFile, type AvatarFileDeps } from './avatar.js'; /** 一轮最多单独弹的通知条数(各带定位);超出部分折叠为一条「查看更多」提示,避免涌入时的通知风暴。 */ const INDIVIDUAL_LIMIT = 5; -/** 通知事件类型 → i18n 文案分组名(new_pr 的 key 为 newPr,其余同名)。 */ +/** 通知事件类型 → i18n 文案分组名(new_pr 的 key 为 newPr,authored_* 转驼峰,其余同名)。 */ const I18N_GROUP: Record = { new_pr: 'newPr', mention: 'mention', reply: 'reply', + authored_comment: 'authoredComment', + authored_needs_work: 'authoredNeedsWork', + authored_conflict: 'authoredConflict', }; /** 类型 emoji(Windows toast 单图标槽给了头像,故类型用 emoji 在标题前标记)。 */ @@ -31,6 +34,9 @@ const TYPE_EMOJI: Record = { new_pr: '🔀', mention: '💬', reply: '↩️', + authored_comment: '💬', + authored_needs_work: '📝', + authored_conflict: '⚠️', }; /** 点击通知:唤起并聚焦主窗口(最小化则先还原)。 */ @@ -138,6 +144,9 @@ export async function showPollNotifications( new_pr: cfg.new_pr, reply: cfg.reply, mention: cfg.mention, + authored_comment: cfg.authored_comment, + authored_needs_work: cfg.authored_needs_work, + authored_conflict: cfg.authored_conflict, }; const filtered = events.filter((e) => allow[e.kind]); if (filtered.length === 0) return; From 11208dbd6135177f8a528704f5d3493258369011 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 19:44:26 +0800 Subject: [PATCH 44/84] =?UTF-8?q?feat(desktop):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=A2=9E=E3=80=8C=E6=88=91=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=E3=80=8DPR=20=E4=B8=89=E9=A1=B9=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=88=E9=BB=98=E8=AE=A4=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationSection 在评审类开关下补三行:我的 PR 新评论 / 需修改 / 冲突,绑定 config.notifications 的 authored_comment / authored_needs_work / authored_conflict (schema 默认 true);四语言 label/hint 一并补全(递归字典序)。总开关关闭时同样置灰禁用。 Co-Authored-By: Claude Opus 4.8 --- .../settings/sections/NotificationSection.tsx | 53 +++++++++++++++++-- .../src/renderer/src/i18n/locales/de-DE.json | 6 +++ .../src/renderer/src/i18n/locales/en-US.json | 6 +++ .../src/renderer/src/i18n/locales/ja-JP.json | 6 +++ .../src/renderer/src/i18n/locales/zh-CN.json | 6 +++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx b/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx index 12f3274d..02cd1c9e 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/sections/NotificationSection.tsx @@ -7,9 +7,10 @@ import { invoke } from '../../../../api'; const IS_MAC = navigator.platform.toLowerCase().includes('mac'); /** - * 通知分区:总开关 + 分类型系统通知(新 PR / 评论回复 / 评论 @)。总开关关闭时下属各项禁用(灰显但保留各自 - * 值)。系统通知受 OS 权限约束——用户在系统设置关闭后应用静默降级,此处仅控制应用侧意图。macOS dock「待回应」 - * 计数角标随总开关默认启用、无独立开关,故此处不列。 + * 通知分区:总开关 + 分类型系统通知——面向评审的(新 PR / 评论回复 / 评论 @)与面向「我创建的」PR 的 + * (新评论 / 被标记需修改 / 出现冲突)。总开关关闭时下属各项禁用(灰显但保留各自值)。系统通知受 OS 权限 + * 约束——用户在系统设置关闭后应用静默降级,此处仅控制应用侧意图。macOS dock「待回应」计数角标随总开关默认 + * 启用、无独立开关,故此处不列。 */ export function NotificationSection({ value, @@ -73,6 +74,52 @@ export function NotificationSection({ ariaLabel={t('settings.notifyMentionLabel')} /> +
  • +
    + {t('settings.notifyAuthoredCommentLabel')} + + {t('settings.notifyAuthoredCommentHint')} + +
    + set({ authored_comment: v })} + ariaLabel={t('settings.notifyAuthoredCommentLabel')} + /> +
  • +
  • +
    + + {t('settings.notifyAuthoredNeedsWorkLabel')} + + + {t('settings.notifyAuthoredNeedsWorkHint')} + +
    + set({ authored_needs_work: v })} + ariaLabel={t('settings.notifyAuthoredNeedsWorkLabel')} + /> +
  • +
  • +
    + + {t('settings.notifyAuthoredConflictLabel')} + + + {t('settings.notifyAuthoredConflictHint')} + +
    + set({ authored_conflict: v })} + ariaLabel={t('settings.notifyAuthoredConflictLabel')} + /> +
  • {IS_MAC && ( // macOS 授权引导:系统层未授权时通知会被静默丢弃,应用无法代为开启 → 提供按钮跳转系统设置由用户开启。 diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index cdcaace0..2332be9b 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -774,6 +774,12 @@ "notificationsEnableLabel": "Benachrichtigungen aktivieren", "notificationsHint": "Werde benachrichtigt, wenn es neue Pull Requests oder an dich gerichtete Kommentare gibt. Ob Systembenachrichtigungen erscheinen, hängt von den Benachrichtigungseinstellungen deines Betriebssystems ab.", "notificationsTitle": "Benachrichtigungen", + "notifyAuthoredCommentHint": "Benachrichtigen, wenn jemand einen von dir erstellten PR kommentiert", + "notifyAuthoredCommentLabel": "Neuer Kommentar zu meinem PR", + "notifyAuthoredConflictHint": "Benachrichtigen, wenn ein von dir erstellter PR einen Merge-Konflikt bekommt", + "notifyAuthoredConflictLabel": "Konflikt in meinem PR", + "notifyAuthoredNeedsWorkHint": "Benachrichtigen, wenn ein Reviewer einen von dir erstellten PR als überarbeitungsbedürftig markiert", + "notifyAuthoredNeedsWorkLabel": "Mein PR überarbeitungsbedürftig", "notifyMacPermissionHint": "macOS steuert die Benachrichtigungsberechtigung auf Systemebene. Falls keine Benachrichtigungen erscheinen, erlaube Benachrichtigungen für diese App in den Systemeinstellungen.", "notifyMentionHint": "Benachrichtigen, wenn dich jemand in einem Kommentar mit @ erwähnt", "notifyMentionLabel": "Kommentar-Erwähnungen", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index dc075776..c8558ac6 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -774,6 +774,12 @@ "notificationsEnableLabel": "Enable notifications", "notificationsHint": "Get notified when there are new pull requests or comments addressed to you. System notifications follow your OS notification settings.", "notificationsTitle": "Notifications", + "notifyAuthoredCommentHint": "Notify when someone comments on a PR you created", + "notifyAuthoredCommentLabel": "New comment on my PR", + "notifyAuthoredConflictHint": "Notify when a PR you created develops a merge conflict", + "notifyAuthoredConflictLabel": "My PR conflict", + "notifyAuthoredNeedsWorkHint": "Notify when a reviewer marks a PR you created as needs-work", + "notifyAuthoredNeedsWorkLabel": "My PR needs-work", "notifyMacPermissionHint": "macOS controls notification permission at the system level. If you don't see notifications, open System Settings and allow notifications for this app.", "notifyMentionHint": "Notify when someone @-mentions you in a comment", "notifyMentionLabel": "Comment mentions", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 52e69275..a88e9a4d 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -757,6 +757,12 @@ "notificationsEnableLabel": "通知を有効化", "notificationsHint": "新しい PR やあなた宛てのコメントがあると通知します。システム通知が表示されるかは OS の通知設定に従います。", "notificationsTitle": "通知", + "notifyAuthoredCommentHint": "自分が作成した PR に他の人がコメントしたときに通知", + "notifyAuthoredCommentLabel": "自分の PR への新規コメント", + "notifyAuthoredConflictHint": "自分が作成した PR にマージ競合が発生したときに通知", + "notifyAuthoredConflictLabel": "自分の PR の競合", + "notifyAuthoredNeedsWorkHint": "自分が作成した PR がレビュアーに要修正と設定されたときに通知", + "notifyAuthoredNeedsWorkLabel": "自分の PR の要修正", "notifyMacPermissionHint": "macOS は通知の許可をシステムレベルで管理します。通知が表示されない場合は、システム設定でこのアプリの通知を許可してください。", "notifyMentionHint": "コメントであなたが @ メンションされたときに通知", "notifyMentionLabel": "コメントのメンション", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index 05891eff..9789e671 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -757,6 +757,12 @@ "notificationsEnableLabel": "启用通知", "notificationsHint": "在有新的 PR 或与你相关的评论时收到提醒。系统通知是否弹出取决于操作系统的通知设置。", "notificationsTitle": "通知", + "notifyAuthoredCommentHint": "我创建的 PR 收到他人新评论时提醒", + "notifyAuthoredCommentLabel": "我的 PR 新评论", + "notifyAuthoredConflictHint": "我创建的 PR 出现合并冲突时提醒", + "notifyAuthoredConflictLabel": "我的 PR 冲突", + "notifyAuthoredNeedsWorkHint": "我创建的 PR 被评审标记「需修改」时提醒", + "notifyAuthoredNeedsWorkLabel": "我的 PR 需修改", "notifyMacPermissionHint": "macOS 在系统层管控通知权限。若收不到通知,请在系统设置中为本应用开启通知。", "notifyMentionHint": "评论中有人 @ 你时提醒", "notifyMentionLabel": "评论提及", From 6aab40f27889afdc41aa8370af65664dca38a9f7 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 19:44:38 +0800 Subject: [PATCH 45/84] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E3=80=8C?= =?UTF-8?q?=E6=88=91=E5=88=9B=E5=BB=BA=E7=9A=84=E3=80=8DPR=20=E4=B8=89?= =?UTF-8?q?=E7=B1=BB=E9=80=9A=E7=9F=A5=EF=BC=88=E8=AE=BE=E8=AE=A1=20+=20ch?= =?UTF-8?q?angelog=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notifications 设计文档补 authored_comment / authored_needs_work / authored_conflict 的投影规则、索引快照字段与配置开关;CHANGELOG 新增一条。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + docs/arch/03-gui/03-notifications.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e44be8..bf9a28f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 开放浏览(当前身份 / PR 列表 / 详情 / diff / 动态 / 提交 / 评审人)、评审 Agent(状态 / 历史 / 自动评审 / 指令 / 对话 / 中断)与评审写动作(通过 / 需修改 / 发评论);不开放合并与变更类 Agent 工具(publish 等)。 - **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr `;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 - **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 +- **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 ### ♻️ 变更 diff --git a/docs/arch/03-gui/03-notifications.md b/docs/arch/03-gui/03-notifications.md index 1850cd94..1b490f61 100644 --- a/docs/arch/03-gui/03-notifications.md +++ b/docs/arch/03-gui/03-notifications.md @@ -4,7 +4,7 @@ ## 范围 -- **系统通知(toast)**:Windows + macOS 原生通知,按事件类型(新 PR / 评论回复 / 评论 @)分别开关。 +- **系统通知(toast)**:Windows + macOS 原生通知,按事件类型分别开关——面向评审的(新 PR / 评论回复 / 评论 @)与面向「我创建的」PR 的(新评论 / 被标记需修改 / 出现冲突)。 - **macOS dock 角标**:dock 图标上显示「@我 / 回复我」待回应总数。 - 不纳入常驻状态栏 / Windows 任务栏 overlay 与闪烁(成本偏重,收益有限)。 @@ -12,10 +12,15 @@ ### 1. 系统通知:poll 事件投影 → 主进程 toast -- **投影(poller)**:`pollOnce` 在常规扫描中顺带产出本轮「值得提醒」事件 `PollNotificationEvent[]`(`kind: new_pr | mention | reply` + PR 标识/标题 + 条数),经 `onNotify` 回调交主进程。复用既有评论拉取,按下文「评论跟踪触发」决定何时扫。 +- **投影(poller)**:`pollOnce` 在常规扫描中顺带产出本轮「值得提醒」事件 `PollNotificationEvent[]`(`kind: new_pr | mention | reply | authored_comment | authored_needs_work | authored_conflict` + PR 标识/标题 + 条数),经 `onNotify` 回调交主进程。复用既有评论拉取,按下文「评论跟踪触发」决定何时扫。 - **新 PR**:`isAdded` 的 PR。 - **@ / 回复**:评论扫描用 [`collectMentionsToMe`](../../../packages/poller/src/unread.ts)(按「父评论作者是我=reply / 正文 @我=mention」分类,每条命中带评论作者),取**晚于历史游标 `lastMentionAt`** 的命中、按类型聚合条数。 - - 事件还带 `repo` / `connectionId` / `actor`(发起人:new_pr=PR 作者,mention/reply=该类最新一条命中的评论作者),供富样式通知用。 + - **「我创建的」PR(作者为本人,`pr.author` == 当前用户)**——仅对这类 PR 额外产出: + - `authored_comment`:他人新评论(用 [`collectCommentsFromOthers`](../../../packages/poller/src/unread.ts) 收全部非本人评论,取晚于独立游标 `lastCommentAt` 的;自己的评论不计,故不会因自评误报)。 + - `authored_needs_work`:新出现的「需修改」评审人(本轮在 needsWork、上一轮 `needsWorkReviewers` 不在)。 + - `authored_conflict`:合并冲突 `hasConflict` false→true。 + - 三者的「上一轮快照」(`lastCommentAt` / `needsWorkReviewers` / `hasConflict`)存于索引条目;快照字段缺失(升级前旧索引)时按基线只播种、不补发。 + - 事件还带 `repo` / `connectionId` / `actor`(发起人:new_pr=PR 作者,mention/reply/authored_comment=该类最新一条命中的评论作者,authored_needs_work=新标记需修改的评审人,authored_conflict=PR 作者)。 - **防风暴**:仅在**已有基线**(本轮之前索引非空)时产出事件——首启 / 清库后的首轮只建基线、不弹通知;新发现 PR 的历史评论不投影为 mention/reply(`prev` 不存在则跳过)。 - **仅「待处理」**:事件只对 `localStatus === 'pending'` 的 PR 产出(投影处 `notifiable = hadBaseline && localStatus === 'pending'` 门控)——已 approve / 标记 needs_work 的 PR 不再打扰。「待处理」天然覆盖「待我评审」(未决断)与「我创建的」(作者非评审人 → 恒 pending)两类。 - **点击定位**:mention/reply 事件带 `comment`(最新一条命中评论的 `remoteId` + `anchor`),供点击跳转。 @@ -60,7 +65,8 @@ | 字段 | 含义 | | --- | --- | | `enabled` | 总开关;关闭后既不弹系统通知也不亮 dock 角标 | -| `new_pr` / `reply` / `mention` | 分类型系统通知开关 | +| `new_pr` / `reply` / `mention` | 面向评审的分类型系统通知开关 | +| `authored_comment` / `authored_needs_work` / `authored_conflict` | 面向「我创建的」PR 的分类型开关(新评论 / 被标记需修改 / 出现冲突);默认开 | macOS dock「待回应」计数角标随总开关默认启用,无独立配置项。 From fbda88dd1c3f44b5c206c1f0c788423020ac8bcf Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 20:28:44 +0800 Subject: [PATCH 46/84] =?UTF-8?q?feat(desktop):=20=E8=AF=84=E5=AE=A1=20Age?= =?UTF-8?q?nt=20=E9=9D=A2=E6=9D=BF=E7=9B=B4=E6=8E=A5=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E7=9A=84=E5=91=BD=E4=BB=A4=E5=8D=B3=E6=97=B6=E5=9B=9E=E6=98=BE?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=B0=94=E6=B3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在 ChatPane 直接发起 /review、/describe、/improve、/ask 等斜杠命令时, 此前无用户气泡回显、不符对话习惯。现按 run 触发来源派生命令回显气泡:仅对 用户手动发起(origin=user)的 run 在其卡片之上补一条 user 消息,运行中与完 成态同一 runId 共用 key 平滑过渡;编排 / AutoPilot 子 run(origin=agent)不 回显,避免与编排会话的用户消息重复。回显由 run 记录派生,重载后依旧一致、 无需额外持久化。 ReviewRun 与 PragentRunInfo 新增 origin 字段(自队列 priority 落盘 / 广播), 历史 run 无此字段则不回显。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../main/services/pr-agent/run-executor.ts | 2 + .../src/main/services/pr-agent/run-queue.ts | 1 + .../features/chat/hooks/useChatTimeline.ts | 37 ++++++++++++++++++- packages/ipc/src/common.ts | 3 ++ packages/poller/src/runs.ts | 3 ++ packages/shared/src/poller-contract.ts | 9 +++++ 7 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf9a28f5..dbfa2888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - **外部集成 · 命令行工具 `meebox`**:随发布提供 Windows / macOS / Linux 跨平台命令行客户端,经本地 API 服务浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成。PR 列表精简且支持分页;PR 关联命令用 `--pr `;连接信息须显式提供(flag / 环境变量 / cli.yaml),不读 GUI 主配置。 - **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 - **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 +- **命令回显气泡**:在评审 Agent 面板直接发起 `/review`、`/describe`、`/improve`、`/ask` 等命令时,命令即时以用户气泡回显在其结果卡片之上,贴合对话习惯;编排 / AutoPilot 派发的子任务不回显,避免与编排会话的用户消息重复。 ### ♻️ 变更 diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts index 774ec5ca..a81d278f 100644 --- a/apps/desktop/src/main/services/pr-agent/run-executor.ts +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -254,6 +254,8 @@ export class RunExecutor { model: activeLlmForRecord?.model || undefined, // 复评引用前向链:随 run 落盘,UI 据此在 /ask 卡上展示「复评自…」徽标 + 裁决动作。 referencedFinding: req.tool === 'ask' ? req.referencedFinding : undefined, + // 触发来源随 run 落盘:user 来源的 run 由 ChatPane 补命令回显气泡;agent 子 run 不回显。 + origin: item.priority, }); // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播(经调度层)。 item.info = { ...item.info, startedAt: run.startedAt }; diff --git a/apps/desktop/src/main/services/pr-agent/run-queue.ts b/apps/desktop/src/main/services/pr-agent/run-queue.ts index a8f02562..fd825f2b 100644 --- a/apps/desktop/src/main/services/pr-agent/run-queue.ts +++ b/apps/desktop/src/main/services/pr-agent/run-queue.ts @@ -96,6 +96,7 @@ export class RunQueue { prNumber: pr.remoteId, tool, question: tool === 'ask' ? question : undefined, + origin: priority, enqueuedAt: new Date().toISOString(), startedAt: null, }, diff --git a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts index ff9a8121..7c63da19 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts +++ b/apps/desktop/src/renderer/src/components/features/chat/hooks/useChatTimeline.ts @@ -17,6 +17,18 @@ function ms(iso: string | null | undefined): number { return Number.isFinite(n) ? n : 0; } +/** + * 用户直接发起的斜杠命令的回显文案:/ask 显示问题正文(更贴近对话;空问题回退 `/ask`), + * describe/review/improve 显示 `/工具名`。仅用于命令回显气泡,不做 i18n(就是用户键入的命令)。 + */ +function echoContent(tool: string, question: string | undefined): string { + if (tool === 'ask') { + const q = question?.trim(); + if (q) return q; + } + return `/${tool}`; +} + /** * 历史时间线 + 实时「思考中」计时锚点。 * @@ -70,7 +82,30 @@ export function useChatTimeline(params: { sortTime: ms(m.at), message: m as AgentMessage | null, })); - return [...runEntries, ...activeEntries, ...stepEntries, ...msgEntries].sort( + // 命令回显气泡:仅对**用户直接发起**(origin==='user')的 run 补一条 user 消息,紧贴其卡片之上 + // (sortTime 取起跑时刻 -1ms)。编排 / AutoPilot 子 run(origin==='agent')不回显——其用户输入已由 + // 编排会话的用户消息承载。历史 run 无 origin(undefined)→ 不回显。active 与完成态同一 runId 互斥, + // key 统一为 `echo-`,运行中→完成的切换平滑不重挂。 + const echoOf = ( + runId: string, + tool: string, + question: string | undefined, + anchorMs: number, + ) => ({ + ...base, + key: `echo-${runId}`, + sortTime: anchorMs - 1, + message: { role: 'user', content: echoContent(tool, question), at: '' } as AgentMessage, + }); + const echoEntries = [ + ...visibleRuns + .filter((r) => r.origin === 'user') + .map((r) => echoOf(r.id, r.tool, r.question, ms(r.startedAt))), + ...myActiveRuns + .filter((a) => a.origin === 'user') + .map((a) => echoOf(a.runId, a.tool, a.question, ms(a.startedAt ?? a.enqueuedAt))), + ]; + return [...runEntries, ...activeEntries, ...stepEntries, ...msgEntries, ...echoEntries].sort( (a, b) => a.sortTime - b.sortTime, ); }, [visibleRuns, myActiveRuns, agentSteps, messages]); diff --git a/packages/ipc/src/common.ts b/packages/ipc/src/common.ts index a94e0bdb..bdfc3c79 100644 --- a/packages/ipc/src/common.ts +++ b/packages/ipc/src/common.ts @@ -1,6 +1,7 @@ import type { PlatformCapabilities, PlatformUser, + ReviewRunOrigin, ReviewRunTool, } from '@meebox/shared'; @@ -56,6 +57,8 @@ export interface PragentRunInfo { prNumber: string; tool: ReviewRunTool; question?: string; + /** 触发来源:user(手动发起)/ agent(编排派发)。ChatPane 据此为 user 来源的运行中 run 补命令回显气泡。 */ + origin: ReviewRunOrigin; /** 入队时间,ISO */ enqueuedAt: string; /** 开始执行时间,ISO;waiting 状态为 null */ diff --git a/packages/poller/src/runs.ts b/packages/poller/src/runs.ts index 2b155344..1c4dbf4b 100644 --- a/packages/poller/src/runs.ts +++ b/packages/poller/src/runs.ts @@ -54,6 +54,8 @@ export interface StartReviewRunInput { model?: string; /** 复评引用:本次 /ask 是对某条 finding 的复评时,记下被引用的源 finding(前向链)。 */ referencedFinding?: ReviewRun['referencedFinding']; + /** 触发来源:user(手动)/ agent(编排派发)。用于 ChatPane 命令回显气泡;缺省不回显。 */ + origin?: ReviewRun['origin']; } /** 写入初始 running 状态;调用方在 pr-agent 调用前必须先 start。 */ @@ -72,6 +74,7 @@ export async function startReviewRun( strategy: input.strategy, model: input.model, referencedFinding: input.referencedFinding, + origin: input.origin, status: 'running', startedAt: at.toISOString(), }; diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index 78601adf..05469938 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -255,6 +255,9 @@ export interface TokenUsage { turns?: number; } +/** pr-agent run 触发来源:user(用户手动发起)/ agent(编排 / AutoPilot 派发)。 */ +export type ReviewRunOrigin = 'user' | 'agent'; + export interface ReviewRun { /** yyyymmdd-HHmmss-ms 时序 id,便于按文件名倒序列出 */ id: string; @@ -269,6 +272,12 @@ export interface ReviewRun { tool: ReviewRunTool; /** /ask 工具的问题内容;其他 tool 不填。UI 把它当用户发言渲染在 run 卡片之上 */ question?: string; + /** + * 触发来源:user(用户在 ChatPane 直接发起的斜杠命令)/ agent(编排 / AutoPilot 派发的子 run)。 + * ChatPane 据此为 user 来源的 run 在其卡片之上补一条命令回显气泡(对话习惯);agent 子 run 不回显 + * (其用户输入已由编排会话的用户消息承载,避免重复冒泡)。历史 run 无此字段(undefined),不回显。 + */ + origin?: ReviewRunOrigin; /** 探测时拿到的 pr-agent 版本(CLI 首行 / 嵌入式查出的 pr-agent 版本) */ prAgentVersion: string; strategy: PrAgentStrategy; From c4602b61e833858939a4278f3b190822c7217a77 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 20:57:26 +0800 Subject: [PATCH 47/84] =?UTF-8?q?feat(desktop):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AF=B9=E9=80=89=E5=AE=9A=20commit=20=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E5=8D=95=20commit=20=E8=8C=83=E5=9B=B4=E7=9A=84=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff 视图变更范围选择器的各提交行新增「评审 / 改进 / 提问」动作:把 review / improve / ask 的 diff 范围限定在该 commit 自身改动(parent..sha)而非 PR 全量。 review / improve 立即发起并展开对话面板;ask 把提交范围挂到输入栏 chip,输入 问题后针对该 commit 作答。评审结果卡展示所限定提交的范围徽标。 机制:pragent:run 契约与 ReviewRun 新增可选 scope(commit sha/parent/短SHA/主 题);run-executor 据 scope 物化 parent..sha 的 worktree(LOCAL__TARGET_BRANCH 指向 parent),pr-agent 只见该 commit 的 diff——无需改动 pr-agent 本身。单 commit 范围为定向动作,与 /ask 一并放行 dedup(可在 PR 全量 review 之外另对某 commit 单独 review);重试沿用原 run 的范围。root commit 无父、不提供该动作。 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + apps/desktop/src/main/controllers/agent.ts | 1 + .../main/services/pr-agent/run-executor.ts | 17 +++++- .../src/main/services/pr-agent/run-queue.ts | 8 ++- apps/desktop/src/renderer/src/App.tsx | 17 ++++++ .../src/components/features/chat/ChatPane.tsx | 57 +++++++++++++++++- .../features/chat/components/ChatInputBar.tsx | 35 ++++++++++- .../chat/components/RunResultView.tsx | 13 +++- .../features/chat/hooks/useChatActions.ts | 9 ++- .../src/components/features/pr/PrPanel.tsx | 6 ++ .../features/pr/tabs/diff/DiffScopeSelect.tsx | 60 ++++++++++++++++++- .../features/pr/tabs/diff/DiffView.tsx | 14 ++++- .../src/renderer/src/i18n/locales/de-DE.json | 4 ++ .../src/renderer/src/i18n/locales/en-US.json | 4 ++ .../src/renderer/src/i18n/locales/ja-JP.json | 4 ++ .../src/renderer/src/i18n/locales/zh-CN.json | 4 ++ .../src/styles/features/chat/run.scss | 7 +++ .../src/styles/features/diff/file-tree.scss | 37 ++++++++++++ packages/ipc/src/agent.ts | 4 ++ packages/poller/src/runs.ts | 3 + packages/shared/src/poller-contract.ts | 21 +++++++ 21 files changed, 313 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfa2888..f49cf9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - **PR 列表发现分类未读圆点**:某发现分类(待我评审 / 我创建 等)下有新的待处理 PR 时,在该分类标签后加未读圆点,一眼看出哪类有新进展;圆点始终基于活跃 PR,即便当前处于「已关闭」视图也正确反映活跃分类的未读。 - **「我创建的」PR 通知**:针对本人创建的 PR 新增三类系统通知——收到他人新评论、被评审标记「需修改」、出现合并冲突;通知分区提供独立开关、默认开启。 - **命令回显气泡**:在评审 Agent 面板直接发起 `/review`、`/describe`、`/improve`、`/ask` 等命令时,命令即时以用户气泡回显在其结果卡片之上,贴合对话习惯;编排 / AutoPilot 派发的子任务不回显,避免与编排会话的用户消息重复。 +- **按提交发起评审 Agent**:Diff 视图的变更范围选择器中,各提交行新增「评审 / 改进 / 提问」动作,可将 review / improve / ask 的 diff 范围限定在该提交自身改动(`parent..sha`)而非 PR 全量;「提问」会把提交范围挂到输入栏、输入问题后针对该提交作答。评审结果卡展示所限定提交的范围徽标。 ### ♻️ 变更 diff --git a/apps/desktop/src/main/controllers/agent.ts b/apps/desktop/src/main/controllers/agent.ts index c0a0ed71..0cf24d49 100644 --- a/apps/desktop/src/main/controllers/agent.ts +++ b/apps/desktop/src/main/controllers/agent.ts @@ -142,6 +142,7 @@ export const runPragent: IpcController<'pragent:run'> = async (_event, req) => { 'user', req.referencedContext, req.referencedFinding, + req.scope, ); }; diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts index a81d278f..4260c57d 100644 --- a/apps/desktop/src/main/services/pr-agent/run-executor.ts +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -87,7 +87,7 @@ export class RunExecutor { return updated ?? { ...run, ...patch }; }; - const wt = await this.prepareWorkspace(pr); + const wt = await this.prepareWorkspace(pr, req.scope); try { const { env, extraArgs, askLangSuffix } = await this.buildInvocation( req, @@ -256,6 +256,8 @@ export class RunExecutor { referencedFinding: req.tool === 'ask' ? req.referencedFinding : undefined, // 触发来源随 run 落盘:user 来源的 run 由 ChatPane 补命令回显气泡;agent 子 run 不回显。 origin: item.priority, + // 单 commit 评审范围随 run 落盘:结果卡据此展示范围徽标。 + scope: req.scope, }); // 把入队时 startedAt=null 的 info 升级为 active 形态 + 广播(经调度层)。 item.info = { ...item.info, startedAt: run.startedAt }; @@ -267,13 +269,22 @@ export class RunExecutor { return run; } - /** 阶段②:同步镜像 + 按固定 merge-base 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 */ - private async prepareWorkspace(pr: QueueItem['pr']) { + /** + * 阶段②:同步镜像 + 物化 worktree(与 UI diff 同源,评审基于 PR 自分叉的改动)。 + * 缺省按固定 merge-base 定界 PR 全量(head=PR 源 sha,base=merge-base);传入单 commit 范围(scope)时 + * 改按该 commit 自身改动定界(head=scope.sha,base=scope.parent),pr-agent 只见 parent..sha 的 diff。 + */ + private async prepareWorkspace(pr: QueueItem['pr'], scope?: QueueItem['req']['scope']) { const { repoMirror, pr: prService } = this.ctx; const repoId = prService.repoIdentityFor(pr); // 走 ensureMirrorReadyForPr(而非裸 syncMirror):与 UI diff 同源,且复用其自愈——源分支被删 / 强推后 // 按平台精确 fetch PR 头引用补齐 head sha,否则 materializeWorktree 建 meebox/head 会因对象缺失失败。 await prService.ensureMirrorReadyForPr(pr); + if (scope) { + // 单 commit 范围:head=目标 commit,base=其父 commit → LOCAL__TARGET_BRANCH 指向 parent, + // pr-agent 只见该 commit 自身改动。parent 是 head 的祖先、随镜像同步而在,无需另取。 + return repoMirror.materializeWorktree(repoId, scope.sha, scope.parent); + } // pr-agent 的 LOCAL__TARGET_BRANCH 用固定 merge-base,而非 targetRef.sha 漂移后混入别的 PR 的两点对比。 const diffBase = await prService.resolveDiffBaseSha(pr); return repoMirror.materializeWorktree(repoId, pr.sourceRef.sha, diffBase); diff --git a/apps/desktop/src/main/services/pr-agent/run-queue.ts b/apps/desktop/src/main/services/pr-agent/run-queue.ts index fd825f2b..ebf83494 100644 --- a/apps/desktop/src/main/services/pr-agent/run-queue.ts +++ b/apps/desktop/src/main/services/pr-agent/run-queue.ts @@ -25,6 +25,7 @@ export interface QueueItem { question?: string; referencedContext?: string; referencedFinding?: ReviewRun['referencedFinding']; + scope?: ReviewRun['scope']; }; pr: StoredPullRequest; resolve: (run: ReviewRun) => void; @@ -76,9 +77,12 @@ export class RunQueue { priority: RunPriority = 'user', referencedContext?: string, referencedFinding?: ReviewRun['referencedFinding'], + scope?: ReviewRun['scope'], ): Promise { const { logger } = this.ctx; - if (tool !== 'ask') { + // dedup 仅约束「PR 全量」的同工具重复;/ask 每次问题不同、单 commit 范围(scope)是定向动作,均放行 + // (允许全量 review 之外再对某 commit 单独 review,互不视作重复)。 + if (tool !== 'ask' && !scope) { const sameTask = (q: QueueItem): boolean => q.info.prLocalId === pr.localId && q.info.tool === tool; if ([...this.active.values()].some(sameTask) || this.waiting.some(sameTask)) { @@ -108,6 +112,8 @@ export class RunQueue { question, referencedContext: tool === 'ask' ? referencedContext : undefined, referencedFinding: tool === 'ask' ? referencedFinding : undefined, + // 单 commit 范围对所有工具生效(不限 ask):executor 据此物化 parent..sha 的 worktree。 + scope, }, pr, priority, diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index f8e37404..b36823ed 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import type { ReviewRunCommitScope, ReviewRunTool } from '@meebox/shared'; import { invoke } from './api'; import { ChatPane } from './components/features/chat'; import { MainPane } from './components/layout/MainPane'; @@ -81,6 +82,19 @@ export default function App() { }, []); // PR 状态筛选(待处理 / 全部 / 冲突 / 可合并等):提升到 App 以便命令面板亦可驱动、折叠侧栏不丢选择。 const [statusFilter, setStatusFilter] = useState('pending'); + // 就某 commit 发起单 commit 范围的评审 Agent 动作:Diff 提交选择器请求 → 展开对话面板 → 交 ChatPane + // 消费(review/improve 立即发起、ask 挂 chip)。消费后置 null。 + const [pendingScopedRun, setPendingScopedRun] = useState<{ + tool: ReviewRunTool; + scope: ReviewRunCommitScope; + } | null>(null); + const requestScopedRun = useCallback( + (tool: ReviewRunTool, scope: ReviewRunCommitScope) => { + setChatCollapsed(false); + setPendingScopedRun({ tool, scope }); + }, + [setChatCollapsed], + ); // PR 导航 / 范围领域(发现分类 / 活跃·归档切换 / 归档懒加载 / 按 URL 打开 / 定位跳转 / 通知点击导航 + // 跨组件 Diff·Tab 跳转意图)——领域逻辑归 usePrNavigation;选中态 / 已读仍由 usePullRequests 拥有。 const { @@ -241,6 +255,7 @@ export default function App() { onRequestDiffNav={(target) => setPendingDiffNav(target)} pendingTab={pendingTab} onPendingTabConsumed={() => setPendingTab(null)} + onRequestScopedRun={requestScopedRun} /> ) : ( 0} /> @@ -263,6 +278,8 @@ export default function App() { currentLlmModel={ boot.config.llm.profiles.find((p) => p.id === boot.config.llm.active_id)?.model ?? null } + pendingScopedRun={pendingScopedRun} + onScopedRunConsumed={() => setPendingScopedRun(null)} />
    void; + /** + * 外部(Diff 视图提交选择器)请求对某 commit 发起单 commit 范围的 run:review/improve 立即发起、 + * 携带 scope;ask 则把 scope 挂到输入栏 chip 待用户输入问题。消费后经 onScopedRunConsumed 清空。 + */ + pendingScopedRun?: { tool: ReviewRunTool; scope: ReviewRunCommitScope } | null; + onScopedRunConsumed?: () => void; } /** @@ -94,6 +102,8 @@ export function ChatPane({ currentLlmModel, llmConfigured = true, onOpenSettings, + pendingScopedRun, + onScopedRunConsumed, }: ChatPaneProps) { const { t } = useTranslation(); const startResize = (e: React.MouseEvent): void => { @@ -139,9 +149,13 @@ export function ChatPane({ // 复评引用态:点 finding「引用」→ 仅挂到输入栏(chip);不自动填写问题,用户自行输入。发送时携带该引用。 const [refFinding, setRefFinding] = useState<{ finding: Finding; run: ReviewRun } | null>(null); - // PR 切换清掉引用态,避免跨 PR 残留。 + // 单 commit 提问范围态:Diff 提交选择器「就此提交提问」→ 把 commit 范围挂到输入栏 chip;发送的 /ask + // 携带该范围(限定 parent..sha 的 diff)。与复评引用互斥(设其一清另一)。 + const [scopeChip, setScopeChip] = useState(null); + // PR 切换清掉引用态 / 范围态,避免跨 PR 残留。 useEffect(() => { setRefFinding(null); + setScopeChip(null); }, [prLocalId]); const onReferenceFinding = (finding: Finding, run: ReviewRun): void => { setRefFinding({ finding, run }); @@ -199,6 +213,27 @@ export function ChatPane({ setRefFinding(null); }; + // 发送一条单 commit 范围的 /ask:把 commit 范围随问题带下去(限定 parent..sha 的 diff);发送后清空范围态。 + const sendScopedAsk = (q: string): void => { + if (!scopeChip) return; + void actions.handleRun('ask', q, undefined, undefined, scopeChip); + setScopeChip(null); + }; + + // 消费外部「就此提交发起 run」请求:review/improve 立即发起(携带 scope);ask 把范围挂到输入栏 chip, + // 待用户输入问题再发。与复评引用互斥(挂范围时清引用)。 + useEffect(() => { + if (!pendingScopedRun) return; + const { tool, scope } = pendingScopedRun; + if (tool === 'ask') { + setRefFinding(null); + setScopeChip(scope); + } else { + void actions.handleRun(tool, undefined, undefined, undefined, scope); + } + onScopedRunConsumed?.(); + }, [pendingScopedRun, onScopedRunConsumed, actions]); + // 历史时间线归并 + 「思考中」实时计时锚点 const { timeline, thinkingSince } = useChatTimeline({ visibleRuns, @@ -403,6 +438,10 @@ export function ChatPane({ sendReferencedAsk(q ?? ''); return; } + if (tool === 'ask' && scopeChip) { + sendScopedAsk(q ?? ''); + return; + } void actions.handleRun(tool, q, tool === 'ask' ? referencedContext : undefined); }} onAgentAsk={(q) => { @@ -410,6 +449,11 @@ export function ChatPane({ sendReferencedAsk(q); return; } + // 挂了 commit 范围时,自然语言提问也走单 commit 范围的 /ask(限定该 commit 的 diff)。 + if (scopeChip) { + sendScopedAsk(q); + return; + } void actions.handleAgentAsk(q, referencedContext); }} onCancel={hasMyActive || agentRunningHere ? actions.handleStopAll : undefined} @@ -436,6 +480,17 @@ export function ChatPane({ } : null } + // 单 commit 提问范围 chip:挂了 commit 范围时展示「短 SHA · 主题」+ 清除;下一条提问走该范围的 /ask。 + commitScopeChip={ + scopeChip + ? { + label: `${scopeChip.abbreviatedSha} · ${scopeChip.subject}`, + onClear: () => { + setScopeChip(null); + }, + } + : null + } /> {showRulePreview && matchedRules.length > 0 && ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index 0f560450..6cd7e499 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -5,7 +5,14 @@ import type { ReviewRunTool, StoredPullRequest, } from '@meebox/shared'; -import { AutoReviewIcon, EyeOffIcon, FileTreeIcon, SendIcon, StopIcon } from '../../../common'; +import { + AutoReviewIcon, + CommitIcon, + EyeOffIcon, + FileTreeIcon, + SendIcon, + StopIcon, +} from '../../../common'; import { useChatInput } from '../hooks/useChatInput'; import { useTextareaAutosizeDrag } from '../hooks/useTextareaAutosizeDrag'; @@ -48,6 +55,8 @@ interface ChatInputBarProps { onToggleSelection: () => void; /** 复评引用 chip:引用了某条 finding 时展示「复评 」+ 清除;null = 不渲染。 */ referenceChip?: { label: string; onClear: () => void } | null; + /** 单 commit 提问范围 chip:挂了 commit 范围时展示「短 SHA · 主题」+ 清除;下一条 /ask 限定该 commit。 */ + commitScopeChip?: { label: string; onClear: () => void } | null; } /** @@ -74,6 +83,7 @@ export function ChatInputBar({ selectionIgnored, onToggleSelection, referenceChip, + commitScopeChip, }: ChatInputBarProps) { const { t } = useTranslation(); const { @@ -268,6 +278,29 @@ export function ChatInputBar({ )} + {/* 单 commit 提问范围 chip:挂了某 commit 范围时展示「短 SHA · 主题」,点 ✕ 清除。 + 发送时本条 /ask 限定该 commit 的 diff(parent..sha)。 */} + {commitScopeChip && ( + <> + + )} diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json index d8a9367a..d714f277 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -147,7 +147,9 @@ "runCancelled": "Abgebrochen", "runFailed": "Ausführung fehlgeschlagen", "runFailedReason": "Ausführung fehlgeschlagen ({{reason}})", + "scopeActiveTitle": "Auf diesen Commit beschränkt – zum Deaktivieren klicken", "scopeCommitTitle": "Auf Commit beschränkt: {{subject}}", + "scopeDisabledTitle": "Commit-Bereich deaktiviert – zum Aktivieren klicken", "scoreTitle": "Wichtigkeitsbewertung von pr-agent, 1-10", "scrollUpForOlder": "Nach oben scrollen, um ältere Verläufe zu laden…", "sectionAskAnalysis": "Analyse", diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json index 4823319a..1268fe85 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -147,7 +147,9 @@ "runCancelled": "Cancelled", "runFailed": "Run failed", "runFailedReason": "Run failed ({{reason}})", + "scopeActiveTitle": "Scoped to this commit — click to disable", "scopeCommitTitle": "Scoped to commit: {{subject}}", + "scopeDisabledTitle": "Commit scope disabled — click to enable", "scoreTitle": "Importance score from pr-agent, 1-10", "scrollUpForOlder": "Scroll up to load earlier history…", "sectionAskAnalysis": "Analysis", diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json index 5d431679..bf254b77 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -147,7 +147,9 @@ "runCancelled": "キャンセル済み", "runFailed": "実行に失敗しました", "runFailedReason": "実行に失敗しました ({{reason}})", + "scopeActiveTitle": "このコミットに限定中(クリックで無効化)", "scopeCommitTitle": "コミットに限定: {{subject}}", + "scopeDisabledTitle": "コミット範囲は無効(クリックで有効化)", "scoreTitle": "pr-agent による重要度スコア 1-10", "scrollUpForOlder": "上にスクロールして過去の履歴を読み込み…", "sectionAskAnalysis": "分析", diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json index ae92acbc..40a76cd7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -147,7 +147,9 @@ "runCancelled": "已取消", "runFailed": "run 失败", "runFailedReason": "run 失败 ({{reason}})", + "scopeActiveTitle": "已限定在此提交,点击临时禁用", "scopeCommitTitle": "限定在提交:{{subject}}", + "scopeDisabledTitle": "提交范围已禁用,点击启用", "scoreTitle": "pr-agent 给出的重要度评分 1-10", "scrollUpForOlder": "向上滚动加载更早历史…", "sectionAskAnalysis": "分析解读", From 363be0520b41b9d0204469ad1b6ff3b9b12d6008 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Wed, 1 Jul 2026 21:29:31 +0800 Subject: [PATCH 50/84] =?UTF-8?q?fix(desktop):=20commit=20=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=20chip=20=E6=81=A2=E5=A4=8D=E6=89=8B=E5=9E=8B?= =?UTF-8?q?=E5=85=89=E6=A0=87=E4=B8=8E=20hover=20=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E3=80=81=E4=BF=9D=E7=95=99=E8=93=9D=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 chip 同时挂 chat-reference-chip(复评引用 chip 的样式,cursor:default 且 hover 无反馈——因其本体不可点),覆盖了选区 chip 的 cursor:pointer,导致可点的范围 chip hover 无手型提示。改挂独立 .chat-scope-chip:复用选区 chip 的手型 / hover 交互,并沿用 蓝色(info)着色以示「范围」语义。 Co-Authored-By: Claude Opus 4.8 --- .../features/chat/components/ChatInputBar.tsx | 2 +- .../src/renderer/src/styles/features/chat/pane.scss | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx index de07a440..5bead23a 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/ChatInputBar.tsx @@ -288,7 +288,7 @@ export function ChatInputBar({