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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12126-pages-and-the-terminal-say-your-ai-a-ven → main 7 commits fork 2 threads · 2 unresolved
19 files
changed +101
−36
src/tui/screens/crewkey.ts +1 −1
| @@ -43,7 +43,7 @@ export function crewKeyScreen(f: Session, ctx: Ctx): Screen { | ||
| 43 | 43 | ctx.push( |
| 44 | 44 | promptScreen(ctx, screen, { |
| 45 | 45 | 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)", | |
| 47 | 47 | mask: true, |
| 48 | 48 | onSubmit: (token) => |
| 49 | 49 | saveCrewKey(f.app, f.user, token).then((saved) => { |
src/tui/screens/grants.ts +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * terminal, as the repository's Crew settings have them: one row for everyone |
| 4 | 4 | * and one per crew member. `h`/`l` choose a subscription, `space` toggles it |
| 5 | 5 | * 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"; | ||
| 29 | 29 | export function modelScreen(f: Session, ctx: Ctx): Screen { |
| 30 | 30 | let note = ""; |
| 31 | 31 | const screen: Screen = { |
| 32 | title: "Model budget", | |
| 32 | title: "Connect your AI", | |
| 33 | 33 | keys: [binding(["a"], "add your subscription"), binding(["esc"], "back")], |
| 34 | 34 | render(area, s) { |
| 35 | 35 | const c = ctx.theme.c; |
| @@ -49,13 +49,13 @@ export function modelScreen(f: Session, ctx: Ctx): Screen { | ||
| 49 | 49 | ctx.push( |
| 50 | 50 | promptScreen(ctx, screen, { |
| 51 | 51 | 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)", | |
| 53 | 53 | mask: true, |
| 54 | 54 | onSubmit: (token) => |
| 55 | 55 | later(ctx, () => |
| 56 | 56 | promptScreen(ctx, screen, { |
| 57 | 57 | title: "Add your subscription", |
| 58 | label: "API base URL (empty for Anthropic)", | |
| 58 | label: "API base URL (empty for your AI's own service)", | |
| 59 | 59 | onSubmit: (apiBase) => { |
| 60 | 60 | const added = addOwnModel(f.app, f.user, { token: token.trim(), apiBase: apiBase.trim() }); |
| 61 | 61 | if (!added.ok) return ADD_MESSAGES[added.error]; |
src/tui/screens/newforge.ts +1 −1
| @@ -44,7 +44,7 @@ const SPECS: readonly Spec[] = [ | ||
| 44 | 44 | { key: "apps", label: "Apps to fork", hint: "owner/name, comma-separated. Optional." }, |
| 45 | 45 | { key: "fresh", label: "Or a fresh app", hint: "One line on what it is. Optional." }, |
| 46 | 46 | { 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." }, | |
| 48 | 48 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 49 | 49 | { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE }, |
| 50 | 50 | { 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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * Subscriptions form on the organization page has them: one decision for every |
| 4 | 4 | * repository of the organization that has no grant of its own. First the |
| 5 | 5 | * viewer's organizations they administer; then one row for everyone and one |
| @@ -134,7 +134,7 @@ export function orgGrantsScreen(f: Session, ctx: Ctx): Screen { | ||
| 134 | 134 | export const entry: PaletteEntry = { |
| 135 | 135 | id: "org-grants", |
| 136 | 136 | 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", | |
| 138 | 138 | after: ["org-teams", "subscriptions", "teams"], |
| 139 | 139 | offered: (session) => grantableOrgs(session).length > 0, |
| 140 | 140 | 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 | ||
| 131 | 131 | if (top < bottom) s.text(area.x, top, truncate("Plugin command: c copies it.", area.w), style(c.mutedFg, c.bg), end); |
| 132 | 132 | return; |
| 133 | 133 | } |
| 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); | |
| 135 | 135 | rows.forEach((row, i) => s.text(area.x, top + 1 + i, row, style(c.fg, c.bg), end)); |
| 136 | 136 | }, |
| 137 | 137 | key(e) { |
src/tui/screens/subscriptions.ts +4 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The crew's Claude subscriptions in the terminal, for whoever MCP | |
| 2 | * The crew's AI subscriptions in the terminal, for whoever MCP | |
| 3 | 3 | * `subscriptions` admits (a forge admin): name, kind, what it pays for, |
| 4 | 4 | * budget and spend, and whether it rests; never a token. `w` wakes the |
| 5 | 5 | * 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 { | ||
| 97 | 97 | return later(ctx, () => |
| 98 | 98 | promptScreen(ctx, screen, { |
| 99 | 99 | 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)", | |
| 101 | 101 | onSubmit: (base) => { |
| 102 | 102 | if (!admitted()) return "Only a forge admin adds an account."; |
| 103 | 103 | const added = addSubscription(f.app.db, f.app.config.dataDir, { |
| @@ -154,7 +154,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 154 | 154 | } else if (e.key === "a") { |
| 155 | 155 | ctx.push( |
| 156 | 156 | promptScreen(ctx, screen, { |
| 157 | title: "Add a Claude account", | |
| 157 | title: "Add an AI account", | |
| 158 | 158 | label: "name, a word or two", |
| 159 | 159 | onSubmit: (name) => |
| 160 | 160 | !isSubscriptionName(name) ? SUBSCRIPTION_WORDS.invalid_name : later(ctx, () => tokenPrompt(name)), |
| @@ -189,7 +189,7 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 189 | 189 | return screen; |
| 190 | 190 | } |
| 191 | 191 | |
| 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). */ | |
| 193 | 193 | export const entry: PaletteEntry = { |
| 194 | 194 | id: "subscriptions", |
| 195 | 195 | title: "Crew subscriptions", |
src/web/pages/account.ts +2 −2
| @@ -788,14 +788,14 @@ export function connectCard(ctx: Ctx, token: string | undefined): Html { | ||
| 788 | 788 | // The plugin marketplace is the forge's configured source, offered only while anyone can clone it. |
| 789 | 789 | const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO); |
| 790 | 790 | 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"); | |
| 792 | 792 | return block( |
| 793 | 793 | "Connect your harness", |
| 794 | 794 | html`<p class="muted small mb"> |
| 795 | 795 | ${token ? "Your new token is filled in." : "Make a token below, then replace <token>."} |
| 796 | 796 | See plugins/claude-code and plugins/opencode. |
| 797 | 797 | </p> |
| 798 | <p class="small">Claude Code</p> | |
| 798 | <p class="small">Terminal agent</p> | |
| 799 | 799 | <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> |
| 800 | 800 | ${market |
| 801 | 801 | ? 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 { | ||
| 140 | 140 | if (crew === undefined && isHostedChild(ctx.app)) |
| 141 | 141 | 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.`); |
| 142 | 142 | 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>.`); | |
| 144 | 144 | if (!crew.ready) |
| 145 | 145 | return line("Cannot run", html`The crew cannot run: ${crew.detail}. Nothing picks up agents' work until this is fixed.`); |
| 146 | 146 | const funded = fundingOf(ctx.app.config.dataDir); |
src/web/pages/model.ts +4 −4
| @@ -46,8 +46,8 @@ export function modelPage(ctx: Ctx): Response { | ||
| 46 | 46 | <label for="f-token">Token</label> |
| 47 | 47 | <input class="input" id="f-token" name="token" type="password" |
| 48 | 48 | 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 | |
| 51 | 51 | subscription's token. It is sealed on this forge, never shown |
| 52 | 52 | again, and pays only for repositories you own.</span> |
| 53 | 53 | </div> |
| @@ -61,8 +61,8 @@ export function modelPage(ctx: Ctx): Response { | ||
| 61 | 61 | </form>`; |
| 62 | 62 | const response = page({ |
| 63 | 63 | 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> | |
| 66 | 66 | ${settingsLayout( |
| 67 | 67 | accountSections("model", { admin: user.isAdmin }), |
| 68 | 68 | html`<section class="settings-block"> |
src/web/pages/new-forge.ts +2 −2
| @@ -154,7 +154,7 @@ const PLAIN: Readonly<Record<string, string>> = { | ||
| 154 | 154 | */ |
| 155 | 155 | const PLAIN_ERRORS: readonly (readonly [RegExp, string])[] = [ |
| 156 | 156 | [/^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"], | |
| 158 | 158 | [/; send modelKey with it/g, ": add the key too"], |
| 159 | 159 | [/\bmodel key\b/g, "AI account key"], |
| 160 | 160 | ]; |
| @@ -341,7 +341,7 @@ function formView( | ||
| 341 | 341 | name: "modelEndpoint", |
| 342 | 342 | type: "url", |
| 343 | 343 | 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.", | |
| 345 | 345 | ...(errors["modelEndpoint"] ? { error: errors["modelEndpoint"] } : {}), |
| 346 | 346 | })} |
| 347 | 347 | ${anon |
src/web/pages/spend.ts +1 −1
| @@ -141,7 +141,7 @@ export function spendView(spend: Spend): Html { | ||
| 141 | 141 | </table> |
| 142 | 142 | </div> |
| 143 | 143 | <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 | |
| 145 | 145 | tokens count as they arrive, its cost when it reports one. "Last 5 h" is |
| 146 | 146 | the last five hours, not any one account's window — each credential's own |
| 147 | 147 | windows are shown with it. |
src/web/pages/subscriptions.ts +5 −5
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * used. The pool is a forge admin's: an account is money, so only an admin |
| 4 | 4 | * adds one, removes one, or says which repository or organization it pays |
| 5 | 5 | * for. A token is typed once and never shown again. |
| @@ -76,7 +76,7 @@ const kindText = (s: Subscription): string => | ||
| 76 | 76 | s.id === PLATFORM |
| 77 | 77 | ? "The forge's own token, from its environment" |
| 78 | 78 | : s.kind === "oauth" |
| 79 | ? "Claude subscription" | |
| 79 | ? "AI subscription" | |
| 80 | 80 | : s.apiBase !== null |
| 81 | 81 | ? `API key via ${s.apiBase}` |
| 82 | 82 | : "API key"; |
| @@ -301,13 +301,13 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 301 | 301 | id="f-token" |
| 302 | 302 | name="token" |
| 303 | 303 | type="password" |
| 304 | placeholder="sk-ant-… or any provider's key" | |
| 304 | placeholder="your provider's key" | |
| 305 | 305 | required |
| 306 | 306 | autocomplete="off" |
| 307 | 307 | spellcheck="false" |
| 308 | 308 | /> |
| 309 | 309 | <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. | |
| 311 | 311 | Sealed here, never shown again.</span |
| 312 | 312 | > |
| 313 | 313 | </div> |
| @@ -324,7 +324,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 324 | 324 | spellcheck="false" |
| 325 | 325 | /> |
| 326 | 326 | <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 | |
| 328 | 328 | > |
| 329 | 329 | </div> |
| 330 | 330 | <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[] { | ||
| 441 | 441 | const where = p.cloned && plugins !== undefined ? `source ${pluginSource(o, plugins)}, ` : "served by this forge, "; |
| 442 | 442 | return `- ${p.label} plugin (${where}path ${p.path}; ${p.setup}): \`${p.install(o, plugins ?? "")}\``; |
| 443 | 443 | }); |
| 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; | |
| 445 | 445 | } |
| 446 | 446 | |
| 447 | 447 | 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 | ||
| 463 | 463 | "", |
| 464 | 464 | `- 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.`, |
| 465 | 465 | `- 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.`, | |
| 467 | 467 | `- 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`, |
| 468 | 468 | `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`, |
| 469 | 469 | "", |
src/web/studio/crew_key.ts +2 −2
| @@ -68,8 +68,8 @@ export function crewFirstLine(app: App, admin: boolean): Html { | ||
| 68 | 68 | |
| 69 | 69 | const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form"> |
| 70 | 70 | <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" /> | |
| 73 | 73 | </div> |
| 74 | 74 | <div>${btn("Save key", { variant: "primary", size: "sm", icon: "key" })}</div> |
| 75 | 75 | </form>`; |
src/web/studio/home.ts +2 −2
| @@ -656,7 +656,7 @@ function packPresses(): Html | null { | ||
| 656 | 656 | </div>`; |
| 657 | 657 | } |
| 658 | 658 | |
| 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. */ | |
| 660 | 660 | const AGENTS = "/agents"; |
| 661 | 661 | |
| 662 | 662 | /** |
| @@ -732,9 +732,9 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 732 | 732 | <div class="door-agent"> |
| 733 | 733 | <p> |
| 734 | 734 | 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> | |
| 735 | 736 | <a href="https://chatgpt.com/?q=${asked}" rel="noopener">Open in ChatGPT</a> |
| 736 | 737 | <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> | |
| 738 | 738 | <span class="muted">(after you sign in)</span> |
| 739 | 739 | </p> |
| 740 | 740 | ${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", () => { | ||
| 96 | 96 | const h = boot("alice"); |
| 97 | 97 | await open(h); |
| 98 | 98 | await h.press("a"); |
| 99 | await h.until("Add a Claude account"); | |
| 99 | await h.until("Add an AI account"); | |
| 100 | 100 | await h.type("bad/name"); |
| 101 | 101 | await h.press("enter"); |
| 102 | 102 | 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", () => { | ||
| 290 | 290 | const narrow = await mintOn(40, 30); |
| 291 | 291 | const rows = narrow.rows(); |
| 292 | 292 | 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):"); | |
| 294 | 294 | const at = rows.findIndex((r) => r.trim().startsWith("claude plugin marketplace")); |
| 295 | 295 | expect(at).toBeGreaterThan(0); |
| 296 | 296 | 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 }[]> = { | |
| 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 | }); |
new above
offline
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.