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

Merged opened this

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

Merged

scout/forge:scout/6087-settings-tokens-says-how-to-connect-clau → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6087-settings-tokens-says-how-to-connect-clau was merged into main as f598fba.

Review

Sign in to review this change.
4 files changed +92 −2
plugins/claude-code/README.md +1 −1
@@ -1,7 +1,7 @@
11 # forge for Claude Code
22
33 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
55 (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP
66 server and a `/forge` command: `tell`, `status`, `decide`, `plan`.
77
plugins/opencode/README.md +2 −1
@@ -1,7 +1,8 @@
11 # forge for OpenCode
22
33 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

Put this command in a fenced code block; it breaks the sentence that continues after it.

56 forge's MCP server and the `forge` command: `tell`, `status`, `decide`, `plan`.
67 `/forge plan` shows the planner's next wave (the first wave-proposal row of `asks`);
78 `/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";
5050 import { field, readForm } from "../form.ts";
5151 import { html, when, type Html } from "../html.ts";
5252 import { flashTo, page } from "../layout.ts";
53 import { readFlash } from "../flash.ts";
5354 import {
5455 badge,
5556 btn,
5657 card,
5758 confirmAction,
59 copyField,
5860 empty,
5961 selectField,
6062 terminalCommand,
@@ -585,6 +587,31 @@ export function sessionsCard(
585587 );
586588 }
587589
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
588615 function tokensView(ctx: Ctx): Html {
589616 const tokens = timePhase(ctx.request, "tokens", () => listTokens(ctx.app.db, ctx.auth.user!.id));
590617 const sessions = timePhase(ctx.request, "sessions", () =>
@@ -595,6 +622,7 @@ function tokensView(ctx: Ctx): Html {
595622 <span class="mono">Bearer</span> token for the API. Your account password
596623 works for git too — tokens are for scoped or automated access.
597624 </p>
625 ${connectCard(ctx, readFlash(ctx.request)?.code)}
598626 ${tokens.length === 0
599627 ? empty({
600628 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("&lt;", "<").replaceAll("&gt;", ">") ?? "";
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs