Public: anyone can read this; the crew can change it.
The Claude Code and OpenCode plugins' `new <name> <words>` makes an app from the harness through MCP `new_app`, after the person confirms #7504
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7499-the-claude-code-and-opencode-plugins-new → main 3 commits fork 3 threads · 3 unresolved
5 files
changed +40
−8
plugins/claude-code/README.md +3 −1
| @@ -3,11 +3,13 @@ | ||
| 3 | 3 | 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 | server and a `/forge` command: `tell`, `status`, `decide`, `plan`, `preview`, | |
| 6 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`, | |
| 7 | 7 | `pulls`, `runs`, `log`, `asked`. |
| 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 | |
| 11 | `/forge new <name> <words>` makes an app named `<name>` whose first issue is the words, through MCP `new_app`, after you confirm; it answers with the app's page and the next step. It needs an `rw` token (an `ro` token is refused). When the forge refuses (the name is taken, the account is unverified, a limit is reached), it says the forge's own words in one line, offers another name when the name is taken, and never retries without asking. | |
| 12 | ||
| 11 | 13 | `/forge preview <url>` says in two lines the app, environment, pull, check |
| 12 | 14 | and who is working at that URL. `/forge pulls <app>` lists the app's open |
| 13 | 15 | pulls with their check. |
plugins/claude-code/commands/forge.md +3 −2
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | --- |
| 2 | description: Talk to your forge: tell, status, decide, plan, preview, pulls, runs, log, asked | |
| 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> | asked [app] | |
| 2 | description: Talk to your forge: new, tell, status, decide, plan, preview, pulls, runs, log, asked | |
| 3 | argument-hint: new <name> <words> | tell <app> <words> | status | decide <approve|redirect|decline> [words] | plan [approve|redirect <words>|later] | preview <url> | pulls <app> | runs <app> | log <app> <run> | asked [app] | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Use the `forge` MCP server's tools for `$ARGUMENTS`. |
| 7 | 7 | |
| 8 | - `new <name> <words>`: say the name and the description (the words) back and ask the person to confirm. Only after they say yes, call `new_app` with `name` = the name and `description` = the words. Relay in two lines: the app's page (its `url`) and the next step (the first issue and who picked it up, or that it waits for the crew). If `new_app` refuses (name_taken, unverified, app_limit, repository_limit, storage_limit, no_such_agent), say its own message in one line; for name_taken, offer another name. Never retry without asking. | |
| 8 | 9 | - `tell <app> <words>`: call `tell` with `repo` = the app (owner/name) and `text` = the words. Pass `context` with `page` (if known), `run`, `commit` (the current `git rev-parse HEAD`) and `said` (the person's own words, verbatim). |
| 9 | 10 | - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person. |
| 10 | 11 | - `decide approve|redirect|decline`: call `asks`, take the first row; approve → `asks_act` with action `ship`, decline → `asks_act` with action `close`, redirect → `say` with the person's words. Plan first (`dryRun` defaults to true), show what would change, and apply (`dryRun: false`) only after the person says yes. |
plugins/opencode/README.md +3 −1
| @@ -3,12 +3,14 @@ | ||
| 3 | 3 | 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 | forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`, | |
| 6 | forge's MCP server and the `forge` command: `new`, `tell`, `status`, `decide`, `plan`, | |
| 7 | 7 | `preview`, `pulls`, `runs`, `log`, `asked`. |
| 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. |
| 11 | 11 | |
| 12 | `/forge new <name> <words>` makes an app named `<name>` whose first issue is the words, through MCP `new_app`, after you confirm; it answers with the app's page and the next step. It needs an `rw` token (an `ro` token is refused). When the forge refuses (the name is taken, the account is unverified, a limit is reached), it says the forge's own words in one line, offers another name when the name is taken, and never retries without asking. | |
| 13 | ||
| 12 | 14 | `/forge preview <url>` says in two lines the app, environment, pull, check |
| 13 | 15 | and who is working at that URL. `/forge pulls <app>` lists the app's open |
| 14 | 16 | pulls with their check. |
plugins/opencode/forge.ts +3 −2
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * forge for OpenCode: registers the forge's MCP server and a `forge` |
| 3 | * command (tell, status, decide, plan, preview, pulls, runs, log, asked). Set FORGE_URL and FORGE_TOKEN. | |
| 3 | * command (new, tell, status, decide, plan, preview, pulls, runs, log, asked). 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"; |
| 7 | 7 | |
| 8 | 8 | const COMMAND = `Use the forge MCP tools for: $ARGUMENTS |
| 9 | - new <name> <words>: say the name and description back and ask the person to confirm. Only after the person says yes, call \`new_app\` with name and description (the words); relay in two lines the app's page (its url) and the next step (the first issue and who picked it up). If \`new_app\` refuses (name_taken, unverified, app_limit, repository_limit, storage_limit, no_such_agent), say its own message in one line; for name_taken, offer another name. Never retry without asking. | |
outdated
Same here: say what to do when |
||
| 9 | 10 | - tell <app> <words>: call \`tell\` with repo and text; pass context { page, run, commit, said }. |
| 10 | 11 | - status: call \`now\` and \`asks\`; say in three sentences what runs and what waits on the person. |
| 11 | 12 | - decide approve|redirect|decline: call \`asks\`; approve -> \`asks_act\` ship, decline -> \`asks_act\` close, redirect -> \`say\`. Plan first, apply (dryRun false) only after the person says yes. |
| @@ -32,7 +33,7 @@ export const Forge: Plugin = async () => ({ | ||
| 32 | 33 | }; |
| 33 | 34 | config["command"] = { |
| 34 | 35 | ...config["command"], |
| 35 | forge: { description: "Tell, status, decide, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 36 | forge: { description: "New, tell, status, decide, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 36 | 37 | }; |
| 37 | 38 | }, |
| 38 | 39 | }); |
test/plugins.test.ts +28 −2
| @@ -21,7 +21,7 @@ const opencodeText = (): string => { | ||
| 21 | 21 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); |
| 22 | 22 | |
| 23 | 23 | /** Backticked words that are the server or an argument, not a tool. */ |
| 24 | 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", "show", "all", "outcomeText"]); | |
| 24 | 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", "name", "description", "show", "all", "outcomeText"]); | |
| 25 | 25 | |
| 26 | 26 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ |
| 27 | 27 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); |
| @@ -127,6 +127,32 @@ test("the Claude Code command names plan's tools, and the forge lists them", () | ||
| 127 | 127 | expect(claudeText()).toContain("`proposal.id`"); |
| 128 | 128 | }); |
| 129 | 129 | |
| 130 | describe("new <name> <words> makes an app through new_app, after a yes", () => { | |
| 131 | for (const [label, text] of [ | |
| 132 | ["Claude Code", claudeText], | |
| 133 | ["OpenCode", opencodeText], | |
| 134 | ] as const) { | |
| 135 | test(`${label} names new_app, which the forge lists`, () => { | |
| 136 | expect(toolsNamed(text())).toContain("new_app"); | |
| 137 | expect(tools.has("new_app")).toBe(true); | |
| 138 | expect(text()).toContain("new <name> <words>"); | |
| 139 | expect(text()).toMatch(/Only after (they say|the person says) yes, call `new_app`/); | |
| 140 | expect(text()).toMatch(/If `new_app` refuses \([^)]*name_taken[^)]*\), say its own message in one line; for name_taken, offer another name\. Never retry without asking\./); | |
| 141 | }); | |
| 142 | } | |
| 143 | ||
| 144 | test("both argument hints offer it", () => { | |
| 145 | expect(read("plugins/claude-code/commands/forge.md")).toMatch(/^argument-hint: new <name> <words> \|/m); | |
| 146 | expect(read("plugins/opencode/forge.ts")).toMatch(/description: "New, tell/); | |
| 147 | expect(opencodeText()).toMatch(/^- new <name> <words>: /m); | |
| 148 | expect(claudeText()).toMatch(/^- `new <name> <words>`: /m); | |
| 149 | }); | |
| 150 | ||
| 151 | test("a command that drops new_app is caught", () => { | |
| 152 | expect(toolsNamed(claudeText().replaceAll("`new_app`", "`tell`"))).not.toContain("new_app"); | |
| 153 | }); | |
| 154 | }); | |
| 155 | ||
| 130 | 156 | describe("the OpenCode plan command", () => { |
| 131 | 157 | test("steers the planner's wave through asks_proposal, after a yes", () => { |
| 132 | 158 | const text = opencodeText(); |
| @@ -174,7 +200,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => | ||
| 174 | 200 | |
| 175 | 201 | describe("the two plugin commands offer the same verbs", () => { |
| 176 | 202 | test("Claude Code and OpenCode have the same set", () => { |
| 177 | expect(verbsOf(claudeText())).toEqual(["asked", "decide", "log", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 203 | expect(verbsOf(claudeText())).toEqual(["asked", "decide", "log", "new", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 178 | 204 | expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] }); |
| 179 | 205 | }); |
| 180 | 206 |
new above
offline
Add the refusal path: if
new_apprefuses (name_taken, unverified, app_limit...), say its message in one line and offer another name when the name is taken. Do not retry unasked.