Public: anyone can read this; the crew can change it.
The terminal lists the crew's Claude subscriptions, wakes a resting one and sets its budget, as MCP `subscriptions` does #7262
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7244-the-terminal-lists-the-crew-s-claude-sub → main 3 commits fork 5 threads · 5 unresolved
docs/CREW.md +1
| @@ -303,6 +303,7 @@ one the provider rests hands its runs to the other until the reset. | ||
| 303 | 303 | |
| 304 | 304 | **Roles.** An account is a guest, a member or an admin (`docs/ROLES.md` has the table of what each can do; `src/core/roles.ts` holds it). Admin is the admin flag, a member is promoted, everyone else is a guest. A guest is refused creating or forking a repository, making an app their own and starting a crew run, in the words "the owner has not made this account a member" (after the older refusals: unverified, suspended, agent, private); nothing switches it off. A forge admin who is a person lists everyone with their role at Settings → People (`/settings/people`) and promotes a guest to member with one button, with an optional starter model allowance in dollars a month (none by default, given as a model grant and audited as one), or demotes a member (the allowance stays until taken back on the account's page). In the terminal it is the **People** command (`r` changes a role: `member 5`, or `guest`); over the API, `GET /api/v1/admin/people` (`{ items, next_cursor }`, as `/admin/accounts` and `/admin/invites` answer, with `limit` 1-200, 50 by default, and `cursor`) and `POST /api/v1/admin/accounts/:name/role` (`{"role": "member", "usdPerMonth": 5}`); over MCP, the `account_role` tool (a plan unless `dryRun: false`, with an admin token). Anyone signed in sees their own role and its row of the table: the account page (`/settings`) shows "What you can do here"; over MCP, `whoami_can` returns `{username, role, can, abilities}` with any token (with `{"kind": "passkeys"}` it lists your own passkeys instead, `{ items }` of id, name, created, lastUsed, people only; the page is Settings → Passkeys); in the terminal it is the **What I can do** command (read only, agents included). |
| 305 | 305 | |
| 306 | **Crew subscriptions in the terminal.** A forge admin presses Ctrl+K and picks **Crew subscriptions**: each Claude account with its kind, what it pays for, budget, spend and whether it rests (never a token). `w` wakes the selected resting one (an awake one is left alone and said so), `b` asks for dollars, or `none`, and sets its whole-life budget. The rules are MCP `subscriptions`, `subscription_wake` and `subscription_budget`'s, from `core/subscriptions.ts`; a budget set by whoever made the forge can be lowered, never raised or cleared. | |
| 306 | 307 | **Where you are signed in.** Anyone signed in sees their own browsers and tokens, never a secret, at `/settings`, over the API (`GET /api/v1/users/:username/signins`), over MCP (`sessions`), and in the terminal as the **Signed in** command (`src/tui/screens/signins.ts`; a guest has no such command). The terminal lists this terminal's SSH key first, marked "this one", then each browser (`web browser` and the tail of its session id, so two read apart) and each token (name and scope), with when it was last seen and since when. `d` on a browser asks to confirm (Keep is the default) and signs it out through the same `endAccountSession` as Settings and MCP, so its cookie reads as signed out at once. `d` does not end this terminal (disconnect, or remove its key in Settings → SSH keys) or a token (revoked in Settings → Personal access tokens); it says so. No browser row is marked as the current one there: the terminal holds no browser cookie. |
| 307 | 308 | |
| 308 | 309 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
src/core/subscriptions.ts +21
| @@ -118,6 +118,27 @@ export function budgetsOf(db: Database): Map<string, Budget> { | ||
| 118 | 118 | ); |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | /** Why a budget change would be refused, in the words MCP and the terminal share. */ | |
| 122 | export type BudgetRefusal = { readonly code: "not_found" | "locked" | "invalid"; readonly says: string }; | |
| 123 | ||
| 124 | /** | |
| 125 | * The refusal for setting (or clearing, null) an account's budget, or | |
| 126 | * undefined when `setBudget` would take it. One rule for MCP, the terminal. | |
| 127 | */ | |
| 128 | export function budgetRefusal(db: Database, subscriptionId: string, usd: number | null): BudgetRefusal | undefined { | |
The doc says this is the one rule for MCP and the terminal, but the web page's |
||
| 129 | const held = budgetsOf(db).get(subscriptionId); | |
| 130 | if (!held) return { code: "not_found", says: "no such account" }; | |
| 131 | if (held.locked && (usd === null || !(usd < (held.budgetUsd ?? 0)))) | |
| 132 | return { | |
| 133 | code: "locked", | |
| 134 | says: "This budget was set by whoever made this forge: it can be lowered, never raised or cleared.", | |
| 135 | }; | |
| 136 | const max = limitValue(db, "subscription.budget_max_usd"); | |
| 137 | if (usd !== null && !(Number.isFinite(usd) && usd > 0 && usd <= max)) | |
| 138 | return { code: "invalid", says: `A budget is dollars from 1 to ${max}, or null for none.` }; | |
| 139 | return undefined; | |
| 140 | } | |
| 141 | ||
| 121 | 142 | /** |
| 122 | 143 | * Set or clear (null) an account's whole-life budget. False for no such |
| 123 | 144 | * sealed account, a budget out of range, or a locked budget: one set by |
src/http/mcp/tools/subscription_budget.ts +4 −12
| @@ -1,5 +1,4 @@ | ||
| 1 | import { limitValue } from "../../../settings/limits.ts"; | |
| 2 | import { budgetsOf, setBudget } from "../../../core/subscriptions.ts"; | |
| 1 | import { budgetRefusal, setBudget } from "../../../core/subscriptions.ts"; | |
| 3 | 2 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 3 | |
| 5 | 4 | export const tool: Tool = { |
| @@ -21,16 +20,9 @@ export const tool: Tool = { | ||
| 21 | 20 | return bad("invalid", "id is the account's id"); |
| 22 | 21 | if (usd !== null && (typeof usd !== "number" || !Number.isFinite(usd))) |
| 23 | 22 | return bad("invalid", "usd is a number of dollars, or null to clear"); |
| 24 | const held = budgetsOf(ctx.app.db).get(id); | |
| 25 | if (!held) return bad("not_found", "no such account"); | |
| 26 | if (held.locked && (usd === null || !(usd < (held.budgetUsd ?? 0)))) | |
| 27 | return bad( | |
| 28 | "locked", | |
| 29 | "This budget was set by whoever made this forge: it can be lowered, never raised or cleared.", | |
| 30 | ); | |
| 31 | const max = limitValue(ctx.app.db, "subscription.budget_max_usd"); | |
| 32 | if (!setBudget(ctx.app.db, id, usd)) | |
| 33 | return bad("invalid", `A budget is dollars from 1 to ${max}, or null for none.`); | |
| 23 | const refused = budgetRefusal(ctx.app.db, id, usd); | |
| 24 | if (refused) return bad(refused.code, refused.says); | |
| 25 | if (!setBudget(ctx.app.db, id, usd)) return bad("invalid", "The budget was not saved."); | |
| 34 | 26 | const budgetUsd = usd === null ? null : Math.round(usd * 100) / 100; |
| 35 | 27 | return good({ |
| 36 | 28 | id, |
src/tui/forge.ts +9
| @@ -61,6 +61,7 @@ import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/mod | ||
| 61 | 61 | import { sshKeysScreen } from "./screens/sshkeys.ts"; |
| 62 | 62 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 63 | 63 | import { invitesScreen } from "./screens/invites.ts"; |
| 64 | import { subscriptionsScreen } from "./screens/subscriptions.ts"; | |
| 64 | 65 | import { signInsScreen } from "./screens/signins.ts"; |
| 65 | 66 | import { quotasScreen } from "./screens/quotas.ts"; |
| 66 | 67 | import { teamsScreen } from "./screens/teams.ts"; |
| @@ -529,6 +530,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 529 | 530 | hint: "make a single-use link, see which are open, revoke one", |
| 530 | 531 | run: () => ctx.push(invitesScreen(session, ctx)), |
| 531 | 532 | }); |
| 533 | // The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). | |
| 534 | if (session.user?.isAdmin === true) | |
| 535 | commands.push({ | |
| 536 | id: "subscriptions", | |
| 537 | title: "Crew subscriptions", | |
| 538 | hint: "each account's budget and rest; wake one, set its budget", | |
| 539 | run: () => ctx.push(subscriptionsScreen(session, ctx)), | |
| 540 | }); | |
| 532 | 541 | // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199). |
| 533 | 542 | if (session.user?.isAdmin === true) |
| 534 | 543 | commands.push({ |
src/tui/screens/subscriptions.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The crew's Claude subscriptions in the terminal, for whoever MCP | |
| 3 | * `subscriptions` admits (a forge admin): name, kind, what it pays for, | |
| 4 | * budget and spend, and whether it rests; never a token. `w` wakes the | |
| 5 | * selected resting one and `b` sets its whole-life budget, with the rules of | |
| 6 | * MCP `subscription_wake` and `subscription_budget` (the core's, not new ones). | |
| 7 | */ | |
| 8 | ||
| 9 | import { | |
| 10 | budgetRefusal, | |
| 11 | budgetsOf, | |
| 12 | getSubscription, | |
| 13 | listSubscriptions, | |
| 14 | scopesOf, | |
| 15 | setBudget, | |
| 16 | wakeSubscription, | |
| 17 | } from "../../core/subscriptions.ts"; | |
| 18 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 19 | import { binding } from "../app/keys.ts"; | |
| 20 | import { style } from "../term/style.ts"; | |
| 21 | import { truncate } from "../term/width.ts"; | |
| 22 | import type { Session } from "../session.ts"; | |
| 23 | import { fill, len, split } from "../ui/layout.ts"; | |
| 24 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 25 | ||
| 26 | const money = (n: number): string => `$${n.toFixed(2)}`; | |
| 27 | ||
| 28 | export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | |
| 29 | const state = new ListState(); | |
| 30 | let note = ""; | |
| 31 | const admitted = () => f.user?.isAdmin === true; | |
| 32 | const items = () => (admitted() ? listSubscriptions(f.app.db) : []); | |
| 33 | ||
| 34 | const rows = (): ListRow[] => { | |
| 35 | const budgets = budgetsOf(f.app.db); | |
| 36 | const scopes = scopesOf(f.app.db); | |
| 37 | const now = Date.now(); | |
| 38 | return items().map((s) => { | |
| 39 | const b = budgets.get(s.id); | |
| 40 | const resting = s.restingUntil !== null && Date.parse(s.restingUntil) > now; | |
| 41 | const pays = (scopes.get(s.id) ?? []).join(", "); | |
| 42 | return { | |
| 43 | title: s.name, | |
| 44 | tone: resting ? "attn" : "success", | |
| 45 | secondary: `${s.kind} ${pays === "" ? "pays for nothing" : `pays for ${pays}`}`, | |
| 46 | meta: | |
| 47 | `${b?.budgetUsd == null ? "no budget" : `budget ${money(b.budgetUsd)}`} spent ${money(b?.spentUsd ?? 0)} ` + | |
| 48 | (resting ? `resting until ${s.restingUntil}` : "awake"), | |
| 49 | }; | |
| 50 | }); | |
| 51 | }; | |
| 52 | ||
| 53 | const setTo = (id: string, raw: string): string | undefined => { | |
This locked-budget branch is a copy of MCP subscription_budget and is untested. Share it from core and cover lower/raise/clear in the test. |
||
| 54 | const text = raw.trim().toLowerCase(); | |
| 55 | const usd = text === "none" ? null : /^\d+(\.\d+)?$/.test(text) ? Number(text) : Number.NaN; | |
| 56 | if (usd !== null && !Number.isFinite(usd)) return "Dollars, like 25 — or none."; | |
| 57 | const refused = budgetRefusal(f.app.db, id, usd); | |
| 58 | if (refused) return refused.says.replace("or null for none", "or none"); | |
Nit: this string replace is coupled to the exact core wording. If |
||
| 59 | if (!setBudget(f.app.db, id, usd)) return "The budget was not saved."; | |
| 60 | note = usd === null ? "No budget: it spends until you stop it." : `Budget set: ${money(Math.round(usd * 100) / 100)} in all.`; | |
| 61 | ctx.invalidate(); | |
| 62 | return undefined; | |
| 63 | }; | |
| 64 | ||
| 65 | const screen: Screen = { | |
| 66 | title: "Crew subscriptions", | |
| 67 | keys: [binding(["j", "k"], "move cursor"), binding(["w"], "wake"), binding(["b"], "set budget"), binding(["esc"], "back")], | |
| 68 | render(area, s) { | |
| 69 | const c = ctx.theme.c; | |
| 70 | const all = items(); | |
| 71 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 72 | const text = note !== "" ? note : all.length === 0 ? "No subscription yet." : "w wakes a resting one, b sets its budget."; | |
| 73 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 74 | drawList(s, body!, c, state, rows()); | |
| 75 | }, | |
| 76 | key(e) { | |
| 77 | note = ""; | |
| 78 | const all = items(); | |
| 79 | if (state.key(e, all.length)) return true; | |
| 80 | if (e.ctrl || e.alt || !admitted()) return false; | |
| 81 | const sub = all[state.index]; | |
| 82 | if (e.key === "w" && sub !== undefined) { | |
| 83 | const now = getSubscription(f.app.db, sub.id); | |
| 84 | if (now === undefined) note = "No such subscription."; | |
| 85 | else if (now.restingUntil === null || Date.parse(now.restingUntil) <= Date.now()) | |
| 86 | note = `${sub.name} is awake already.`; | |
| 87 | else { | |
| 88 | wakeSubscription(f.app.db, sub.id); | |
| 89 | note = `${sub.name}: in use again.`; | |
| 90 | } | |
| 91 | ctx.invalidate(); | |
| 92 | } else if (e.key === "b" && sub !== undefined) { | |
| 93 | ctx.push( | |
| 94 | promptScreen(ctx, screen, { | |
| 95 | title: `Budget for ${sub.name}`, | |
| 96 | label: "dollars in all (none clears it)", | |
| 97 | onSubmit: (raw) => setTo(sub.id, raw), | |
| 98 | }), | |
| 99 | ); | |
| 100 | } else return false; | |
| 101 | return true; | |
| 102 | }, | |
| 103 | }; | |
| 104 | return screen; | |
| 105 | } |
test/tui-subscriptions.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * The crew's subscriptions in the terminal (#7244): list, wake, budget, with | |
| 3 | * the gate of MCP `subscriptions`; no frame carries a token. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 10 | import { | |
| 11 | addSubscription, | |
| 12 | budgetsOf, | |
| 13 | getSubscriptionByName, | |
| 14 | lockBudget, | |
| 15 | restSubscription, | |
| 16 | } from "../src/core/subscriptions.ts"; | |
| 17 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 18 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 21 | import { quietLogs } from "./helpers/globals.ts"; | |
| 22 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | const TOKEN_A = "sk-ant-oat01-secretalpha"; | |
| 27 | const TOKEN_B = "sk-ant-api03-secretbeta"; | |
| 28 | ||
| 29 | describe("crew subscriptions in the terminal", () => { | |
| 30 | let dataDir: string; | |
| 31 | let forge: Forge; | |
| 32 | ||
| 33 | function boot(username: string) { | |
| 34 | const terminal = headlessTerminal(140, 32); | |
| 35 | const user = getUserByName(forge.db, username)!; | |
| 36 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 37 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 38 | app.start(); | |
| 39 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 40 | return { | |
| 41 | text: () => app.screen.toText(), | |
| 42 | press: async (...keys: string[]) => { | |
| 43 | keys.forEach(key); | |
| 44 | await settleApp(app); | |
| 45 | }, | |
| 46 | ctrl: async (k: string) => { | |
| 47 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 48 | await settleApp(app); | |
| 49 | }, | |
| 50 | type: async (t: string) => { | |
| 51 | for (const ch of t) key(ch); | |
| 52 | await settleApp(app); | |
| 53 | }, | |
| 54 | until: (what: string) => settleUntil(app, what, { timeoutMs: 4_000 }), | |
| 55 | stop: () => app.stop(), | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | const openScreen = async (h: ReturnType<typeof boot>) => { | |
| 60 | await h.ctrl("k"); | |
| 61 | await h.type("Crew subscriptions"); | |
| 62 | await h.until("each account's budget"); | |
| 63 | await h.press("enter"); | |
| 64 | await h.until("w wakes"); | |
| 65 | }; | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-subs-")); | |
| 69 | forge = createApp({ dataDir, port: 0 }); | |
| 70 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 71 | const made = await createUser(forge.db, { | |
| 72 | origin: "seed", | |
| 73 | trusted: true, | |
| 74 | username, | |
| 75 | email: `${username}@forge.local`, | |
| 76 | password: "a-good-passphrase", | |
| 77 | adminIfFirst, | |
| 78 | }); | |
| 79 | if (!made.ok) throw new Error(made.error); | |
| 80 | } | |
| 81 | for (const [name, token] of [["alpha", TOKEN_A], ["beta", TOKEN_B]] as const) { | |
| 82 | const made = addSubscription(forge.db, dataDir, { name, token }); | |
| 83 | if (!made.ok) throw new Error(made.error); | |
| 84 | } | |
| 85 | const alpha = getSubscriptionByName(forge.db, "alpha")!; | |
| 86 | restSubscription(forge.db, alpha.id, new Date(Date.now() + 3_600_000).toISOString(), "limit"); | |
| 87 | }); | |
| 88 | afterAll(async () => { | |
| 89 | forge.close(); | |
| 90 | await rm(dataDir, { recursive: true, force: true }); | |
| 91 | }); | |
| 92 | ||
| 93 | test("lists two, wakes the resting one, says so for an awake one, never shows a token", async () => { | |
| 94 | const h = boot("alice"); | |
| 95 | await openScreen(h); | |
| 96 | expect(h.text()).toContain("alpha"); | |
| 97 | expect(h.text()).toContain("beta"); | |
| 98 | expect(h.text()).toContain("resting until"); | |
| 99 | expect(h.text()).not.toContain("secretalpha"); | |
| 100 | expect(h.text()).not.toContain("secretbeta"); | |
| 101 | await h.press("w"); | |
| 102 | await h.until("alpha: in use again."); | |
| 103 | expect(getSubscriptionByName(forge.db, "alpha")!.restingUntil).toBeNull(); | |
| 104 | await h.press("w"); | |
| 105 | await h.until("alpha is awake already."); | |
| 106 | h.stop(); | |
| 107 | }); | |
| 108 | ||
| 109 | test("sets a budget, refuses a bad one in words", async () => { | |
| 110 | const beta = getSubscriptionByName(forge.db, "beta")!; | |
| 111 | const h = boot("alice"); | |
| 112 | await openScreen(h); | |
| 113 | await h.press("j", "b"); | |
| 114 | await h.until("Budget for beta"); | |
| 115 | await h.type("25"); | |
outdated
Add cases: |
||
| 116 | await h.press("enter"); | |
| 117 | expect(budgetsOf(forge.db).get(beta.id)!.budgetUsd).toBe(25); | |
| 118 | await h.until("Budget set: $25.00 in all."); | |
| 119 | await h.press("b"); | |
| 120 | await h.until("Budget for beta"); | |
| 121 | await h.type("-5"); | |
| 122 | await h.press("enter"); | |
| 123 | await h.until("Dollars, like 25"); | |
| 124 | expect(budgetsOf(forge.db).get(beta.id)!.budgetUsd).toBe(25); | |
| 125 | await h.press("esc"); | |
| 126 | await h.press("b"); | |
| 127 | await h.type("99999999"); | |
| 128 | await h.press("enter"); | |
| 129 | await h.until("A budget is dollars from 1 to"); | |
| 130 | expect(budgetsOf(forge.db).get(beta.id)!.budgetUsd).toBe(25); | |
| 131 | expect(h.text()).not.toContain("secretbeta"); | |
| 132 | h.stop(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("none clears an unlocked budget", async () => { | |
| 136 | const beta = getSubscriptionByName(forge.db, "beta")!; | |
| 137 | const h = boot("alice"); | |
| 138 | await openScreen(h); | |
| 139 | await h.press("j", "b"); | |
| 140 | await h.until("Budget for beta"); | |
| 141 | await h.type("none"); | |
| 142 | await h.press("enter"); | |
| 143 | await h.until("No budget"); | |
| 144 | expect(budgetsOf(forge.db).get(beta.id)!.budgetUsd).toBeNull(); | |
| 145 | h.stop(); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a locked budget set by whoever made this forge", async () => { | |
| 149 | const alpha = getSubscriptionByName(forge.db, "alpha")!; | |
| 150 | lockBudget(forge.db, alpha.id, 10); | |
| 151 | const h = boot("alice"); | |
| 152 | await openScreen(h); | |
| 153 | const attempt = async (v: string) => { | |
| 154 | await h.press("b"); | |
| 155 | await h.until("Budget for alpha"); | |
| 156 | await h.type(v); | |
| 157 | await h.press("enter"); | |
| 158 | }; | |
| 159 | await attempt("20"); | |
| 160 | await h.until("set by whoever made this forge"); | |
| 161 | await h.press("esc"); | |
| 162 | await attempt("none"); | |
| 163 | await h.until("set by whoever made this forge"); | |
| 164 | await h.press("esc"); | |
| 165 | expect(budgetsOf(forge.db).get(alpha.id)!.budgetUsd).toBe(10); | |
| 166 | await attempt("5"); | |
| 167 | await h.until("Budget set: $5.00 in all."); | |
| 168 | expect(budgetsOf(forge.db).get(alpha.id)!.budgetUsd).toBe(5); | |
| 169 | h.stop(); | |
| 170 | }); | |
| 171 | ||
| 172 | test("someone MCP refuses is never offered the screen", async () => { | |
| 173 | const h = boot("bob"); | |
| 174 | await h.ctrl("k"); | |
| 175 | await h.type("Crew subscriptions"); | |
| 176 | await h.until("Crew subscriptions"); // the palette has drawn the query | |
| 177 | expect(h.text()).not.toContain("each account's budget"); | |
| 178 | h.stop(); | |
| 179 | }); | |
| 180 | }); |
Non-blocking: there is no blank line between this paragraph and the merged-in Where you are signed in. paragraph below it, so Markdown renders the two as one paragraph. Add a blank line after this one.