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

Pages and the terminal say "your AI"; a vendor's name appears only in the picker, recommended first #12309

Merged opened this

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

Merged

scout/forge:scout/12126-pages-and-the-terminal-say-your-ai-a-ven → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/12126-pages-and-the-terminal-say-your-ai-a-ven was merged into main as 38399ee.

Review

Sign in to review this change.
19 files changed +101 −36
src/tui/screens/crewkey.ts +1 −1
@@ -43,7 +43,7 @@ export function crewKeyScreen(f: Session, ctx: Ctx): Screen {
4343 ctx.push(
4444 promptScreen(ctx, screen, {
4545 title: "Your model key",
46 label: "API key or Claude token (never shown again)",
46 label: "API key or sign-in token (never shown again)",
4747 mask: true,
4848 onSubmit: (token) =>
4949 saveCrewKey(f.app, f.user, token).then((saved) => {
src/tui/screens/grants.ts +1 −1
@@ -1,5 +1,5 @@
11 /**
2 * Which Claude subscriptions each crew member of a repository may use, in the
2 * Which AI subscriptions each crew member of a repository may use, in the
33 * terminal, as the repository's Crew settings have them: one row for everyone
44 * and one per crew member. `h`/`l` choose a subscription, `space` toggles it
55 * and the row is saved whole through `setGrants`. The gate is the page's: a
src/tui/screens/model.ts +3 −3
@@ -29,7 +29,7 @@ import type { PaletteEntry } from "./entries.ts";
2929 export function modelScreen(f: Session, ctx: Ctx): Screen {
3030 let note = "";
3131 const screen: Screen = {
32 title: "Model budget",
32 title: "Connect your AI",
3333 keys: [binding(["a"], "add your subscription"), binding(["esc"], "back")],
3434 render(area, s) {
3535 const c = ctx.theme.c;
@@ -49,13 +49,13 @@ export function modelScreen(f: Session, ctx: Ctx): Screen {
4949 ctx.push(
5050 promptScreen(ctx, screen, {
5151 title: "Add your subscription",
52 label: "token (from `claude setup-token`, or an API key)",
52 label: "token (from your AI's sign-in command, or an API key)",
5353 mask: true,
5454 onSubmit: (token) =>
5555 later(ctx, () =>
5656 promptScreen(ctx, screen, {
5757 title: "Add your subscription",
58 label: "API base URL (empty for Anthropic)",
58 label: "API base URL (empty for your AI's own service)",
5959 onSubmit: (apiBase) => {
6060 const added = addOwnModel(f.app, f.user, { token: token.trim(), apiBase: apiBase.trim() });
6161 if (!added.ok) return ADD_MESSAGES[added.error];
src/tui/screens/newforge.ts +1 −1
@@ -44,7 +44,7 @@ const SPECS: readonly Spec[] = [
4444 { key: "apps", label: "Apps to fork", hint: "owner/name, comma-separated. Optional." },
4545 { key: "fresh", label: "Or a fresh app", hint: "One line on what it is. Optional." },
4646 { key: "modelKey", label: "AI account key", hint: "Sealed in the new forge, never shown again. Optional.", secret: true },
47 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." },
47 { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for your AI's own service. Optional." },
4848 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
4949 { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE },
5050 { key: "size", label: "Size", hint: "small (default), medium or large: left and right choose, or type it.", choices: SIZE_CHOICES },
src/tui/screens/orggrants.ts +2 −2
@@ -1,5 +1,5 @@
11 /**
2 * An organization's default Claude subscription grants in the terminal, as the
2 * An organization's default AI subscription grants in the terminal, as the
33 * Subscriptions form on the organization page has them: one decision for every
44 * repository of the organization that has no grant of its own. First the
55 * viewer's organizations they administer; then one row for everyone and one
@@ -134,7 +134,7 @@ export function orgGrantsScreen(f: Session, ctx: Ctx): Screen {
134134 export const entry: PaletteEntry = {
135135 id: "org-grants",
136136 title: "org grants",
137 hint: "which Claude accounts your organization's repositories without a grant of their own use",
137 hint: "which AI accounts your organization's repositories without a grant of their own use",
138138 after: ["org-teams", "subscriptions", "teams"],
139139 offered: (session) => grantableOrgs(session).length > 0,
140140 run: (session, ctx) => ctx.push(orgGrantsScreen(session, ctx)),
src/tui/screens/signins.ts +1 −1
@@ -131,7 +131,7 @@ function secretScreen(ctx: Ctx, secret: string, install: string | null): Screen
131131 if (top < bottom) s.text(area.x, top, truncate("Plugin command: c copies it.", area.w), style(c.mutedFg, c.bg), end);
132132 return;
133133 }
134 s.text(area.x, top, truncate("Claude Code plugin (c copies):", area.w), style(c.mutedFg, c.bg), end);
134 s.text(area.x, top, truncate("Terminal agent plugin (c copies):", area.w), style(c.mutedFg, c.bg), end);
135135 rows.forEach((row, i) => s.text(area.x, top + 1 + i, row, style(c.fg, c.bg), end));
136136 },
137137 key(e) {
src/tui/screens/subscriptions.ts +4 −4
@@ -1,5 +1,5 @@
11 /**
2 * The crew's Claude subscriptions in the terminal, for whoever MCP
2 * The crew's AI subscriptions in the terminal, for whoever MCP
33 * `subscriptions` admits (a forge admin): name, kind, what it pays for,
44 * budget and spend, and whether it rests; never a token. `w` wakes the
55 * selected resting one and `b` sets its whole-life budget, with the rules of
@@ -97,7 +97,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
9797 return later(ctx, () =>
9898 promptScreen(ctx, screen, {
9999 title: `API base for ${name}`,
100 label: "API base URL (empty is Anthropic)",
100 label: "API base URL (empty is your AI's own service)",
101101 onSubmit: (base) => {
102102 if (!admitted()) return "Only a forge admin adds an account.";
103103 const added = addSubscription(f.app.db, f.app.config.dataDir, {
@@ -154,7 +154,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
154154 } else if (e.key === "a") {
155155 ctx.push(
156156 promptScreen(ctx, screen, {
157 title: "Add a Claude account",
157 title: "Add an AI account",
158158 label: "name, a word or two",
159159 onSubmit: (name) =>
160160 !isSubscriptionName(name) ? SUBSCRIPTION_WORDS.invalid_name : later(ctx, () => tokenPrompt(name)),
@@ -189,7 +189,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
189189 return screen;
190190 }
191191
192 /** The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). */
192 /** The crew's AI subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). */
193193 export const entry: PaletteEntry = {
194194 id: "subscriptions",
195195 title: "Crew subscriptions",
src/web/pages/account.ts +2 −2
@@ -788,14 +788,14 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html {
788788 // The plugin marketplace is the forge's configured source, offered only while anyone can clone it.
789789 const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO);
790790 const market = repo !== undefined;
791 const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Claude Code command", "Copy command");
791 const clone = copyField("connect-claude", `${env} claude --plugin-dir plugins/claude-code`, "Terminal agent command", "Copy command");
792792 return block(
793793 "Connect your harness",
794794 html`<p class="muted small mb">
795795 ${token ? "Your new token is filled in." : "Make a token below, then replace <token>."}
796796 See plugins/claude-code and plugins/opencode.
797797 </p>
798 <p class="small">Claude Code</p>
798 <p class="small">Terminal agent</p>
799799 <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>
800800 ${market
801801 ? html`${copyField("connect-claude-install", claudeInstall(url), "Install the plugin (one command)", "Copy command")}
src/web/pages/crew.ts +1 −1
@@ -140,7 +140,7 @@ export function crewReadiness(ctx: Ctx): Html {
140140 if (crew === undefined && isHostedChild(ctx.app))
141141 return line("No runner", html`This forge is hosted inside another forge's process, which has no crew runner for it: assigned issues will wait.`);
142142 if (crew === undefined)
143 return line("No runner", html`No crew runner is up on this forge, so assigned issues will wait: start it with apps on and Claude Code on <span class="mono">PATH</span> or at <span class="mono">FORGE_AGENT_RUNNER</span>.`);
143 return line("No runner", html`No crew runner is up on this forge, so assigned issues will wait: start it with apps on and the runner on <span class="mono">PATH</span> or at <span class="mono">FORGE_AGENT_RUNNER</span>.`);
144144 if (!crew.ready)
145145 return line("Cannot run", html`The crew cannot run: ${crew.detail}. Nothing picks up agents' work until this is fixed.`);
146146 const funded = fundingOf(ctx.app.config.dataDir);
src/web/pages/model.ts +4 −4
@@ -46,8 +46,8 @@ export function modelPage(ctx: Ctx): Response {
4646 <label for="f-token">Token</label>
4747 <input class="input" id="f-token" name="token" type="password"
4848 required autocomplete="off" spellcheck="false"
49 placeholder="sk-ant-… or any provider's key" />
50 <span class="hint"><code>claude setup-token</code> prints a
49 placeholder="your AI's token or key" />
50 <span class="hint">Your AI's sign-in command prints a
5151 subscription's token. It is sealed on this forge, never shown
5252 again, and pays only for repositories you own.</span>
5353 </div>
@@ -61,8 +61,8 @@ export function modelPage(ctx: Ctx): Response {
6161 </form>`;
6262 const response = page({
6363 ctx,
64 title: "Model",
65 body: html`<div class="page-head"><h1>Model</h1></div>
64 title: "Connect your AI",
65 body: html`<div class="page-head"><h1>Connect your AI</h1></div>
6666 ${settingsLayout(
6767 accountSections("model", { admin: user.isAdmin }),
6868 html`<section class="settings-block">
src/web/pages/new-forge.ts +2 −2
@@ -154,7 +154,7 @@ const PLAIN: Readonly<Record<string, string>> = {
154154 */
155155 const PLAIN_ERRORS: readonly (readonly [RegExp, string])[] = [
156156 [/^is empty or over 4096 characters[^]*$/, "is empty or too long: paste the whole key from your AI account"],
157 [/a subscription token \(sk-ant-oat…\)/g, "a Claude sign-in key (sk-ant-oat…)"],
157 [/a subscription token \(sk-ant-oat…\)/g, "an AI sign-in key"],
158158 [/; send modelKey with it/g, ": add the key too"],
159159 [/\bmodel key\b/g, "AI account key"],
160160 ];
@@ -341,7 +341,7 @@ function formView(
341341 name: "modelEndpoint",
342342 type: "url",
343343 value: values.modelEndpoint,
344 hint: "Anthropic-compatible gateway for the key. OpenRouter: https://openrouter.ai/api. Leave empty for Anthropic.",
344 hint: "A gateway for the key, for example https://openrouter.ai/api. Leave empty to use your AI's own service.",
345345 ...(errors["modelEndpoint"] ? { error: errors["modelEndpoint"] } : {}),
346346 })}
347347 ${anon
src/web/pages/spend.ts +1 −1
@@ -141,7 +141,7 @@ export function spendView(spend: Spend): Html {
141141 </table>
142142 </div>
143143 <p class="muted small">
144 US dollars at list price, as Claude Code reports them; a running session's
144 US dollars at list price, as the runner reports them; a running session's
145145 tokens count as they arrive, its cost when it reports one. "Last 5 h" is
146146 the last five hours, not any one account's window — each credential's own
147147 windows are shown with it.
src/web/pages/subscriptions.ts +5 −5
@@ -1,5 +1,5 @@
11 /**
2 * Subscriptions — the Claude accounts the crew works on, and where each is
2 * Subscriptions — the AI accounts the crew works on, and where each is
33 * used. The pool is a forge admin's: an account is money, so only an admin
44 * adds one, removes one, or says which repository or organization it pays
55 * for. A token is typed once and never shown again.
@@ -76,7 +76,7 @@ const kindText = (s: Subscription): string =>
7676 s.id === PLATFORM
7777 ? "The forge's own token, from its environment"
7878 : s.kind === "oauth"
79 ? "Claude subscription"
79 ? "AI subscription"
8080 : s.apiBase !== null
8181 ? `API key via ${s.apiBase}`
8282 : "API key";
@@ -301,13 +301,13 @@ export function subscriptionsPage(ctx: Ctx): Response {
301301 id="f-token"
302302 name="token"
303303 type="password"
304 placeholder="sk-ant-… or any provider's key"
304 placeholder="your provider's key"
305305 required
306306 autocomplete="off"
307307 spellcheck="false"
308308 />
309309 <span class="hint"
310 ><code>claude setup-token</code> prints one; an API key works too.
310 >Your AI's sign-in command prints one; an API key works too.
311311 Sealed here, never shown again.</span
312312 >
313313 </div>
@@ -324,7 +324,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
324324 spellcheck="false"
325325 />
326326 <span class="hint"
327 >Empty for Anthropic itself; a setup-token takes none.</span
327 >Empty for your AI's own service; a sign-in token takes none.</span
328328 >
329329 </div>
330330 <div>${btn("Add", { variant: "primary", size: "sm", icon: "plus" })}</div>
src/web/standards.ts +2 −2
@@ -441,7 +441,7 @@ function pluginLines(o: string, plugins: string | undefined): string[] {
441441 const where = p.cloned && plugins !== undefined ? `source ${pluginSource(o, plugins)}, ` : "served by this forge, ";
442442 return `- ${p.label} plugin (${where}path ${p.path}; ${p.setup}): \`${p.install(o, plugins ?? "")}\``;
443443 });
444 return plugins === undefined ? [...lines, "- Claude Code plugin: not offered here (no public source)"] : lines;
444 return plugins === undefined ? [...lines, "- Terminal agent plugin: not offered here (no public source)"] : lines;
445445 }
446446
447447 function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined): string {
@@ -463,7 +463,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
463463 "",
464464 `- Build now, no account: \`curl -sX POST ${o}/api/v1/start -d '{"name":"my-app"}'\` -> {user, token, repo, git, app, claim}. Clone git.url (user:token@), \`git push\` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.`,
465465 `- Or with an account: sign up at ${o}/signup, then follow "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}. If /signup says 403, use "Report a problem".`,
466 `- Run it yourself instead: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or hosted (plans below): ${o}/forges/new, or MCP \`spawn\` with the person's consent. A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.`,
466 `- Run it yourself instead: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs a runner CLI and a model credential. Or hosted (plans below): ${o}/forges/new, or MCP \`spawn\` with the person's consent. A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.`,
467467 `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`,
468468 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
469469 "",
src/web/studio/crew_key.ts +2 −2
@@ -68,8 +68,8 @@ export function crewFirstLine(app: App, admin: boolean): Html {
6868
6969 const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form">
7070 <div class="field">
71 <label for="crew-key">Your Anthropic API key or Claude login</label>
72 <input class="input" id="crew-key" name="token" type="password" required maxlength="512" autocomplete="off" spellcheck="false" placeholder="sk-ant-…" />
71 <label for="crew-key">Your AI's API key or login</label>
72 <input class="input" id="crew-key" name="token" type="password" required maxlength="512" autocomplete="off" spellcheck="false" placeholder="paste it here" />
7373 </div>
7474 <div>${btn("Save key", { variant: "primary", size: "sm", icon: "key" })}</div>
7575 </form>`;
src/web/studio/home.ts +2 −2
@@ -656,7 +656,7 @@ function packPresses(): Html | null {
656656 </div>`;
657657 }
658658
659 /** The page the prompt and the Open in ChatGPT, Claude and Grok links send an assistant to. */
659 /** The page the prompt and the Open in Claude, ChatGPT and Grok links send an assistant to. */
660660 const AGENTS = "/agents";
661661
662662 /**
@@ -732,9 +732,9 @@ export function studioDoor(ctx: Ctx): Response {
732732 <div class="door-agent">
733733 <p>
734734 Have an AI assistant? Give it this:
735 <a href="https://claude.ai/new?q=${asked}" rel="noopener">Open in Claude</a> <span class="muted">(recommended)</span>
735736 <a href="https://chatgpt.com/?q=${asked}" rel="noopener">Open in ChatGPT</a>
736737 <a href="https://grok.com/?q=${asked}" rel="noopener">Open in Grok</a>
737 <a href="https://claude.ai/new?q=${asked}" rel="noopener">Open in Claude</a>
738738 <span class="muted">(after you sign in)</span>
739739 </p>
740740 ${copyField("door-prompt", prompt, "A message for any AI assistant", "Copy the message")}
test/tui-subscriptions-add.test.ts +1 −1
@@ -96,7 +96,7 @@ describe("adding and removing a subscription in the terminal", () => {
9696 const h = boot("alice");
9797 await open(h);
9898 await h.press("a");
99 await h.until("Add a Claude account");
99 await h.until("Add an AI account");
100100 await h.type("bad/name");
101101 await h.press("enter");
102102 await h.until("A name is a word or two");
test/tui-token-make.test.ts +1 −1
@@ -290,7 +290,7 @@ describe("making a token in the terminal", () => {
290290 const narrow = await mintOn(40, 30);
291291 const rows = narrow.rows();
292292 expect(rows.every((r) => r.length <= 40)).toBe(true);
293 expect(rows.join("\n")).toContain("Claude Code plugin (c copies):");
293 expect(rows.join("\n")).toContain("Terminal agent plugin (c copies):");
294294 const at = rows.findIndex((r) => r.trim().startsWith("claude plugin marketplace"));
295295 expect(at).toBeGreaterThan(0);
296296 expect(rows.slice(at, at + 4).map((r) => r.trim()).join("").replace(/\s+/g, "")).toContain(cmd.replace(/\s+/g, "").slice(0, 60));
test/vendor-words.test.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * Pages and the terminal say "your AI", "model" or "runner" (#12126, part of
3 * #12068). A vendor's name may appear only where a person picks one, or where
4 * it is a literal the machine needs (a command, an identifier, a protocol key).
5 */
6 import { expect, test } from "bun:test";
7 import { readdirSync, readFileSync, statSync } from "node:fs";
8 import { join } from "node:path";
9
10 const VENDOR = /anthropic|sk-ant|claude|openai|chatgpt|gemini|codex|grok/i;
11
12 // Lines allowed whole, by file, each with its reason.
13 // Anything else in these files is still checked.
14 const LINES: Record<string, { match: RegExp; why: string }[]> = {
outdated

Whole-file exemptions hide any new vendor wording in these files. Exempt the specific literals (log prefix, globals key) with line patterns instead, so the test stays strict.

15 "src/web/studio/cards.ts": [
16 { match: /ChatGPT reads the same key|ChatGPT's Apps SDK|globalThis\.openai|openai:set_globals|fromOpenAI/, why: "MCP Apps hosts: the globals key is a protocol name" },
17 ],
18 "src/tui/screens/changelog.ts": [{ match: /^\/\*\* Who built a pull.*"by Scout, with Claude/, why: "a doc example of data, not wording" }],
19 "src/web/client.ts": [{ match: /var FORGE = \/\^\(claude:/, why: "matches the runner's own log prefix" }],
20 "src/web/pages/new-forge.ts": [{ match: /a subscription token \\\(sk-ant-oat/, why: "matches the refusal text a validator sends, to reword it" }],
21 "src/web/pages/runs.ts": [{ match: /^\s*\/\^\(claude:/, why: "matches the runner's own log prefix" }],
22 };
23 /** Literals removed from a line before it is checked. */
24 const LITERALS = [
25 /claudeInstall/g, /opencodeInstall/g, /FORGE_CLAUDE/g, /plugins\/claude-code/g,
26 /connect-claude(-install)?/g, /\bclaude --plugin-dir\b/g, /["'`]claude["'`]/g,
27 // The assistant picker on the front door: where a person picks, vendors may be named.
28 /<a href="https:\/\/(chatgpt\.com|grok\.com|claude\.ai)[^\n]*<\/a>/g,
29 /Open in Claude, ChatGPT and Grok/g,
30 // Public docs filenames, not wording.
31 /CLAUDE\.md/g, /claude CLI/g,
32 ];
33
34 function walk(dir: string, out: string[] = []): string[] {
35 for (const n of readdirSync(dir)) {
36 if (n === "vendor") continue;
37 const p = join(dir, n);
38 if (statSync(p).isDirectory()) walk(p, out);
39 else if (p.endsWith(".ts")) out.push(p);
40 }
41 return out;
42 }
43
44 test("pages and the terminal name no AI vendor outside the picker", () => {
45 const bad: string[] = [];
46 for (const f of [...walk("src/web"), ...walk("src/tui")]) {
47 readFileSync(f, "utf8").split("\n").forEach((line, i) => {
48 if (/^\s*import/.test(line)) return;
49 if (LINES[f]?.some((e) => e.match.test(line))) return;
50 let l = line;
51 for (const r of LITERALS) l = l.replace(r, "");
52 if (VENDOR.test(l)) bad.push(`${f}:${i + 1}: ${line.trim().slice(0, 100)}`);
53 });
54 }
55 expect(bad).toEqual([]);
56 });
57
58 test("the front-door assistant picker lists the recommended one first, marked", () => {
59 const src = readFileSync("src/web/studio/home.ts", "utf8");
60 const at = (s: string) => src.indexOf(s);
61 expect(at("Open in Claude")).toBeGreaterThan(0);
62 expect(at("Open in Claude")).toBeLessThan(at("Open in ChatGPT"));
63 expect(at("Open in Claude")).toBeLessThan(at("Open in Grok"));
64 expect(src).toContain("(recommended)");
65 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs