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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11090-claude-code-adds-the-forge-s-plugin-in-o → main 3 commits fork
14 files
changed +195
−99
Dockerfile +2
| @@ -23,6 +23,8 @@ COPY docs/PRINCIPLES.md ./docs/PRINCIPLES.md | ||
| 23 | 23 | COPY docs/DESIGN.md ./docs/DESIGN.md |
| 24 | 24 | # The OpenCode plugin the forge serves at /plugins/opencode/forge.ts (src/http/plugin_routes.ts). |
| 25 | 25 | 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 | |
| 26 | 28 | # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts), |
| 27 | 29 | # and the boot proof below fails the build if that ever stops being so (#2652). |
| 28 | 30 |
docs/MCP.md +2 −3
| @@ -546,9 +546,8 @@ answers | ||
| 546 | 546 | entries: `forge` sends no Authorization header, so Claude Code signs in |
| 547 | 547 | through the OAuth flow (the default); `forge-token` sends `FORGE_TOKEN` as a |
| 548 | 548 | 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`). | |
| 552 | 551 | From a clone, `claude --plugin-dir plugins/claude-code` is the local way. |
| 553 | 552 | |
| 554 | 553 | `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). | ||
| 18 | 18 | |
| 19 | 19 | ## Connect Claude Code from anywhere |
| 20 | 20 | |
| 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): | |
| 25 | 24 | |
| 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 | |
| 28 | 26 | |
| 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 @@ | ||
| 1 | 1 | # forge for Claude Code |
| 2 | 2 | |
| 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: | |
| 4 | 4 | |
| 5 | 5 | |
| 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 | |
| 8 | 7 | |
| 9 | 8 | |
| 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 | |
| 11 | 10 | server and a `/forge` command: `new`, `tell`, `status`, `decide`, `plan`, `preview`, |
| 12 | 11 | `pulls`, `runs`, `log`, `asked`. |
| 13 | 12 |
src/core/mcp_card.ts +3 −17
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * later standard's field names; when one settles, the card follows it. |
| 8 | 8 | */ |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { opencodeInstall } from "../http/plugin_routes.ts"; | |
| 10 | import { claudeInstall, opencodeInstall, pluginSource } from "./plugin_install.ts"; | |
| 11 | 11 | import { running, type Running } from "../kernel/version.ts"; |
| 12 | 12 | import { forgeSource } from "../settings/forge_source.ts"; |
| 13 | 13 | import { repoVisibility } from "./repos.ts"; |
| @@ -87,20 +87,7 @@ export function offeredPluginRepo(db: Database, fromEnv: string | undefined, ups | ||
| 87 | 87 | return upstream; |
| 88 | 88 | } |
| 89 | 89 | |
| 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 }; | |
| 104 | 91 | |
| 105 | 92 | /** The one list the card and /llms.txt both read, so they cannot drift. */ |
| 106 | 93 | export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ |
| @@ -109,8 +96,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 109 | 96 | label: "Claude Code", |
| 110 | 97 | path: "plugins/claude-code", |
| 111 | 98 | 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), | |
| 114 | 100 | setup: "set FORGE_URL, then sign in through /mcp with the forge server; or also set FORGE_TOKEN and use the forge-token server", |
| 115 | 101 | }, |
| 116 | 102 | { |
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 @@ | ||
| 7 | 7 | * server, as for any route. `plugins` is a reserved name (src/core/reserved.ts). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { readFileSync } from "node:fs"; | |
| 10 | import { readdirSync, readFileSync } from "node:fs"; | |
| 11 | 11 | 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"; | |
| 12 | 16 | import type { Handler } from "./context.ts"; |
| 13 | 17 | import { holds, weakEtag } from "./etag.ts"; |
| 14 | 18 | import type { Router } from "./router.ts"; |
| 15 | 19 | |
| 16 | 20 | const FILE = join(import.meta.dir, "../../plugins/opencode/forge.ts"); |
| 17 | 21 | |
| 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 | } | |
| 21 | 32 | } |
| 22 | 33 | |
| 23 | 34 | const headers = { |
| @@ -47,6 +58,25 @@ export const opencodePlugin: Handler = (ctx) => { | ||
| 47 | 58 | : new Response(ctx.request.method === "HEAD" ? null : bytes, { headers: { ...headers, etag } }); |
| 48 | 59 | }; |
| 49 | 60 | |
| 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 | ||
| 50 | 79 | export function pluginRoutes(router: Router<Handler>): void { |
| 80 | router.add("GET", "/plugins/claude-code/marketplace.json", marketplaceManifest()); | |
| 51 | 81 | router.add("GET", "/plugins/opencode/forge.ts", opencodePlugin); |
| 52 | 82 | } |
src/tui/screens/signins.ts +15 −19
| @@ -30,7 +30,8 @@ import { | ||
| 30 | 30 | createToken, |
| 31 | 31 | deleteToken, |
| 32 | 32 | } 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"; | |
| 34 | 35 | import { publicOrigin } from "../../core/public_origin.ts"; |
| 35 | 36 | import { withinPasswordCap } from "../../core/hash-gate.ts"; |
| 36 | 37 | import { listKeys } from "../../core/keys.ts"; |
| @@ -102,13 +103,13 @@ function hardWrap(text: string, width: number): string[] { | ||
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | /** 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 { | |
| 106 | 107 | let copied = ""; |
| 107 | 108 | return { |
| 108 | 109 | title: "New access code", |
| 109 | 110 | keys: [ |
| 110 | 111 | 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")]), | |
| 112 | 113 | binding(["esc", "q"], "done"), |
| 113 | 114 | ], |
| 114 | 115 | render(area, s) { |
| @@ -122,30 +123,25 @@ function secretScreen(ctx: Ctx, secret: string, market: { add: string; install: | ||
| 122 | 123 | s.text(area.x, area.y + 3 + lines.length, truncate(foot, area.w), style(c.mutedFg, c.bg), end); |
| 123 | 124 | if (lines.length > 1) |
| 124 | 125 | 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; | |
| 127 | 128 | 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); | |
| 133 | 132 | return; |
| 134 | 133 | } |
| 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)); | |
| 137 | 136 | }, |
| 138 | 137 | key(e) { |
| 139 | 138 | if (e.ctrl || e.alt) return true; |
| 140 | 139 | if (e.key === "y") { |
| 141 | 140 | ctx.copy(secret); |
| 142 | 141 | 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."; | |
| 149 | 145 | } else if (e.key === "esc" || e.key === "q") { |
| 150 | 146 | ctx.pop(); |
| 151 | 147 | } |
| @@ -216,7 +212,7 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen { | ||
| 216 | 212 | note = `Made ${name} (${scope}).`; |
| 217 | 213 | const repo = offeredPluginRepo(f.db, f.app.config.forgeRepo, UPSTREAM_REPO); |
| 218 | 214 | 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))); | |
| 220 | 216 | }; |
| 221 | 217 | |
| 222 | 218 | /** Asks admin to say it twice; a plain y mints. */ |
src/web/pages/account.ts +4 −5
| @@ -44,12 +44,12 @@ import { | ||
| 44 | 44 | import { endAccountSession } from "../../actions/signins.ts"; |
| 45 | 45 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 46 | 46 | 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"; | |
| 48 | 48 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 49 | 49 | import { isStandInEmail } from "../../core/users.ts"; |
| 50 | 50 | import { cookieValue, isAccountItself, type Ctx } from "../../http/context.ts"; |
| 51 | 51 | 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"; | |
| 53 | 53 | import { publicOrigin } from "../../core/public_origin.ts"; |
| 54 | 54 | import { sshCommand } from "../../ssh/command.ts"; |
| 55 | 55 | import { |
| @@ -720,7 +720,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 720 | 720 | const env = `FORGE_URL=${url} FORGE_TOKEN=${token ?? "<token>"}`; |
| 721 | 721 | // The plugin marketplace is the forge's configured source, offered only while anyone can clone it. |
| 722 | 722 | 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; | |
| 724 | 724 | const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command"); |
| 725 | 725 | return block( |
| 726 | 726 | "Connect your harness", |
| @@ -731,8 +731,7 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 731 | 731 | <p class="small">Claude Code</p> |
| 732 | 732 | <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> |
| 733 | 733 | ${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")} | |
| 736 | 735 | <p class="muted small mb">From a clone</p>` |
| 737 | 736 | : null} |
| 738 | 737 | ${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 | ||
| 66 | 66 | const cookie = ((await fetch(`${base}/signin`, { method: "POST", body: new URLSearchParams({ username: "ada", password: "connect-password" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!; |
| 67 | 67 | const get = async () => (await fetch(`${base}/settings/tokens`, { headers: { cookie } })).text(); |
| 68 | 68 | 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 | |
| 70 | 70 | expect(await get()).toContain('id="connect-claude"'); |
| 71 | 71 | |
| 72 | 72 | const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "connect-password" }); |
| 73 | 73 | if (!owner.ok) throw new Error(owner.error); |
| 74 | 74 | const made = await initRepo(app.db, app.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" }); |
| 75 | 75 | 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 | |
| 77 | 77 | app.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); |
| 78 | 78 | |
| 79 | 79 | 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`); | |
| 82 | 81 | expect(value(page, "connect-claude")).toContain("--plugin-dir plugins/claude-code"); |
| 83 | 82 | expect(value(page, "connect-opencode")).toContain(`FORGE_URL=${url}`); |
| 84 | 83 | |
| @@ -88,7 +87,7 @@ test("the marketplace lines show only while the forge's plugin repository is pub | ||
| 88 | 87 | try { |
| 89 | 88 | const hostile = await get(); |
| 90 | 89 | expect(hostile).not.toContain("<script>x</script>"); |
| 91 | expect(hostile).toContain("marketplace add http://h.test/"><script>x</script>/trevato/forge.git"); | |
| 90 | expect(hostile).toContain("marketplace add http://h.test/"><script>x</script>/plugins/claude-code/marketplace.json"); | |
| 92 | 91 | } finally { |
| 93 | 92 | Object.assign(app.config, { url: was }); |
| 94 | 93 | } |
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 | ||
| 64 | 64 | expect(p.install).not.toMatch(/\bcp /); |
| 65 | 65 | } |
| 66 | 66 | 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`); | |
| 69 | 68 | const oc = card.plugins.find((p: any) => p.harness === "opencode"); |
| 70 | 69 | expect(oc.install).toContain(`${servers.url}/plugins/opencode/forge.ts`); |
| 71 | 70 | 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 @@ | ||
| 1 | 1 | /** /plugins/opencode/forge.ts serves the repository's plugin file (#10198). */ |
| 2 | 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 3 | import { readFileSync } from "node:fs"; | |
| 3 | import { readdirSync, readFileSync } from "node:fs"; | |
| 4 | 4 | import { mkdtemp, rm } from "node:fs/promises"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { reservedName } from "../src/core/reserved.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -82,3 +82,37 @@ test("llms.txt and the card print opencodeInstall, never the code host", async ( | ||
| 82 | 82 | const card = mcpCard(servers.url, "trevato/forge"); |
| 83 | 83 | expect(card.plugins.find((p: any) => p.harness === "opencode")?.install).toBe(cmd); |
| 84 | 84 | }); |
| 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", () => { | ||
| 228 | 228 | test("both docs show the two commands and the install", () => { |
| 229 | 229 | for (const p of ["plugins/claude-code/README.md", "docs/MCP.md"]) { |
| 230 | 230 | 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"); | |
| 233 | 233 | expect(t).toContain("--plugin-dir plugins/claude-code"); |
| 234 | 234 | } |
| 235 | 235 | }); |
test/tui-token-make.test.ts +18 −22
| @@ -237,7 +237,7 @@ describe("making a token in the terminal", () => { | ||
| 237 | 237 | app.stop(); |
| 238 | 238 | }); |
| 239 | 239 | |
| 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 () => { | |
| 241 | 241 | const mint = async () => { |
| 242 | 242 | const h = boot("carol"); |
| 243 | 243 | await h.open(); |
| @@ -249,22 +249,21 @@ describe("making a token in the terminal", () => { | ||
| 249 | 249 | h.stop(); |
| 250 | 250 | return text; |
| 251 | 251 | }; |
| 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 | |
| 253 | 253 | const { createRepo } = await import("../src/core/repos.ts"); |
| 254 | 254 | const owner = await createUser(forge.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false }); |
| 255 | 255 | if (!owner.ok) throw new Error(owner.error); |
| 256 | 256 | const made = await createRepo(forge.db, forge.reposRoot, { ownerId: owner.value.id, name: "forge", visibility: "private", description: "" }); |
| 257 | 257 | 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 | |
| 259 | 259 | forge.db.query("UPDATE repo SET visibility = 'public' WHERE name = 'forge'").run(); |
| 260 | 260 | 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"); | |
| 265 | 264 | }); |
| 266 | 265 | |
| 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 () => { | |
| 268 | 267 | const { createRepo, getRepo } = await import("../src/core/repos.ts"); |
| 269 | 268 | if (getUserByName(forge.db, "trevato") === undefined) { |
| 270 | 269 | 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", () => { | ||
| 287 | 286 | for (const k of ["enter", "m", "enter", "enter"]) await key(k); |
| 288 | 287 | return { app, terminal, key, rows: () => app.screen.toText().split("\n").map((l) => l.trimEnd()) }; |
| 289 | 288 | }; |
| 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"; | |
| 291 | 290 | const narrow = await mintOn(40, 30); |
| 292 | 291 | const rows = narrow.rows(); |
| 293 | 292 | 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")); | |
| 297 | 295 | 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)); | |
| 299 | 297 | 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."); | |
| 305 | 301 | narrow.app.stop(); |
| 306 | 302 | |
| 307 | 303 | const short = await mintOn(40, 12); |
| 308 | 304 | 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"); | |
| 311 | 307 | 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")}`); | |
| 314 | 310 | short.app.stop(); |
| 315 | 311 | }); |
| 316 | 312 | }); |