diff --git a/AGENTS.md b/AGENTS.md index b8552b65..286aa0d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,9 +79,11 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 - **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 - **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 - **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 -- **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。改命令树 / 边界时同步更新 `SKILL.md` 与 `README.md`。 +- **压缩包即 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 `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 +- **领域归类**:CLI / API / GUI 都是同一 service 层之上的薄前端,命令树与端点应**镜像业务领域**。新增 CLI 命令 / API 端点按语义归位——PR 相关入 `pr`、评审 Agent 入 `agent`;与具体 PR / Agent 无关的**系统性 / 会话级**操作(whoami / version 等)置于**根层级**,不套领域组。归属看**语义而非是否 PR 维度**(如 `categories` / `refresh` 无 `--pr` 仍属 `pr`——它们服务于 PR 列表)。完整理由见 [CLI 设计](docs/arch/04-integration/02-cli.md)。 +- **文档对齐**:CLI 改动(命令树 / 写边界 / 输出契约 / 连接配置)须同步三类文档,缺一即漂移——① **arch 设计**:[docs/arch/04-integration/02-cli.md](docs/arch/04-integration/02-cli.md)(命令树与边界),涉及 API 端点再改 [01-service-api.md](docs/arch/04-integration/01-service-api.md)(端点表);② **guide 用法**:[docs/guide/06-cli.md](docs/guide/06-cli.md);③ **skill 交付**:`cli/SKILL.md` + `cli/README.md`(随压缩包投放为 agent skill 的主形态)。新增 / 改命令务必三类齐更。 ## 约定 diff --git a/README.md b/README.md index 564b51bd..d878cffd 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,9 @@ Code Meeseeks(内部开发代号 `meebox`)是命令行工具 [pr-agent](http #### 🔌 外部集成与 CLI -- **本地 API 服务** —— 可选开启一个本机 API,把 PR 浏览与评审 Agent 操作以接口形式开放给外部 agent / 脚本;默认仅本机可达、强制访问令牌鉴权,不开放合并与变更类工具。 -- **跨平台命令行工具 `meebox`** —— 随发布提供 Windows / macOS / Linux 命令行客户端,经本地 API 浏览 PR、操作评审 Agent 并执行评审写动作(approve / needswork / comment),便于脚本与外部 agent 集成;压缩包即 agent skill 目录,可直接投放。用法见 **[CLI 命令行工具](docs/guide/06-cli.md)**。 +- **可供外部 Agent 集成** —— PR 评审能力经本机 HTTP 接口 + 跨平台 CLI 对外开放,让本机 agentic 工具(如 claude / codex)、脚本、CI 把 PR 发现 / 浏览 / 评审 Agent 操作纳入自动化流程。 +- **本地 API 服务** —— 可选开启一个本机 API,将 PR 发现 / 浏览 / diff / 评审 Agent 操作 / 评审写动作以语言无关的 HTTP 契约开放出来;默认仅本机可达、强制访问令牌鉴权,不开放合并与变更类工具。 +- **跨平台命令行工具 `meebox`** —— 随发布提供 Windows / macOS / Linux 客户端,经本地 API 浏览 PR、驱动评审 Agent 并执行评审写动作(approve / needswork / comment);`meebox login` 一次存好凭据即可免传参,**压缩包即 agent skill 目录、可直接投放到 agent 的 skills 目录**。用法见 **[CLI 命令行工具](docs/guide/06-cli.md)**。 #### 🎨 界面与体验 diff --git a/apps/desktop/src/main/services/api-server/routes.ts b/apps/desktop/src/main/services/api-server/routes.ts deleted file mode 100644 index 854e760b..00000000 --- a/apps/desktop/src/main/services/api-server/routes.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { IpcMainInvokeEvent } from 'electron'; -import type { DiffSide } from '@meebox/ipc'; -import { - ERROR_CODES, - PR_SECONDARY_FILTERS, - filterPullRequests, - type PrDiscoveryFilter, - type PrSecondaryFilter, - type ReviewRunTool, -} from '@meebox/shared'; -import * as agentCtl from '../../controllers/agent.js'; -import * as prCtl from '../../controllers/pr.js'; -import { getContext } from '../context.js'; -import { HttpError } from './http.js'; -import { toPrAgentRuns, toPrListItem } from './views.js'; - -/** - * 本地 API 的路由表与处理器。处理器**复用 IPC controller 同源逻辑**——controller 形态为 - * `(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 在被复用的只读 / 队列路径中均未使用,占位即可。 -const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; - -/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ -const READ_ONLY_TOOLS: ReadonlySet = new Set([ - 'describe', - 'review', - 'ask', - 'improve', -]); - -export interface RouteContext { - params: Record; - query: URLSearchParams; - body: unknown; -} - -export type RouteHandler = (rc: RouteContext) => Promise | unknown; - -export interface Route { - method: 'GET' | 'POST'; - segments: string[]; - handler: RouteHandler; -} - -function seg(path: string): string[] { - return path.split('/').filter(Boolean); -} - -/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */ -const DEFAULT_LIMIT = 100; - -/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */ -const categories: RouteHandler = () => { - const ctx = getContext(); - const activeId = ctx.bootstrap.config.active_connection_id; - const built = activeId - ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) - : undefined; - const caps = built?.adapter.connection.capabilities(); - const categoryList: PrDiscoveryFilter[] = caps?.discoveryFilters - ? [...caps.discoveryFilters] - : ['review-requested']; - return { - platform: built?.adapter.kind ?? null, - 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 列表:`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); - 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); - -const reviewers: RouteHandler = async ({ params }) => - (await getContext().pr.findPrOrThrow(params.id)).reviewers; - -/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ -const diff: RouteHandler = ({ params, query }) => { - const path = query.get('path'); - if (path) { - const side: DiffSide = query.get('side') === 'base' ? 'base' : 'head'; - return prCtl.getFileContent(NO_EVENT, { localId: params.id, side, path }); - } - return prCtl.listChangedFiles(NO_EVENT, { localId: params.id }); -}; - -const activity: RouteHandler = ({ params }) => - prCtl.listActivity(NO_EVENT, { localId: params.id }); - -const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); - -const agentStatus: RouteHandler = ({ params }) => - agentCtl.getSession(NO_EVENT, { localId: params.id }); - -const agentHistory: RouteHandler = ({ params }) => - agentCtl.getConversation(NO_EVENT, { localId: params.id }); - -const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); - -/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ -const agentInstruct: RouteHandler = ({ params, body }) => { - const b = (body ?? {}) as { command?: string; args?: string }; - const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; - if (!READ_ONLY_TOOLS.has(command)) { - throw new HttpError(403, ERROR_CODES.SV_WRITE_NOT_ALLOWED, { command: b.command ?? '' }); - } - if (command === 'ask' && !b.args?.trim()) { - throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'ask requires args' }); - } - return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); -}; - -/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ -const agentChat: RouteHandler = ({ params, body }) => { - const b = (body ?? {}) as { message?: string }; - if (!b.message?.trim()) { - throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'message required' }); - } - return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); -}; - -/** 中断该 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 }, - { method: 'GET', segments: seg('/api/v1/prs/:id/activity'), handler: activity }, - { method: 'GET', segments: seg('/api/v1/prs/:id/commits'), handler: commits }, - { method: 'GET', segments: seg('/api/v1/prs/:id/reviewers'), handler: reviewers }, - { method: 'GET', segments: seg('/api/v1/prs/:id/agent'), handler: agentStatus }, - { method: 'GET', segments: seg('/api/v1/prs/:id/agent/conversation'), handler: agentHistory }, - { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, - { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, - { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, - { 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。 */ -export function matchRoute( - method: string, - pathname: string, -): { route: Route; params: Record } | null { - const parts = seg(pathname); - for (const route of routes) { - if (route.method !== method || route.segments.length !== parts.length) continue; - const params: Record = {}; - let ok = true; - for (let i = 0; i < route.segments.length; i++) { - const s = route.segments[i]; - if (s.startsWith(':')) params[s.slice(1)] = decodeURIComponent(parts[i]); - else if (s !== parts[i]) { - ok = false; - break; - } - } - if (ok) return { route, params }; - } - return null; -} diff --git a/apps/desktop/src/main/services/api-server/routes/agent.ts b/apps/desktop/src/main/services/api-server/routes/agent.ts new file mode 100644 index 00000000..dcfb8848 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/agent.ts @@ -0,0 +1,85 @@ +import { ERROR_CODES, type ReviewRunTool } from '@meebox/shared'; +import * as agentCtl from '../../../controllers/agent.js'; +import { HttpError } from '../http.js'; +import { toPrAgentRuns } from '../views.js'; +import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; + +/** + * 评审 Agent 领域端点:状态 / 会话(浏览),auto review / 指令 / 聊天 / 中断(写入型,复用既有 run 队列), + * 以及按 run 的发现与取消。Agent `instruct` **仅只读工具**,变更类工具(publish 等)在 API 层硬拒绝。 + */ + +/** API 仅允许的只读 Agent 指令(与工具注册表 isRun 只读族一致;写工具不在此列)。 */ +const READ_ONLY_TOOLS: ReadonlySet = new Set([ + 'describe', + 'review', + 'ask', + 'improve', +]); + +const agentStatus: RouteHandler = ({ params }) => + agentCtl.getSession(NO_EVENT, { localId: params.id }); + +const agentHistory: RouteHandler = ({ params }) => + agentCtl.getConversation(NO_EVENT, { localId: params.id }); + +const agentReview: RouteHandler = ({ params }) => agentCtl.runReview(NO_EVENT, { localId: params.id }); + +/** 发送只读 Agent 指令(describe / review / ask / improve);写工具硬拒绝(403),无二次确认。 */ +const agentInstruct: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { command?: string; args?: string }; + const command = (b.command ?? '').replace(/^\//, '') as ReviewRunTool; + if (!READ_ONLY_TOOLS.has(command)) { + throw new HttpError(403, ERROR_CODES.SV_WRITE_NOT_ALLOWED, { command: b.command ?? '' }); + } + if (command === 'ask' && !b.args?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'ask requires args' }); + } + return agentCtl.runPragent(NO_EVENT, { localId: params.id, tool: command, question: b.args }); +}; + +/** 发送自然语言聊天(可触发 Agent 任务):运行中入队、否则起一轮自由规划兜底。 */ +const agentChat: RouteHandler = ({ params, body }) => { + const b = (body ?? {}) as { message?: string }; + if (!b.message?.trim()) { + throw new HttpError(400, ERROR_CODES.SV_BAD_REQUEST, { reason: 'message required' }); + } + return agentCtl.enqueueMessage(NO_EVENT, { localId: params.id, message: b.message }); +}; + +/** 中断该 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 }); +}; + +export const agentRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/prs/:id/agent'), handler: agentStatus }, + { method: 'GET', segments: seg('/api/v1/prs/:id/agent/conversation'), handler: agentHistory }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/review'), handler: agentReview }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/instruct'), handler: agentInstruct }, + { method: 'POST', segments: seg('/api/v1/prs/:id/agent/chat'), handler: agentChat }, + { 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, + }, +]; diff --git a/apps/desktop/src/main/services/api-server/routes/index.ts b/apps/desktop/src/main/services/api-server/routes/index.ts new file mode 100644 index 00000000..45293411 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/index.ts @@ -0,0 +1,36 @@ +import { agentRoutes } from './agent.js'; +import { prRoutes } from './pr.js'; +import { seg, type Route } from './shared.js'; +import { systemRoutes } from './system.js'; + +/** + * 本地 API 的路由**聚合注册 + 匹配**。各业务领域的处理器分置于同目录的 system / pr / agent 模块 + * (均复用 IPC controller 同源逻辑);本文件只做注册与路径匹配,不含业务逻辑。 + * 端点全表与写边界见 docs/arch/04-integration/01-service-api.md。 + */ +export const routes: Route[] = [...systemRoutes, ...prRoutes, ...agentRoutes]; + +export type { Route, RouteContext, RouteHandler } from './shared.js'; + +/** 按方法 + 路径匹配路由,提取 `:param` 路径参数;无匹配返回 null。 */ +export function matchRoute( + method: string, + pathname: string, +): { route: Route; params: Record } | null { + const parts = seg(pathname); + for (const route of routes) { + if (route.method !== method || route.segments.length !== parts.length) continue; + const params: Record = {}; + let ok = true; + for (let i = 0; i < route.segments.length; i++) { + const s = route.segments[i]; + if (s.startsWith(':')) params[s.slice(1)] = decodeURIComponent(parts[i]); + else if (s !== parts[i]) { + ok = false; + break; + } + } + if (ok) return { route, params }; + } + return null; +} diff --git a/apps/desktop/src/main/services/api-server/routes/pr.ts b/apps/desktop/src/main/services/api-server/routes/pr.ts new file mode 100644 index 00000000..60666364 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/pr.ts @@ -0,0 +1,116 @@ +import type { DiffSide } from '@meebox/ipc'; +import { + ERROR_CODES, + PR_SECONDARY_FILTERS, + filterPullRequests, + type PrDiscoveryFilter, + type PrSecondaryFilter, +} from '@meebox/shared'; +import * as prCtl from '../../../controllers/pr.js'; +import { getContext } from '../../context.js'; +import { HttpError } from '../http.js'; +import { toPrListItem } from '../views.js'; +import { NO_EVENT, seg, type Route, type RouteHandler } from './shared.js'; + +/** + * PR 领域端点:列表 / 详情 / diff / 动态 / 提交 / 评审人(浏览),刷新(refresh)与分类词表(categories), + * 以及评审写动作(approve / needswork / comment,真实远端写,复用 GUI 同源 controller)。 + * 仍**不**暴露 merge(合并)。写边界见 docs/arch/04-integration/01-service-api.md。 + */ + +/** 列表分页默认页大小(`limit` 缺省 / 非法 / ≤0 时取此值)。 */ +const DEFAULT_LIMIT = 100; + +/** 当前启用平台下可用的分类标签:`categories`(平台发现分类)+ `statuses`(状态 / 合并态筛选)。 */ +const categories: RouteHandler = () => { + const ctx = getContext(); + const activeId = ctx.bootstrap.config.active_connection_id; + const built = activeId + ? ctx.connectionRuntime.adapters.find((a) => a.connectionId === activeId) + : undefined; + const caps = built?.adapter.connection.capabilities(); + const categoryList: PrDiscoveryFilter[] = caps?.discoveryFilters + ? [...caps.discoveryFilters] + : ['review-requested']; + return { + platform: built?.adapter.kind ?? null, + categories: categoryList, + statuses: [...PR_SECONDARY_FILTERS], + }; +}; + +/** + * 触发一次立即轮询刷新(等价 GUI 的手动刷新 / 窗口聚焦刷新):拉取所有连接的最新 PR、落本地, + * 返回本轮计数汇总(fetched / changed / added / removed / errors)。复用 GUI 同源 poller.tick + * (`prs:refresh`)。无远端写副作用(纯读远端 + 落本地),列为安全的开放动作。 + */ +const refresh: RouteHandler = () => prCtl.refreshPrs(NO_EVENT, undefined); + +/** + * 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); + 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); + +const reviewers: RouteHandler = async ({ params }) => + (await getContext().pr.findPrOrThrow(params.id)).reviewers; + +/** 无 path → 变更文件列表;带 path → 取该文件某一侧(默认 head)内容。 */ +const diff: RouteHandler = ({ params, query }) => { + const path = query.get('path'); + if (path) { + const side: DiffSide = query.get('side') === 'base' ? 'base' : 'head'; + return prCtl.getFileContent(NO_EVENT, { localId: params.id, side, path }); + } + return prCtl.listChangedFiles(NO_EVENT, { localId: params.id }); +}; + +const activity: RouteHandler = ({ params }) => + prCtl.listActivity(NO_EVENT, { localId: params.id }); + +const commits: RouteHandler = ({ params }) => prCtl.listCommits(NO_EVENT, { localId: params.id }); + +/** 评审决断「通过」:先写远端评审状态、再落本地(复用 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 prRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/categories'), handler: categories }, + { method: 'POST', segments: seg('/api/v1/refresh'), handler: refresh }, + { method: 'GET', segments: seg('/api/v1/prs'), handler: listPrs }, + { method: 'GET', segments: seg('/api/v1/prs/:id'), handler: showPr }, + { method: 'GET', segments: seg('/api/v1/prs/:id/diff'), handler: diff }, + { method: 'GET', segments: seg('/api/v1/prs/:id/activity'), handler: activity }, + { method: 'GET', segments: seg('/api/v1/prs/:id/commits'), handler: commits }, + { method: 'GET', segments: seg('/api/v1/prs/:id/reviewers'), handler: reviewers }, + { method: 'POST', segments: seg('/api/v1/prs/:id/approve'), handler: approve }, + { method: 'POST', segments: seg('/api/v1/prs/:id/needswork'), handler: needswork }, + { method: 'POST', segments: seg('/api/v1/prs/:id/comment'), handler: comment }, +]; diff --git a/apps/desktop/src/main/services/api-server/routes/shared.ts b/apps/desktop/src/main/services/api-server/routes/shared.ts new file mode 100644 index 00000000..0e9c27a7 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/shared.ts @@ -0,0 +1,30 @@ +import type { IpcMainInvokeEvent } from 'electron'; + +/** + * 路由框架原语,供同目录各业务领域模块(system / pr / agent)与聚合器(index)共用。 + * 各域处理器**复用 IPC controller 同源逻辑**——controller 形态为 `(event, req)` 且这些路径不触碰 + * event,故以 {@link NO_EVENT} 占位调用,避免在 HTTP 侧另起一套实现。 + */ + +/** 单条路由处理器的入参:路径参数 / 查询串 / 已解析 body。 */ +export interface RouteContext { + params: Record; + query: URLSearchParams; + body: unknown; +} + +export type RouteHandler = (rc: RouteContext) => Promise | unknown; + +export interface Route { + method: 'GET' | 'POST'; + segments: string[]; + handler: RouteHandler; +} + +/** 把 `/api/v1/prs/:id` 切成非空段数组(注册与匹配共用)。 */ +export function seg(path: string): string[] { + return path.split('/').filter(Boolean); +} + +/** controller 形参 event 在被复用的只读 / 队列路径中均未使用,占位即可。 */ +export const NO_EVENT = undefined as unknown as IpcMainInvokeEvent; diff --git a/apps/desktop/src/main/services/api-server/routes/system.ts b/apps/desktop/src/main/services/api-server/routes/system.ts new file mode 100644 index 00000000..3296fe1d --- /dev/null +++ b/apps/desktop/src/main/services/api-server/routes/system.ts @@ -0,0 +1,39 @@ +import { buildAppInfo } from '../../app.js'; +import { getContext } from '../../context.js'; +import { seg, type Route, type RouteHandler } from './shared.js'; + +/** + * 系统性 / 会话级端点:与具体 PR / Agent 无关的工具层信息——身份(whoami)与版本(version)。 + * 对应 CLI 的根层级系统性命令。 + */ + +/** + * 当前身份与集成平台:活动连接的 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, + }; +}; + +/** 服务端(桌面应用)版本,供 CLI `version` 同时展示客户端 + 服务端版本。 */ +const version: RouteHandler = () => ({ version: buildAppInfo(getContext().bootstrap).appVersion }); + +export const systemRoutes: Route[] = [ + { method: 'GET', segments: seg('/api/v1/whoami'), handler: whoami }, + { method: 'GET', segments: seg('/api/v1/version'), handler: version }, +]; diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts index 75e7a82f..868d469f 100644 --- a/apps/desktop/src/main/services/api-server/server.ts +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -4,7 +4,7 @@ import type { BootstrapResult } from '@meebox/config'; import { ERROR_CODES } from '@meebox/shared'; import type { Logger } from 'pino'; import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; -import { matchRoute } from './routes.js'; +import { matchRoute } from './routes/index.js'; /** * 本地 API 服务监听器(见 docs/arch/04-integration/01-service-api.md)。 diff --git a/cli/README.md b/cli/README.md index edfc7e3f..7b894ea3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -12,7 +12,9 @@ merging and the agent's publish/mutating tools are intentionally not exposed. `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. +directory yields a working skill. `SKILL.md` is also embedded in the binary: if you have +only the binary, `meebox skill > SKILL.md` reconstructs the doc (a fallback for rebuilding +the skill directory without the archive). ## Build & run @@ -46,14 +48,28 @@ Connection details must be provided explicitly; the CLI does **not** read the ap `~/.code-meeseeks/config.yaml` (which holds connection-layer secrets). The API URL defaults to `http://127.0.0.1:18765` when unset. +`meebox login --token [--server ]` persists the token (and optional server +URL) to `cli.yaml` so later commands need no flags/env — the write counterpart to the +`cli.yaml` read above: + +```bash +meebox login --token # default server http://127.0.0.1:18765 +meebox login --token --server http://host:18765 +``` + ## Commands -Two domains, `pr` and `agent`, both PR-scoped via the required `--pr ` flag +Root-level `whoami` / `version` need no PR. Two domains — `pr` (also holds `categories` +and `refresh`) and `agent` — carry PR-scoped commands via the required `--pr ` flag (`id` comes from `pr list`): ```text +meebox login --token [--server ] # save credentials to cli.yaml meebox whoami -meebox categories +meebox version # CLI (client) + app (server) versions +meebox skill # print the embedded agent usage doc (SKILL.md) +meebox pr categories +meebox pr refresh # trigger one immediate poll for the latest PRs meebox pr list [--category ] [--status ] [--query ] [--skip N] [--limit N] meebox pr show --pr meebox pr diff --pr [--file ] [--side base|head] diff --git a/cli/SKILL.md b/cli/SKILL.md index 82aab248..bd0d8916 100644 --- a/cli/SKILL.md +++ b/cli/SKILL.md @@ -19,20 +19,29 @@ review outcomes. Output defaults to YAML; pass `--output json` when parsing resu ## Connect -Provide the API base URL + token explicitly — the CLI never reads the app's `config.yaml`: +Provide the API base URL + token explicitly — the CLI never reads the app's `config.yaml`. +Easiest is `meebox login` (persists to `~/.code-meeseeks/cli.yaml`); or use env vars: ```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 +meebox login --token # save token (default server http://127.0.0.1:18765) +meebox login --token --server http://host:18765 # remote server +# — or, instead of login — +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`): +Root-level `meebox whoami` and `meebox version` need no PR. The rest split into two domains — +`pr` and `agent`; PR-scoped subcommands take the **required `--pr `** flag (`id` from `pr list`), +while `pr categories` / `pr refresh` / `pr list` are collection-level (no `--pr`). **Browse / inspect — `pr`** +- `meebox pr categories` — the active platform's `categories` / `statuses` filter vocabulary for `pr list`. +- `meebox pr refresh` — trigger one immediate poll for the latest PRs (same as the app's manual refresh); returns change counts (fetched / changed / added / removed / errors). Run before `pr list` for fresh data. - `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. @@ -50,7 +59,11 @@ Two domains, both PR-scoped via the **required `--pr `** flag (`id` comes fr - `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`. +Root-level (no PR): +- `meebox login --token [--server ]` — save credentials to `cli.yaml` (default server is loopback); later commands need no flags/env. +- `meebox whoami` — current user + platform + connection (confirm your token resolves). +- `meebox version` — CLI (client) + app (server) versions; client-only when the server is unreachable. +- `meebox skill` — print this doc (SKILL.md is embedded in the binary), so usage is retrievable even without the file. ## Typical loop diff --git a/cli/cmd/categories.go b/cli/cmd/categories.go index e3ee2a71..680e1449 100644 --- a/cli/cmd/categories.go +++ b/cli/cmd/categories.go @@ -2,8 +2,9 @@ package cmd import "github.com/spf13/cobra" -// newCategoriesCmd builds `meebox categories`: lists the enabled platform's available +// newCategoriesCmd builds `meebox pr categories`: lists the enabled platform's available // filter labels — `categories` (discovery) and `statuses` (review/merge) (GET /categories). +// Lives under `pr` because it is the filter vocabulary for `pr list` (--category / --status). func newCategoriesCmd() *cobra.Command { return &cobra.Command{ Use: "categories", diff --git a/cli/cmd/integration_test.go b/cli/cmd/integration_test.go index a7bdda07..9def1eb6 100644 --- a/cli/cmd/integration_test.go +++ b/cli/cmd/integration_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -47,6 +49,9 @@ func mockServer(rec *capturedReq, status int, dataJSON string) *httptest.Server })) } +// testSkillDoc stands in for the embedded SKILL.md (main injects the real one at build). +const testSkillDoc = "# meebox (test skill doc)\n" + // runCmd runs the root command with captured output, returning stdout + the error // (Execute()'s os.Exit wrapper is bypassed so tests can assert on the error). func runCmd(args ...string) (string, error) { @@ -55,7 +60,7 @@ func runCmd(args ...string) (string, error) { render.Stdout, render.Stderr = &buf, io.Discard defer func() { render.Stdout, render.Stderr = origOut, origErr }() - root := newRootCmd() + root := newRootCmd(testSkillDoc) root.SetArgs(args) err := root.Execute() return buf.String(), err @@ -72,7 +77,7 @@ func TestCategories(t *testing.T) { srv := mockServer(&rec, 200, `{"platform":"github","categories":["review-requested"],"statuses":["all"]}`) defer srv.Close() - out, err := runCmd(base(srv.URL, "categories")...) + out, err := runCmd(base(srv.URL, "pr", "categories")...) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -104,6 +109,70 @@ func TestWhoami(t *testing.T) { } } +func TestPrRefreshPost(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"fetched":3,"changed":1,"added":1,"removed":0,"errors":0}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "pr", "refresh")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/api/v1/refresh" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + if !strings.Contains(out, "added: 1") { + t.Errorf("output missing rendered field: %q", out) + } +} + +func TestVersion(t *testing.T) { + var rec capturedReq + srv := mockServer(&rec, 200, `{"version":"9.9.9"}`) + defer srv.Close() + + out, err := runCmd(base(srv.URL, "version")...) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/api/v1/version" { + t.Errorf("wrong request: %s %s", rec.method, rec.path) + } + // Both client (build-time "dev" in tests) and server versions render. + if !strings.Contains(out, "client:") || !strings.Contains(out, "server: 9.9.9") { + t.Errorf("version output missing client/server: %q", out) + } +} + +func TestLoginWritesConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Setenv("MEEBOX_API_URL", "") + t.Setenv("MEEBOX_TOKEN", "") + + if _, err := runCmd("login", "--token", "tok123", "--server", "http://saved:9"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".code-meeseeks", "cli.yaml")) + if err != nil { + t.Fatalf("cli.yaml not written: %v", err) + } + if !strings.Contains(string(data), "tok123") || !strings.Contains(string(data), "http://saved:9") { + t.Errorf("cli.yaml missing token/server: %q", string(data)) + } +} + +func TestSkillPrintsEmbeddedDoc(t *testing.T) { + out, err := runCmd("skill") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != testSkillDoc { + t.Errorf("skill output = %q, want embedded doc %q", out, testSkillDoc) + } +} + func TestPrListFilters(t *testing.T) { var rec capturedReq srv := mockServer(&rec, 200, `[]`) @@ -298,7 +367,7 @@ func TestAuthFailureExitCode(t *testing.T) { srv := mockServer(&rec, 401, "") defer srv.Close() - _, err := runCmd(base(srv.URL, "categories")...) + _, err := runCmd(base(srv.URL, "pr", "categories")...) if err == nil { t.Fatal("expected auth error") } @@ -326,7 +395,7 @@ func TestOutputJSON(t *testing.T) { srv := mockServer(&rec, 200, `{"platform":"github"}`) defer srv.Close() - out, err := runCmd(base(srv.URL, "--output", "json", "categories")...) + out, err := runCmd(base(srv.URL, "--output", "json", "pr", "categories")...) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/cmd/login.go b/cli/cmd/login.go new file mode 100644 index 00000000..38b6820b --- /dev/null +++ b/cli/cmd/login.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/huhamhire/code-meeseeks/cli/internal/render" + "github.com/huhamhire/code-meeseeks/cli/internal/settings" + "github.com/spf13/cobra" +) + +// newLoginCmd builds `meebox login --token [--server ]`: persists the API token +// (and optional server URL, default loopback) to ~/.code-meeseeks/cli.yaml so later commands +// need no `--token` / env each time. Completes CLI config management — the CLI could read +// cli.yaml but had no way to write it. Purely local: it saves credentials, it does not verify +// them against the server (run `meebox whoami` afterwards to confirm they resolve). +func newLoginCmd() *cobra.Command { + var token, server string + cmd := &cobra.Command{ + Use: "login", + Short: "Save the API token (and optional server URL) to ~/.code-meeseeks/cli.yaml", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if server == "" { + server = settings.DefaultAPIURL() + } + path, err := settings.Save(settings.Settings{APIURL: server, Token: token}) + if err != nil { + return err + } + if !gflags.quiet { + fmt.Fprintf(render.Stdout, "Saved credentials for %s to %s\n", server, path) + } + return nil + }, + } + cmd.Flags().StringVar(&token, "token", "", "API bearer token (from Settings → Integration)") + cmd.Flags().StringVar(&server, "server", "", "API base URL (default "+settings.DefaultAPIURL()+")") + _ = cmd.MarkFlagRequired("token") + return cmd +} diff --git a/cli/cmd/pr.go b/cli/cmd/pr.go index deba26dc..bbd12154 100644 --- a/cli/cmd/pr.go +++ b/cli/cmd/pr.go @@ -26,6 +26,8 @@ func newPrCmd() *cobra.Command { Short: "Browse and act on pull requests", } pr.AddCommand( + newCategoriesCmd(), + newRefreshCmd(), newPrListCmd(), newPrShowCmd(), newPrDiffCmd(), diff --git a/cli/cmd/refresh.go b/cli/cmd/refresh.go new file mode 100644 index 00000000..14020ece --- /dev/null +++ b/cli/cmd/refresh.go @@ -0,0 +1,18 @@ +package cmd + +import "github.com/spf13/cobra" + +// newRefreshCmd builds `meebox pr refresh`: trigger one immediate poll across all connections +// (the same action as the GUI's manual refresh), fetching the latest PRs into local state and +// returning a summary of what changed (POST /refresh). Returns counts: +// fetched / changed / added / removed / errors. Global — not PR-scoped, so no --pr flag. +func newRefreshCmd() *cobra.Command { + return &cobra.Command{ + Use: "refresh", + Short: "Trigger an immediate poll for the latest PRs", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return postAndRender("/api/v1/refresh", nil) + }, + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 15925c50..cbd24145 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -23,7 +23,7 @@ type globalFlags struct { var gflags globalFlags -func newRootCmd() *cobra.Command { +func newRootCmd(skillDoc string) *cobra.Command { root := &cobra.Command{ Use: "meebox", Short: "Code Meeseeks CLI — integrate PR review capabilities over the local API", @@ -38,8 +38,10 @@ func newRootCmd() *cobra.Command { pf.BoolVar(&gflags.quiet, "quiet", false, "suppress non-essential output") root.AddCommand( + newLoginCmd(), newWhoamiCmd(), - newCategoriesCmd(), + newVersionCmd(), + newSkillCmd(skillDoc), newPrCmd(), newAgentCmd(), ) @@ -47,9 +49,10 @@ func newRootCmd() *cobra.Command { } // Execute runs the root command, printing errors to stderr and mapping them -// to process exit codes per docs/arch/04-integration/02-cli.md. -func Execute() { - if err := newRootCmd().Execute(); err != nil { +// to process exit codes per docs/arch/04-integration/02-cli.md. skillDoc is the +// SKILL.md embedded into the binary (see main), surfaced by `meebox skill`. +func Execute(skillDoc string) { + if err := newRootCmd(skillDoc).Execute(); err != nil { render.Errorln(err) os.Exit(render.ExitCodeFor(err)) } diff --git a/cli/cmd/skill.go b/cli/cmd/skill.go new file mode 100644 index 00000000..d388361c --- /dev/null +++ b/cli/cmd/skill.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + + "github.com/huhamhire/code-meeseeks/cli/internal/render" + "github.com/spf13/cobra" +) + +// newSkillCmd builds `meebox skill`: prints the agent-facing usage doc (SKILL.md) embedded +// into the binary at build time. Lets the binary self-document even when detached from its +// release archive (self-introspection), and keeps the emitted doc in lock-step with the +// shipped SKILL.md. doc is injected from main; empty only in bare unit-test builds. +func newSkillCmd(doc string) *cobra.Command { + return &cobra.Command{ + Use: "skill", + Short: "Print the embedded agent usage doc (SKILL.md)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + if doc == "" { + return fmt.Errorf("skill doc not embedded in this build") + } + fmt.Fprint(render.Stdout, doc) + return nil + }, + } +} diff --git a/cli/cmd/version.go b/cli/cmd/version.go new file mode 100644 index 00000000..55f55333 --- /dev/null +++ b/cli/cmd/version.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/huhamhire/code-meeseeks/cli/internal/render" + "github.com/spf13/cobra" +) + +// newVersionCmd builds `meebox version`: prints the CLI (client) version and, when +// the local API is reachable, the desktop app (server) version — mirroring `docker version`. +// The client version always renders; if the server can't be reached its field is null and a +// warning goes to stderr, but the exit stays 0 so the client version is usable offline. +// (The root `--version` flag remains the quick client-only path.) +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Show the CLI (client) and app (server) versions", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + out := struct { + Client string `json:"client"` + Server *string `json:"server"` + }{Client: version} + if sv, err := fetchServerVersion(); err != nil { + render.Errorln(fmt.Errorf("server version unavailable: %w", err)) + } else { + out.Server = &sv + } + raw, err := json.Marshal(out) + if err != nil { + return err + } + return renderData(raw) + }, + } +} + +// fetchServerVersion asks the local API for the desktop app version (GET /version). +func fetchServerVersion() (string, error) { + c, err := resolveClient() + if err != nil { + return "", err + } + data, err := c.Get("/api/v1/version", nil) + if err != nil { + return "", err + } + var sv struct { + Version string `json:"version"` + } + if err := json.Unmarshal(data, &sv); err != nil { + return "", err + } + return sv.Version, nil +} diff --git a/cli/internal/settings/settings.go b/cli/internal/settings/settings.go index 2fe70a0a..2b2d5b26 100644 --- a/cli/internal/settings/settings.go +++ b/cli/internal/settings/settings.go @@ -74,7 +74,7 @@ func Resolve(ov Overrides) (Settings, error) { } if s.APIURL == "" { - s.APIURL = fmt.Sprintf("http://%s:%d", defaultHost, defaultPort) + s.APIURL = DefaultAPIURL() } if s.Token == "" { return Settings{}, ErrNoToken @@ -82,6 +82,34 @@ func Resolve(ov Overrides) (Settings, error) { return s, nil } +// DefaultAPIURL is the loopback API base URL used when none is provided. +func DefaultAPIURL() string { + return fmt.Sprintf("http://%s:%d", defaultHost, defaultPort) +} + +// Save writes the connection settings to ~/.code-meeseeks/cli.yaml (creating the directory +// if needed) with owner-only file permissions, since the token is a secret. It overwrites +// any existing CLI config. Returns the path written. This is the write counterpart to the +// cli.yaml read in Resolve — the CLI's `login` command uses it to persist credentials. +func Save(s Settings) (string, error) { + home, ok := appHome() + if !ok { + return "", errors.New("cannot resolve home directory") + } + if err := os.MkdirAll(home, 0o700); err != nil { + return "", err + } + path := filepath.Join(home, "cli.yaml") + data, err := yaml.Marshal(cliConfig{APIURL: s.APIURL, Token: s.Token}) + if err != nil { + return "", err + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", err + } + return path, nil +} + // appHome returns the app's fixed data directory (~/.code-meeseeks), shared by the GUI // 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). diff --git a/cli/internal/settings/settings_test.go b/cli/internal/settings/settings_test.go index 0f39b2fa..0fb70e3a 100644 --- a/cli/internal/settings/settings_test.go +++ b/cli/internal/settings/settings_test.go @@ -55,3 +55,21 @@ func TestResolveNoTokenErrors(t *testing.T) { t.Fatalf("expected ErrNoToken, got %v", err) } } + +func TestSaveThenResolveRoundTrips(t *testing.T) { + isolateHome(t) + t.Setenv(EnvAPIURL, "") + t.Setenv(EnvToken, "") + + if _, err := Save(Settings{APIURL: "http://saved:9", Token: "saved-token"}); err != nil { + t.Fatalf("save failed: %v", err) + } + // With no flags/env, Resolve must read back exactly what Save wrote. + got, err := Resolve(Overrides{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.APIURL != "http://saved:9" || got.Token != "saved-token" { + t.Fatalf("round-trip mismatch, got %+v", got) + } +} diff --git a/cli/main.go b/cli/main.go index 9b078614..3e6130bf 100644 --- a/cli/main.go +++ b/cli/main.go @@ -6,8 +6,20 @@ // not provided — integrators implement those against the platform directly. package main -import "github.com/huhamhire/code-meeseeks/cli/cmd" +import ( + _ "embed" + + "github.com/huhamhire/code-meeseeks/cli/cmd" +) + +// skillDoc is SKILL.md embedded at build time so the binary can self-document its agent +// usage (`meebox skill`) even when detached from its release archive. Embedding keeps the +// emitted doc in lock-step with the shipped SKILL.md. The embed must live in this root +// package because SKILL.md sits at the module root (go:embed cannot reach parent dirs). +// +//go:embed SKILL.md +var skillDoc string func main() { - cmd.Execute() + cmd.Execute(skillDoc) } diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md index cd860893..dec390a9 100644 --- a/docs/arch/04-integration/01-service-api.md +++ b/docs/arch/04-integration/01-service-api.md @@ -9,7 +9,8 @@ 负责:服务监听开关与生命周期、bearer token 鉴权、请求路由与响应封装、把内部能力映射成稳定的 HTTP 契约。 开放的**写操作**限定为评审动作:approve / needswork(远端评审决断)与顶层 comment(发评论),复用 -GUI 同源 controller(见下「写边界」)。 +GUI 同源 controller(见下「写边界」)。另有 `POST …/refresh` 触发一次本地轮询刷新——虽用 POST,但 +**无远端写副作用**(纯读远端 + 落本地),不属评审写动作范畴,与合并 / 变更类工具的禁令无关。 **不负责**: @@ -54,6 +55,8 @@ GUI 同源 controller(见下「写边界」)。 (`ctx.pr` / `ctx.orchestrator` / `ctx.poller` / `ctx.connectionRuntime` 等),**不重复业务逻辑**。 - 原则:核心能力沉在 service 层,IPC 与 HTTP 各自只做**薄封装 + 协议适配**。新增 API 端点前,先确保对应能力 在 service 层有可复用方法(必要时把 controller 内联逻辑下沉到 service)。 +- **路由按领域分模块**:HTTP 路由处理器按业务领域(系统性 / PR / Agent)分置于独立模块,聚合层只做**注册与路径 + 匹配**、不含业务逻辑——与 [CLI](02-cli.md) 命令树的领域划分对称,新增端点归入对应域、便于定位与扩展。 ### 写边界(放开评审动作,拒绝合并与变更类 Agent 工具) @@ -120,6 +123,8 @@ service: | --- | --- | --- | | `GET /api/v1/whoami` | 当前身份:活动连接 PAT 所属用户(`name`/`displayName`/`slug`)+ 集成平台 + 连接显示名;无活动连接各项 null | 连接摘要(当前用户 + 平台) | | `GET /api/v1/categories` | 当前启用平台下可用的分类标签:`categories`(`PrDiscoveryFilter`)+ `statuses`(状态 / 合并态筛选),按平台能力裁剪 | 平台能力位 + 列表筛选语义 | +| `POST /api/v1/refresh` | 触发一次立即轮询刷新(拉取所有连接的最新 PR、落本地),返回本轮计数汇总(`PollResult`:fetched / changed / added / removed / errors);等价 GUI 手动刷新,无远端写副作用 | `poller.tick`(`prs:refresh` 同源) | +| `GET /api/v1/version` | 服务端(桌面应用)版本(`{ version }`),供 CLI `version` 命令同时展示客户端 + 服务端版本 | `buildAppInfo().appVersion`(`app:info` 同源) | | `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` 同源 | diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index a21d6cdf..cbe4079e 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -45,7 +45,9 @@ CLI 需 API base URL + token。来源优先级(高 → 低): 2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN`; 3. CLI 自身配置文件 `~/.code-meeseeks/cli.yaml`(与 GUI 的 `config.yaml` 同目录、独立文件,隔离二者配置)。 -连接信息须**显式提供**(flag / 环境变量 / `cli.yaml` 三者之一),token 缺失即报鉴权错误。 +连接信息须**显式提供**(flag / 环境变量 / `cli.yaml` 三者之一),token 缺失即报鉴权错误。`meebox login +--token [--server ]` 把 token(与可选 server,默认 loopback)写入 `cli.yaml`,免去后续每次传参—— +它是 CLI 唯一的配置**写入**命令,与 `cli.yaml` 的读取(上述优先级)配对,使配置管理自洽。 **不读取 GUI 主配置**:CLI 刻意**不**读应用主配置 `~/.code-meeseeks/config.yaml`。该文件承载连接层机密 (各代码平台的访问令牌等),若从中静默取服务令牌,等于让 CLI 触达其本不应接触的凭据——属预期外的越权访问, @@ -59,14 +61,27 @@ 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` 平级。 +命令分两类——**根层级系统性命令** 与 **两个领域组**: + +- **系统性命令(根层级)** —— `login`(保存凭据到 `cli.yaml`)、`whoami`(身份)、`version`(客户端 + + 服务端版本)、`skill`(打印内嵌的 SKILL.md):与具体 PR / Agent 无关的工具 / 会话层操作,直接置于根层级、 + 不套领域组(符合 `kubectl version` / `gh auth` 等惯例)。 +- **`pr`** —— PR 相关操作:浏览 + 评审写动作,并含 `categories`(`pr list` 的筛选词表)与 `refresh` + (触发一次拉取、刷新 PR 列表)。 +- **`agent`** —— 评审 Agent 操作。 + +`pr` / `agent` 下的 PR 维度子命令用**必填 flag `--pr `** 传 PR 标识(`id` 由 `pr list` 输出获得)—— +agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 `pr` 平级;根层级系统性命令与 +`pr categories` / `pr refresh` / `pr list` 非 PR 维度,无需 `--pr`。 | 命令 | 用途 | 对应 API | | --- | --- | --- | +| `meebox login --token [--server ]` | 保存 token(与可选 server,默认 loopback)到 `cli.yaml`,供后续命令免传参 | —(本地写,无 API) | | `meebox whoami` | 当前身份(用户 + 平台 + 连接名) | `GET /whoami` | -| `meebox categories` | 列当前启用平台的分类标签(`categories` 一级 + `statuses` 二级) | `GET /categories` | +| `meebox version` | 客户端(CLI)+ 服务端(应用)版本;服务端不可达时仅客户端、退出码仍 0 | `GET /version` | +| `meebox skill` | 打印构建时 `go:embed` 内嵌的 agent 使用说明(SKILL.md) | —(本地,无 API) | +| `meebox pr categories` | 列当前启用平台的分类标签(`categories` 一级 + `statuses` 二级)——`pr list` 的筛选词表 | `GET /categories` | +| `meebox pr refresh` | 触发一次立即轮询刷新(拉取最新 PR、落本地),返回本轮计数汇总(fetched / changed / added / removed / errors);等价 GUI 手动刷新 | `POST /refresh` | | `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` | @@ -115,6 +130,10 @@ meebox [全局 flag] <组> <命令> [参数] - **压缩包内容 = 可直接投放的 skill 目录**:除二进制外一并打包 `LICENSE` + `README.md` + `SKILL.md`。解压到 agent 的 skills 目录即得一个可用 skill——`SKILL.md`(frontmatter `name: meebox`)教 agent 用法,紧邻其驱动 的二进制。这是 CLI「面向 agent 交付」的主形态。 +- **二进制自述(`go:embed`)**:同一份 `SKILL.md` 经 `go:embed` 于构建期内嵌进二进制,`meebox skill` 打印之。 + 即便二进制脱离压缩包(如 `go install` 或裸放 `PATH`)也能自述用法,且内嵌内容与随包 `SKILL.md` 构建期一致。 + 刻意**不做** `--manifest` 之类的 function-calling JSON——skill 的消费形态是 markdown,非工具 schema 注入; + 真有此需求应从命令树生成、而非另手维护一份 JSON。 ## 分发与 CI diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index be17d071..4064cd37 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -31,7 +31,16 @@ CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → 2. 环境变量:`MEEBOX_API_URL` / `MEEBOX_TOKEN` 3. CLI 配置文件:`~/.code-meeseeks/cli.yaml`(字段 `api_url` / `token`) -连接信息须**显式提供**其一。令牌在设置页「集成」分区查看 / 复制。本机免逐次传参推荐用环境变量: +连接信息须**显式提供**其一。令牌在设置页「集成」分区查看 / 复制。最省事的方式是用 `meebox login` 存一次令牌 +(写入 `cli.yaml`),之后所有命令免传参: + +```bash +meebox login --token <令牌> # 默认连本机 http://127.0.0.1:18765 +meebox login --token <令牌> --server http://<主机>:18765 # 指定远端服务 +meebox pr list # 后续命令直接用已存的凭据 +``` + +或用环境变量(适合 CI / shell 注入): ```bash export MEEBOX_API_URL=http://127.0.0.1:18765 @@ -54,13 +63,18 @@ meebox --api-url http://<主机>:18765 --token <令牌> pr list meebox [全局参数] <组> <命令> [参数] ``` -命令分 `pr`(直接的 PR 操作)与 `agent`(评审 Agent 操作)两个领域组,均用**必填参数 `--pr `** 指定 PR -(`id` 由 `meebox pr list` 输出获得)。 +根层级的系统性命令 `whoami` / `version` 与具体 PR 无关;其余命令分 `pr`(PR 操作,含 `categories` 筛选词表 +与 `refresh` 刷新)与 `agent`(评审 Agent 操作)两个领域组,其 PR 维度子命令用**必填参数 `--pr `** 指定 +PR(`id` 由 `meebox pr list` 输出获得)。 | 命令 | 用途 | | --- | --- | +| `meebox login --token <令牌> [--server <地址>]` | 保存令牌(与可选服务地址)到 `cli.yaml`,后续命令免传参 | | `meebox whoami` | 当前登录身份与集成平台(用户 + 平台 + 连接名) | -| `meebox categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选) | +| `meebox version` | 客户端(CLI)+ 服务端(应用)版本;未连接服务端时仅显示客户端版本 | +| `meebox skill` | 打印内嵌的使用说明(SKILL.md),便于二进制脱离压缩包时自述用法 | +| `meebox pr categories` | 列出当前平台可用的分类标签(一级发现分类 + 二级状态 / 合并态筛选)——`pr list` 的筛选词表 | +| `meebox pr refresh` | 触发一次立即刷新(拉取最新 PR),返回本轮变化计数(新增 / 变更 / 移除等);等同 GUI 里的手动刷新 | | `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` 列变更文件;有则取该文件内容 | @@ -94,6 +108,34 @@ meebox pr list --output json | jq '.[].title' **退出码**:`0` 成功;非 0 表错误(`2` 鉴权失败、`3` 资源不存在、`1` 其他);错误信息打到 `stderr`。 +## 6. 作为 Agent Skill 集成 + +`meebox` 的主要交付形态是**可直接投放的 agent skill**:发布压缩包除二进制外一并含 `SKILL.md` / +`README.md` / `LICENSE`,整个解压目录即是一个可用 skill。 + +- **投放即用**:把解压目录放进 agent 的 skills 目录(如 `~/.claude/skills/meebox/`)。`SKILL.md` + (frontmatter `name: meebox`)向 agent 说明命令树、连接方式与写边界,紧邻其驱动的二进制。 +- **二进制自述**:同一份 `SKILL.md` 于构建期经 `go:embed` 内嵌进二进制,`meebox skill` 可打印之—— + 二进制即便脱离压缩包(如单独放入 `PATH`)也能取回用法,且内容与随包文档构建期一致。 +- **仅有二进制的 fallback**:若手头只有 `meebox` 二进制(缺压缩包 / `SKILL.md` 文件),用 `meebox skill` + 即可从二进制导出说明、就地重建 skill 目录,无需另找原始文件: + + ```bash + mkdir -p ~/.claude/skills/meebox + cp "$(command -v meebox)" ~/.claude/skills/meebox/ # 二进制放入 skill 目录 + meebox skill > ~/.claude/skills/meebox/SKILL.md # 从内嵌副本导出说明 + ``` + + 导出的内容与该二进制同源,天然匹配当前版本。 +- **集成流程**:读 `SKILL.md` 了解能力 → `meebox login` 存一次凭据 → 以 `meebox pr list` / `pr show` / + `agent review` 等浏览与驱动评审 → 用 `meebox pr approve` / `needswork` / `comment` 记录结论;机器消费统一 + 取 `--output json`(其字段形状为稳定契约)。 +- **边界内建**:仅开放浏览 + 评审写动作,不含合并与变更类工具(详见下「注意事项」),agent 集成天然不会触发 + 高影响远端操作。 +- **框架无关的接入**:`SKILL.md` 的自动发现是 Claude Code 的 skill 约定,并非跨框架标准。其它 agent / 脚本 + 无需依赖该约定即可集成——直接以 shell 调用 `meebox`、用 `meebox skill` 或 `--help` 取用法、`--output json` + 取结构化结果。真正可移植的接口是「命令行 + JSON」,`SKILL.md` 自动发现只是 Claude 生态的锦上添花。 + ## 网络代理 `meebox` 遵循标准的 HTTP 代理环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,大小写均可),无需额外配置: