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

Merged opened this

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

Merged

scout/forge:scout/7440-the-terminal-s-subscriptions-screen-adds → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7440-the-terminal-s-subscriptions-screen-adds was merged into main as 26a0952.

Review

Sign in to review this change.
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 file
src/tui/forge.ts +2 −2
@@ -569,11 +569,11 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
569569 run: () => ctx.push(invitesScreen(session, ctx)),
570570 });
571571 // 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")
573573 commands.push({
574574 id: "subscriptions",
575575 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",
577577 run: () => ctx.push(subscriptionsScreen(session, ctx)),
578578 });
579579 // Cloud accounts, as Settings → Cloud accounts has them: a forge admin (#2199).
src/tui/screens/subscriptions.ts +72 −4
@@ -4,31 +4,38 @@
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
66 * 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.
710 */
811
912 import {
13 addSubscription,

Order the imports alphabetically: isSubscriptionName belongs after getSubscription/listSubscriptions with the rest.

1014 budgetRefusal,
1115 budgetsOf,
16 deleteSubscription,
1217 getSubscription,
18 isSubscriptionName,
1319 listSubscriptions,
1420 scopesOf,
1521 setBudget,
1622 wakeSubscription,
1723 } from "../../core/subscriptions.ts";
24 import { SUBSCRIPTION_WORDS, addRefusal } from "../../core/subscriptions_words.ts";
1825 import type { Ctx, Screen } from "../app/app.ts";
1926 import { binding } from "../app/keys.ts";
2027 import { style } from "../term/style.ts";
2128 import { truncate } from "../term/width.ts";
2229 import type { Session } from "../session.ts";
2330 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";
2532
2633 const money = (n: number): string => `$${n.toFixed(2)}`;
2734
2835 export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
2936 const state = new ListState();
3037 let note = "";
31 const admitted = () => f.user?.isAdmin === true;
38 const admitted = () => f.user?.isAdmin === true && f.user.kind === "user";
3239 const items = () => (admitted() ? listSubscriptions(f.app.db) : []);
3340
3441 const rows = (): ListRow[] => {
@@ -62,14 +69,45 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
6269 return undefined;
6370 };
6471
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
65103 const screen: Screen = {
66104 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")],
68106 render(area, s) {
69107 const c = ctx.theme.c;
70108 const all = items();
71109 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.";
73111 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
74112 drawList(s, body!, c, state, rows());
75113 },
@@ -97,6 +135,36 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
97135 onSubmit: (raw) => setTo(sub.id, raw),
98136 }),
99137 );
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 );
100168 } else return false;
101169 return true;
102170 },
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs