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

Merged opened this

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

Merged

mason/forge:mason/7499-the-claude-code-and-opencode-plugins-new → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/7499-the-claude-code-and-opencode-plugins-new was merged into main as c25f1e7.

Review

Sign in to review this change.
5 files changed +40 −8
plugins/claude-code/README.md +3 −1
@@ -3,11 +3,13 @@
33 Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN`
44 (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
55 (`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`,
77 `pulls`, `runs`, `log`, `asked`.
88
99 `/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.
1010
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
1113 `/forge preview <url>` says in two lines the app, environment, pull, check
1214 and who is working at that URL. `/forge pulls <app>` lists the app's open
1315 pulls with their check.
plugins/claude-code/commands/forge.md +3 −2
@@ -1,10 +1,11 @@
11 ---
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]
44 ---
55
66 Use the `forge` MCP server's tools for `$ARGUMENTS`.
77
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.
outdated

Add the refusal path: if new_app refuses (name_taken, unverified, app_limit...), say its message in one line and offer another name when the name is taken. Do not retry unasked.

89 - `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).
910 - `status`: call `now` and `asks`; say in three sentences what is running and what waits on the person.
1011 - `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 @@
33 Copy `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 && 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`,
77 `preview`, `pulls`, `runs`, `log`, `asked`.
88 `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`);
99 `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`,
1010 applied only after you say yes. Only the owner may act on a proposal.
1111
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
1214 `/forge preview <url>` says in two lines the app, environment, pull, check
1315 and who is working at that URL. `/forge pulls <app>` lists the app's open
1416 pulls with their check.
plugins/opencode/forge.ts +3 −2
@@ -1,11 +1,12 @@
11 /**
22 * 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.
44 * No logic lives here: the tools are the forge's own (`docs/MCP.md`).
55 */
66 import type { Plugin } from "@opencode-ai/plugin";
77
88 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 new_app refuses, in person-words, and keep the two plugin texts in step.

910 - tell <app> <words>: call \`tell\` with repo and text; pass context { page, run, commit, said }.
1011 - status: call \`now\` and \`asks\`; say in three sentences what runs and what waits on the person.
1112 - 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 () => ({
3233 };
3334 config["command"] = {
3435 ...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 },
3637 };
3738 },
3839 });
test/plugins.test.ts +28 −2
@@ -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", "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"]);
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));
@@ -127,6 +127,32 @@ test("the Claude Code command names plan's tools, and the forge lists them", ()
127127 expect(claudeText()).toContain("`proposal.id`");
128128 });
129129
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
130156 describe("the OpenCode plan command", () => {
131157 test("steers the planner's wave through asks_proposal, after a yes", () => {
132158 const text = opencodeText();
@@ -174,7 +200,7 @@ const verbDiff = (a: string, b: string): { onlyA: string[]; onlyB: string[] } =>
174200
175201 describe("the two plugin commands offer the same verbs", () => {
176202 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"]);
178204 expect(verbDiff(claudeText(), opencodeText())).toEqual({ onlyA: [], onlyB: [] });
179205 });
180206

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs