@flowapt/flowiq-cli 0.9.6 → 0.9.8

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.
@@ -1,8 +1,11 @@
1
1
  // `flowiq agent config <organization_id> [flags]` — the prompt-builder skill's
2
2
  // Step 5 as a staff-key call. Writes an allowlisted set of agent fields
3
- // (use_settings_prompt, model, tool flags, discount.enabled, rename) via
4
- // /cli/agent-config. With no write flags, prints the current config.
3
+ // (use_settings_prompt, model, tool flags, discount.enabled, rename, tool
4
+ // instructions, link shortening, email identity / request recipients, send
5
+ // success rules, after-escalation text, knowledge search, human_notify /
6
+ // order_notify) via /cli/agent-config. With no write flags, prints the current config.
5
7
 
8
+ import { readFileSync } from "node:fs";
6
9
  import { http } from "../http.js";
7
10
 
8
11
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -29,10 +32,77 @@ export function collectToolName(val, acc) {
29
32
  return acc;
30
33
  }
31
34
 
32
- function printSnapshot(title, snap) {
35
+ // "@path" reads the file; anything else is the literal text.
36
+ export function readValue(v, label) {
37
+ if (typeof v !== "string" || !v.startsWith("@")) return v;
38
+ try {
39
+ return readFileSync(v.slice(1), "utf8").replace(/\r?\n$/, "");
40
+ } catch (e) {
41
+ console.error(`Error: ${label} could not read ${v.slice(1)}: ${e.message}`);
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ // "key=value" → [key, value]; the value may itself contain "=".
47
+ export function splitPair(pair, label) {
48
+ const eq = pair.indexOf("=");
49
+ if (eq < 1) {
50
+ console.error(`Error: ${label} expects key=value (got "${pair}").`);
51
+ process.exit(1);
52
+ }
53
+ return [pair.slice(0, eq).trim(), pair.slice(eq + 1)];
54
+ }
55
+
56
+ export function parseShorten(v, label) {
57
+ const s = String(v).trim().toLowerCase();
58
+ if (["on", "true", "yes", "1"].includes(s)) return true;
59
+ if (["off", "false", "no", "0"].includes(s)) return false;
60
+ if (s === "default") return null;
61
+ console.error(`Error: ${label} must be on, off or default (got "${v}").`);
62
+ process.exit(1);
63
+ }
64
+
65
+ // --knowledge enabled=true,match_count=8,min_similarity=0.3 ("default" clears a key)
66
+ export function parseKnowledge(v) {
67
+ const out = {};
68
+ for (const part of String(v).split(",").map((p) => p.trim()).filter(Boolean)) {
69
+ const [k, raw] = splitPair(part, "--knowledge");
70
+ const val = raw.trim();
71
+ if (val === "default" || val === "") out[k] = null;
72
+ else if (k === "enabled") out[k] = parseBool(val, "--knowledge enabled");
73
+ else if (!Number.isNaN(Number(val))) out[k] = Number(val);
74
+ else out[k] = val; // the server names the bad value
75
+ }
76
+ return out;
77
+ }
78
+
79
+ export function parseJsonObject(v, label) {
80
+ const text = readValue(v, label);
81
+ let obj;
82
+ try {
83
+ obj = JSON.parse(text);
84
+ } catch (e) {
85
+ console.error(`Error: ${label} must be a JSON object or @file.json (${e.message}).`);
86
+ process.exit(1);
87
+ }
88
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
89
+ console.error(`Error: ${label} must be a JSON object (keys are merged; null deletes a key).`);
90
+ process.exit(1);
91
+ }
92
+ return obj;
93
+ }
94
+
95
+ function short(v, full) {
96
+ if (v === null || v === undefined) return "(unset)";
97
+ const s = String(v).replace(/\s+/g, " ");
98
+ if (full) return String(v);
99
+ return s.length <= 140 ? s : `${s.slice(0, 137)}… (${String(v).length} chars; --full shows all)`;
100
+ }
101
+
102
+ function printSnapshot(title, snap, full = false) {
33
103
  console.log(` ${title}:`);
34
104
  for (const [k, v] of Object.entries(snap)) {
35
- console.log(` ${k.padEnd(30)} ${v === null ? "(unset)" : v}`);
105
+ console.log(` ${k.padEnd(38)} ${short(v, full)}`);
36
106
  }
37
107
  }
38
108
 
@@ -60,6 +130,47 @@ export async function config(orgId, opts = {}) {
60
130
  };
61
131
  }
62
132
 
133
+ if (opts.toolInstruction?.length) {
134
+ body.tool_instructions = {};
135
+ for (const pair of opts.toolInstruction) {
136
+ const [tool, text] = splitPair(pair, "--tool-instruction");
137
+ body.tool_instructions[tool] = readValue(text, `--tool-instruction ${tool}`);
138
+ }
139
+ }
140
+ if (opts.shortenLinks?.length) {
141
+ body.url_shortening = {};
142
+ for (const pair of opts.shortenLinks) {
143
+ const [ch, v] = splitPair(pair, "--shorten-links");
144
+ body.url_shortening[ch] = parseShorten(v, `--shorten-links ${ch}`);
145
+ }
146
+ }
147
+ const identity = {};
148
+ if (opts.emailFromName !== undefined) identity.from_name = opts.emailFromName;
149
+ if (opts.emailSignatureName !== undefined) identity.signature_name = opts.emailSignatureName;
150
+ if (opts.emailSignatureTitle !== undefined) identity.signature_title = opts.emailSignatureTitle;
151
+ if (Object.keys(identity).length) body.email_identity = identity;
152
+ const emailRequest = {};
153
+ if (opts.emailRequestTo !== undefined) {
154
+ emailRequest.to = String(opts.emailRequestTo).split(/[,;\s]+/).map((e) => e.trim()).filter(Boolean);
155
+ }
156
+ if (opts.emailRequestSubjectPrefix !== undefined) emailRequest.subject_prefix = opts.emailRequestSubjectPrefix;
157
+ if (Object.keys(emailRequest).length) body.email_request = emailRequest;
158
+ if (opts.sendSuccessRule?.length || opts.sendSuccess !== undefined) {
159
+ body.send_success = {};
160
+ if (opts.sendSuccess !== undefined) body.send_success.enabled = parseBool(opts.sendSuccess, "--send-success");
161
+ if (opts.sendSuccessRule?.length) {
162
+ body.send_success.rules = {};
163
+ for (const pair of opts.sendSuccessRule) {
164
+ const [when, text] = splitPair(pair, "--send-success-rule");
165
+ body.send_success.rules[when] = readValue(text, `--send-success-rule ${when}`);
166
+ }
167
+ }
168
+ }
169
+ if (opts.afterEscalation !== undefined) body.after_escalation = readValue(opts.afterEscalation, "--after-escalation");
170
+ if (opts.knowledge !== undefined) body.knowledge = parseKnowledge(opts.knowledge);
171
+ if (opts.humanNotify !== undefined) body.human_notify = parseJsonObject(opts.humanNotify, "--human-notify");
172
+ if (opts.orderNotify !== undefined) body.order_notify = parseJsonObject(opts.orderNotify, "--order-notify");
173
+
63
174
  if (opts.tool && opts.tool.length) {
64
175
  const flags = {};
65
176
  for (const pair of opts.tool) {
@@ -74,8 +185,12 @@ export async function config(orgId, opts = {}) {
74
185
  body.tool_flags = flags;
75
186
  }
76
187
 
77
- const hasWrite =
78
- body.settings || body.name !== undefined || body.tool_flags || body.base_tool_changes || body.discount_enabled !== undefined;
188
+ const WRITE_KEYS = [
189
+ "settings", "name", "tool_flags", "base_tool_changes", "discount_enabled", "tool_instructions",
190
+ "url_shortening", "email_identity", "email_request", "send_success", "after_escalation",
191
+ "knowledge", "human_notify", "order_notify",
192
+ ];
193
+ const hasWrite = WRITE_KEYS.some((k) => body[k] !== undefined);
79
194
 
80
195
  // No write flags → just show current config.
81
196
  if (!hasWrite) {
@@ -86,9 +201,26 @@ export async function config(orgId, opts = {}) {
86
201
  console.error(`Lookup failed: ${e.message}`);
87
202
  process.exit(1);
88
203
  }
204
+ if (opts.json) {
205
+ console.log(JSON.stringify(resp, null, 2));
206
+ return;
207
+ }
89
208
  console.log(`${resp.organization_name} → agent ${resp.agent_id}${resp.is_active === false ? " [NON-active]" : ""}`);
90
- printSnapshot("current", resp.current);
91
- console.log("\n(pass --use-settings-prompt / --model / --reasoning-effort / --rename / --tool / --disable-base-tool / --enable-base-tool / --discount / --test-contact-number / --test-contact-name to change)");
209
+ printSnapshot("current", resp.current, opts.full);
210
+ console.log(`
211
+ Settable (flowiq agent config --help for the value formats):
212
+ --use-settings-prompt --model --reasoning-effort --rename --discount
213
+ --tool <flag=bool> --disable-base-tool / --enable-base-tool <name>
214
+ --test-contact-number / --test-contact-name
215
+ --tool-instruction <tool=text|@file> settings.tool_instructions ("" removes)
216
+ --shorten-links <channel=on|off|default> disable_url_shortening
217
+ --email-from-name / --email-signature-name / --email-signature-title
218
+ --email-request-to <a,b> / --email-request-subject-prefix
219
+ --send-success-rule <when=text|@file> --send-success <bool>
220
+ --after-escalation <text|@file> human_notify.success_instructions
221
+ --knowledge enabled=…,match_count=…,min_similarity=…
222
+ --human-notify / --order-notify <json|@file.json> (merged; null deletes a key)
223
+ Not settable here yet: additional_config (use \`flowiq knowledge\`), additional_tools (\`flowiq ct\`).`);
92
224
  return;
93
225
  }
94
226
 
@@ -106,7 +238,7 @@ export async function config(orgId, opts = {}) {
106
238
  for (const k of resp.changed) {
107
239
  const b = resp.before?.[k];
108
240
  const a = resp.after?.[k];
109
- console.log(` ${k.padEnd(30)} ${b === null || b === undefined ? "(unset)" : b} → ${a === null || a === undefined ? "(unset)" : a}`);
241
+ console.log(` ${k.padEnd(38)} ${short(b, opts.full)} → ${short(a, opts.full)}`);
110
242
  }
111
243
  for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
112
244
  }
@@ -4,10 +4,10 @@
4
4
  // expect/expectNot checks client-side, and prints the transcript.
5
5
  //
6
6
  // test resolve <org_id> [--agent <id>]
7
- // test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json]
7
+ // test send <org_id> <message...> [--agent] [--clear] [--sender] [--image] [--timeout] [--json] [--force]
8
8
  // test scenario <org_id> <file.json> [--agent] [--no-clear-between] [--json] [--sender] [--timeout]
9
9
  // test qa <org_id> [--agent] ... (bundled generic smoke pack)
10
- // test clear <org_id> [--agent]
10
+ // test clear <org_id> [--agent] [--force]
11
11
  // test stress <org_id> [pack.json] [--agent] [--concurrency 6] [--timeout] [--out] [--contact]
12
12
  // test cleanup <org_id> [--agent] --confirm (deletes stress contacts)
13
13
  // test extract <org_id> [--out] (rebuild transcripts from DB)
@@ -28,7 +28,9 @@ function requireOrg(orgId) {
28
28
  }
29
29
  }
30
30
 
31
- async function runTurn(orgId, { agent, message, image, clear, sender, timeout }) {
31
+ // force: the server refuses send / clear on a test contact that had real
32
+ // (non-web) inbound in the last 6 h, i.e. a live customer conversation.
33
+ async function runTurn(orgId, { agent, message, image, clear, sender, timeout, force }) {
32
34
  return http.post("agent-test", {
33
35
  action: "send",
34
36
  organization_id: orgId,
@@ -38,11 +40,18 @@ async function runTurn(orgId, { agent, message, image, clear, sender, timeout })
38
40
  clear: !!clear,
39
41
  sender,
40
42
  timeout_ms: timeout,
43
+ force: !!force,
41
44
  });
42
45
  }
43
46
 
44
- async function clearConv(orgId, agent) {
45
- return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
47
+ async function clearConv(orgId, agent, force) {
48
+ return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent, force: !!force });
49
+ }
50
+
51
+ let warned = false;
52
+ function printWarnings(r) {
53
+ if (warned) return;
54
+ for (const w of r?.warnings || []) { console.error(`⚠ ${w}`); warned = true; }
46
55
  }
47
56
 
48
57
  // A bare string is ONE needle, not a list of characters. Iterating a string
@@ -88,9 +97,11 @@ export async function send(orgId, messageParts, opts = {}) {
88
97
  if (!message) { console.error("usage: test send <org_id> <message...>"); process.exit(1); }
89
98
  let r;
90
99
  try {
91
- r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout });
100
+ r = await runTurn(orgId, { agent: opts.agent, message, image: opts.image, clear: opts.clear, sender: opts.sender, timeout: opts.timeout, force: opts.force });
92
101
  } catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
93
102
  if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
103
+ printWarnings(r);
104
+ if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
94
105
  console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
95
106
  printTurn({ title: "send", tq: "" }, 0, message, r, []);
96
107
  }
@@ -103,14 +114,15 @@ async function runPack(orgId, pack, opts) {
103
114
  for (let s = 0; s < scenarios.length; s++) {
104
115
  const sc = scenarios[s];
105
116
  if (clearBetween) {
106
- try { await clearConv(orgId, opts.agent); } catch (e) { console.error(` (clear failed: ${e.message})`); }
117
+ try { printWarnings(await clearConv(orgId, opts.agent, opts.force)); } catch (e) { console.error(` (clear failed: ${e.message})`); }
107
118
  }
108
119
  const scResult = { id: sc.id, tq: sc.tq, title: sc.title, turns: [] };
109
120
  for (let t = 0; t < sc.turns.length; t++) {
110
121
  const turn = sc.turns[t];
111
122
  let r;
112
123
  try {
113
- r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout });
124
+ r = await runTurn(orgId, { agent: opts.agent, message: turn.text, sender: opts.sender, timeout: opts.timeout, force: opts.force });
125
+ printWarnings(r);
114
126
  } catch (e) {
115
127
  r = { status: "error", reply: null, tool_calls: [], raw: { error: e.message } };
116
128
  }
@@ -144,10 +156,12 @@ export async function qa(orgId, opts = {}) {
144
156
  export async function clear(orgId, opts = {}) {
145
157
  requireOrg(orgId);
146
158
  let resp;
147
- try { resp = await clearConv(orgId, opts.agent); }
159
+ try { resp = await clearConv(orgId, opts.agent, opts.force); }
148
160
  catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
161
+ printWarnings(resp);
149
162
  const tools = Number(resp.tools_cleared || 0);
150
163
  console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; ${tools} remembered tool result(s) set aside in more_data.tool_usage_history_cleared; onboarding/disclaimer state NOT reset).`);
164
+ console.log(`Previous memory_cutoff: ${resp.previous_memory_cutoff ?? "(none)"}${resp.previous_memory_cutoff ? " (to undo, set contacts.memory_cutoff back to it)" : ""}`);
151
165
  }
152
166
 
153
167
  // ---------- phase 2: stress + extract ----------
@@ -320,8 +320,8 @@ export async function resolve(updateId, opts = {}) {
320
320
  process.exit(1);
321
321
  }
322
322
  const status = opts.status || "resolved";
323
- if (!["resolved", "declined"].includes(status)) {
324
- console.error(`Error: --status must be resolved or declined (got "${status}").`);
323
+ if (!["resolved", "declined", "in_progress"].includes(status)) {
324
+ console.error(`Error: --status must be resolved, declined or in_progress (got "${status}").`);
325
325
  process.exit(1);
326
326
  }
327
327
  // R-3: a client-facing note is mandatory on resolve (--yes does NOT waive it).
@@ -332,7 +332,7 @@ export async function resolve(updateId, opts = {}) {
332
332
  }
333
333
 
334
334
  if (!opts.yes) {
335
- console.log(`About to mark update ${updateId} as ${status.toUpperCase()}.`);
335
+ console.log(`About to mark update ${updateId} as ${status.toUpperCase()}${status === "in_progress" ? " (not closed: no resolution stamp)" : ""}.`);
336
336
  if (opts.note) console.log(`The client will read: FlowIQ: ${opts.note}`);
337
337
  if (opts.internal) console.log(`Staff-only note: ${opts.internal}`);
338
338
  const answer = await confirmPrompt("Type y to confirm: ");
@@ -363,6 +363,7 @@ export async function resolve(updateId, opts = {}) {
363
363
  console.log(` title: ${resp.title}`);
364
364
  console.log(` was: ${resp.previous_status}`);
365
365
  console.log(` by: ${resp.resolved_by_email ?? resp.resolved_by}`);
366
+ if (resp.status === "in_progress") console.log(` still open: resolve it later with --note once the fix is proven.`);
366
367
  if (resp.superadmin_comment) console.log(` client: FlowIQ: ${resp.superadmin_comment}`);
367
368
  if (resp.internal_notes) console.log(` internal: ${resp.internal_notes}`);
368
369
  if (resp.already_resolved) console.log(` ⚠ was already terminal (${resp.previous_status}) — note/status overwritten.`);
@@ -61,6 +61,7 @@ export async function list(orgId, opts = {}) {
61
61
  user: opts.user,
62
62
  since: opts.since,
63
63
  until: opts.until,
64
+ before: opts.before,
64
65
  limit: opts.limit,
65
66
  });
66
67
  } catch (e) {
@@ -80,7 +81,10 @@ export async function list(orgId, opts = {}) {
80
81
  }
81
82
 
82
83
  console.log("");
83
- console.log(`${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
+ const total = resp.total ?? entries.length;
85
+ console.log(resp.capped
86
+ ? `${entries.length} of ${total.toLocaleString()} matching entries (newest first; limit ${resp.limit}${resp.requested_limit > resp.limit ? `, the most one call returns — you asked for ${resp.requested_limit}` : ""})`
87
+ : `${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
88
  console.log("");
85
89
  console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
86
90
  console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
@@ -101,6 +105,9 @@ export async function list(orgId, opts = {}) {
101
105
  for (const e of entries) {
102
106
  console.log(` ${e.id} ${e.endpoint} ${e.action}`);
103
107
  }
108
+ if (resp.capped && resp.next_before) {
109
+ console.log(`\n ${(total - entries.length).toLocaleString()} older match(es) not shown: add --before ${resp.next_before}`);
110
+ }
104
111
  console.log("");
105
112
  }
106
113
 
@@ -1935,15 +1935,19 @@ export async function listRemote(orgId, opts = {}) {
1935
1935
  ...(opts.limit ? { limit: Number(opts.limit) } : {}),
1936
1936
  ...(opts.template ? { template: opts.template } : {}),
1937
1937
  ...(opts.since ? { since: opts.since } : {}),
1938
+ ...(opts.minRecipients ? { min_recipients: Number(opts.minRecipients) } : {}),
1938
1939
  });
1939
1940
  } catch (e) { console.error(`Listing failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1940
1941
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1941
1942
  const rows = resp.broadcasts || [];
1942
- if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since ? " matching the filters" : ""}.`); return; }
1943
+ if (!rows.length) { console.log(`No broadcasts found for ${resp.organization_name}${opts.template || opts.since || opts.minRecipients ? " matching the filters" : ""}.`); return; }
1943
1944
  // Full ids one per row, deliberately — an id you can't paste into `bc status`
1944
1945
  // is useless (the same lesson the audit listing learned).
1945
1946
  const sast = (iso) => { try { return new Date(iso).toLocaleString("sv-SE", { timeZone: "Africa/Johannesburg" }).slice(0, 16); } catch { return iso; } };
1946
- console.log(`Broadcasts — ${resp.organization_name} (${rows.length} shown, newest first; times SAST)`);
1947
+ const shown = resp.capped
1948
+ ? `${rows.length} of ${resp.total.toLocaleString()} shown`
1949
+ : `${rows.length} shown`;
1950
+ console.log(`Broadcasts — ${resp.organization_name} (${shown}${resp.min_recipients ? `, ${resp.min_recipients}+ recipients` : ""}, newest first; times SAST)`);
1947
1951
  for (const b of rows) {
1948
1952
  console.log("");
1949
1953
  console.log(` ${b.id}`);
@@ -1951,6 +1955,9 @@ export async function listRemote(orgId, opts = {}) {
1951
1955
  console.log(` status ${b.status ?? "?"} · ${b.total_recipients ?? "?"} recipient(s)`);
1952
1956
  }
1953
1957
  console.log("");
1958
+ if (resp.capped) {
1959
+ console.log(`⚠ ${(resp.total - rows.length).toLocaleString()} older broadcast(s) not shown (limit ${resp.limit}). Narrow with --since, --template${resp.min_recipients ? "" : " or --min-recipients 2 (hides one-recipient sends like welcomes and cart reminders)"}.`);
1960
+ }
1954
1961
  console.log(`Delivery detail: flowiq bc status ${orgId} <broadcast_id> [--failures]`);
1955
1962
  }
1956
1963
 
@@ -1984,6 +1991,15 @@ export async function status(orgId, broadcastId, opts = {}) {
1984
1991
  if (b.total_recipients) {
1985
1992
  console.log(` ${((accepted / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients accepted${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
1986
1993
  }
1994
+ const p = resp.payload;
1995
+ if (p && (Object.keys(p.body_parameters || {}).length || Object.keys(p.button_parameters || {}).length || Object.keys(p.header_parameters || {}).length)) {
1996
+ console.log(` sent with:`);
1997
+ const slot = (k) => String(k).replace(/^param/, "{{") + (String(k).startsWith("param") ? "}}" : "");
1998
+ for (const [k, v] of Object.entries(p.header_parameters || {})) console.log(` header ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
1999
+ for (const [k, v] of Object.entries(p.body_parameters || {})) console.log(` body ${slot(k)} ${String(v).replace(/\s+/g, " ")}`);
2000
+ for (const [k, v] of Object.entries(p.button_parameters || {})) console.log(` button ${slot(k)} ${v}`);
2001
+ for (const l of p.links || []) console.log(` link ${l.short_code} → ${l.destination} (${l.clicks} click${l.clicks === 1 ? "" : "s"})`);
2002
+ }
1987
2003
  if (resp.reply_routing) {
1988
2004
  const rr = resp.reply_routing;
1989
2005
  console.log(` replies → ${describeRouting(rr)}${rr.enabled === false ? " (switched OFF)" : ""}`);
@@ -101,10 +101,19 @@ export async function doctor(opts = {}) {
101
101
  }
102
102
  } catch (e) {
103
103
  lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
104
- problems.push({
105
- what: "the API rejected this key",
106
- fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
107
- });
104
+ if (e.code === "EVERCELCHALLENGE") {
105
+ problems.push({
106
+ what: "Vercel's bot protection is challenging this network; the key was never checked",
107
+ fix: "wait a few minutes or switch network (hotspot), then flowiq doctor again; do NOT re-login",
108
+ });
109
+ } else {
110
+ // Only a JSON 401/403 from our API means the key itself was refused.
111
+ const keyRefused = (e.status === 401 || e.status === 403) && e.code !== "ENONJSON";
112
+ problems.push({
113
+ what: keyRefused ? "the API rejected this key" : "the API could not be reached properly",
114
+ fix: keyRefused ? "flowiq auth login" : "check api_url / network, then retry",
115
+ });
116
+ }
108
117
  }
109
118
  }
110
119