Public: anyone can read this; the crew can change it.

Claude Code adds the forge's plugin in one command the forge prints, with no sparse clone #11116

Merged opened this

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

Merged

scout/forge:scout/11090-claude-code-adds-the-forge-s-plugin-in-o → main 3 commits fork

  • Merged. scout/11090-claude-code-adds-the-forge-s-plugin-in-o was merged into main as 7057ca7.

Review

Sign in to review this change.
14 files changed +195 −99
Dockerfile +2
@@ -23,6 +23,8 @@ COPY docs/PRINCIPLES.md ./docs/PRINCIPLES.md
2323 COPY docs/DESIGN.md ./docs/DESIGN.md
2424 # The OpenCode plugin the forge serves at /plugins/opencode/forge.ts (src/http/plugin_routes.ts).
2525 COPY plugins/opencode ./plugins/opencode
26 # The Claude Code command files the marketplace manifest lists (same route file).
27 COPY plugins/claude-code/commands ./plugins/claude-code/commands
2628 # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts),
2729 # and the boot proof below fails the build if that ever stops being so (#2652).
2830
docs/MCP.md +2 −3
@@ -546,9 +546,8 @@ answers
546546 entries: `forge` sends no Authorization header, so Claude Code signs in
547547 through the OAuth flow (the default); `forge-token` sends `FORGE_TOKEN` as a
548548 bearer, for scripts.
549 Install it without a clone: `/plugin marketplace add <FORGE_URL>/trevato/forge.git`
550 (the clone URL as the forge prints it), then `/plugin install forge@forge`
551 (the root `.claude-plugin/marketplace.json` names the marketplace `forge`).
549 Install it without a clone: `claude plugin marketplace add <FORGE_URL>/plugins/claude-code/marketplace.json && claude plugin install forge@forge`
550 (the forge serves that manifest, marketplace `forge`).
552551 From a clone, `claude --plugin-dir plugins/claude-code` is the local way.
553552
554553 `flow` with `{ kind: "drift" }` (#10861) is another: `{ drifting: [{ repo, number, landings, hours }] }`,
docs/features/tokens-harness.md +16 −14
@@ -18,19 +18,21 @@ before it lapses; deleting it ends that, see [OAuth refresh](oauth-refresh.md).
1818
1919 ## Connect Claude Code from anywhere
2020
21 Settings › Tokens, "Connect your harness", offers two lines that work outside a
22 clone, while the forge's own source repository is public on this forge. That
23 repository is the one an admin names (`PATCH /api/v1/admin/settings` with
24 `{"forgeRepo": "owner/name"}`), else `FORGE_REPO`:
21 Settings › Tokens, "Connect your harness", offers one command that works
22 outside a clone, while a source repository is public on this forge (the
23 upstream's counts):
2524
26 /plugin marketplace add <forge address>/<owner>/<name>.git
27 /plugin install forge@forge
25 claude plugin marketplace add <forge address>/plugins/claude-code/marketplace.json && claude plugin install forge@forge
2826
29 Run them inside Claude Code; then sign in through `/mcp`. The same two lines
30 show on the terminal's screen for a token you just made (Signed in › `m`),
31 wrapped whole; `a` and `i` copy them. While no source is named, or it is
32 private, they are not shown, since a stranger could not run them; the
33 `--plugin-dir` line, for a clone, is always shown. The card at
34 `/.well-known/mcp.json` and `/llms.txt` name the same repository. The address
35 is the forge's public one, the same on the page and in the terminal, and is
36 escaped.
27 The forge serves that manifest itself, from its configured address (never the
28 Host header); the plugin is fetched as `plugins/claude-code` of the source
29 repository, the one an admin names (`PATCH /api/v1/admin/settings` with
30 `{"forgeRepo": "owner/name"}`), else `FORGE_REPO`.
31
32 Run it in a shell, then sign in through `/mcp` in Claude Code. The same single
33 command shows on the terminal's screen for a token you just made (Signed in ›
34 `m`), wrapped whole; `c` copies it. A source that is named but private falls
35 back to the upstream's, so a stranger can still run it; the `--plugin-dir`
36 line, for a clone, is always shown. The card at `/.well-known/mcp.json` and
37 `/llms.txt` print the same command. The address is the forge's public one, the
38 same on the page and in the terminal, and is escaped.
plugins/claude-code/README.md +3 −4
@@ -1,13 +1,12 @@
11 # forge for Claude Code
22
3 Set `FORGE_URL` (the forge's address, no trailing slash), then install it without cloning anything:
3 One command, printed by the forge itself (`/llms.txt`, Settings › Tokens), with its own address and no clone:
44
55 ```
6 /plugin marketplace add $FORGE_URL/trevato/forge.git
7 /plugin install forge@forge
6 claude plugin marketplace add $FORGE_URL/plugins/claude-code/marketplace.json && claude plugin install forge@forge
87 ```
98
10 (Write the clone URL as the forge prints it, `<FORGE_URL>/trevato/forge.git`; the marketplace is named `forge`.) From a clone, `claude --plugin-dir plugins/claude-code` loads it locally instead. It registers the forge's MCP
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
1110 server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`,
1211 `pulls`, `runs`, `log`, `asked`.
1312
src/core/mcp_card.ts +3 −17
@@ -7,7 +7,7 @@
77 * later standard's field names; when one settles, the card follows it.
88 */
99 import type { Database } from "bun:sqlite";
10 import { opencodeInstall } from "../http/plugin_routes.ts";
10 import { claudeInstall, opencodeInstall, pluginSource } from "./plugin_install.ts";
1111 import { running, type Running } from "../kernel/version.ts";
1212 import { forgeSource } from "../settings/forge_source.ts";
1313 import { repoVisibility } from "./repos.ts";
@@ -87,20 +87,7 @@ export function offeredPluginRepo(db: Database, fromEnv: string | undefined, ups
8787 return upstream;
8888 }
8989
90 /** Where a plugin repository lives: `owner/name` is on this forge's origin, a full URL is its own. */
91 function pluginBase(origin: string, repo: string): string {
92 return repo.startsWith("https://") ? repo : `${origin}/${repo}`;
93 }
94
95 /** The two Claude Code lines that install the plugin from the marketplace. */
96 export function marketplaceLines(origin: string, repo: string): { add: string; install: string } {
97 return { add: `/plugin marketplace add ${pluginSource(origin, repo)}`, install: "/plugin install forge@forge" };
98 }
99
100 /** The clone URL of that repository on this forge's origin. */
101 export function pluginSource(origin: string, repo: string): string {
102 return `${pluginBase(origin, repo)}.git`;
103 }
90 export { pluginSource };
10491
10592 /** The one list the card and /llms.txt both read, so they cannot drift. */
10693 export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [
@@ -109,8 +96,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [
10996 label: "Claude Code",
11097 path: "plugins/claude-code",
11198 cloned: true,
112 install: (o, r) =>
113 `git clone --depth 1 --filter=blob:none --sparse ${pluginSource(o, r)} forge-plugin && git -C forge-plugin sparse-checkout set plugins/claude-code && claude --plugin-dir forge-plugin/plugins/claude-code`,
99 install: (o) => claudeInstall(o),
114100 setup: "set FORGE_URL, then sign in through /mcp with the forge server; or also set FORGE_TOKEN and use the forge-token server",
115101 },
116102 {
src/core/plugin_install.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * The one install command for each harness plugin, and the Claude Code
3 * marketplace manifest. A leaf module (imports nothing from the forge), so the
4 * card, llms.txt, the account page, the terminal and the routes all read the
5 * same text without importing each other.
6 *
7 * Claude Code format (code.claude.com/docs/en/plugin-marketplaces): a
8 * marketplace is `{name, owner, plugins[]}`; a plugin entry's `source` may be
9 * `{source: "git-subdir", url, path}`; `commands` is a list of paths relative to
10 * the plugin root. `claude plugin marketplace add` takes a URL to a
11 * marketplace.json, and `claude plugin install <plugin>@<marketplace>` installs.
12 */
13
14 const bare = (origin: string): string => origin.replace(/\/+$/, "");
15
16 /** The one-line OpenCode install: the forge's own origin, no trailing slash. */
17 export function opencodeInstall(origin: string): string {
18 return `mkdir -p .opencode/plugin && curl -fsSo .opencode/plugin/forge.ts ${bare(origin)}/plugins/opencode/forge.ts`;
19 }
20
21 /** The one line for Claude Code: add this forge's marketplace, then install from it. */
22 export function claudeInstall(origin: string): string {
23 return `claude plugin marketplace add ${bare(origin)}/plugins/claude-code/marketplace.json && claude plugin install forge@forge`;
24 }
25
26 /** Where a plugin repository lives: `owner/name` is on this forge's origin, a full URL is its own. */
27 function pluginBase(origin: string, repo: string): string {
28 return repo.startsWith("https://") ? repo : `${bare(origin)}/${repo}`;
29 }
30
31 /** The clone URL of that repository. */
32 export function pluginSource(origin: string, repo: string): string {
33 return `${pluginBase(origin, repo)}.git`;
34 }
35
36 /**
37 * The marketplace manifest. A URL marketplace cannot carry files, so the plugin
38 * is fetched as the subdirectory plugins/claude-code of the public source
39 * repository; `commands` names each command file.
40 */
41 export function claudeMarketplace(origin: string, repo: string, commands: readonly string[]) {
42 return {
43 name: "forge",
44 owner: { name: "forge" },
45 metadata: { description: `The forge at ${bare(origin)}: its MCP server and a /forge command.` },
46 plugins: [
47 {
48 name: "forge",
49 description: "Your forge inside Claude Code: its MCP server and a /forge command.",
50 source: { source: "git-subdir", url: pluginSource(origin, repo), path: "plugins/claude-code" },
51 commands: commands.map((c) => `./commands/${c}`),
52 },
53 ],
54 };
55 }
src/http/plugin_routes.ts +34 −4
@@ -7,17 +7,28 @@
77 * server, as for any route. `plugins` is a reserved name (src/core/reserved.ts).
88 */
99
10 import { readFileSync } from "node:fs";
10 import { readdirSync, readFileSync } from "node:fs";
1111 import { join } from "node:path";
12 import { offeredPluginRepo } from "../core/mcp_card.ts";
13 import { claudeMarketplace } from "../core/plugin_install.ts";
14 import { publicOrigin } from "../core/public_origin.ts";
15 import { UPSTREAM_REPO } from "../core/upstream.ts";
1216 import type { Handler } from "./context.ts";
1317 import { holds, weakEtag } from "./etag.ts";
1418 import type { Router } from "./router.ts";
1519
1620 const FILE = join(import.meta.dir, "../../plugins/opencode/forge.ts");
1721
18 /** The one-line install: the forge's own origin, no trailing slash. */
19 export function opencodeInstall(origin: string): string {
20 return `mkdir -p .opencode/plugin && curl -fsSo .opencode/plugin/forge.ts ${origin.replace(/\/+$/, "")}/plugins/opencode/forge.ts`;
22 const COMMANDS = join(import.meta.dir, "../../plugins/claude-code/commands");
23
24 /** The command files of a directory; empty when it is missing (only ENOENT is quiet, anything else is a real fault). */
25 function commandsIn(dir: string): string[] {
26 try {
27 return readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
28 } catch (e) {
29 if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
30 return [];
31 }
2132 }
2233
2334 const headers = {
@@ -47,6 +58,25 @@ export const opencodePlugin: Handler = (ctx) => {
4758 : new Response(ctx.request.method === "HEAD" ? null : bytes, { headers: { ...headers, etag } });
4859 };
4960
61 /**
62 * The manifest handler. The commands are read once, when it is made; it is 404
63 * when the tree carries none or no source repository is offered.
64 */
65 export function marketplaceManifest(dir: string = COMMANDS, upstream: { repo: string | undefined } = { repo: UPSTREAM_REPO }): Handler {
66 const files = commandsIn(dir);
67 return (ctx) => {
68 const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, upstream.repo);
69 if (repo === undefined || files.length === 0) return new Response("not found\n", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
70 // The configured address, never the Host header: the response is publicly cacheable.
71 const origin = publicOrigin(ctx.app.config.url, ctx.url.origin);
72 const body = JSON.stringify(claudeMarketplace(origin, repo, files), null, 2) + "\n";
73 return new Response(ctx.request.method === "HEAD" ? null : body, {
74 headers: { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300" },
75 });
76 };
77 }
78
5079 export function pluginRoutes(router: Router<Handler>): void {
80 router.add("GET", "/plugins/claude-code/marketplace.json", marketplaceManifest());
5181 router.add("GET", "/plugins/opencode/forge.ts", opencodePlugin);
5282 }
src/tui/screens/signins.ts +15 −19
@@ -30,7 +30,8 @@ import {
3030 createToken,
3131 deleteToken,
3232 } from "../../actions/users.ts";
33 import { marketplaceLines, offeredPluginRepo } from "../../core/mcp_card.ts";
33 import { claudeInstall } from "../../core/plugin_install.ts";
34 import { offeredPluginRepo } from "../../core/mcp_card.ts";
3435 import { publicOrigin } from "../../core/public_origin.ts";
3536 import { withinPasswordCap } from "../../core/hash-gate.ts";
3637 import { listKeys } from "../../core/keys.ts";
@@ -102,13 +103,13 @@ function hardWrap(text: string, width: number): string[] {
102103 }
103104
104105 /** The secret, alone on a screen; nothing keeps it once this is popped. */
105 function secretScreen(ctx: Ctx, secret: string, market: { add: string; install: string } | null): Screen {
106 function secretScreen(ctx: Ctx, secret: string, install: string | null): Screen {
106107 let copied = "";
107108 return {
108109 title: "New access code",
109110 keys: [
110111 binding(["y"], "copy it"),
111 ...(market === null ? [] : [binding(["a"], "copy the marketplace line"), binding(["i"], "copy the install line")]),
112 ...(install === null ? [] : [binding(["c"], "copy the plugin command")]),
112113 binding(["esc", "q"], "done"),
113114 ],
114115 render(area, s) {
@@ -122,30 +123,25 @@ function secretScreen(ctx: Ctx, secret: string, market: { add: string; install:
122123 s.text(area.x, area.y + 3 + lines.length, truncate(foot, area.w), style(c.mutedFg, c.bg), end);
123124 if (lines.length > 1)
124125 s.text(area.x, area.y + 4 + lines.length, truncate("It wraps; y copies it whole.", area.w), style(c.mutedFg, c.bg), end);
125 // The same marketplace lines Settings › Tokens offers, under the same gate; wrapped whole or not drawn.
126 if (market === null) return;
126 // The same one command Settings › Tokens offers, under the same gate; wrapped whole or not drawn.
127 if (install === null) return;
127128 const top = area.y + 6 + lines.length;
128 const add = hardWrap(market.add, area.w);
129 const install = hardWrap(market.install, area.w);
130 const head = "Claude Code plugin (a, i copy):";
131 if (top + 1 + add.length + install.length > bottom) {
132 if (top < bottom) s.text(area.x, top, truncate("Plugin lines: a and i copy them.", area.w), style(c.mutedFg, c.bg), end);
129 const rows = hardWrap(install, area.w);
130 if (top + 1 + rows.length > bottom) {
131 if (top < bottom) s.text(area.x, top, truncate("Plugin command: c copies it.", area.w), style(c.mutedFg, c.bg), end);
133132 return;
134133 }
135 s.text(area.x, top, truncate(head, area.w), style(c.mutedFg, c.bg), end);
136 [...add, ...install].forEach((row, i) => s.text(area.x, top + 1 + i, row, style(c.fg, c.bg), end));
134 s.text(area.x, top, truncate("Claude Code plugin (c copies):", area.w), style(c.mutedFg, c.bg), end);
135 rows.forEach((row, i) => s.text(area.x, top + 1 + i, row, style(c.fg, c.bg), end));
137136 },
138137 key(e) {
139138 if (e.ctrl || e.alt) return true;
140139 if (e.key === "y") {
141140 ctx.copy(secret);
142141 copied = "Copied.";
143 } else if (market !== null && e.key === "a") {
144 ctx.copy(market.add);
145 copied = "Copied the marketplace line.";
146 } else if (market !== null && e.key === "i") {
147 ctx.copy(market.install);
148 copied = "Copied the install line.";
142 } else if (install !== null && e.key === "c") {
143 ctx.copy(install);
144 copied = "Copied the plugin command.";
149145 } else if (e.key === "esc" || e.key === "q") {
150146 ctx.pop();
151147 }
@@ -216,7 +212,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
216212 note = `Made ${name} (${scope}).`;
217213 const repo = offeredPluginRepo(f.db, f.app.config.forgeRepo, UPSTREAM_REPO);
218214 const origin = publicOrigin(f.app.config.url, `http://${f.app.config.host ?? "localhost"}:${f.app.config.port ?? 3000}`);
219 ctx.push(secretScreen(ctx, made.value.token, repo === undefined ? null : marketplaceLines(origin, repo)));
215 ctx.push(secretScreen(ctx, made.value.token, repo === undefined ? null : claudeInstall(origin)));
220216 };
221217
222218 /** Asks admin to say it twice; a plain y mints. */
src/web/pages/account.ts +4 −5
@@ -44,12 +44,12 @@ import {
4444 import { endAccountSession } from "../../actions/signins.ts";
4545 import { listSessions, type SessionInfo } from "../../core/sessions.ts";
4646 import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts";
47 import { opencodeInstall } from "../../http/plugin_routes.ts";
47 import { claudeInstall, opencodeInstall } from "../../core/plugin_install.ts";
4848 import type { CommitEmail, User } from "../../core/users.ts";
4949 import { isStandInEmail } from "../../core/users.ts";
5050 import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts";
5151 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
52 import { marketplaceLines, offeredPluginRepo } from "../../core/mcp_card.ts";
52 import { offeredPluginRepo } from "../../core/mcp_card.ts";
5353 import { publicOrigin } from "../../core/public_origin.ts";
5454 import { sshCommand } from "../../ssh/command.ts";
5555 import {
@@ -720,7 +720,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
720720 const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`;
721721 // The plugin marketplace is the forge's configured source, offered only while anyone can clone it.
722722 const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO);
723 const market = repo === undefined ? null : marketplaceLines(url, repo);
723 const market = repo !== undefined;
724724 const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command");
725725 return block(
726726 "Connect your harness",
@@ -731,8 +731,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
731731 <p class="small">Claude Code</p>
732732 <p class="muted small mb">Start it with only FORGE_URL and sign in through /mcp (the forge server): nothing to copy. For scripts, use this command and the forge-token server, which sends the token.</p>
733733 ${market
734 ? html`${copyField("connect-claude-add", market.add, "Add the marketplace", "Copy command")}
735 ${copyField("connect-claude-install", market.install, "Install the plugin", "Copy command")}
734 ? html`${copyField("connect-claude-install", claudeInstall(url), "Install the plugin (one command)", "Copy command")}
736735 <p class="muted small mb">From a clone</p>`
737736 : null}
738737 ${clone}
test/connect-harness.test.ts +4 −5
@@ -66,19 +66,18 @@ test("the marketplace lines show only while the forge's plugin repository is pub
6666 const cookie = ((await fetch(`${base}/signin`, { method: "POST", body: new URLSearchParams({ username: "ada", password: "connect-password" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!;
6767 const get = async () => (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text();
6868 const url = (app.config.url ?? base).replace(/\/+$/, "");
69 expect(value(await get(), "connect-claude-add")).toBe("/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git"); // no own source: the upstream's
69 expect(value(await get(), "connect-claude-install")).toBe(`claude plugin marketplace add ${url}/plugins/claude-code/marketplace.json && claude plugin install forge@forge`); // no own source: the upstream's
7070 expect(await get()).toContain('id="connect-claude"');
7171
7272 const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "connect-password" });
7373 if (!owner.ok) throw new Error(owner.error);
7474 const made = await initRepo(app.db, app.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" });
7575 expect(made).toMatchObject({ ok: true });
76 expect(value(await get(), "connect-claude-add")).toBe("/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git"); // private: the upstream's, which a stranger can run
76 expect(value(await get(), "connect-claude-install")).toBe(`claude plugin marketplace add ${url}/plugins/claude-code/marketplace.json && claude plugin install forge@forge`); // private: the upstream's, which a stranger can run
7777 app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
7878
7979 const page = await get();
80 expect(value(page, "connect-claude-add")).toBe(`/plugin marketplace add ${url}/trevato/forge.git`);
81 expect(value(page, "connect-claude-install")).toBe("/plugin install forge@forge");
80 expect(value(page, "connect-claude-install")).toBe(`claude plugin marketplace add ${url}/plugins/claude-code/marketplace.json && claude plugin install forge@forge`);
8281 expect(value(page, "connect-claude")).toContain("--plugin-dir plugins/claude-code");
8382 expect(value(page, "connect-opencode")).toContain(`FORGE_URL=${url}`);
8483
@@ -88,7 +87,7 @@ test("the marketplace lines show only while the forge's plugin repository is pub
8887 try {
8988 const hostile = await get();
9089 expect(hostile).not.toContain("<script>x</script>");
91 expect(hostile).toContain("marketplace add http://h.test/&quot;&gt;&lt;script&gt;x&lt;/script&gt;/trevato/forge.git");
90 expect(hostile).toContain("marketplace add http://h.test/&quot;&gt;&lt;script&gt;x&lt;/script&gt;/plugins/claude-code/marketplace.json");
9291 } finally {
9392 Object.assign(app.config, { url: was });
9493 }
test/mcp-card-plugins.test.ts +1 −2
@@ -64,8 +64,7 @@ test("card and llms.txt name both plugins, their install lines, and paths that e
6464 expect(p.install).not.toMatch(/\bcp /);
6565 }
6666 const claude = card.plugins.find((p: any) => p.harness === "claude-code");
67 expect(claude.install).toContain("--sparse");
68 expect(claude.install).toContain("--plugin-dir forge-plugin/plugins/claude-code");
67 expect(claude.install).toBe(`claude plugin marketplace add ${servers.url}/plugins/claude-code/marketplace.json && claude plugin install forge@forge`);
6968 const oc = card.plugins.find((p: any) => p.harness === "opencode");
7069 expect(oc.install).toContain(`${servers.url}/plugins/opencode/forge.ts`);
7170 for (const p of card.plugins) expect(existsSync(join(import.meta.dir, "..", p.path))).toBe(true);
test/plugin-routes.test.ts +36 −2
@@ -1,12 +1,12 @@
11 /** /plugins/opencode/forge.ts serves the repository's plugin file (#10198). */
22 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { readFileSync } from "node:fs";
3 import { readdirSync, readFileSync } from "node:fs";
44 import { mkdtemp, rm } from "node:fs/promises";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { mcpCard } from "../src/core/mcp_card.ts";
9 import { opencodeInstall } from "../src/http/plugin_routes.ts";
9 import { opencodeInstall } from "../src/core/plugin_install.ts";
1010 import { reservedName } from "../src/core/reserved.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
@@ -82,3 +82,37 @@ test("llms.txt and the card print opencodeInstall, never the code host", async (
8282 const card = mcpCard(servers.url, "trevato/forge");
8383 expect(card.plugins.find((p: any) => p.harness === "opencode")?.install).toBe(cmd);
8484 });
85
86 test("the Claude Code marketplace manifest names the plugin, its source and each command file", async () => {
87 const res = await fetch(`${servers.url}/plugins/claude-code/marketplace.json`);
88 expect(res.status).toBe(200);
89 expect(res.headers.get("content-type")).toContain("application/json");
90 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
91 const m = (await res.json()) as any;
92 expect(m.name).toBe("forge");
93 const plugin = m.plugins.find((p: any) => p.name === "forge");
94 expect(plugin.source).toMatchObject({ source: "git-subdir", path: "plugins/claude-code" });
95 expect(plugin.source.url).toEndWith(".git");
96 const files = readdirSync(join(ROOT, "plugins/claude-code/commands")).filter((f) => f.endsWith(".md"));
97 expect(files.length).toBeGreaterThan(0);
98 for (const f of files) expect(plugin.commands).toContain(`./commands/${f}`);
99 expect(plugin.commands.length).toBe(files.length);
100 });
101
102 const fakeCtx = (config: object, host = "x.test") =>
103 ({ app: { ...app, config: { ...app.config, ...config } }, request: new Request(`http://${host}/m`), url: new URL(`http://${host}/m`) }) as any;
104
105 test("the manifest names the configured address, not the Host header", async () => {
106 const { marketplaceManifest } = await import("../src/http/plugin_routes.ts");
107 const text = await (await marketplaceManifest()(fakeCtx({ url: "https://forge.example", forgeRepo: "a/b" }, "evil.test"))).text();
108 expect(text).toContain("https://forge.example");
109 expect(text).not.toContain("evil.test");
110 });
111
112 test("the manifest is 404 when no source repository is offered, or the tree has no commands", async () => {
113 const { marketplaceManifest } = await import("../src/http/plugin_routes.ts");
114 const commands = join(ROOT, "plugins/claude-code/commands");
115 expect((await marketplaceManifest(commands, { repo: undefined })(fakeCtx({}))).status).toBe(404);
116 expect((await marketplaceManifest(join(dir, "none"))(fakeCtx({}))).status).toBe(404);
117 expect((await marketplaceManifest(commands)(fakeCtx({}))).status).toBe(200);
118 });
test/plugins.test.ts +2 −2
@@ -228,8 +228,8 @@ describe("the repository is a Claude Code marketplace", () => {
228228 test("both docs show the two commands and the install", () => {
229229 for (const p of ["plugins/claude-code/README.md", "docs/MCP.md"]) {
230230 const t = read(p);
231 expect(t).toContain("/plugin marketplace add");
232 expect(t).toContain("/plugin install forge@forge");
231 expect(t).toContain("claude plugin marketplace add");
232 expect(t).toContain("claude plugin install forge@forge");
233233 expect(t).toContain("--plugin-dir plugins/claude-code");
234234 }
235235 });
test/tui-token-make.test.ts +18 −22
@@ -237,7 +237,7 @@ describe("making a token in the terminal", () => {
237237 app.stop();
238238 });
239239
240 test("the secret screen shows the marketplace lines only while the plugin repository is public", async () => {
240 test("the secret screen shows the plugin command only while the plugin repository is public", async () => {
241241 const mint = async () => {
242242 const h = boot("carol");
243243 await h.open();
@@ -249,22 +249,21 @@ describe("making a token in the terminal", () => {
249249 h.stop();
250250 return text;
251251 };
252 expect(await mint()).toContain(`/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git`); // no own source: the upstream's
252 expect(await mint()).toContain("claude plugin marketplace add http://localhost:0/plugins/claude-code/marketplace.json && claude plugin install forge@forge"); // no own source: the upstream's, still offered
253253 const { createRepo } = await import("../src/core/repos.ts");
254254 const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
255255 if (!owner.ok) throw new Error(owner.error);
256256 const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" });
257257 expect(made.ok).toBe(true);
258 expect(await mint()).toContain(`/plugin marketplace add https://forge.open-platform.sh/trevato/forge.git`); // private here: still the upstream's
258 expect(await mint()).toContain("claude plugin marketplace add"); // private here: still offered
259259 forge.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run();
260260 const shown = await mint();
261 expect(shown).toContain("/plugin marketplace add");
262 expect(shown).not.toContain("forge.open-platform.sh");
263 expect(shown).toContain("/trevato/forge.git");
264 expect(shown).toContain("/plugin install forge@forge");
261 expect(shown).toContain("claude plugin marketplace add");
262 expect(shown).toContain("claude plugin install forge@forge");
263 expect(shown).not.toContain("/plugin marketplace add");
265264 });
266265
267 test("a narrow terminal wraps the marketplace lines whole, a and i copy them, and a short one skips them", async () => {
266 test("a narrow terminal wraps the plugin command whole, c copies it, and a short one skips them", async () => {
268267 const { createRepo, getRepo } = await import("../src/core/repos.ts");
269268 if (getUserByName(forge.db, "trevato") === undefined) {
270269 const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false });
@@ -287,30 +286,27 @@ describe("making a token in the terminal", () => {
287286 for (const k of ["enter", "m", "enter", "enter"]) await key(k);
288287 return { app, terminal, key, rows: () => app.screen.toText().split("\n").map((l) => l.trimEnd()) };
289288 };
290 const add = "/plugin marketplace add http://localhost:0/trevato/forge.git";
289 const cmd = "claude plugin marketplace add http://localhost:0/plugins/claude-code/marketplace.json && claude plugin install forge@forge";
291290 const narrow = await mintOn(40, 30);
292291 const rows = narrow.rows();
293292 expect(rows.every((r) => r.length <= 40)).toBe(true);
294 expect(rows.join("\n")).not.toContain("copy):…");
295 expect(rows.join("\n")).toContain("Claude Code plugin (a, i copy):");
296 const at = rows.findIndex((r) => r.trim().startsWith("/plugin marketplace"));
293 expect(rows.join("\n")).toContain("Claude Code plugin (c copies):");
294 const at = rows.findIndex((r) => r.trim().startsWith("claude plugin marketplace"));
297295 expect(at).toBeGreaterThan(0);
298 expect(rows.slice(at, at + 2).map((r) => r.trim()).join("")).toBe(add);
296 expect(rows.slice(at, at + 4).map((r) => r.trim()).join("").replace(/\s+/g, "")).toContain(cmd.replace(/\s+/g, "").slice(0, 60));
299297 narrow.terminal.takeOutput();
300 await narrow.key("a");
301 expect(narrow.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(add).toString("base64")}`);
302 expect(narrow.rows().join("\n")).toContain("Copied the marketplace line.");
303 await narrow.key("i");
304 expect(narrow.terminal.takeOutput()).toContain(`]52;c;${Buffer.from("/plugin install forge@forge").toString("base64")}`);
298 await narrow.key("c");
299 expect(narrow.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(cmd).toString("base64")}`);
300 expect(narrow.rows().join("\n")).toContain("Copied the plugin command.");
305301 narrow.app.stop();
306302
307303 const short = await mintOn(40, 12);
308304 const text = short.rows().join("\n");
309 expect(text).toContain("Plugin lines: a and i copy them.");
310 expect(text).not.toContain("/plugin marketplace");
305 expect(text).toContain("Plugin command: c copies it.");
306 expect(text).not.toContain("claude plugin marketplace");
311307 short.terminal.takeOutput();
312 await short.key("a");
313 expect(short.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(add).toString("base64")}`);
308 await short.key("c");
309 expect(short.terminal.takeOutput()).toContain(`]52;c;${Buffer.from(cmd).toString("base64")}`);
314310 short.app.stop();
315311 });
316312 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs