Public: anyone can read this; the crew can change it.
The Claude Code and OpenCode plugins say what the crew is doing on an app and show a run's last lines, through MCP `runs` and `run_log` #7395
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7371-the-claude-code-and-opencode-plugins-say → main 1 commit fork 1 thread · 1 unresolved
5 files
changed +44
−8
plugins/claude-code/README.md +6 −1
| @@ -4,7 +4,7 @@ Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN` | ||
| 4 | 4 | (an `rw` token; `ro` reads only; make one at `/settings/tokens` on the forge, which also shows this line filled in), then add this directory as a plugin |
| 5 | 5 | (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP |
| 6 | 6 | server and a `/forge` command: `tell`, `status`, `decide`, `plan`, `preview`, |
| 7 | `pulls`. | |
| 7 | `pulls`, `runs`, `log`. | |
| 8 | 8 | |
| 9 | 9 | `/forge plan` shows the planner's proposed wave in three lines. `/forge plan approve`, `/forge plan redirect <words>` or `/forge plan later` answers it, after you confirm. |
| 10 | 10 | |
| @@ -12,4 +12,9 @@ server and a `/forge` command: `tell`, `status`, `decide`, `plan`, `preview`, | ||
| 12 | 12 | and who is working at that URL. `/forge pulls <app>` lists the app's open |
| 13 | 13 | pulls with their check. |
| 14 | 14 | |
| 15 | `/forge runs <app>` says what the crew is doing on the app: the running runs | |
| 16 | and the last three finished, one line each (kind, issue or pull, state, how | |
| 17 | long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40 | |
| 18 | lines verbatim, through MCP `run_log`. | |
| 19 | ||
| 15 | 20 | `test/plugins.test.ts` fails when this plugin names an MCP tool the forge does not list, so a rename in `src/http/mcp.ts` cannot leave it behind. |
plugins/claude-code/commands/forge.md +4 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | description: Talk to your forge: tell, status, decide, plan, preview, pulls | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | |
| 2 | description: Talk to your forge: tell, status, decide, plan, preview, pulls, runs, log | |
| 3 | argument-hint: tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Use the `forge` MCP server's tools for `$ARGUMENTS`. |
| @@ -11,5 +11,7 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`. | ||
| 11 | 11 | - `plan`: call `asks`, take the first row whose kind is `proposal` (none: say no wave waits). Show it in three lines: the wave's title, what it holds, why now. With `approve`, `redirect <words>` or `later`, ask the person to confirm, and only after they say yes call `asks_proposal` with `id` = that row's `proposal.id` (the proposal's own id, not the row's `id`, which starts `proposal:`), `verb` = the word, and `words` for redirect. |
| 12 | 12 | - `preview <url>`: call `preview` with `url`; say in two lines the app, environment, pull, check and who is working. |
| 13 | 13 | - `pulls <app>`: call `pulls` with `repo` = the app (owner/name); list the open pulls, one line each with its check. |
| 14 | - `runs <app>`: call `runs` with `repo` = the app (owner/name); say, one line each, the running runs and the last three finished ones: kind, issue or pull number, state, and how long (from `startedAt` to `endedAt`, or to now while running). | |
| 15 | - `log <app> <run>`: call `run_log` with `repo` = the app, `run` = the run id and `tail` = 40; relay the lines verbatim, in a code block. | |
| 14 | 16 | |
| 15 | 17 | Keep what you relay short. Never merge: a person lands changes. |
plugins/opencode/README.md +6 −1
| @@ -4,7 +4,7 @@ Copy `forge.ts` into `.opencode/plugin/` (or list this package under `plugin` | ||
| 4 | 4 | in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN` (make the token at `/settings/tokens` on the forge, which shows the line with its own address filled in): |
| 5 | 5 | `mkdir -p .opencode/plugin && cp plugins/opencode/forge.ts .opencode/plugin/ && FORGE_URL=<url> FORGE_TOKEN=<token> opencode`. It registers the |
| 6 | 6 | forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`, |
| 7 | `preview`, `pulls`. | |
| 7 | `preview`, `pulls`, `runs`, `log`. | |
| 8 | 8 | `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`); |
| 9 | 9 | `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`, |
| 10 | 10 | applied only after you say yes. Only the owner may act on a proposal. |
| @@ -13,6 +13,11 @@ applied only after you say yes. Only the owner may act on a proposal. | ||
| 13 | 13 | and who is working at that URL. `/forge pulls <app>` lists the app's open |
| 14 | 14 | pulls with their check. |
| 15 | 15 | |
| 16 | `/forge runs <app>` says what the crew is doing on the app: the running runs | |
| 17 | and the last three finished, one line each (kind, issue or pull, state, how | |
| 18 | long), through MCP `runs`. `/forge log <app> <run>` relays that run's last 40 | |
| 19 | lines verbatim, through MCP `run_log`. | |
| 20 | ||
| 16 | 21 | Code Mode can also read `GET /contract/openapi.json`. |
| 17 | 22 | |
| 18 | 23 | `test/plugins.test.ts` fails when this plugin names an MCP tool the forge does not list, so a rename in `src/http/mcp.ts` cannot leave it behind. |
plugins/opencode/forge.ts +4 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * forge for OpenCode: registers the forge's MCP server and a `forge` |
| 3 | * command (tell, status, decide, plan, preview, pulls). Set FORGE_URL and FORGE_TOKEN. | |
| 3 | * command (tell, status, decide, plan, preview, pulls, runs, log). Set FORGE_URL and FORGE_TOKEN. | |
| 4 | 4 | * No logic lives here: the tools are the forge's own (`docs/MCP.md`). |
| 5 | 5 | */ |
| 6 | 6 | import type { Plugin } from "@opencode-ai/plugin"; |
| @@ -12,6 +12,8 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS | ||
| 12 | 12 | - plan [approve|redirect <words>|later]: call \`asks\`; show the first wave-proposal row (the planner's next wave) in a few lines. approve|redirect|later -> \`asks_proposal\` with that row's id and verb (redirect carries the words as \`words\`). Say what it will do and apply only after the person says yes. |
| 13 | 13 | - preview <url>: call \`preview\` with url; say in two lines the app, environment, pull, check and who is working. |
| 14 | 14 | - pulls <app>: call \`pulls\` with repo; list the open pulls, one line each with its check. |
| 15 | - runs <app>: call \`runs\` with repo; say, one line each, the running runs and the last three finished: kind, issue or pull number, state, how long (startedAt to endedAt, or to now). | |
| 16 | - log <app> <run>: call \`run_log\` with repo, run (the run id) and \`tail\` 40; relay the lines verbatim. | |
| 15 | 17 | Never merge: a person lands changes.`; |
| 16 | 18 | |
| 17 | 19 | export const Forge: Plugin = async () => ({ |
| @@ -29,7 +31,7 @@ export const Forge: Plugin = async () => ({ | ||
| 29 | 31 | }; |
| 30 | 32 | config["command"] = { |
| 31 | 33 | ...config["command"], |
| 32 | forge: { description: "Tell, status, decide, plan, preview or pulls on your forge", template: COMMAND }, | |
| 34 | forge: { description: "Tell, status, decide, plan, preview, pulls, runs or log on your forge", template: COMMAND }, | |
| 33 | 35 | }; |
| 34 | 36 | }, |
| 35 | 37 | }); |
test/plugins.test.ts +24 −2
| @@ -19,7 +19,7 @@ const opencodeText = (): string => { | ||
| 19 | 19 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); |
| 20 | 20 | |
| 21 | 21 | /** Backticked words that are the server or an argument, not a tool. */ |
| 22 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status", "words", "id", "verb", "proposal", "approve", "later", "url"]); | |
| 22 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status", "words", "id", "verb", "proposal", "approve", "later", "url", "tail"]); | |
| 23 | 23 | |
| 24 | 24 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ |
| 25 | 25 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); |
| @@ -37,6 +37,28 @@ for (const [label, text] of [ | ||
| 37 | 37 | }); |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | describe("what the crew is doing on an app", () => { | |
| 41 | for (const [label, text] of [ | |
| 42 | ["Claude Code", claudeText], | |
| 43 | ["OpenCode", opencodeText], | |
| 44 | ] as const) { | |
| 45 | test(`${label} names runs and run_log, which the forge lists`, () => { | |
| 46 | const named = toolsNamed(text()); | |
| 47 | for (const tool of ["runs", "run_log"]) { | |
| 48 | expect(named).toContain(tool); | |
| 49 | expect(tools.has(tool)).toBe(true); | |
| 50 | } | |
| 51 | expect(text()).toMatch(/log <app> <run>/); | |
| 52 | expect(text()).toMatch(/`tail`\s*(=\s*)?40/); | |
| 53 | expect(text()).toContain("verbatim"); | |
| 54 | }); | |
| 55 | } | |
| 56 | ||
| 57 | test("a command that drops run_log is caught", () => { | |
| 58 | expect(toolsNamed("- runs <app>: call `runs` with `repo`")).not.toContain("run_log"); | |
| 59 | }); | |
| 60 | }); | |
| 61 | ||
| 40 | 62 | test("a renamed tool is caught", () => { |
| 41 | 63 | expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]); |
| 42 | 64 | expect(backticked("`a_b` and `C`")).toEqual(["a_b"]); |
| @@ -98,7 +120,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => | ||
| 98 | 120 | |
| 99 | 121 | describe("the two plugin commands offer the same verbs", () => { |
| 100 | 122 | test("Claude Code and OpenCode have the same set", () => { |
| 101 | expect(verbsOf(claudeText())).toEqual(["decide", "plan", "preview", "pulls", "status", "tell"]); | |
| 123 | expect(verbsOf(claudeText())).toEqual(["decide", "log", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 102 | 124 | expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] }); |
| 103 | 125 | }); |
| 104 | 126 |
new above
offline
Pass
limit= 50 so the last three finished runs are not pushed out of the default 20 by running ones.