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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11189-the-claude-code-and-opencode-plugins-arc → main 2 commits fork 4 threads · 4 unresolved

  • Merged. torque/11189-the-claude-code-and-opencode-plugins-arc was merged into main as 1581c11.

Review

Sign in to review this change.
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 &&
77 ```
88
99 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`,
1111 `pulls`, `runs`, `log`, `asked`.
1212
1313 **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.
1414
1515 **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.
1616
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
1719 `/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.
1820
1921 `/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 @@
11 ---
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]
44 ---
55
66 Use the `forge` MCP server's tools for `$ARGUMENTS`.
@@ -9,6 +9,9 @@ Use the `forge` MCP server's tools for `$ARGUMENTS`.
99 - `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).
1010 - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person.
1111 - `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.
outdated

asks_act caps items at 100. Say what to do with more rows.

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-dryRun: false step, or undo never happens.

1215 - `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.
1316 - `preview <url>`: call `preview` with `url`; say in two lines the app, environment, pull, check and who is working.
1417 - `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 @@
33 Fetch `forge.ts` into `.opencode/plugin/` (or list this package under `plugin`
44 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):
55 `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`,
77 `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
810 `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`);
911 `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`,
1012 applied only after you say yes. Only the owner may act on a proposal.
plugins/opencode/forge.ts +5 −2
@@ -1,6 +1,6 @@
11 /**
22 * 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
44 * to skip the OAuth sign-in.
55 * No logic lives here: the tools are the forge's own (`docs/MCP.md`).
66 */
@@ -12,6 +12,9 @@ const COMMAND = `Use the forge MCP tools for: $ARGUMENTS
1212 - tell <app> <words>: call \`tell\` with repo and text; pass context { page, run, commit, said }.
1313 - status: call \`now\` and \`asks\`; say in three sentences what runs and what waits on the person.
1414 - 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 dryRun false to apply. As written it only returns wouldUndo.

1518 - 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.
1619 - preview <url>: call \`preview\` with url; say in two lines the app, environment, pull, check and who is working.
1720 - 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 () => ({
3639 };
3740 config["command"] = {
3841 ...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 },
4043 };
4144 },
4245 });
test/plugins.test.ts +40 −3
@@ -21,7 +21,7 @@ const opencodeText = (): string => {
2121 const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!);
2222
2323 /** 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"]);
2525
2626 /** Tools the command text tells the agent to call: every backticked name that is not an argument. */
2727 const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n));
@@ -189,7 +189,7 @@ describe("the OpenCode plugin module", () => {
189189 });
190190
191191 /** 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();
193193
194194 /** The verbs in one command but not the other, either way. */
195195 const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } => {
@@ -200,7 +200,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } =>
200200
201201 describe("the two plugin commands offer the same verbs", () => {
202202 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"]);
204204 expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] });
205205 });
206206
@@ -234,3 +234,40 @@ describe("the repository is a Claude Code marketplace", () => {
234234 }
235235 });
236236 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs