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

Merged opened this

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

Merged

scout/forge:scout/7244-the-terminal-lists-the-crew-s-claude-sub → main 3 commits fork 5 threads · 5 unresolved

  • Merged. scout/7244-the-terminal-lists-the-crew-s-claude-sub was merged into main as b155c97.

Review

Sign in to review this change.
6 files changed +320 −12
docs/CREW.md +1
@@ -303,6 +303,7 @@ one the provider rests hands its runs to the other until the reset.
303303
304304 **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).
305305
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.
306307 **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.

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.

307308
308309 **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> {
118118 );
119119 }
120120
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 subscriptionBudget (src/web/pages/subscriptions.ts) still repeats this check inline. Worth pointing that door here too in a follow-up so all three can't drift.

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
121142 /**
122143 * Set or clear (null) an account's whole-life budget. False for no such
123144 * 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";
32 import { good, bad, type Tool } from "../shared.ts";
43
54 export const tool: Tool = {
@@ -21,16 +20,9 @@ export const tool: Tool = {
2120 return bad("invalid", "id is the account's id");
2221 if (usd !== null && (typeof usd !== "number" || !Number.isFinite(usd)))
2322 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.");
3426 const budgetUsd = usd === null ? null : Math.round(usd * 100) / 100;
3527 return good({
3628 id,
src/tui/forge.ts +9
@@ -61,6 +61,7 @@ import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/mod
6161 import { sshKeysScreen } from "./screens/sshkeys.ts";
6262 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
6363 import { invitesScreen } from "./screens/invites.ts";
64 import { subscriptionsScreen } from "./screens/subscriptions.ts";
6465 import { signInsScreen } from "./screens/signins.ts";
6566 import { quotasScreen } from "./screens/quotas.ts";
6667 import { teamsScreen } from "./screens/teams.ts";
@@ -529,6 +530,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
529530 hint: "make a single-use link, see which are open, revoke one",
530531 run: () => ctx.push(invitesScreen(session, ctx)),
531532 });
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 });
532541 // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199).
533542 if (session.user?.isAdmin === true)
534543 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 budgetRefusal's message changes, the terminal goes back to saying null without any test failing. Consider a door-neutral says in core.

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: none clears an unlocked budget; a locked budget refuses a raise and a clear in words.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs