Public: anyone can read this; the crew can change it.
/settings/tokens says how to connect Claude Code and OpenCode to this forge, with its own address #6108
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6087-settings-tokens-says-how-to-connect-clau → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +92
−2
plugins/claude-code/README.md +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # forge for Claude Code |
| 2 | 2 | |
| 3 | 3 | Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN` |
| 4 | (an `rw` token; `ro` reads only), then add this directory as a plugin | |
| 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`. |
| 7 | 7 |
plugins/opencode/README.md +2 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | # forge for OpenCode |
| 2 | 2 | |
| 3 | 3 | Copy `forge.ts` into `.opencode/plugin/` (or list this package under `plugin` |
| 4 | in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN`. It registers the | |
| 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 | `mkdir -p .opencode/plugin && cp plugins/opencode/forge.ts .opencode/plugin/ && FORGE_URL=<url> FORGE_TOKEN=<token> opencode`. It registers the | |
| 5 | 6 | forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`. |
| 6 | 7 | `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`); |
| 7 | 8 | `/forge plan approve`, `redirect <words>` or `later` steers it through `asks_proposal`, |
src/web/pages/account.ts +28
| @@ -50,11 +50,13 @@ import { accountSections } from "./origins.ts"; | ||
| 50 | 50 | import { field, readForm } from "../form.ts"; |
| 51 | 51 | import { html, when, type Html } from "../html.ts"; |
| 52 | 52 | import { flashTo, page } from "../layout.ts"; |
| 53 | import { readFlash } from "../flash.ts"; | |
| 53 | 54 | import { |
| 54 | 55 | badge, |
| 55 | 56 | btn, |
| 56 | 57 | card, |
| 57 | 58 | confirmAction, |
| 59 | copyField, | |
| 58 | 60 | empty, |
| 59 | 61 | selectField, |
| 60 | 62 | terminalCommand, |
| @@ -585,6 +587,31 @@ export function sessionsCard( | ||
| 585 | 587 | ); |
| 586 | 588 | } |
| 587 | 589 | |
| 590 | /** | |
| 591 | * The two lines that connect a harness to this forge: its own public address, | |
| 592 | * and the token just minted when there is one (else a placeholder). | |
| 593 | */ | |
| 594 | export function connectCard(ctx: Ctx, token: string | undefined): Html { | |
| 595 | const url = (ctx.app.config.url ?? ctx.url.origin).replace(/\/+$/, ""); | |
| 596 | const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`; | |
| 597 | return card( | |
| 598 | html`<p class="muted small mb"> | |
| 599 | ${token ? "Your new token is filled in." : "Make a token below, then replace <token>."} | |
A bare <token> is a shell redirect if pasted unedited. A placeholder like YOUR_TOKEN fails clearly instead. |
||
| 600 | See plugins/claude-code and plugins/opencode. | |
| 601 | </p> | |
| 602 | <p class="small">Claude Code</p> | |
| 603 | ${copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command")} | |
| 604 | <p class="small">OpenCode</p> | |
| 605 | ${copyField( | |
| 606 | "connect-opencode", | |
| 607 | `mkdir -p .opencode/plugin && cp plugins/opencode/forge.ts .opencode/plugin/ && ${env} opencode`, | |
| 608 | "OpenCode command", | |
| 609 | "Copy command", | |
| 610 | )}`, | |
| 611 | { head: html`<h3>Connect your harness</h3>` }, | |
| 612 | ); | |
| 613 | } | |
| 614 | ||
| 588 | 615 | function tokensView(ctx: Ctx): Html { |
| 589 | 616 | const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id)); |
| 590 | 617 | const sessions = timePhase(ctx.request, "sessions", () => |
| @@ -595,6 +622,7 @@ function tokensView(ctx: Ctx): Html { | ||
| 595 | 622 | <span class="mono">Bearer</span> token for the API. Your account password |
| 596 | 623 | works for git too — tokens are for scoped or automated access. |
| 597 | 624 | </p> |
| 625 | ${connectCard(ctx, readFlash(ctx.request)?.code)} | |
| 598 | 626 | ${tokens.length === 0 |
| 599 | 627 | ? empty({ |
| 600 | 628 | icon: "key", |
test/connect-harness.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** /settings/tokens: the "Connect your harness" lines carry this forge's address and only a just-minted token. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { quietLogs } from "./helpers/globals.ts"; | |
| 9 | ||
| 10 | quietLogs(); | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let base: string; | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dir = await mkdtemp(join(tmpdir(), "connect-")); | |
| 19 | app = createApp({ dataDir: dir, port: 0 }); | |
| 20 | servers = await serve(app); | |
| 21 | base = servers.url; | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | servers.stop(); | |
| 25 | app.close(); | |
| 26 | await rm(dir, { recursive: true, force: true }); | |
| 27 | }); | |
| 28 | ||
| 29 | const value = (html: string, id: string) => | |
| 30 | new RegExp(`id="${id}"[^>]*value="([^"]*)"`).exec(html)?.[1]?.replaceAll("<", "<").replaceAll(">", ">") ?? ""; | |
| 31 | ||
| 32 | test("lines use the forge's address, a placeholder, then the minted token once", async () => { | |
| 33 | const signup = await fetch(`${base}/signup`, { | |
| 34 | method: "POST", | |
| 35 | body: new URLSearchParams({ username: "ada", email: "[email protected]", password: "connect-password" }), | |
| 36 | redirect: "manual", | |
| 37 | }); | |
| 38 | const cookie = (signup.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 39 | const url = (app.config.url ?? base).replace(/\/+$/, ""); | |
| 40 | ||
| 41 | const before = await (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text(); | |
| 42 | expect(before).toContain("Connect your harness"); | |
| 43 | expect(value(before, "connect-claude")).toBe(`FORGE_URL=${url} FORGE_TOKEN=<token> claude --plugin-dir plugins/claude-code`); | |
| 44 | expect(value(before, "connect-opencode")).toContain(`FORGE_URL=${url} FORGE_TOKEN=<token> opencode`); | |
| 45 | ||
| 46 | const made = await fetch(`${base}/settings/tokens`, { | |
| 47 | method: "POST", | |
| 48 | headers: { cookie, origin: base }, | |
| 49 | body: new URLSearchParams({ name: "laptop", scope: "rw" }), | |
| 50 | redirect: "manual", | |
| 51 | }); | |
| 52 | const flash = made.headers.getSetCookie().find((c) => c.startsWith("forge_flash="))!.split(";")[0]!; | |
| 53 | const after = await (await fetch(`${base}/settings/tokens`, { headers: { cookie: `${cookie}; ${flash}` } })).text(); | |
| 54 | const token = JSON.parse(decodeURIComponent(flash.slice("forge_flash=".length))).code as string; | |
| 55 | expect(token.length).toBeGreaterThan(10); | |
| 56 | expect(value(after, "connect-claude")).toBe(`FORGE_URL=${url} FORGE_TOKEN=${token} claude --plugin-dir plugins/claude-code`); | |
| 57 | expect(value(after, "connect-opencode")).toContain(`FORGE_TOKEN=${token} opencode`); | |
| 58 | ||
| 59 | const later = await (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text(); | |
| 60 | expect(later).not.toContain(token); | |
| 61 | }); |
new above
offline
Put this command in a fenced code block; it breaks the sentence that continues after it.