Public: anyone can read this; the crew can change it.
The terminal's subscriptions screen adds a Claude account with `a` (token typed masked) and removes one with `D` after a confirm, as MCP `subscription_add` and `subscription_delete` do #7551
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7440-the-terminal-s-subscriptions-screen-adds → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +264
−7
docs/CREW.md +1 −1
Diff not shown — a line 2,344 characters wide (limit 2,000). +1 −1
View filesrc/tui/forge.ts +2 −2
| @@ -569,11 +569,11 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 569 | 569 | run: () => ctx.push(invitesScreen(session, ctx)), |
| 570 | 570 | }); |
| 571 | 571 | // The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). |
| 572 | if (session.user?.isAdmin === true) | |
| 572 | if (session.user?.isAdmin === true && session.user.kind === "user") | |
| 573 | 573 | commands.push({ |
| 574 | 574 | id: "subscriptions", |
| 575 | 575 | title: "Crew subscriptions", |
| 576 | hint: "each account's budget and rest; wake one, set its budget", | |
| 576 | hint: "each account's budget and rest; wake one, set its budget, add or remove one", | |
| 577 | 577 | run: () => ctx.push(subscriptionsScreen(session, ctx)), |
| 578 | 578 | }); |
| 579 | 579 | // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199). |
src/tui/screens/subscriptions.ts +72 −4
| @@ -4,31 +4,38 @@ | ||
| 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 |
| 6 | 6 | * MCP `subscription_wake` and `subscription_budget` (the core's, not new ones). |
| 7 | * `a` adds an account (name, token typed masked, optional API base) and `D` | |
| 8 | * removes the selected one after a confirm, as `subscription_add` and | |
| 9 | * `subscription_delete` do; an agent is offered neither. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 9 | 12 | import { |
| 13 | addSubscription, | |
| 10 | 14 | budgetRefusal, |
| 11 | 15 | budgetsOf, |
| 16 | deleteSubscription, | |
| 12 | 17 | getSubscription, |
| 18 | isSubscriptionName, | |
| 13 | 19 | listSubscriptions, |
| 14 | 20 | scopesOf, |
| 15 | 21 | setBudget, |
| 16 | 22 | wakeSubscription, |
| 17 | 23 | } from "../../core/subscriptions.ts"; |
| 24 | import { SUBSCRIPTION_WORDS, addRefusal } from "../../core/subscriptions_words.ts"; | |
| 18 | 25 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 26 | import { binding } from "../app/keys.ts"; |
| 20 | 27 | import { style } from "../term/style.ts"; |
| 21 | 28 | import { truncate } from "../term/width.ts"; |
| 22 | 29 | import type { Session } from "../session.ts"; |
| 23 | 30 | import { fill, len, split } from "../ui/layout.ts"; |
| 24 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 31 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 25 | 32 | |
| 26 | 33 | const money = (n: number): string => `$${n.toFixed(2)}`; |
| 27 | 34 | |
| 28 | 35 | export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { |
| 29 | 36 | const state = new ListState(); |
| 30 | 37 | let note = ""; |
| 31 | const admitted = () => f.user?.isAdmin === true; | |
| 38 | const admitted = () => f.user?.isAdmin === true && f.user.kind === "user"; | |
| 32 | 39 | const items = () => (admitted() ? listSubscriptions(f.app.db) : []); |
| 33 | 40 | |
| 34 | 41 | const rows = (): ListRow[] => { |
| @@ -62,14 +69,45 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 62 | 69 | return undefined; |
| 63 | 70 | }; |
| 64 | 71 | |
| 72 | // The token is held only between its prompt and the base prompt, then handed over once. | |
| 73 | const tokenPrompt = (name: string): Screen => | |
| 74 | promptScreen(ctx, screen, { | |
| 75 | title: `Token for ${name}`, | |
| 76 | label: "token (never shown again)", | |
| 77 | mask: true, | |
| 78 | onSubmit: (token) => { | |
| 79 | const given = token.trim(); | |
| 80 | if (given === "") return SUBSCRIPTION_WORDS.invalid_token; | |
| 81 | return later(ctx, () => | |
| 82 | promptScreen(ctx, screen, { | |
| 83 | title: `API base for ${name}`, | |
| 84 | label: "API base URL (empty is Anthropic)", | |
| 85 | onSubmit: (base) => { | |
| 86 | if (!admitted()) return "Only a forge admin adds an account."; | |
| 87 | const added = addSubscription(f.app.db, f.app.config.dataDir, { | |
| 88 | name, | |
| 89 | token: given, | |
| 90 | apiBase: base, | |
| 91 | createdBy: f.user?.id, | |
| 92 | }); | |
| 93 | if (!added.ok) return addRefusal(added.error, name); | |
| 94 | note = `${added.value.name} is in the pool.`; | |
| 95 | ctx.invalidate(); | |
| 96 | return undefined; | |
| 97 | }, | |
| 98 | }), | |
| 99 | ); | |
| 100 | }, | |
| 101 | }); | |
| 102 | ||
| 65 | 103 | const screen: Screen = { |
| 66 | 104 | title: "Crew subscriptions", |
| 67 | keys: [binding(["j", "k"], "move cursor"), binding(["w"], "wake"), binding(["b"], "set budget"), binding(["esc"], "back")], | |
| 105 | keys: [binding(["j", "k"], "move cursor"), binding(["w"], "wake"), binding(["b"], "set budget"), binding(["a"], "add"), binding(["D"], "remove"), binding(["esc"], "back")], | |
| 68 | 106 | render(area, s) { |
| 69 | 107 | const c = ctx.theme.c; |
| 70 | 108 | const all = items(); |
| 71 | 109 | 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."; | |
| 110 | const text = note !== "" ? note : all.length === 0 ? "No subscription yet. a adds one." : "w wakes a resting one, b sets its budget, a adds, D removes."; | |
| 73 | 111 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 74 | 112 | drawList(s, body!, c, state, rows()); |
| 75 | 113 | }, |
| @@ -97,6 +135,36 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 97 | 135 | onSubmit: (raw) => setTo(sub.id, raw), |
| 98 | 136 | }), |
| 99 | 137 | ); |
| 138 | } else if (e.key === "a") { | |
| 139 | ctx.push( | |
| 140 | promptScreen(ctx, screen, { | |
| 141 | title: "Add a Claude account", | |
| 142 | label: "name, a word or two", | |
| 143 | onSubmit: (name) => | |
| 144 | !isSubscriptionName(name) ? SUBSCRIPTION_WORDS.invalid_name : later(ctx, () => tokenPrompt(name)), | |
| 145 | }), | |
| 146 | ); | |
| 147 | } else if (e.key === "D" && sub !== undefined) { | |
| 148 | ctx.push( | |
| 149 | confirmScreen(ctx, screen, { | |
| 150 | title: `Remove ${sub.name}?`, | |
| 151 | body: [ | |
| 152 | `${sub.name} and its grants go.`, | |
| 153 | "Runs it paid for keep it in their history.", | |
| 154 | ], | |
| 155 | actions: [ | |
| 156 | { label: "Remove", variant: "danger" }, | |
| 157 | { label: "Cancel", variant: "ghost" }, | |
| 158 | ], | |
| 159 | selected: 1, | |
| 160 | onChoose: (choice) => { | |
| 161 | if (choice !== 0 || !admitted()) return; | |
| 162 | note = deleteSubscription(f.app.db, sub.id) ? `${sub.name} is removed.` : SUBSCRIPTION_WORDS.not_found; | |
| 163 | state.index = Math.max(0, Math.min(state.index, items().length - 1)); | |
| 164 | ctx.invalidate(); | |
| 165 | }, | |
| 166 | }), | |
| 167 | ); | |
| 100 | 168 | } else return false; |
| 101 | 169 | return true; |
| 102 | 170 | }, |
test/tui-subscriptions-add.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
Header cites #7440; this pull is #7551. |
||
| 2 | * Adding and removing a crew subscription in the terminal (#7551), as MCP | |
| 3 | * `subscription_add` and `subscription_delete` do; 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 { addSubscription, deleteSubscription, listSubscriptions } from "../src/core/subscriptions.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { subscriptionsScreen } from "../src/tui/screens/subscriptions.ts"; | |
| 14 | import { Session } from "../src/tui/session.ts"; | |
| 15 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | const TOKEN = "sk-ant-oat01-secretgamma"; | |
| 24 | ||
| 25 | describe("adding and removing a subscription in the terminal", () => { | |
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | ||
| 29 | function boot(username: string) { | |
| 30 | const frames: string[] = []; | |
| 31 | const terminal = headlessTerminal(140, 32); | |
| 32 | const user = getUserByName(forge.db, username)!; | |
| 33 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 34 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 35 | app.start(); | |
| 36 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 37 | const seen = () => frames.push(app.screen.toText()); | |
| 38 | return { | |
| 39 | text: () => app.screen.toText(), | |
| 40 | frames, | |
| 41 | press: async (...keys: string[]) => { | |
| 42 | for (const k of keys) { | |
| 43 | key(k); | |
| 44 | seen(); | |
| 45 | } | |
| 46 | await settleApp(app); | |
| 47 | seen(); | |
| 48 | }, | |
| 49 | ctrl: async (k: string) => { | |
| 50 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 51 | await settleApp(app); | |
| 52 | }, | |
| 53 | type: async (t: string) => { | |
| 54 | for (const ch of t) { | |
| 55 | key(ch); | |
| 56 | seen(); | |
| 57 | } | |
| 58 | await settleApp(app); | |
| 59 | seen(); | |
| 60 | }, | |
| 61 | until: (what: string) => settleUntil(app, what, { timeoutMs: 4_000 }), | |
| 62 | stop: () => app.stop(), | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | const open = async (h: ReturnType<typeof boot>) => { | |
| 67 | await h.ctrl("k"); | |
| 68 | await h.type("Crew subscriptions"); | |
| 69 | await h.until("each account's budget"); | |
| 70 | await h.press("enter"); | |
| 71 | await h.until("a adds"); | |
| 72 | }; | |
| 73 | ||
| 74 | beforeAll(async () => { | |
| 75 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-subadd-")); | |
| 76 | forge = createApp({ dataDir, port: 0 }); | |
| 77 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 78 | const made = await createUser(forge.db, { | |
| 79 | origin: "seed", | |
| 80 | trusted: true, | |
| 81 | username, | |
| 82 | email: `${username}@forge.local`, | |
| 83 | password: "a-good-passphrase", | |
| 84 | adminIfFirst, | |
| 85 | }); | |
| 86 | if (!made.ok) throw new Error(made.error); | |
| 87 | } | |
| 88 | }); | |
| 89 | afterAll(async () => { | |
| 90 | forge.close(); | |
| 91 | await rm(dataDir, { recursive: true, force: true }); | |
| 92 | }); | |
| 93 | ||
| 94 | test("adds one with refusals in words, never drawing the token; D asks first", async () => { | |
| 95 | const h = boot("alice"); | |
| 96 | await open(h); | |
| 97 | await h.press("a"); | |
| 98 | await h.until("Add a Claude account"); | |
| 99 | await h.type("bad/name"); | |
| 100 | await h.press("enter"); | |
| 101 | await h.until("A name is a word or two"); | |
| 102 | await h.press("esc", "a"); | |
| 103 | await h.type("gamma"); | |
| 104 | await h.press("enter"); | |
| 105 | await h.until("Token for gamma"); | |
| 106 | await h.type(TOKEN); | |
| 107 | await h.press("enter"); | |
| 108 | await h.until("API base for gamma"); | |
| 109 | await h.type("http://nope"); | |
| 110 | await h.press("enter"); | |
| 111 | await h.until("The API base URL must be https"); | |
| 112 | expect(listSubscriptions(forge.db)).toHaveLength(0); | |
| 113 | await h.press("backspace"); | |
| 114 | for (let i = 0; i < 10; i++) await h.press("backspace"); | |
| 115 | await h.press("enter"); | |
| 116 | await h.until("gamma is in the pool."); | |
| 117 | expect(listSubscriptions(forge.db).map((s) => s.name)).toEqual(["gamma"]); | |
| 118 | expect(h.text()).toContain("gamma"); | |
| 119 | for (const frame of h.frames) expect(frame).not.toContain("secretgamma"); | |
| 120 | ||
| 121 | await h.press("a"); | |
| 122 | await h.type("empty"); | |
| 123 | await h.press("enter"); | |
| 124 | await h.until("Token for empty"); | |
| 125 | await h.press("enter"); | |
| 126 | await h.until("That is not a model token"); | |
| 127 | expect(listSubscriptions(forge.db)).toHaveLength(1); | |
| 128 | await h.press("esc", "a"); | |
| 129 | await h.type("gamma"); | |
| 130 | await h.press("enter"); | |
| 131 | await h.type(TOKEN); | |
| 132 | await h.press("enter"); | |
| 133 | await h.press("enter"); | |
| 134 | await h.until("There is already a subscription called gamma."); | |
| 135 | await h.press("esc"); | |
| 136 | ||
| 137 | await h.press("D"); | |
| 138 | await h.until("Remove gamma?"); | |
| 139 | expect(h.text()).toContain("keep it in their history"); | |
| 140 | await h.press("esc"); | |
| 141 | expect(listSubscriptions(forge.db)).toHaveLength(1); | |
| 142 | await h.press("D"); | |
| 143 | await h.until("Remove gamma?"); | |
| 144 | await h.press("enter"); // the default is Cancel | |
| 145 | expect(listSubscriptions(forge.db)).toHaveLength(1); | |
| 146 | await h.press("D"); | |
| 147 | await h.until("Remove gamma?"); | |
| 148 | await h.press("left", "enter"); | |
| 149 | await h.until("gamma is removed."); | |
| 150 | expect(listSubscriptions(forge.db)).toHaveLength(0); | |
| 151 | for (const frame of h.frames) expect(frame).not.toContain("secretgamma"); | |
| 152 | h.stop(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("an agent that is an admin is offered nothing and changes nothing", async () => { | |
| 156 | forge.db.query("UPDATE user SET is_agent = 1 WHERE username = 'alice'").run(); | |
| 157 | const h = boot("alice"); | |
| 158 | await h.ctrl("k"); | |
| 159 | await h.type("Crew subscriptions"); | |
| 160 | await h.until("Crew subscriptions"); | |
| 161 | expect(h.text()).not.toContain("each account's budget"); | |
| 162 | h.stop(); | |
| 163 | forge.db.query("UPDATE user SET is_agent = 0 WHERE username = 'alice'").run(); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a person admin sees a token-free list; a non-admin's a and D do nothing", async () => { | |
| 167 | const pushed: unknown[] = []; | |
| 168 | const ctx = { push: (x: unknown) => pushed.push(x), invalidate() {}, pop() {} } as unknown as Ctx; | |
| 169 | const made = addSubscription(forge.db, dataDir, { name: "delta", token: TOKEN }); | |
| 170 | if (!made.ok) throw new Error(made.error); | |
| 171 | const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false }); | |
| 172 | const session = (name: string) => | |
| 173 | new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, name)! }); | |
| 174 | const bob = subscriptionsScreen(session("bob"), ctx); | |
| 175 | expect(bob.key(key("a"))).toBe(false); | |
| 176 | expect(bob.key(key("D"))).toBe(false); | |
| 177 | expect(pushed).toHaveLength(0); | |
| 178 | const alice = subscriptionsScreen(session("alice"), ctx); | |
| 179 | expect(alice.key(key("D"))).toBe(true); | |
| 180 | expect(pushed).toHaveLength(1); | |
| 181 | expect(listSubscriptions(forge.db)).toHaveLength(1); | |
| 182 | const h = boot("alice"); | |
| 183 | await open(h); | |
| 184 | expect(h.text()).toContain("delta"); | |
| 185 | expect(h.text()).not.toContain("secretgamma"); | |
| 186 | h.stop(); | |
| 187 | deleteSubscription(forge.db, made.value.id); | |
| 188 | }); | |
| 189 | }); |
new above
offline
Order the imports alphabetically: isSubscriptionName belongs after getSubscription/listSubscriptions with the rest.