Public: anyone can read this; the crew can change it.
The Claude Code and OpenCode plugins archive an app and decide many asks at once, with a confirm and an undo #11193
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11189-the-claude-code-and-opencode-plugins-arc → main 2 commits fork 4 threads · 4 unresolved
5 files
changed +56
−9
plugins/claude-code/README.md +3 −1
| @@ -7,13 +7,15 @@ claude plugin marketplace add $FORGE_URL/plugins/claude-code/marketplace.json && | ||
| 7 | 7 | |
| 8 | 8 | |
| 9 | 9 | The forge serves that marketplace manifest (name `forge`, one plugin `forge`, fetched by Claude Code as the `plugins/claude-code` subdirectory of the forge's public source repository). Inside Claude Code, the same from the repository: `/plugin marketplace add $FORGE_URL/trevato/forge.git` then `/plugin install forge@forge`. Then set `FORGE_URL` (the forge's address, no trailing slash) in your environment. From a clone, `claude --plugin-dir plugins/claude-code` loads it locally instead. It registers the forge's MCP |
| 10 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`, | |
| 10 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `archive`, `undo`, `plan`, `preview`, | |
| 11 | 11 | `pulls`, `runs`, `log`, `asked`. |
| 12 | 12 | |
| 13 | 13 | **Sign in (nothing to copy).** The `forge` server sends no Authorization header, so Claude Code takes the forge's OAuth sign-in: open `/mcp` in Claude Code, choose `forge`, and approve in the browser. |
| 14 | 14 | |
| 15 | 15 | **A token (for scripts).** Also set `FORGE_TOKEN` (an `rw` token; `ro` reads only; make one at `/settings/tokens` on the forge, which also shows this line filled in) and use the `forge-token` server, which sends it as a bearer. `forge-token` defaults a missing `FORGE_TOKEN` to empty, so an OAuth-only user sees no missing-variable error; that entry then just sends an empty bearer and is refused, so leave it unused. A plugin.json cannot make a header conditional, so the two are separate entries; `forge` is the default. |
| 16 | 16 | |
| 17 | `/forge archive <app>` plans archiving the app, then archives it after you say yes (put away, not deleted; the receipt says how to wake it). `/forge decide all <dismiss|ship|close> [kind]` plans the same action over every waiting ask (of that kind), says how many and which, applies after a yes and prints the `operation` id; `/forge undo <operation>` reverses it. | |
| 18 | ||
| 17 | 19 | `/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. |
| 18 | 20 | |
| 19 | 21 | `/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. |
plugins/claude-code/commands/forge.md +5 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 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] | |
| 2 | description: Talk to your forge: new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked | |
| 3 | argument-hint: new <name> <words> | tell <app> <words> | status | decide <approve|redirect|decline> [words] | decide all <dismiss|ship|close> [kind] | archive <app> | undo <operation> | 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`. |
| @@ -9,6 +9,9 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`. | ||
| 9 | 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). |
| 10 | 10 | - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person. |
| 11 | 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. |
| 12 | - `archive <app>`: call `archive` with `repo` = the app (owner/name), planned first (`dryRun` defaults to true; `archived` true): show what would change and ask the person to confirm. Only after they say yes, call it again with `dryRun: false`. The receipt says it is put away, not deleted, and how to wake it: `archive` with `archived: false`, by saying `archive <app>` again. | |
| 13 | - `decide all <dismiss|ship|close> [kind]`: call `asks`; take every row (only those whose kind is `kind`, when given) and plan `asks_act` with `action` = the word and `items` = their ids. Say how many and which, one line each. Only after the person says yes, call `asks_act` again with the same `items` and `dryRun: false`, then print the `operation` id with `undo <operation>`. `asks_act` takes at most 100 `items`: with more rows, plan and apply only the first 100, say how many remain, and offer to run it again for the rest. | |
| 14 | - `undo <operation>`: call `asks_act` with `action: "undo"` and `operation` = the id, planned first (`dryRun` defaults to true, and then only answers `wouldUndo`); after the person says yes, call again with `dryRun: false`; say what it reversed from the receipt. Never say it is undone before that second call. | |
outdated
asks_act is dryByDefault, so this only plans. Add the confirm-then- |
||
| 12 | 15 | - `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. |
| 13 | 16 | - `preview <url>`: call `preview` with `url`; say in two lines the app, environment, pull, check and who is working. |
| 14 | 17 | - `pulls <app>`: call `pulls` with `repo` = the app (owner/name); list the open pulls, one line each with its check. |
plugins/opencode/README.md +3 −1
| @@ -3,8 +3,10 @@ | ||
| 3 | 3 | Fetch `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 && curl -fsSo .opencode/plugin/forge.ts <forge>/plugins/opencode/forge.ts && FORGE_URL=<forge> FORGE_TOKEN=<token> opencode`, where `<forge>` is the forge's own origin (it serves this file). It registers the |
| 6 | forge's MCP server and the `forge` command: `new`, `tell`, `status`, `decide`, `plan`, | |
| 6 | forge's MCP server and the `forge` command: `new`, `tell`, `status`, `decide`, `archive`, `undo`, `plan`, | |
| 7 | 7 | `preview`, `pulls`, `runs`, `log`, `asked`. |
| 8 | `/forge archive <app>` plans archiving the app, then archives it after you say yes (put away, not deleted; the receipt says how to wake it). `/forge decide all <dismiss|ship|close> [kind]` plans the same action over every waiting ask (of that kind), says how many and which, applies after a yes and prints the `operation` id; `/forge undo <operation>` reverses it. | |
| 9 | ||
| 8 | 10 | `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`); |
| 9 | 11 | `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`, |
| 10 | 12 | applied only after you say yes. Only the owner may act on a proposal. |
plugins/opencode/forge.ts +5 −2
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * forge for OpenCode: registers the forge's MCP server and a `forge` |
| 3 | * command (new, tell, status, decide, plan, preview, pulls, runs, log, asked). Set FORGE_URL, and FORGE_TOKEN too | |
| 3 | * command (new, tell, status, decide, archive, undo, plan, preview, pulls, runs, log, asked). Set FORGE_URL, and FORGE_TOKEN too | |
| 4 | 4 | * to skip the OAuth sign-in. |
| 5 | 5 | * No logic lives here: the tools are the forge's own (`docs/MCP.md`). |
| 6 | 6 | */ |
| @@ -12,6 +12,9 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS | ||
| 12 | 12 | - tell <app> <words>: call \`tell\` with repo and text; pass context { page, run, commit, said }. |
| 13 | 13 | - status: call \`now\` and \`asks\`; say in three sentences what runs and what waits on the person. |
| 14 | 14 | - 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. |
| 15 | - archive <app>: call \`archive\` with \`repo\` = the app (owner/name), planned first (\`dryRun\` defaults to true; \`archived\` true): show what would change and ask the person to confirm. Only after they say yes, call it again with \`dryRun: false\`. The receipt says it is put away, not deleted, and how to wake it: \`archive\` with \`archived: false\`, by saying archive <app> again. | |
| 16 | - decide all <dismiss|ship|close> [kind]: call \`asks\`; take every row (only those whose kind is \`kind\`, when given) and plan \`asks_act\` with \`action\` = the word and \`items\` = their ids. Say how many and which, one line each. Only after the person says yes, call \`asks_act\` again with the same \`items\` and \`dryRun: false\`, then print the \`operation\` id with undo <operation>. \`asks_act\` takes at most 100 \`items\`: with more rows, plan and apply only the first 100, say how many remain, and offer to run it again for the rest. | |
| 17 | - undo <operation>: call \`asks_act\` with \`action: "undo"\` and \`operation\` = the id, planned first (\`dryRun\` defaults to true, and then only answers \`wouldUndo\`); after the person says yes, call again with \`dryRun: false\`; say what it reversed from the receipt. Never say it is undone before that second call. | |
outdated
Same: undo needs |
||
| 15 | 18 | - 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. |
| 16 | 19 | - preview <url>: call \`preview\` with url; say in two lines the app, environment, pull, check and who is working. |
| 17 | 20 | - pulls <app>: call \`pulls\` with repo; list the open pulls, one line each with its check. |
| @@ -36,7 +39,7 @@ export const Forge: Plugin = async () => ({ | ||
| 36 | 39 | }; |
| 37 | 40 | config["command"] = { |
| 38 | 41 | ...config["command"], |
| 39 | forge: { description: "New, tell, status, decide, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 42 | forge: { description: "New, tell, status, decide, archive, undo, plan, preview, pulls, runs, log or asked on your forge", template: COMMAND }, | |
| 40 | 43 | }; |
| 41 | 44 | }, |
| 42 | 45 | }); |
test/plugins.test.ts +40 −3
| @@ -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", "name", "description", "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", "items", "operation", "archived", "kind", "undo"]); | |
| 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)); |
| @@ -189,7 +189,7 @@ describe("the OpenCode plugin module", () => { | ||
| 189 | 189 | }); |
| 190 | 190 | |
| 191 | 191 | /** The verbs a command offers: the first word of each `- <verb>` line. */ |
| 192 | const verbsOf = (text: string): string[] => [...text.matchAll(/^- `?([a-z]+)/gm)].map((m) => m[1]!).sort(); | |
| 192 | const verbsOf = (text: string): string[] => [...new Set([...text.matchAll(/^- `?([a-z]+)/gm)].map((m) => m[1]!))].sort(); | |
| 193 | 193 | |
| 194 | 194 | /** The verbs in one command but not the other, either way. */ |
| 195 | 195 | const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => { |
| @@ -200,7 +200,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => | ||
| 200 | 200 | |
| 201 | 201 | describe("the two plugin commands offer the same verbs", () => { |
| 202 | 202 | test("Claude Code and OpenCode have the same set", () => { |
| 203 | expect(verbsOf(claudeText())).toEqual(["asked", "decide", "log", "new", "plan", "preview", "pulls", "runs", "status", "tell"]); | |
| 203 | expect(verbsOf(claudeText())).toEqual(["archive", "asked", "decide", "log", "new", "plan", "preview", "pulls", "runs", "status", "tell", "undo"]); | |
| 204 | 204 | expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] }); |
| 205 | 205 | }); |
| 206 | 206 | |
| @@ -234,3 +234,40 @@ describe("the repository is a Claude Code marketplace", () => { | ||
| 234 | 234 | } |
| 235 | 235 | }); |
| 236 | 236 | }); |
| 237 | ||
| 238 | describe("archive and bulk decisions run through tools the forge lists", () => { | |
| 239 | for (const [label, text] of [ | |
| 240 | ["Claude Code", claudeText], | |
| 241 | ["OpenCode", opencodeText], | |
| 242 | ] as const) { | |
| 243 | test(`${label} names archive and asks_act for archive, decide all and undo`, () => { | |
| 244 | const t = text(); | |
| 245 | expect(toolsNamed(t)).toEqual(expect.arrayContaining(["archive", "asks_act"])); | |
| 246 | expect(tools.has("archive")).toBe(true); | |
| 247 | expect(t).toMatch(/archive <app>/); | |
| 248 | expect(t).toMatch(/decide all <dismiss\|ship\|close> \[kind\]/); | |
| 249 | expect(t).toMatch(/undo <operation>/); | |
| 250 | const undo = t.split("\n").find((l) => /^- `?undo <operation>/.test(l))!; | |
| 251 | expect(undo).toMatch(/`action: "undo"`/); | |
| 252 | expect(undo).toContain("dryRun: false"); | |
| 253 | expect(undo).toMatch(/after the person says yes/); | |
outdated
Too loose: matches any line with 'action' before 'undo'. Anchor it to the undo verb line and require dryRun there. |
||
| 254 | const all = t.split("\n").find((l) => /^- `?decide all/.test(l))!; | |
| 255 | expect(all).toMatch(/at most 100 `items`[^.]*first 100[^.]*remain/); | |
| 256 | expect(t).toContain("wake"); | |
| 257 | expect(t.split("\n").find((l) => /^- `?archive <app>/.test(l))).toMatch(/Only after (they say|the person says) yes, call it again with `dryRun: false`/); | |
| 258 | }); | |
| 259 | } | |
| 260 | ||
| 261 | test("both argument hints and both READMEs list the verbs", () => { | |
| 262 | expect(read("plugins/claude-code/commands/forge.md")).toMatch(/argument-hint:.*archive <app>.*undo <operation>/); | |
| 263 | expect(read("plugins/opencode/forge.ts")).toMatch(/description: "[^"]*archive, undo/); | |
| 264 | for (const p of ["plugins/claude-code/README.md", "plugins/opencode/README.md"]) { | |
| 265 | const r = read(p); | |
| 266 | for (const v of ["`/forge archive <app>`", "`/forge decide all <dismiss|ship|close> [kind]`", "`/forge undo <operation>`"]) expect(r).toContain(v); | |
| 267 | } | |
| 268 | }); | |
| 269 | ||
| 270 | test("a verb naming a tool the server lacks is caught", () => { | |
| 271 | expect(toolsNamed(claudeText().replaceAll("`archive`", "`archive_renamed`")).filter((n) => !tools.has(n))).toEqual(["archive_renamed"]); | |
| 272 | }); | |
| 273 | }); |
asks_act caps items at 100. Say what to do with more rows.