@bli-cockpit/cli 0.2.212 → 0.2.217

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,129 @@
1
+ /**
2
+ * `cockpit notes sync <account-id>` — read one notetaker now, until it is caught
3
+ * up (BLI-17974; split out of `notes-accounts.ts`).
4
+ *
5
+ * ONE door call is a bounded pass: it answers well inside Vercel's 60 s limit
6
+ * with `more: true` and a cursor it saved after every meeting. The terminal asks
7
+ * again until Tower says the account is caught up, printing each pass, and exits
8
+ * 1 with the reason when a pass fails. The loop calls the same door the web
9
+ * Sync button, the MCP `notes_sync` tool and JARVIS call; nothing here reads a
10
+ * notetaker itself.
11
+ *
12
+ * stdout is the answer, stderr is the commentary (in `--json` the progress lines
13
+ * move to stderr so stdout stays one object).
14
+ */
15
+ import { writeLine } from "./cli-io.js";
16
+ import { ask, emit, fail, TAG } from "./notes-door.js";
17
+ /** One door call is a bounded pass (under a minute); the terminal allows twice that. */
18
+ const WRITE_DEADLINE_MS = 120_000;
19
+ /** BLI-17974: bounds on the sync loop, so a stuck account ends in a reason, not a hang. */
20
+ const MAX_SYNC_PASSES = 200;
21
+ const MAX_STALLED_PASSES = 3;
22
+ const MAX_TRANSIENT_FAILURES = 3;
23
+ const MAX_LIVE_WAITS = 40;
24
+ /** How long to wait for a pass another caller has live before asking again. */
25
+ const LIVE_PASS_WAIT_MS = 5_000;
26
+ export async function syncNotetaker(command, door) {
27
+ const accountId = command.accountId ?? "";
28
+ if (accountId === "") {
29
+ // One account per call on purpose: the sweep over ALL of them is the cron,
30
+ // and a terminal asking for "everything" would hide which one failed.
31
+ return fail(door, "account_required", "Name the notetaker to sync: `cockpit notes sync <account-id>`. `cockpit notes accounts` lists the ids.");
32
+ }
33
+ // BLI-17974: ONE door call is a bounded pass (it answers well inside Vercel's
34
+ // 60 s limit with `more: true` and a saved cursor). The terminal asks again
35
+ // until Tower says the account is caught up, printing each pass, and exits 1
36
+ // with the reason when a pass fails. The loop is the same door the web button
37
+ // and the MCP tool call; nothing here reads a notetaker itself.
38
+ const env = door.io.env ?? {};
39
+ const pollMs = Number.parseInt(env["COCKPIT_NOTES_SYNC_POLL_MS"] ?? "", 10);
40
+ const liveWaitMs = Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS;
41
+ const totals = { stored: 0, unchanged: 0, skipped: 0 };
42
+ let passes = 0;
43
+ let stalled = 0;
44
+ let liveWaits = 0;
45
+ let transientFailures = 0;
46
+ let last;
47
+ while (passes < MAX_SYNC_PASSES) {
48
+ const answer = await ask(door, {
49
+ path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
50
+ method: "POST",
51
+ label: "notes sync",
52
+ timeoutMs: WRITE_DEADLINE_MS,
53
+ });
54
+ if (!answer.ok) {
55
+ // A timeout or a 5xx mid-history is the call's, not the account's: the
56
+ // cursor was saved after every meeting, so asking again resumes. Three in
57
+ // a row is a real outage and is reported with its reason.
58
+ const transient = /^(timeout|network|unreachable|http_5\d\d|BLI-E5\d\d|read_failed)/i.test(answer.reason);
59
+ if (transient && transientFailures < MAX_TRANSIENT_FAILURES) {
60
+ transientFailures += 1;
61
+ writeLine(door.io.stderr, `${TAG} sync call failed, retrying ${JSON.stringify({ reason: answer.reason, attempt: transientFailures })}`);
62
+ await sleepMs(liveWaitMs);
63
+ continue;
64
+ }
65
+ return failRun(door, answer.reason, answer.detail, last, passes, totals);
66
+ }
67
+ transientFailures = 0;
68
+ const run = answer.body.run;
69
+ if (!run) {
70
+ writeLine(door.io.stdout, "Tower answered without a run.");
71
+ return failRun(door, "no_run_in_answer", "Tower answered the sync without a run.", last, passes, totals);
72
+ }
73
+ last = run;
74
+ if (run.reason === "running") {
75
+ // Another pass (the cron, a second terminal) is live on this account. It
76
+ // writes the same cursor; wait for it rather than racing it.
77
+ liveWaits += 1;
78
+ writeLine(door.io.stderr, `${TAG} another pass is running ${JSON.stringify({ run_id: run.runId ?? null, waits: liveWaits })}`);
79
+ if (liveWaits > MAX_LIVE_WAITS) {
80
+ return failRun(door, "pass_still_running", "Another pass kept running on this notetaker. Check `cockpit notes sync-status`, then try again.", last, passes, totals);
81
+ }
82
+ await sleepMs(liveWaitMs);
83
+ continue;
84
+ }
85
+ passes += 1;
86
+ totals.stored += run.notesAdded;
87
+ totals.unchanged += run.notesUpdated;
88
+ totals.skipped += run.notesSkipped;
89
+ const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
90
+ const line = `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
91
+ // Progress is commentary in --json (stdout is the one answer), and the
92
+ // answer in a terminal.
93
+ writeLine(door.json ? door.io.stderr : door.io.stdout, line);
94
+ if (run.detail && !run.ok)
95
+ writeLine(door.json ? door.io.stderr : door.io.stdout, run.detail);
96
+ if (!run.ok) {
97
+ return failRun(door, run.reason, run.detail || `The pass ended ${run.reason}.`, run, passes, totals);
98
+ }
99
+ if (!run.more) {
100
+ if (door.json)
101
+ return emit(door, { ok: true, passes, totals, run });
102
+ writeLine(door.io.stdout, `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
103
+ return 0;
104
+ }
105
+ // A pass that handled nothing and still says "more" would loop forever.
106
+ if (run.notesAdded + run.notesUpdated + run.notesSkipped === 0)
107
+ stalled += 1;
108
+ else
109
+ stalled = 0;
110
+ if (stalled >= MAX_STALLED_PASSES) {
111
+ return failRun(door, "no_progress", `Three passes in a row handled no meeting (last: ${run.reason}). The cursor is saved; try again later or check \`cockpit notes sync-status\`.`, run, passes, totals);
112
+ }
113
+ }
114
+ return failRun(door, "too_many_passes", `Stopped after ${MAX_SYNC_PASSES} passes with meetings still waiting. Run \`cockpit notes sync ${accountId}\` again to continue from the saved cursor.`, last, passes, totals);
115
+ }
116
+ /** A sync that did not finish: the reason on stderr, the structured answer on stdout in --json. */
117
+ function failRun(door, reason, detail, run, passes, totals) {
118
+ writeLine(door.io.stderr, `${TAG} sync stopped ${JSON.stringify({ reason, passes })}`);
119
+ if (door.json) {
120
+ writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail, passes, totals, run }));
121
+ }
122
+ else {
123
+ writeLine(door.io.stderr, detail);
124
+ }
125
+ return 1;
126
+ }
127
+ function sleepMs(ms) {
128
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
129
+ }
@@ -1,219 +1,188 @@
1
1
  /**
2
- * `cockpit notes connect --provider wisprflow` — signing a person into Wispr
3
- * Flow's MCP server from a terminal (BLI-12926).
2
+ * `cockpit notes connect --provider wisprflow` — connecting Wispr Flow from a
3
+ * terminal (BLI-12926, rebuilt by BLI-17973 as one backend door).
4
4
  *
5
- * Wispr Flow has no API key for meetings. Its Notetaker is read through a
6
- * remote MCP server behind OAuth (docs.wisprflow.ai, "Connect Claude, ChatGPT
7
- * or another MCP client to Wispr Flow": "no API key or token is needed"). Its
8
- * authorization server publishes, at
9
- * `https://mcp-auth.wisprflow.com/.well-known/oauth-authorization-server`
10
- * (read 2026-10-08), dynamic client registration, PKCE `S256`, a refresh-token
11
- * grant and the RFC 8628 DEVICE grant. The device grant is the one a terminal
12
- * can use: it prints an address and a short code, the person approves in their
13
- * own browser (Google, Apple, Microsoft or company single sign-on; an
14
- * email-and-password Wispr account cannot complete MCP authorization), and this
15
- * file polls until Wispr hands back a refresh token.
5
+ * Wispr Flow has no API key for meetings: its Notetaker is read through a
6
+ * remote MCP server behind OAuth, and the sign-in is a person approving on
7
+ * Wispr's page. The first terminal version registered a device-grant client
8
+ * straight from the collector; Wispr's registration endpoint now refuses it
9
+ * (HTTP 400 `invalid_client_metadata`) and the command still exited 0. The
10
+ * second version only opened Tower's Settings page, which made the CLI a
11
+ * wrapper around the web UI.
16
12
  *
17
- * What leaves this machine: a client registration, then the device and token
18
- * calls, all to Wispr's own authorization server. The refresh token goes into a
19
- * bundle (`{ v, client_id, refresh_token }`) that `connect` posts to Tower's
20
- * accounts door, which seals it. It is never printed, never written to a file
21
- * and never put in a flag; `--json` shows the account, not the bundle.
13
+ * Now the terminal is a plain client of the same backend door the Settings
14
+ * button, the MCP tools and JARVIS call, with its own caller identity (this
15
+ * machine's device token):
22
16
  *
23
- * **No live call has been made from this code.** Every shape is the RFC's, and
24
- * the first real sign-in is the proof. In particular it is NOT known whether
25
- * Wispr's server accepts a dynamically registered client on the device grant
26
- * (the document lists both, not that they combine); if it does not, the refusal
27
- * below says what Wispr answered and `--key-stdin` accepts a bundle made by any
28
- * other means.
17
+ * 1. `POST /api/notes/accounts/wisprflow/start` answers `{ authorize_url,
18
+ * attempt_id, expires_at }`. The OAuth state and PKCE verifier stay on
19
+ * Tower's server; the terminal never sees a secret. Wispr Flow already
20
+ * connected is refused by the door (`account_already_connected`).
21
+ * 2. The address is printed first (a machine with no browser loses nothing),
22
+ * then opened with `browser-open.ts`, the one opener the collector has.
23
+ * 3. `GET /api/notes/accounts/wisprflow/status?attempt_id=` is polled every few
24
+ * seconds, up to ten minutes, until the attempt is `connected`, `failed` or
25
+ * `expired`. Every refusal carries a reason label and the caller exits 1.
29
26
  *
30
27
  * stdout carries nothing from here; every line is stderr commentary.
31
28
  */
32
29
  import { writeLine } from "./cli-io.js";
33
- export const WISPRFLOW_AUTH_ISSUER = "https://mcp-auth.wisprflow.com";
34
- export const WISPRFLOW_MCP_RESOURCE = "https://api.wisprflow.ai/connect/mcp";
35
- const SCOPE = "openid offline_access";
36
- const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
37
- const REQUEST_DEADLINE_MS = 30_000;
38
- /** RFC 8628: a client polls no faster than the server's interval, default 5 s. */
39
- const DEFAULT_INTERVAL_SECONDS = 5;
40
- /** Never wait on a person longer than this, whatever the server says. */
41
- const MAX_WAIT_SECONDS = 15 * 60;
30
+ import { openInBrowser } from "./browser-open.js";
31
+ import { ask, READ_DEADLINE_MS } from "./notes-door.js";
32
+ export const WISPRFLOW_START_PATH = "/api/notes/accounts/wisprflow/start";
33
+ export const WISPRFLOW_STATUS_PATH = "/api/notes/accounts/wisprflow/status";
34
+ /** How often the status door is asked whether the sign-in has landed. */
35
+ const POLL_INTERVAL_MS = 5_000;
36
+ /** Never wait on a person longer than this. */
37
+ const MAX_WAIT_MS = 10 * 60 * 1000;
38
+ /** A flaky read is retried; this many in a row is a real outage and ends the wait. */
39
+ const MAX_CONSECUTIVE_READ_FAILURES = 4;
42
40
  const TAG = "[notes wisprflow signin]";
43
- export async function signInToWisprflow(options) {
44
- const { io } = options;
41
+ /** What a person reads for a failed attempt, keyed on the status door's reason label. */
42
+ const FAILURE_SENTENCES = {
43
+ access_denied: "Wispr Flow says the sign-in was declined, so nothing was connected.",
44
+ state_expired: "The sign-in was not approved within ten minutes, so it expired and nothing was connected.",
45
+ state_already_used: "That sign-in link was already used, so nothing was connected.",
46
+ wisprflow_token_refused: "Wispr Flow refused to finish the sign-in, so nothing was connected.",
47
+ no_refresh_token: "Wispr Flow signed you in but did not allow Tower to keep reading, so it would stop working within the hour. Nothing was connected.",
48
+ account_already_connected: "Wispr Flow is already connected to your Tower account. Detach it first, then sign in again.",
49
+ credential_key_unavailable: "Tower could not safely store the sign-in just now, so nothing was connected.",
50
+ wisprflow_unreachable: "Tower could not reach Wispr Flow just now, so nothing was connected.",
51
+ wisprflow_registration_refused: "Wispr Flow would not start a sign-in for Tower, so nothing was connected.",
52
+ };
53
+ function failureDetail(reason) {
54
+ return FAILURE_SENTENCES[reason] ?? `The Wispr Flow sign-in ended without connecting (${reason}).`;
55
+ }
56
+ export async function connectWisprflowInBrowser(door, options = {}) {
45
57
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
58
  const now = options.now ?? Date.now;
47
- const metadata = await readMetadata(io);
48
- if (!metadata.ok)
49
- return metadata;
50
- // 1. Register a client. A public client: no secret, only an id.
51
- const registered = await postJson(io, metadata.value.registration_endpoint, {
52
- client_name: "Tower (BLI) notes sync",
53
- grant_types: [DEVICE_GRANT, "refresh_token"],
54
- response_types: [],
55
- token_endpoint_auth_method: "none",
56
- scope: SCOPE,
59
+ const open = options.open ?? ((url) => openInBrowser(url));
60
+ const stderr = door.io.stderr;
61
+ // 1. Ask the one start door for a sign-in.
62
+ const started = await ask(door, {
63
+ path: WISPRFLOW_START_PATH,
64
+ method: "POST",
65
+ label: "notes wisprflow start",
66
+ timeoutMs: READ_DEADLINE_MS,
67
+ body: {},
57
68
  });
58
- if (!registered.ok)
59
- return explain(io, "registration", registered);
60
- const clientId = stringField(registered.body, "client_id");
61
- if (!clientId) {
62
- return { ok: false, reason: "wisprflow_registration_unusable", detail: "Wispr Flow accepted the registration but sent no client id." };
69
+ if (!started.ok) {
70
+ log(door, "start refused", { reason: started.reason });
71
+ if (started.reason === "account_already_connected")
72
+ return alreadyConnected(door);
73
+ return { ok: false, reason: started.reason, detail: started.detail };
63
74
  }
64
- // 2. Ask for a code.
65
- const device = await postForm(io, metadata.value.device_authorization_endpoint, {
66
- client_id: clientId,
67
- scope: SCOPE,
68
- resource: WISPRFLOW_MCP_RESOURCE,
69
- });
70
- if (!device.ok)
71
- return explain(io, "device code", device);
72
- const deviceCode = stringField(device.body, "device_code");
73
- const userCode = stringField(device.body, "user_code");
74
- const address = stringField(device.body, "verification_uri_complete") ?? stringField(device.body, "verification_uri");
75
- if (!deviceCode || !userCode || !address) {
76
- return { ok: false, reason: "wisprflow_device_unusable", detail: "Wispr Flow's device answer was missing its code or its address." };
75
+ const body = started.body;
76
+ const authorizeUrl = typeof body.authorize_url === "string" ? body.authorize_url : "";
77
+ const attemptId = typeof body.attempt_id === "string" ? body.attempt_id : "";
78
+ if (authorizeUrl === "" || attemptId === "") {
79
+ log(door, "start answer unusable", { reason: "start_answer_unusable" });
80
+ return {
81
+ ok: false,
82
+ reason: "start_answer_unusable",
83
+ detail: "Tower started a sign-in but did not say where to send you or how to watch it, so nothing was connected.",
84
+ };
77
85
  }
78
- const waitSeconds = Math.min(numberField(device.body, "expires_in") ?? MAX_WAIT_SECONDS, MAX_WAIT_SECONDS);
79
- let intervalMs = (numberField(device.body, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000;
80
- writeLine(io.stderr, "Sign in to Wispr Flow to let Tower read your meeting notes:");
81
- writeLine(io.stderr, ` 1. Open ${address}`);
82
- writeLine(io.stderr, ` 2. If it asks, enter the code ${userCode}`);
83
- writeLine(io.stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
84
- writeLine(io.stderr, `Waiting up to ${Math.round(waitSeconds / 60)} minutes for you to approve...`);
85
- log(io, "waiting", { interval_s: intervalMs / 1000, wait_s: waitSeconds });
86
- // 3. Poll.
87
- const deadline = now() + waitSeconds * 1000;
86
+ log(door, "started", { attempt_id: attemptId, expires_at: typeof body.expires_at === "string" ? body.expires_at : null });
87
+ // 2. Print the address first, then open it.
88
+ writeLine(stderr, "Connect Wispr Flow to Tower: approve on Wispr's page in your browser.");
89
+ writeLine(stderr, ` ${authorizeUrl}`);
90
+ writeLine(stderr, " Use the Wispr account that holds your notes, signed in with Google, Apple, Microsoft or company single sign-on.");
91
+ const opened = open(authorizeUrl);
92
+ log(door, "browser open attempted", opened.ok ? { reason: opened.reason, program: opened.program } : { reason: opened.reason });
93
+ if (!opened.ok)
94
+ writeLine(stderr, `(${opened.detail} The address above still works.)`);
95
+ writeLine(stderr, `Waiting up to ${MAX_WAIT_MS / 60_000} minutes for the sign-in to finish...`);
96
+ // 3. Poll the one status door.
97
+ const deadline = now() + MAX_WAIT_MS;
98
+ let polls = 0;
99
+ let failures = 0;
88
100
  while (now() < deadline) {
89
- await sleep(intervalMs);
90
- const polled = await postForm(io, metadata.value.token_endpoint, {
91
- grant_type: DEVICE_GRANT,
92
- device_code: deviceCode,
93
- client_id: clientId,
94
- resource: WISPRFLOW_MCP_RESOURCE,
101
+ polls += 1;
102
+ const polled = await ask(door, {
103
+ path: `${WISPRFLOW_STATUS_PATH}?attempt_id=${encodeURIComponent(attemptId)}`,
104
+ method: "GET",
105
+ label: "notes wisprflow status",
106
+ timeoutMs: READ_DEADLINE_MS,
95
107
  });
96
- if (polled.ok) {
97
- const refreshToken = stringField(polled.body, "refresh_token");
98
- if (!refreshToken) {
99
- return {
100
- ok: false,
101
- reason: "wisprflow_no_offline_access",
102
- detail: "Wispr Flow signed you in but did not hand back a refresh token, so Tower could not read your meetings"
103
- + " every half hour without you. Nothing was connected.",
104
- };
108
+ if (!polled.ok) {
109
+ failures += 1;
110
+ log(door, "poll failed", { attempt_id: attemptId, polls, reason: polled.reason, consecutive_failures: failures });
111
+ // The attempt itself is gone or not ours: waiting longer cannot change that.
112
+ if (polled.reason === "attempt_not_found" || failures >= MAX_CONSECUTIVE_READ_FAILURES) {
113
+ return { ok: false, reason: polled.reason, detail: polled.detail };
105
114
  }
106
- log(io, "signed in", { client_registered: true, refresh_token_received: true });
107
- return {
108
- ok: true,
109
- bundle: JSON.stringify({ v: 1, client_id: clientId, refresh_token: refreshToken }),
110
- };
111
- }
112
- const code = stringField(polled.body, "error");
113
- if (code === "authorization_pending")
114
- continue;
115
- if (code === "slow_down") {
116
- intervalMs += 5000;
117
- continue;
118
115
  }
119
- if (code === "expired_token") {
120
- return { ok: false, reason: "wisprflow_code_expired", detail: "The code expired before it was approved. Run the command again." };
121
- }
122
- if (code === "access_denied") {
123
- return { ok: false, reason: "wisprflow_denied", detail: "Wispr Flow says the request was declined. Nothing was connected." };
116
+ else {
117
+ failures = 0;
118
+ const status = polled.body;
119
+ const state = typeof status.status === "string" ? status.status : "unknown";
120
+ const reason = typeof status.reason === "string" ? status.reason : null;
121
+ log(door, "poll", { attempt_id: attemptId, polls, status: state, reason });
122
+ if (state === "connected") {
123
+ const accountId = typeof status.account_id === "string" ? status.account_id : "";
124
+ log(door, "connected", { attempt_id: attemptId, account_id: accountId || null, polls });
125
+ return { ok: true, account: await readAccount(door, accountId), already: false };
126
+ }
127
+ if (state === "failed" || state === "expired") {
128
+ const why = reason ?? (state === "expired" ? "state_expired" : "unknown");
129
+ log(door, "ended without connecting", { attempt_id: attemptId, status: state, reason: why });
130
+ return { ok: false, reason: why, detail: failureDetail(why) };
131
+ }
124
132
  }
125
- return explain(io, "sign-in", polled);
133
+ await sleep(POLL_INTERVAL_MS);
126
134
  }
127
- return { ok: false, reason: "wisprflow_timed_out", detail: "Nobody approved the code in time. Run the command again." };
128
- }
129
- /** Metadata only: a step, a status and an OAuth error label. Never a code or a token. */
130
- function log(io, message, fields) {
131
- writeLine(io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
135
+ log(door, "timed out", { attempt_id: attemptId, polls });
136
+ return {
137
+ ok: false,
138
+ reason: "wisprflow_timed_out",
139
+ detail: "The sign-in did not finish within 10 minutes, so nothing was connected."
140
+ + " Run this again for a fresh link, or check `cockpit notes accounts`.",
141
+ };
132
142
  }
133
- async function readMetadata(io) {
134
- const got = await send(io, `${WISPRFLOW_AUTH_ISSUER}/.well-known/oauth-authorization-server`, { method: "GET" });
135
- if (!got.ok)
136
- return explain(io, "discovery", got);
137
- const registration = stringField(got.body, "registration_endpoint");
138
- const device = stringField(got.body, "device_authorization_endpoint");
139
- const token = stringField(got.body, "token_endpoint");
140
- if (!registration || !device || !token) {
143
+ /** The start door said Wispr Flow is already connected: read which account and report it honestly. */
144
+ async function alreadyConnected(door) {
145
+ const accounts = await readAccounts(door);
146
+ const existing = accounts?.find((account) => account.provider === "wisprflow");
147
+ if (!existing) {
141
148
  return {
142
149
  ok: false,
143
- reason: "wisprflow_discovery_changed",
144
- detail: "Wispr Flow's sign-in no longer advertises client registration and the device flow. Tower cannot sign you in from a terminal until it does.",
150
+ reason: "account_already_connected",
151
+ detail: failureDetail("account_already_connected"),
145
152
  };
146
153
  }
147
- // Every endpoint must stay on Wispr's own host: a discovery document that
148
- // points the token call somewhere else is not one to send a code to.
149
- for (const endpoint of [registration, device, token]) {
150
- if (!endpoint.startsWith(`${WISPRFLOW_AUTH_ISSUER}/`)) {
151
- return { ok: false, reason: "wisprflow_discovery_changed", detail: "Wispr Flow's sign-in points at a host Tower does not recognise, so nothing was sent." };
152
- }
153
- }
154
- return { ok: true, value: { registration_endpoint: registration, device_authorization_endpoint: device, token_endpoint: token } };
155
- }
156
- async function postJson(io, url, body) {
157
- return send(io, url, {
158
- method: "POST",
159
- headers: { "Content-Type": "application/json", Accept: "application/json" },
160
- body: JSON.stringify(body),
161
- });
162
- }
163
- async function postForm(io, url, body) {
164
- return send(io, url, {
165
- method: "POST",
166
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
167
- body: new URLSearchParams(body).toString(),
168
- });
169
- }
170
- async function send(io, url, init) {
171
- const call = io.fetch;
172
- const controller = new AbortController();
173
- const deadline = setTimeout(() => controller.abort(), REQUEST_DEADLINE_MS);
174
- try {
175
- const response = await call(url, { ...init, signal: controller.signal });
176
- let body = null;
177
- try {
178
- body = await response.json();
179
- }
180
- catch {
181
- body = null;
182
- }
183
- return response.ok ? { ok: true, body } : { ok: false, status: response.status, body, network: false };
184
- }
185
- catch {
186
- return { ok: false, status: 0, body: null, network: true };
187
- }
188
- finally {
189
- clearTimeout(deadline);
190
- }
191
- }
192
- /** A refusal in the vendor's own words: its OAuth error label, never a body dump. */
193
- function explain(io, step, failure) {
194
- if (failure.network) {
195
- return { ok: false, reason: "wisprflow_unreachable", detail: `Could not reach Wispr Flow's sign-in during ${step}. Check the connection and try again.` };
196
- }
197
- const label = stringField(failure.body, "error");
198
- const words = stringField(failure.body, "error_description");
199
- log(io, "refused", { step, http_status: failure.status, oauth_error: label ?? "none" });
154
+ log(door, "already connected", { account_id: existing.id, status: existing.status });
155
+ if (existing.status === "active")
156
+ return { ok: true, account: existing, already: true };
200
157
  return {
201
158
  ok: false,
202
- reason: `wisprflow_${step.replace(/\s+/g, "_")}_refused`,
203
- detail: `Wispr Flow refused the ${step} step (HTTP ${failure.status}${label ? `, ${label}` : ""}).`
204
- + `${words ? ` It said: ${words.slice(0, 200)}` : ""}`
205
- + " Nothing was connected.",
159
+ reason: "wisprflow_needs_detach",
160
+ detail: `Wispr Flow is connected (${existing.id}) but its status is ${existing.status}`
161
+ + `${existing.status_reason ? ` (${existing.status_reason})` : ""}. `
162
+ + `Detach it first with \`cockpit notes detach ${existing.id}\`, then run this again.`,
206
163
  };
207
164
  }
208
- function stringField(body, name) {
209
- if (typeof body !== "object" || body === null)
210
- return null;
211
- const value = body[name];
212
- return typeof value === "string" && value.trim() !== "" ? value : null;
165
+ /** The account row for the id the status door named. A failed read still reports the id. */
166
+ async function readAccount(door, accountId) {
167
+ const accounts = await readAccounts(door);
168
+ const found = accounts?.find((account) => account.id === accountId) ?? accounts?.find((account) => account.provider === "wisprflow");
169
+ return found ?? { id: accountId, provider: "wisprflow", label: null, workspace: null, status: "active", status_reason: null };
213
170
  }
214
- function numberField(body, name) {
215
- if (typeof body !== "object" || body === null)
171
+ async function readAccounts(door) {
172
+ const answer = await ask(door, {
173
+ path: "/api/notes/accounts",
174
+ method: "GET",
175
+ label: "notes accounts",
176
+ timeoutMs: READ_DEADLINE_MS,
177
+ });
178
+ if (!answer.ok) {
179
+ log(door, "accounts read failed", { reason: answer.reason });
216
180
  return null;
217
- const value = body[name];
218
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
181
+ }
182
+ const accounts = answer.body.accounts;
183
+ return Array.isArray(accounts) ? accounts : [];
184
+ }
185
+ /** Metadata only: a step and counts or a reason label. Never a token, never a URL, never a body. */
186
+ function log(door, message, fields) {
187
+ writeLine(door.io.stderr, `${TAG} ${message} ${JSON.stringify(fields)}`);
219
188
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.212");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.217");
25
25
  return 0;
26
26
  }
27
27
 
@@ -37,6 +37,8 @@ export async function runTeam(command, io) {
37
37
  return deviceList(command, tower, io);
38
38
  if (command.action === "device-doctor")
39
39
  return deviceDoctor(command, tower, io);
40
+ if (command.action === "device-scope")
41
+ return deviceScope(command, tower, io);
40
42
  return deviceRevoke(command, tower, io);
41
43
  }
42
44
  async function showMembers(command, tower, io) {
@@ -212,6 +214,36 @@ async function deviceDoctor(command, tower, io) {
212
214
  writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, ...asRecord(result.body) }) : `Doctor requested for ${device.device_name ?? device.device_id}. Waiting for its next check-in.`);
213
215
  return 0;
214
216
  }
217
+ /**
218
+ * `cockpit team device scope <id|name> notetaker_bot|none` (BLI-17978): the ONE door that gives the meeting bot's
219
+ * device the capability Tower's claim door requires, or takes it away. Super admin on the server; the word `none`
220
+ * is the CLI's spelling of "no scope". It replaces the manual SQL step the bot's README used to carry.
221
+ */
222
+ async function deviceScope(command, tower, io) {
223
+ const listed = await callTower(tower, { path: "/api/team/devices", label: "team device scope lookup" });
224
+ if (!listed.ok)
225
+ return writeCommandFailure(io, command.json, listed);
226
+ const device = findDevice(devicesOf(asRecord(listed.body)), command.deviceRef ?? "");
227
+ if (!device)
228
+ return writeCommandFailure(io, command.json, { reason: "device_not_found", detail: "No unique device matches. Run cockpit team device list to see ids and names." });
229
+ const result = await callTower(tower, {
230
+ path: `/api/ambient/devices/${encodeURIComponent(device.device_id)}/scope`,
231
+ method: "POST", label: "team device scope", body: { scope: command.deviceScope },
232
+ });
233
+ writeLine(io.stderr, `[team cli] device scope ${JSON.stringify({ device_id: device.device_id, scope: command.deviceScope, outcome: result.ok ? "answered" : result.reason })}`);
234
+ if (!result.ok)
235
+ return writeCommandFailure(io, command.json, result);
236
+ const body = asRecord(result.body);
237
+ if (command.json) {
238
+ writeLine(io.stdout, JSON.stringify({ ok: true, ...body }));
239
+ return 0;
240
+ }
241
+ const name = String(device.device_name ?? device.device_id);
242
+ writeLine(io.stdout, body.changed === false
243
+ ? `${name} already has ${command.deviceScope === "none" ? "no scope" : "the notetaker_bot scope"}. Nothing changed.`
244
+ : command.deviceScope === "none" ? `Took the service scope away from ${name}.` : `${name} now holds the notetaker_bot scope. The bot's next claim will go through.`);
245
+ return 0;
246
+ }
215
247
  /** Exact directory labels locate a roster id; attribution remains the server resolver's job. */
216
248
  async function githubLogin(command, tower, io) {
217
249
  let personId = command.personRef ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.212",
3
+ "version": "0.2.217",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.66",
31
- "@bli-cockpit/mcp": "0.1.121",
31
+ "@bli-cockpit/mcp": "0.1.125",
32
32
  "@bli-cockpit/telemetry-core": "0.1.75"
33
33
  },
34
34
  "repository": {