Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ jobs:
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
run: |
VERSION="${GITHUB_REF_NAME#v}" # tag 去 v 前缀;非 tag(dispatch)取 ref 名,仅用于编译冒烟
# 版本与 app 同源:取自 apps/desktop/package.json(app 运行期版本的唯一真相源),
# 而非独立依赖 git tag——发布前置已校验 tag == 该版本,故二者一致但只有一个来源。
VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")"
mkdir -p dist
go build -trimpath \
-ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \
Expand All @@ -179,7 +181,7 @@ jobs:
shell: bash
working-directory: cli/dist
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # 与 app 同源
BIN="meebox${{ matrix.ext }}"
ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}"
# Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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。
- **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`)。版本**取自 `apps/desktop/package.json`(与 app 同源,即 app 运行期版本的唯一真相源)**经 `-ldflags -X …/cmd.version` 注入——不独立依赖 git tag(发布前置已校验 tag == 该版本)
- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。
- **写边界**: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 <id>`。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/main/services/api-server/compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { lt as semverLt, valid as semverValid } from 'semver';

/**
* CLI ↔ server 兼容性门控(见 docs/arch/04-integration/01-service-api.md)。
*
* CLI 每个请求带上自身版本头,服务端据集中管理的**最低可兼容版本**统一拦截过旧的 CLI——对所有 API 调用
* 一视同仁,不做按端点的差异化兼容。默认宽松:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地
* `dev` 构建)均放行,保证既有 CLI 默认可用;仅当版本头**可解析且低于下限**时才门控。
*/

/** CLI 在此请求头声明自身版本(Node 会小写化头名)。与 CLI 端手写常量对齐(无代码级共享)。 */
export const CLI_VERSION_HEADER = 'x-meebox-cli-version';

/**
* 服务端可兼容的最低 CLI 版本。破坏性线协议变更时上调此值,即门控掉更旧的 CLI。
* 取当前 CLI 首发版本为下限(此前无更旧的已发布 CLI),默认不拦截任何在用版本。
*/
export const MIN_CLI_VERSION = '0.9.0';

/** 请求携带的 CLI 版本是否过旧(应拦截)。缺头 / 不可解析 → false(放行)。 */
export function isClientTooOld(rawHeader: string | string[] | undefined): boolean {
const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
if (!raw) return false;
const v = semverValid(raw.trim());
if (!v) return false; // 不可解析(dev 等)→ 放行
return semverLt(v, MIN_CLI_VERSION);
}
8 changes: 8 additions & 0 deletions apps/desktop/src/main/services/api-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import type { BootstrapResult } from '@meebox/config';
import { ERROR_CODES } from '@meebox/shared';
import type { Logger } from 'pino';
import { CLI_VERSION_HEADER, MIN_CLI_VERSION, isClientTooOld } from './compat.js';
import { HttpError, readJsonBody, sendError, sendOk } from './http.js';
import { matchRoute } from './routes/index.js';

Expand Down Expand Up @@ -96,6 +97,13 @@ export class ApiServer {
let outcome: { status: number; code?: string };
try {
if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED);
// 兼容性门控:对所有 API 调用统一拦截过旧的 CLI(缺版本头 / 不可解析 → 放行)。
if (isClientTooOld(req.headers[CLI_VERSION_HEADER])) {
throw new HttpError(426, ERROR_CODES.SV_CLIENT_TOO_OLD, {
minVersion: MIN_CLI_VERSION,
clientVersion: String(req.headers[CLI_VERSION_HEADER] ?? ''),
});
}
const matched = matchRoute(method, pathname);
if (!matched) throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND);
const body = method === 'POST' ? await readJsonBody(req) : undefined;
Expand Down
12 changes: 12 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ directory yields a working skill. `SKILL.md` is also embedded in the binary: if
only the binary, `meebox skill > SKILL.md` reconstructs the doc (a fallback for rebuilding
the skill directory without the archive).

## Install

macOS / Linux one-liner — downloads the latest release, verifies its SHA-256, installs `meebox` to PATH:

```bash
curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash
```

`MEEBOX_VERSION` / `MEEBOX_BIN_DIR` env vars (or `--version` / `--bin-dir` flags) override the release
and install dir. Windows: download the `.zip` from Releases. No separate `SKILL.md` needed — it is
embedded in the binary (`meebox skill` prints it).

## Build & run

This is an independent Go module (`go.mod`), not part of the npm/Nx workspace.
Expand Down
2 changes: 1 addition & 1 deletion cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func resolveClient() (*apiclient.Client, error) {
if err != nil {
return nil, err
}
return apiclient.New(s.APIURL, s.Token), nil
return apiclient.New(s.APIURL, s.Token, version), nil
}

func outputMode() render.Mode {
Expand Down
40 changes: 38 additions & 2 deletions cli/internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,29 @@ import (
"time"
)

// CLIVersionHeader announces the CLI version so the server can gate incompatible
// (too-old) clients. Kept in sync with the server-side constant by convention.
const CLIVersionHeader = "X-Meebox-CLI-Version"

// CodeClientTooOld is the server error code (ESV0005) returned when this CLI is older
// than the server's minimum supported version. Kept in sync with the shared registry.
const CodeClientTooOld = "ESV0005"

// Client talks to the local API with a bearer token.
type Client struct {
baseURL string
token string
version string
http *http.Client
}

// New builds a client for the given base URL and bearer token.
func New(baseURL, token string) *Client {
// New builds a client for the given base URL, bearer token, and CLI version (sent as a
// header for server-side compatibility gating; empty is fine and simply omits the header).
func New(baseURL, token, version string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
version: version,
http: &http.Client{Timeout: 60 * time.Second},
}
}
Expand All @@ -40,12 +51,34 @@ type APIError struct {
}

func (e *APIError) Error() string {
if e.Code == CodeClientTooOld {
got := metaString(e.Meta, "clientVersion")
min := metaString(e.Meta, "minVersion")
return fmt.Sprintf(
"incompatible CLI: this meebox (%s) is older than the app requires (minimum %s); "+
"upgrade the CLI — re-run the install script or download the latest release",
orUnknown(got), orUnknown(min))
}
if e.Code != "" {
return fmt.Sprintf("API error %s (HTTP %d)", e.Code, e.Status)
}
return fmt.Sprintf("API error (HTTP %d)", e.Status)
}

func metaString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}

func orUnknown(s string) string {
if s == "" {
return "unknown"
}
return s
}

type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Expand Down Expand Up @@ -90,6 +123,9 @@ func (c *Client) Post(path string, body any) (json.RawMessage, error) {
func (c *Client) do(req *http.Request) (json.RawMessage, error) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if c.version != "" {
req.Header.Set(CLIVersionHeader, c.version)
}

resp, err := c.http.Do(req)
if err != nil {
Expand Down
68 changes: 68 additions & 0 deletions cli/internal/apiclient/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package apiclient

import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestSendsCLIVersionHeader(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get(CLIVersionHeader)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"data":null}`)
}))
defer srv.Close()

if _, err := New(srv.URL, "tk", "1.2.3").Get("/api/v1/whoami", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("%s header = %q, want %q", CLIVersionHeader, got, "1.2.3")
}
}

func TestNoVersionHeaderWhenEmpty(t *testing.T) {
seen := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, seen = r.Header[http.CanonicalHeaderKey(CLIVersionHeader)]
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"ok":true,"data":null}`)
}))
defer srv.Close()

if _, err := New(srv.URL, "tk", "").Get("/api/v1/whoami", nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if seen {
t.Errorf("version header should be omitted when version is empty")
}
}

func TestClientTooOldMessage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUpgradeRequired) // 426
_, _ = io.WriteString(w, `{"ok":false,"error":{"code":"ESV0005","meta":{"minVersion":"0.9.0","clientVersion":"0.1.0"}}}`)
}))
defer srv.Close()

_, err := New(srv.URL, "tk", "0.1.0").Get("/api/v1/whoami", nil)
if err == nil {
t.Fatal("expected an error for a too-old client")
}
var ae *APIError
if !errors.As(err, &ae) || ae.Code != CodeClientTooOld {
t.Fatalf("expected APIError with code %s, got %v", CodeClientTooOld, err)
}
msg := err.Error()
// The message must name both versions and steer the user to upgrade.
if !strings.Contains(msg, "0.9.0") || !strings.Contains(msg, "0.1.0") ||
!strings.Contains(strings.ToLower(msg), "upgrade") {
t.Errorf("compat message lacks versions/guidance: %q", msg)
}
}
13 changes: 12 additions & 1 deletion docs/arch/04-integration/01-service-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ GUI 同源 controller(见下「写边界」)。另有 `POST …/refresh` 触
- **路由按领域分模块**:HTTP 路由处理器按业务领域(系统性 / PR / Agent)分置于独立模块,聚合层只做**注册与路径
匹配**、不含业务逻辑——与 [CLI](02-cli.md) 命令树的领域划分对称,新增端点归入对应域、便于定位与扩展。

### CLI 版本兼容门控

服务端对**所有 API 调用**统一做 CLI 版本门控(不按端点差异化):

- CLI 每个请求带版本头 `X-Meebox-CLI-Version: <version>` 声明自身版本(取自与 app 同源的版本,见 [CLI](02-cli.md))。
- 服务端集中管理**最低可兼容 CLI 版本**,请求中间件比对:版本头**可解析且低于下限** → 拦截,返回
`426 Upgrade Required` + 错误码 `SV_CLIENT_TOO_OLD`(meta 带 `minVersion` / `clientVersion`)。
- **默认宽松**:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 `dev` 构建)→ 放行,保证既有 CLI
默认可用;破坏性线协议变更时上调最低版本即门控掉更旧的 CLI。
- CLI 收到该错误码即输出明确的「CLI 过旧、请升级」提示(含双方版本),不裸报 HTTP 错误。

### 写边界(放开评审动作,拒绝合并与变更类 Agent 工具)

- 开放的写操作**限定评审动作**:`POST …/approve`·`…/needswork`(远端评审决断,复用 `prs:setLocalStatus`——
Expand Down Expand Up @@ -111,7 +122,7 @@ service:
```

- HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写工具被拒(未开放的写操作)/ `404` 资源不存在 /
`409` 冲突 / `500` 内部错误。
`409` 冲突 / `426` CLI 版本过旧 / `500` 内部错误。
- 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)):
如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。

Expand Down
11 changes: 9 additions & 2 deletions docs/arch/04-integration/02-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 `
- **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。
- **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。
- **二进制与压缩包命名**:`meebox-cli-<version>-<os>-<arch>.<ext>`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`),
附 `.sha256` 校验和。`<version>` 与应用版本对齐(同一 `v*` tag
附 `.sha256` 校验和。`<version>` 取自 `apps/desktop/package.json`(与 app 同源,唯一真相源),发布前置校验其与 `v*` tag 一致
- **压缩包内容 = 可直接投放的 skill 目录**:除二进制外一并打包 `LICENSE` + `README.md` + `SKILL.md`。解压到
agent 的 skills 目录即得一个可用 skill——`SKILL.md`(frontmatter `name: meebox`)教 agent 用法,紧邻其驱动
的二进制。这是 CLI「面向 agent 交付」的主形态。
Expand All @@ -141,7 +141,11 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 `
- **随主工程一起发布**:发布流程的 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译矩阵)产出
四平台压缩包(含二进制 + `LICENSE` + `README.md` + `SKILL.md`)+ 校验和,与桌面安装包一并上传到**同一个
GitHub Release**(由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。
- 版本号与应用同源(同 tag),确保 CLI 与服务端 API 契约版本可对应。
- 版本号**取自 `apps/desktop/package.json`(与 app 同源)**,经 `-ldflags` 注入 `cmd.version`——不独立依赖 git tag(发布前置校验 tag 与之一致),确保 CLI 与服务端 API 契约版本可对应。
- **一键安装脚本(macOS / Linux)**:`tools/cli/install.sh` 经 `curl … | bash` 一条命令完成安装——探测系统 /
架构 → 取匹配的 Release 压缩包 → 校验 SHA-256 → 解出 `meebox` 装入 `PATH`(默认 `/usr/local/bin`,不可写回退
`~/.local/bin`;`MEEBOX_VERSION` / `MEEBOX_BIN_DIR` 可覆盖)。刻意**不落地 `SKILL.md`**(已内嵌、`meebox skill`
可导出)。Windows 不在脚本覆盖内,走手动下载。

## 扩展与注意事项

Expand All @@ -150,6 +154,9 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 `
- **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。
- **不触碰 GUI 机密**:CLI 不读应用主配置 `~/.code-meeseeks/config.yaml`(含各平台访问令牌等连接层机密);服务令牌须经 flag / 环境变量 / `cli.yaml` 显式提供,避免越权触达预期外凭据。
- **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。
- **版本兼容门控**:CLI 每次请求带 `X-Meebox-CLI-Version` 头声明自身版本(与 app 同源);服务端据集中管理的
最低可兼容版本统一门控,CLI 收到 `SV_CLIENT_TOO_OLD`(HTTP 426)即输出「过旧、请升级」提示。见
[服务端契约](01-service-api.md) 的「CLI 版本兼容门控」。
- **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。
- **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` /
`NO_PROXY`;loopback(`127.0.0.1` / `localhost`)默认直连不走代理——无需自实现代理逻辑。
Loading
Loading