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 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 37d6b1e2..b8552b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,9 @@ 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`。 +- **压缩包即 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/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/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts index 1a47f81b..854e760b 100644 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ b/apps/desktop/src/main/services/api-server/routes.ts @@ -12,11 +12,15 @@ 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 { toPrAgentRuns, 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 在被复用的只读 / 队列路径中均未使用,占位即可。 @@ -48,7 +52,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 +63,55 @@ 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], + }; +}; + +/** + * 当前身份与集成平台:活动连接的 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 列表(不分页)+ 一级 / 二级分类过滤 + 检索。过滤语义复用 @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); @@ -129,8 +164,48 @@ 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 }); + +/** 该 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' }); + +/** 评审决断「需修改」:先写远端评审状态、再落本地。 */ +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/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 }, @@ -142,6 +217,12 @@ 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: '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 }, ]; /** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ 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..4f9677e3 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/views.ts @@ -0,0 +1,104 @@ +import type { PragentRunInfo } from '@meebox/ipc'; +import type { + LocalPrStatus, + PlatformKind, + PrDiscoveryFilter, + ReviewRunTool, + 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 在运行队列里的一个 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 { + 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/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; 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/cli/cmd/agent.go b/cli/cmd/agent.go index 0fd23da9..97a8207b 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,9 @@ var readOnlyInstructions = map[string]bool{ "improve": true, } +// 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", @@ -29,94 +33,187 @@ func newAgentCmd() *cobra.Command { newAgentReviewCmd(), 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 `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 `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 `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 `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 `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 `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 `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 +} + +// 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 { + 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/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 e6104e18..a7bdda07 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")...) @@ -87,18 +87,35 @@ 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, `[]`) 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) } @@ -110,7 +127,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 +140,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 +158,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, "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 +171,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, "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 +187,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, "agent", "instruct", "--pr", "abc123", "approve")...) if err == nil { t.Fatal("expected write tool to be rejected") } @@ -184,7 +201,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, "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" { @@ -195,6 +212,87 @@ 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 TestAgentStopPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"ok":true}`) + defer srv.Close() + + 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" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } +} + +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, "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, "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, "") @@ -214,7 +312,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 10390280..deba26dc 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -2,14 +2,28 @@ package cmd import ( "net/url" + "strconv" + "strings" "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: 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", - Short: "Browse pull requests", + Short: "Browse and act on pull requests", } pr.AddCommand( newPrListCmd(), @@ -18,15 +32,22 @@ func newPrCmd() *cobra.Command { newPrActivityCmd(), newPrCommitsCmd(), newPrReviewersCmd(), + newPrApproveCmd(), + newPrNeedsworkCmd(), + newPrCommentCmd(), ) 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 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 +55,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,30 +78,38 @@ 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 } +// 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 @@ -86,47 +121,110 @@ 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), + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return getAndRender("/api/v1/prs/" + url.PathEscape(pr) + "/reviewers") + }, + } + 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 getAndRender("/api/v1/prs/" + url.PathEscape(args[0]) + "/reviewers") + 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 8707981f..15925c50 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -32,12 +32,13 @@ 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") root.AddCommand( + newWhoamiCmd(), newCategoriesCmd(), newPrCmd(), newAgentCmd(), @@ -89,3 +90,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) +} 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") + }, + } +} 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 }() 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/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 fa8589dd..a21d6cdf 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 **不内嵌进安装包**,是独立可分发物(见下「分发」)。 ## 核心设计 @@ -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 环境注入)。 ### 命令结构 @@ -57,23 +59,36 @@ meebox [全局 flag] <组> <命令> [参数] 全局 flag:--api-url · --token · --output (yaml|json) · --quiet ``` +命令分两个领域组:`pr`(直接的 PR 实体操作)与 `agent`(评审 Agent 操作)。二者都用**必填 flag +`--pr `** 传 PR 标识(`id` 由 `pr list` 输出获得)——meebox 只管理 PR,故 agent **不再嵌进 `pr`** +(避免 `pr agent … --pr` 里 `pr` 重复),而与 `pr` 平级。 + | 命令 | 用途 | 对应 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 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。 ### 输出与退出码 @@ -92,25 +107,29 @@ 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`), 附 `.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 契约版本可对应。 ## 扩展与注意事项 -- **只读边界**:写操作显式不提供;新增命令前先确认对应 API 端点已存在且为只读。 +- **写边界与服务端一致**:仅提供评审写动作(approve / needswork / comment);合并与变更类 Agent 工具不提供。 + 新增命令前先确认对应 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..1f0e487c 100644 --- a/docs/arch/README.md +++ b/docs/arch/README.md @@ -48,8 +48,8 @@ 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) -│ └── 02-cli.md CLI 工具(Go 独立二进制 / 命令树 / 本机自动发现 / 跨平台分发) +│ ├── 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 / 设置页 / 首启向导) diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index f811edd4..be17d071 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 服务 @@ -19,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 地址与令牌(高 → 低): @@ -26,46 +30,56 @@ 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 meebox [全局参数] <组> <命令> [参数] ``` +命令分 `pr`(直接的 PR 操作)与 `agent`(评审 Agent 操作)两个领域组,均用**必填参数 `--pr `** 指定 PR +(`id` 由 `meebox pr list` 输出获得)。 + | 命令 | 用途 | | --- | --- | +| `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 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` 输出获得。 ## 5. 输出格式 @@ -89,6 +103,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` 暴露到局域网时尤需保密,并及时通过「重新生成」吊销泄露的令牌。