@tokenoftrust/cli 1.3.4 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `tot grants` — what can the signed-in identity actually do on each store?
3
+ *
4
+ * A diagnostic sibling of `tot whoami`: where whoami answers "who am I and which
5
+ * stores can I touch", `grants` drills into the DEVELOPER-ACCESS GRANT behind each
6
+ * of those stores — its capability, ship-on-behalf tier, expiry, and whether it's
7
+ * been revoked. This is the RFC 7662-shaped introspection the MCP already runs
8
+ * internally on every `tenant_checkout` (tot-mcp: operator/grant-introspection.ts,
9
+ * `GrantIntrospector.introspect({developer, tenant}) -> {active, capability, tier,
10
+ * exp, revokedAt}`) — surfaced to the developer so a "why can't I check this store
11
+ * out?" is answerable without an operator.
12
+ *
13
+ * Forward-compatible by design: it PREFERS a server-side `grant_introspect` tool
14
+ * (the follow-up that exposes the introspector per the signed-in session's stores)
15
+ * and, until that tool ships, FALLS BACK to `client_list` — which today returns only
16
+ * the store ids + environment, no grant detail. In the fallback it renders the store
17
+ * list and says plainly that the capability/tier/expiry/revocation picture needs the
18
+ * server-side tool, rather than inventing numbers it can't see. The moment
19
+ * `grant_introspect` lands, the same command consumes it with no further CLI change.
20
+ *
21
+ * Auth/session and MCP transport are reused verbatim from the doctor/whoami pattern
22
+ * (establishSession attaches the developer bearer BEFORE the handshake so the server
23
+ * binds this identity — the same ordering that fixes the "no stores" dead-end).
24
+ * Dependency-free.
25
+ */
26
+ import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
28
+ import { createMcpClient } from "../mcp.mjs";
29
+ import { storeListError } from "./checkout.mjs";
30
+ import { offerSignIn } from "./login.mjs";
31
+ import { recordServerPolicy } from "../update-check.mjs";
32
+
33
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
34
+
35
+ const USAGE = `tot grants — capability / tier / expiry / revocation for the stores you can act on
36
+
37
+ tot grants show each store's developer-access grant
38
+ tot grants --json machine-readable output`;
39
+
40
+ function parseArgs(argv) {
41
+ const a = { json: false, help: false };
42
+ for (const t of argv) {
43
+ if (t === "--json") a.json = true;
44
+ else if (t === "--help" || t === "-h") a.help = true;
45
+ }
46
+ return a;
47
+ }
48
+
49
+ /**
50
+ * Normalize the grant source into plain rows, tolerant of the two shapes we may
51
+ * read: the introspector-backed `grant_introspect` result (`{ activeTenant?, grants:
52
+ * [{ tenant, environment, active, capability, tier, exp, revokedAt }] }`) and the
53
+ * `client_list` fallback (`{ activeTenant?, clients: [{ tenant, environment, active
54
+ * }] }` or a bare array). The introspector's `active` means "grant currently valid";
55
+ * client_list's `active` means "the currently-SELECTED tenant" — two different bits,
56
+ * so they're kept apart here (`grantActive` vs `selected`) and never conflated.
57
+ *
58
+ * @param {unknown} resp
59
+ * @returns {{ introspected: boolean, rows: Array<{ tenant: string, environment: string|null,
60
+ * selected: boolean, grantActive: boolean|null, capability: string|null, tier: string|null,
61
+ * exp: number|null, revokedAt: string|null }> }}
62
+ */
63
+ export function normalizeGrantRows(resp) {
64
+ if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(resp.grants)) {
65
+ const activeTenant = typeof resp.activeTenant === "string" ? resp.activeTenant : null;
66
+ const rows = resp.grants
67
+ .map((g) => {
68
+ const tenant = g?.tenant ?? g?.id ?? null;
69
+ return {
70
+ tenant,
71
+ environment: g?.environment ?? null,
72
+ selected: activeTenant ? tenant === activeTenant : Boolean(g?.selected),
73
+ grantActive: typeof g?.active === "boolean" ? g.active : null,
74
+ capability: g?.capability ?? null,
75
+ tier: g?.tier ?? null,
76
+ exp: typeof g?.exp === "number" ? g.exp : null,
77
+ revokedAt: g?.revokedAt ?? null,
78
+ };
79
+ })
80
+ .filter((r) => r.tenant);
81
+ return { introspected: true, rows };
82
+ }
83
+
84
+ // Fallback: client_list — store ids + environment + the selected marker only. No
85
+ // grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
86
+ const clients = Array.isArray(resp) ? resp : resp?.clients || resp?.tenants || [];
87
+ const rows = (Array.isArray(clients) ? clients : [])
88
+ .map((c) => ({
89
+ tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
90
+ environment: c?.environment ?? null,
91
+ selected: Boolean(c?.active),
92
+ grantActive: null,
93
+ capability: null,
94
+ tier: null,
95
+ exp: null,
96
+ revokedAt: null,
97
+ }))
98
+ .filter((r) => r.tenant);
99
+ return { introspected: false, rows };
100
+ }
101
+
102
+ /**
103
+ * Classify a grant row into a single status word. `unknown` is the honest answer
104
+ * for a fallback (client_list) row where the grant state simply isn't observable
105
+ * yet. For an introspected row it derives from grantActive + exp + revokedAt, and
106
+ * defensively downgrades an active-but-past-exp grant to `expired` even if the
107
+ * server hasn't flipped `active` (clock-skew / caching robustness).
108
+ *
109
+ * @param {{ grantActive: boolean|null, exp: number|null, revokedAt: string|null }} row
110
+ * @param {{ now?: number }} [opts]
111
+ * @returns {"active"|"revoked"|"expired"|"inactive"|"unknown"}
112
+ */
113
+ export function grantStatus(row, { now = Date.now() } = {}) {
114
+ if (!row || row.grantActive === null || row.grantActive === undefined) return "unknown";
115
+ if (row.grantActive === false) return row.revokedAt ? "revoked" : "inactive";
116
+ if (typeof row.exp === "number" && row.exp * 1000 <= now) return "expired";
117
+ return "active";
118
+ }
119
+
120
+ /**
121
+ * Humanize an RFC 7662 `exp` (epoch SECONDS) into an ISO instant + a coarse relative
122
+ * hint. `no expiry` for a grant that never expires; `expired <iso>` once it's past.
123
+ * @param {number|null|undefined} exp
124
+ * @param {{ now?: number }} [opts]
125
+ * @returns {string}
126
+ */
127
+ export function formatExpiry(exp, { now = Date.now() } = {}) {
128
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return "no expiry";
129
+ const ms = exp * 1000;
130
+ const iso = new Date(ms).toISOString();
131
+ const deltaMs = ms - now;
132
+ if (deltaMs <= 0) return `expired ${iso}`;
133
+ const days = Math.floor(deltaMs / 86_400_000);
134
+ const hours = Math.floor((deltaMs % 86_400_000) / 3_600_000);
135
+ const rel = days > 0 ? `in ${days}d ${hours}h` : hours > 0 ? `in ${hours}h` : "in <1h";
136
+ return `${iso} (${rel})`;
137
+ }
138
+
139
+ /**
140
+ * The one-line grant detail printed (and asserted in tests) under each store. Pure —
141
+ * no I/O — so the exact copy is unit-tested. An unknown (fallback) row says so instead
142
+ * of pretending; every other status is `status · capability=… · tier=… · <expiry|revoked>`.
143
+ * @param {ReturnType<typeof normalizeGrantRows>["rows"][number]} row
144
+ * @param {{ now?: number }} [opts]
145
+ * @returns {string}
146
+ */
147
+ export function describeGrantRow(row, { now = Date.now() } = {}) {
148
+ const status = grantStatus(row, { now });
149
+ if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
150
+ const parts = [status];
151
+ if (row.capability) parts.push(`capability=${row.capability}`);
152
+ if (row.tier) parts.push(`tier=${row.tier}`);
153
+ if (status === "revoked" && row.revokedAt) parts.push(`revoked ${row.revokedAt}`);
154
+ else if (row.grantActive) parts.push(`expiry ${formatExpiry(row.exp, { now })}`);
155
+ return parts.join(" · ");
156
+ }
157
+
158
+ /**
159
+ * Best-effort read of the introspector-backed tool. Returns the result only when it
160
+ * is a genuine introspection response (carries a `grants` array); anything else —
161
+ * including the JSON-RPC error a server that doesn't register `grant_introspect` yet
162
+ * throws — resolves to null so the caller falls back to `client_list`.
163
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
164
+ */
165
+ async function tryIntrospect(client) {
166
+ try {
167
+ const r = await client.callTool("grant_introspect", {});
168
+ if (r && typeof r === "object" && Array.isArray(r.grants)) return r;
169
+ return null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /** @param {string[]} argv @param {any} _ctx */
176
+ export async function run(argv, _ctx) {
177
+ const args = parseArgs(argv);
178
+ if (args.help) {
179
+ console.log(USAGE);
180
+ return 0;
181
+ }
182
+ const env = process.env;
183
+ const now = Date.now();
184
+
185
+ // Same MCP-URL resolution as whoami: the cached session's origin wins, then env
186
+ // overrides, then the public default.
187
+ const creds = readCredentials(defaultCredentialsPath(env));
188
+ const mcpUrl = creds?.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
189
+ const client = createMcpClient(mcpUrl);
190
+
191
+ // Attach the developer bearer BEFORE the handshake (establishSession) so the server
192
+ // binds this identity — the grants belong to the signed-in developer. When there's
193
+ // no session yet and we're on a TTY, offer to sign in inline and retry once;
194
+ // otherwise the AuthUnavailableError surfaces house-style with its next step.
195
+ try {
196
+ await establishSession(client, { env });
197
+ } catch (e) {
198
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
199
+ const signedIn = await offerSignIn(mcpUrl, env, {});
200
+ if (!signedIn) throw e;
201
+ await establishSession(client, { env }); // retry once, in-flow
202
+ } else {
203
+ throw e;
204
+ }
205
+ }
206
+
207
+ // Prefer the introspector-backed tool; fall back to client_list until it ships.
208
+ let resp = await tryIntrospect(client);
209
+ if (!resp) {
210
+ resp = await client.callTool("client_list", {});
211
+ // Update-awareness Layer 2: an authed response may carry a version-support policy.
212
+ recordServerPolicy(resp?.cliPolicy, env);
213
+ } else {
214
+ recordServerPolicy(resp?.cliPolicy, env);
215
+ }
216
+
217
+ const { introspected, rows } = normalizeGrantRows(resp);
218
+ const listErr = storeListError(resp);
219
+
220
+ if (args.json) {
221
+ console.log(
222
+ JSON.stringify(
223
+ {
224
+ introspected,
225
+ error: listErr || null,
226
+ grants: rows.map((r) => ({ ...r, status: grantStatus(r, { now }) })),
227
+ },
228
+ null,
229
+ 2,
230
+ ),
231
+ );
232
+ return listErr && rows.length === 0 ? 1 : 0;
233
+ }
234
+
235
+ if (listErr && rows.length === 0) {
236
+ console.log(`\nCouldn't read your grants: ${listErr}`);
237
+ console.log("Run `tot whoami` to check your session, or `tot login` again.");
238
+ return 1;
239
+ }
240
+ if (rows.length === 0) {
241
+ console.log("\nNo stores you can act on yet for this identity.");
242
+ console.log("If you were just invited, it may still be propagating — try again in a minute.");
243
+ return 0;
244
+ }
245
+
246
+ console.log("\nDeveloper-access grants for the stores you can act on:\n");
247
+ for (const row of rows) {
248
+ const envLabel = row.environment ? ` [${row.environment}]` : "";
249
+ const marker = row.selected ? " (active tenant)" : "";
250
+ console.log(` ${row.tenant}${envLabel}${marker}`);
251
+ console.log(` ${describeGrantRow(row, { now })}`);
252
+ }
253
+
254
+ if (!introspected) {
255
+ console.log(
256
+ "\nNote: full grant detail (capability / tier / expiry / revocation) needs the",
257
+ );
258
+ console.log(
259
+ "server-side `grant_introspect` MCP tool, which this MCP doesn't expose yet —",
260
+ );
261
+ console.log("showing the stores you can act on only. Run `tot whoami` for session status.");
262
+ }
263
+ return 0;
264
+ }
@@ -23,7 +23,8 @@ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oa
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
26
- import { cockpitRecoveryUrl, normalizeEmailHint } from "../auth.mjs";
26
+ import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
27
+ import { isInteractive, promptYesNo } from "../prompt.mjs";
27
28
 
28
29
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
29
30
 
@@ -140,6 +141,52 @@ export async function loginAndCache(mcpUrl, env = process.env, { log = () => {},
140
141
  return merged;
141
142
  }
142
143
 
144
+ /**
145
+ * Interactive sign-in assist — the no-dead-end path. When a command hits "you're
146
+ * not signed in" on a TTY, don't tell the developer to go run `tot login` and come
147
+ * back: offer to sign them in RIGHT HERE, open the browser, and hand control back
148
+ * so they continue in the same flow (handoff 2026-08-02 v2 §UX bar — "the product
149
+ * taking their hand", no re-run, no OAuth jargon).
150
+ *
151
+ * Returns the cached credentials on success, or null when the offer is declined,
152
+ * skipped (non-interactive and not auto-yes — never hang CI), or the sign-in
153
+ * doesn't complete. A null return means "fall back to your own not-signed-in
154
+ * behavior" (the free sample preview for `tot start`, a crisp error for others).
155
+ *
156
+ * All I/O is injectable so this unit-tests with no real prompt or browser:
157
+ * interactive — is this a TTY we can prompt on (default: stdin+stdout are a TTY)
158
+ * yes — auto-accept, skip the prompt (the caller's --yes)
159
+ * prompt — async (question, defaultYes) => boolean
160
+ * login — async (mcpUrl, env, {log}) => creds (defaults to loginAndCache)
161
+ * out — where the human-facing lines go (default: stderr, like the rest)
162
+ *
163
+ * @returns {Promise<object|null>}
164
+ */
165
+ export async function offerSignIn(mcpUrl, env = process.env, {
166
+ interactive = isInteractive(),
167
+ yes = false,
168
+ prompt = promptYesNo,
169
+ login = loginAndCache,
170
+ out = (m) => console.error(m),
171
+ } = {}) {
172
+ if (!interactive && !yes) return null; // non-TTY without --yes: never prompt/hang
173
+ if (!yes) {
174
+ out("");
175
+ out(" One quick step — sign in to Token of Trust and I'll take you straight to your store.");
176
+ const ok = await prompt(" Sign in now?", true);
177
+ if (!ok) return null;
178
+ }
179
+ out(" → opening your browser to sign in …");
180
+ try {
181
+ const creds = await login(mcpUrl, env, { log: out });
182
+ out(" ✓ signed in");
183
+ return creds;
184
+ } catch (e) {
185
+ out(` couldn't finish signing in (${e?.message || e}).`);
186
+ return null;
187
+ }
188
+ }
189
+
143
190
  /**
144
191
  * The core of `tot login --code`: run the browserless redemption (POST the invite
145
192
  * token to the MCP, get a grant back) and cache it, reusing a previously-registered
@@ -164,20 +211,29 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
164
211
  * (a full-object overwrite, not a merge) — carry it forward when re-authing against
165
212
  * the SAME mcpUrl (a different MCP means a different session; the old bridge
166
213
  * credential and trace no longer apply).
214
+ *
215
+ * IDENTITY GUARD: only carry it when the new sign-in is the SAME person. If we can
216
+ * POSITIVELY tell it's a different identity — both the prior and the new token
217
+ * carry an email and they differ — we drop the prior bridge/trace/hint rather than
218
+ * bleed one identity's hosted /dev panel + feedback trace into another's (the
219
+ * staff↔developer re-login on one machine). When we can't tell (opaque token, no
220
+ * hint either side) we preserve the same-person carry, so this never regresses a
221
+ * routine re-login. For genuinely concurrent identities, prefer per-terminal
222
+ * `TOT_PROFILE` (separate credential files — no carry to guard at all).
167
223
  */
168
224
  export function mergeActivityBridge(prior, mcpUrl, creds) {
169
- if (prior?.mcpUrl === mcpUrl) {
170
- const emailHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
171
- return {
172
- ...creds,
173
- ...(prior.activityToken && prior.activityUrl
174
- ? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
175
- : {}),
176
- ...(prior.traceId ? { traceId: prior.traceId } : {}),
177
- ...(emailHint ? { emailHint } : {}),
178
- };
179
- }
180
- return creds;
225
+ if (prior?.mcpUrl !== mcpUrl) return creds;
226
+ const priorHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
227
+ const newHint = redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "");
228
+ if (priorHint && newHint && priorHint !== newHint) return creds; // different identity → no bleed
229
+ return {
230
+ ...creds,
231
+ ...(prior.activityToken && prior.activityUrl
232
+ ? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
233
+ : {}),
234
+ ...(prior.traceId ? { traceId: prior.traceId } : {}),
235
+ ...(priorHint ? { emailHint: priorHint } : {}),
236
+ };
181
237
  }
182
238
 
183
239
  export function inviteCodeRecoveryNext(activityUrl, emailHint = null) {
@@ -5,7 +5,8 @@
5
5
  * It COMPOSES the other commands' cores in-process (no shelling out to `tot …`):
6
6
  *
7
7
  * preflight collectChecks() (F — self-healing where it can be)
8
- * login resolveSession() (operator today; device-code is the B seam)
8
+ * login establishSession() (developer OAuth; offers inline sign-in when
9
+ * there's no session yet — no re-run — see tryResolveSession)
9
10
  * (A2 — run concurrently: neither gates the other; wait on the slower)
10
11
  * ↓
11
12
  * store normalizeStores(client_list) → auto-pick if exactly one, else
@@ -69,19 +70,20 @@ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
69
70
  import { streamDevLogs } from "../dev-logs.mjs";
70
71
  import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
71
72
  import { IDEAS } from "./ideas.mjs";
73
+ import { offerSignIn } from "./login.mjs";
74
+ import { isInteractive, promptYesNo } from "../prompt.mjs";
72
75
 
73
76
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
74
77
  const CLAUDE_MCP_ARGS = ["mcp", "add", "--transport", "http", "tot", `${DEFAULT_MCP_URL}/mcp`];
75
78
 
76
79
  function parseArgs(argv) {
77
80
  const a = {
78
- mcp: null, identity: null, tenant: null, port: "4321",
81
+ mcp: null, tenant: null, port: "4321",
79
82
  noOpen: false, noConnect: false, yes: false, docker: false, sample: false, help: false,
80
83
  };
81
84
  for (let i = 0; i < argv.length; i++) {
82
85
  const t = argv[i];
83
86
  if (t === "--mcp") a.mcp = argv[++i];
84
- else if (t === "--identity") a.identity = argv[++i];
85
87
  else if (t === "--tenant") a.tenant = argv[++i];
86
88
  else if (t === "--port") a.port = argv[++i];
87
89
  else if (t === "--no-open") a.noOpen = true;
@@ -106,8 +108,7 @@ Options:
106
108
  artifact can't be fetched
107
109
  --no-open don't auto-open the browser
108
110
  --no-connect skip the "Connect Claude for AI editing?" prompt
109
- --yes, -y assume yes for prompts (non-interactive)
110
- --identity <who> force "operator" or "developer" auth (default: auto)
111
+ --yes, -y assume yes for prompts (non-interactive; also auto-signs-in)
111
112
  --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
112
113
 
113
114
  On a multi-store identity, the tenant you pick (or pass via --tenant) is
@@ -373,15 +374,14 @@ async function runSampleStart(args, ctx, env, startedAt) {
373
374
  * MCP init + session resolve — the "login" half of preflight. Throws
374
375
  * AuthUnavailableError (no creds) or a CliError (can't reach the MCP).
375
376
  */
376
- async function loginStep(client, env, args) {
377
+ async function loginStep(client, env, _args) {
377
378
  // establishSession attaches auth in the right order relative to the handshake:
378
- // the developer bearer BEFORE initialize (so the server binds this identity at
379
- // initialize time — otherwise the session is anonymous and client_list is empty),
380
- // the operator credential_validate after it. We inject our own initialize so the
381
- // unreachable-MCP case still surfaces the actionable CliError below.
379
+ // the developer bearer BEFORE initialize, so the server binds this identity at
380
+ // initialize time — otherwise the session is anonymous and client_list is empty.
381
+ // We inject our own initialize so the unreachable-MCP case still surfaces the
382
+ // actionable CliError below.
382
383
  return establishSession(client, {
383
384
  env,
384
- prefer: args.identity || undefined,
385
385
  initialize: async () => {
386
386
  try {
387
387
  await client.initialize();
@@ -398,12 +398,27 @@ async function loginStep(client, env, args) {
398
398
  * Resolve a session but return null (instead of throwing) for the two conditions
399
399
  * the free-taste fallback should absorb: no usable session, or the MCP being
400
400
  * unreachable. A blocking machine problem (or any other error) still throws.
401
+ *
402
+ * When there's simply no session yet, we don't dead-end at "go run tot login" —
403
+ * on a TTY (or --yes) we OFFER to sign the developer in right here (browser opens),
404
+ * then continue straight into the run with no re-run (handoff 2026-08-02 v2). A
405
+ * declined/skipped offer falls through to the free sample preview as before.
401
406
  */
402
407
  async function tryResolveSession(client, env, args) {
403
408
  try {
404
409
  return await loginStep(client, env, args);
405
410
  } catch (e) {
406
- if (e instanceof AuthUnavailableError && e.reason === "missing") return null;
411
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
412
+ const signedIn = await offerSignIn(client.mcpUrl, env, { yes: args.yes });
413
+ if (!signedIn) return null; // declined/skipped → sample preview
414
+ try {
415
+ return await loginStep(client, env, args); // retry once, in-flow
416
+ } catch (e2) {
417
+ if (e2 instanceof AuthUnavailableError && e2.reason === "missing") return null;
418
+ if (e2 instanceof CliError && /can't reach the Token of Trust MCP/.test(e2.message)) return null;
419
+ throw e2;
420
+ }
421
+ }
407
422
  if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
408
423
  throw e;
409
424
  }
@@ -499,13 +514,10 @@ async function prefetchDockerLogin(client, devArgs, env) {
499
514
  }
500
515
 
501
516
  /** A human label for a resolved session — the email when we could read it from
502
- * the OAuth token, else the operator app domain, else the identity kind. */
517
+ * the OAuth token, else a generic developer label. */
503
518
  function describeIdentity(session) {
504
519
  if (!session) return "an unknown identity";
505
520
  if (session.email) return session.email;
506
- if (session.identity === "operator") {
507
- return session.appDomain ? `operator (${session.appDomain})` : "operator";
508
- }
509
521
  return "your developer identity";
510
522
  }
511
523
 
@@ -727,22 +739,8 @@ function connectClaude() {
727
739
  }
728
740
 
729
741
  // ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
730
-
731
- function isInteractive() {
732
- return Boolean(process.stdin.isTTY && process.stdout.isTTY);
733
- }
734
-
735
- async function promptYesNo(question, defaultYes) {
736
- if (!isInteractive()) return defaultYes;
737
- const rl = createInterface({ input: process.stdin, output: process.stdout });
738
- try {
739
- const ans = (await rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
740
- if (!ans) return defaultYes;
741
- return ans === "y" || ans === "yes";
742
- } finally {
743
- rl.close();
744
- }
745
- }
742
+ // isInteractive + promptYesNo now live in ../prompt.mjs (shared with the sign-in
743
+ // offer); promptChoice stays here as it's only used by the store picker.
746
744
 
747
745
  /** Prompt for a 1..n choice; returns a 0-based index (defaults to first). */
748
746
  async function promptChoice(n) {