fuzzi-cli 0.1.4 → 0.1.5

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,4 +1,14 @@
1
1
  [
2
+ {
3
+ "version": "0.1.5",
4
+ "date": "2026-06-19",
5
+ "highlights": [
6
+ "Fixed API error parsing (msg.toLowerCase crash)",
7
+ "Loading spinners on all commands",
8
+ "Login state persists from saved credentials",
9
+ "Fixed readline prompt after interactive menus"
10
+ ]
11
+ },
2
12
  {
3
13
  "version": "0.1.4",
4
14
  "date": "2026-06-19",
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ var init_brand = __esm({
35
35
  HIGH: BRAND.danger,
36
36
  CRITICAL: BRAND.critical
37
37
  };
38
- VERSION = "0.1.4";
38
+ VERSION = "0.1.5";
39
39
  APP_ORIGIN = "https://fuzzi-ten.vercel.app";
40
40
  DEFAULT_API_URL = `${APP_ORIGIN}/api`;
41
41
  SETTINGS_API_KEYS_URL = `${APP_ORIGIN}/settings/api-keys`;
@@ -219,10 +219,40 @@ var init_logger = __esm({
219
219
  }
220
220
  });
221
221
 
222
+ // src/lib/api-utils.ts
223
+ function errorText(value) {
224
+ if (value == null) return "";
225
+ if (typeof value === "string") return value;
226
+ if (Array.isArray(value)) return value.map(errorText).filter(Boolean).join("; ");
227
+ if (typeof value === "object") {
228
+ const o = value;
229
+ if (typeof o.detail === "string") return o.detail;
230
+ if (Array.isArray(o.detail)) return o.detail.map(errorText).join("; ");
231
+ if (typeof o.message === "string") return o.message;
232
+ if (typeof o.error === "string") return o.error;
233
+ try {
234
+ return JSON.stringify(value);
235
+ } catch {
236
+ return String(value);
237
+ }
238
+ }
239
+ return String(value);
240
+ }
241
+ function asList(data) {
242
+ if (!data) return [];
243
+ if (Array.isArray(data)) return data;
244
+ return data.results ?? data.data ?? [];
245
+ }
246
+ var init_api_utils = __esm({
247
+ "src/lib/api-utils.ts"() {
248
+ "use strict";
249
+ }
250
+ });
251
+
222
252
  // src/lib/api-client.ts
223
253
  function mapErrorMessage(status, body) {
224
- const code = body.code?.toLowerCase();
225
- const msg = body.error || body.message || "";
254
+ const code = typeof body.code === "string" ? body.code.toLowerCase() : "";
255
+ const msg = errorText(body.error ?? body.message ?? body.detail);
226
256
  if (status === 401) {
227
257
  if (code === "key_revoked" || msg.toLowerCase().includes("revoked")) {
228
258
  return "API key has been revoked. Please log in again.";
@@ -230,7 +260,7 @@ function mapErrorMessage(status, body) {
230
260
  if (code === "key_expired" || msg.toLowerCase().includes("expired")) {
231
261
  return `API key has expired. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
232
262
  }
233
- return `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
263
+ return msg || `Invalid API key. Generate a new one at ${SETTINGS_API_KEYS_URL}`;
234
264
  }
235
265
  if (status === 403 && (code === "ssrf" || msg.toLowerCase().includes("private ip"))) {
236
266
  return "This URL is not allowed (private IP address detected). Please scan a public-facing URL.";
@@ -262,6 +292,7 @@ var init_api_client = __esm({
262
292
  init_credentials();
263
293
  init_logger();
264
294
  init_brand();
295
+ init_api_utils();
265
296
  ApiError = class extends Error {
266
297
  constructor(message, status, code, body, exitCode) {
267
298
  super(message);
@@ -334,7 +365,7 @@ var init_api_client = __esm({
334
365
  message = `Rate limit exceeded. Retry after ${seconds} seconds.`;
335
366
  }
336
367
  if (res.status >= 500) {
337
- message = `Scan failed: ${errBody.error || errBody.message || res.statusText}`;
368
+ message = `Request failed: ${errorText(errBody.error ?? errBody.message ?? errBody.detail) || res.statusText}`;
338
369
  }
339
370
  throw new ApiError(message, res.status, errBody.code, data, 2);
340
371
  }
@@ -718,6 +749,58 @@ async function openCliAuthPage() {
718
749
 
719
750
  // src/commands/auth.ts
720
751
  init_brand();
752
+
753
+ // src/terminal/interactive.ts
754
+ init_theme();
755
+ import { select, search } from "@inquirer/prompts";
756
+ var pauseHook = null;
757
+ var resumeHook = null;
758
+ function setReadlineHooks(pause, resume) {
759
+ pauseHook = pause;
760
+ resumeHook = resume;
761
+ }
762
+ async function runInteractive(fn) {
763
+ return withReadlinePaused(fn);
764
+ }
765
+ async function withReadlinePaused(fn) {
766
+ pauseHook?.();
767
+ try {
768
+ return await fn();
769
+ } finally {
770
+ resumeHook?.();
771
+ }
772
+ }
773
+ async function pickFromList(message, items) {
774
+ if (!items.length) return null;
775
+ try {
776
+ return await withReadlinePaused(() => select({ message, choices: items }));
777
+ } catch {
778
+ return null;
779
+ }
780
+ }
781
+ async function searchPalette(message, choices) {
782
+ try {
783
+ return await withReadlinePaused(
784
+ () => search({
785
+ message,
786
+ source: async (input5) => {
787
+ if (!input5) return choices;
788
+ const q = input5.toLowerCase();
789
+ return choices.filter(
790
+ (c) => c.value.toLowerCase().includes(q) || String(c.description ?? "").toLowerCase().includes(q)
791
+ );
792
+ }
793
+ })
794
+ );
795
+ } catch {
796
+ return null;
797
+ }
798
+ }
799
+ function formatChoice(name, description) {
800
+ return `${accent(name)}${muted(" \u2014 " + description)}`;
801
+ }
802
+
803
+ // src/commands/auth.ts
721
804
  async function runAssistedBrowserLogin() {
722
805
  await openCliAuthPage();
723
806
  console.log("");
@@ -753,15 +836,17 @@ async function runApiKeyLogin(opts = {}) {
753
836
  2
754
837
  );
755
838
  }
756
- apiKey = await password({
757
- message: "Paste your API key (fz_live_...):",
758
- mask: "\u2022",
759
- validate: (v) => {
760
- if (!v.trim()) return "API key is required";
761
- if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
762
- return true;
763
- }
764
- });
839
+ apiKey = await runInteractive(
840
+ () => password({
841
+ message: "Paste your API key (fz_live_...):",
842
+ mask: "\u2022",
843
+ validate: (v) => {
844
+ if (!v.trim()) return "API key is required";
845
+ if (!isValidApiKeyFormat(v)) return "Key must start with fz_live_";
846
+ return true;
847
+ }
848
+ })
849
+ );
765
850
  }
766
851
  apiKey = apiKey.trim();
767
852
  if (!isValidApiKeyFormat(apiKey)) {
@@ -1087,6 +1172,17 @@ function createProgress(label, stream = false) {
1087
1172
  }
1088
1173
  };
1089
1174
  }
1175
+ async function withSpinner(label, fn) {
1176
+ const p = createProgress(label);
1177
+ try {
1178
+ const result = await fn();
1179
+ p.stop();
1180
+ return result;
1181
+ } catch (e) {
1182
+ p.fail();
1183
+ throw e;
1184
+ }
1185
+ }
1090
1186
 
1091
1187
  // src/commands/scan.ts
1092
1188
  init_strings();
@@ -1186,6 +1282,7 @@ async function runScanCommand(client, opts) {
1186
1282
  }
1187
1283
 
1188
1284
  // src/commands/scans.ts
1285
+ init_api_utils();
1189
1286
  async function runScansListCommand(client, format = "table", filters) {
1190
1287
  const params = new URLSearchParams({ page: "1", page_size: String(filters?.limit || 20) });
1191
1288
  if (filters?.status) params.set("status", filters.status);
@@ -1193,7 +1290,7 @@ async function runScansListCommand(client, format = "table", filters) {
1193
1290
  const data = await client.get(
1194
1291
  `/scans?${params.toString()}`
1195
1292
  );
1196
- return renderScansList(data.results || [], format);
1293
+ return renderScansList(asList(data), format);
1197
1294
  }
1198
1295
  async function runScanGetCommand(client, scanId, format = "table") {
1199
1296
  const detail = await client.get(`/scan/${scanId}`);
@@ -1453,6 +1550,10 @@ init_width();
1453
1550
  function visibleLen(s) {
1454
1551
  return s.replace(/\x1b\[[0-9;]*m/g, "").length;
1455
1552
  }
1553
+ function padCell(content, width) {
1554
+ const inner = width - 2;
1555
+ return " " + padEndVisible(content, inner) + " ";
1556
+ }
1456
1557
  function topBanner(title, width = contentWidth()) {
1457
1558
  const inner = ` ${title} `;
1458
1559
  const dashes = Math.max(0, width - 2 - visibleLen(inner));
@@ -1480,7 +1581,7 @@ function tripleColumnPanel(cols, totalWidth = contentWidth()) {
1480
1581
  const maxRows = Math.max(...cols.map((c) => c.lines.length), 1);
1481
1582
  const body = [top];
1482
1583
  for (let i = 0; i < maxRows; i++) {
1483
- const cells = cols.map((c, idx) => padEndVisible(c.lines[i] ?? "", widths[idx]));
1584
+ const cells = cols.map((c, idx) => padCell(c.lines[i] ?? "", widths[idx]));
1484
1585
  body.push(
1485
1586
  border("\u2502") + cells[0] + border("\u2502") + cells[1] + border("\u2502") + cells[2] + border("\u2502")
1486
1587
  );
@@ -1498,7 +1599,7 @@ function singlePanel(title, lines, width = contentWidth()) {
1498
1599
  const dashes = Math.max(0, inner - visibleLen(prefix));
1499
1600
  const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1500
1601
  const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1501
- const body = lines.map((l) => border("\u2502") + padEndVisible(l, inner) + border("\u2502"));
1602
+ const body = lines.map((l) => border("\u2502") + padCell(l, inner + 2) + border("\u2502"));
1502
1603
  return [top, ...body, bottom].join("\n");
1503
1604
  }
1504
1605
  function tipPanel(text, width = contentWidth()) {
@@ -1507,9 +1608,9 @@ function tipPanel(text, width = contentWidth()) {
1507
1608
  const dashes = Math.max(0, inner - visibleLen(prefix));
1508
1609
  const top = border(`\u250C${prefix}${"\u2500".repeat(dashes)}\u2510`);
1509
1610
  const bottom = border(`\u2514${"\u2500".repeat(inner)}\u2518`);
1510
- return [top, border("\u2502") + padEndVisible(text, inner) + border("\u2502"), bottom].join("\n");
1611
+ return [top, border("\u2502") + padCell(text, inner + 2) + border("\u2502"), bottom].join("\n");
1511
1612
  }
1512
- function besideMark(mark, text, markCol = 14, gap = 2) {
1613
+ function besideMark(mark, text, markCol = 18, gap = 3) {
1513
1614
  const rows = Math.max(mark.length, text.length);
1514
1615
  const out = [];
1515
1616
  for (let i = 0; i < rows; i++) {
@@ -1701,47 +1802,17 @@ function emptyState(title, hint, action) {
1701
1802
 
1702
1803
  // src/commands/keys.ts
1703
1804
  init_brand();
1704
-
1705
- // src/terminal/interactive.ts
1706
- init_theme();
1707
- import { select, search } from "@inquirer/prompts";
1708
- async function pickFromList(message, items) {
1709
- if (!items.length) return null;
1710
- try {
1711
- return await select({ message, choices: items });
1712
- } catch {
1713
- return null;
1714
- }
1715
- }
1716
- async function searchPalette(message, choices) {
1717
- try {
1718
- return await search({
1719
- message,
1720
- source: async (input5) => {
1721
- if (!input5) return choices;
1722
- const q = input5.toLowerCase();
1723
- return choices.filter((c) => c.value.includes(q) || c.description?.includes(q));
1724
- }
1725
- });
1726
- } catch {
1727
- return null;
1728
- }
1729
- }
1730
- function formatChoice(name, description) {
1731
- return `${accent(name)}${muted(" \u2014 " + description)}`;
1732
- }
1733
-
1734
- // src/commands/keys.ts
1805
+ init_api_utils();
1735
1806
  async function runKeysListCommand(client) {
1736
1807
  const data = await client.get("/keys");
1737
- const keys = data.results || [];
1808
+ const keys = asList(data);
1738
1809
  if (!keys.length) {
1739
1810
  return emptyState("No API keys", `Create one at ${SETTINGS_API_KEYS_URL}`, "[n] new key in this view");
1740
1811
  }
1741
1812
  const rows = keys.map((k) => [
1742
1813
  k.name,
1743
1814
  k.prefix,
1744
- k.scopes?.join(", ") || muted("\u2014"),
1815
+ k.scopes && Array.isArray(k.scopes) ? k.scopes.join(", ") : muted("\u2014"),
1745
1816
  k.revoked ? muted("Revoked") : k.active ? success("Active") : muted("Inactive"),
1746
1817
  k.last_used_at ? new Date(k.last_used_at).toLocaleDateString() : muted("Never")
1747
1818
  ]);
@@ -1753,13 +1824,16 @@ async function runKeyRevoke(client, keyId) {
1753
1824
  }
1754
1825
  async function pickKeyForRevoke(client) {
1755
1826
  const data = await client.get("/keys");
1756
- const active = (data.results || []).filter((k) => !k.revoked && k.active);
1827
+ const active = asList(data).filter((k) => !k.revoked && k.active);
1757
1828
  return pickFromList(
1758
1829
  "Select a key to revoke",
1759
1830
  active.map((k) => ({ name: `${k.name} (${k.prefix})`, value: k.id }))
1760
1831
  );
1761
1832
  }
1762
1833
 
1834
+ // src/shell/slash-commands.ts
1835
+ init_api_utils();
1836
+
1763
1837
  // src/shell/help-screen.ts
1764
1838
  init_layout();
1765
1839
  init_theme();
@@ -1901,6 +1975,135 @@ function normalizeScanUrl(url) {
1901
1975
  if (!/^https?:\/\//i.test(u)) return `https://${u}`;
1902
1976
  return u;
1903
1977
  }
1978
+ var SPINNER_LABELS = {
1979
+ "/scan": "Scanning target...",
1980
+ "/status": "Loading account status...",
1981
+ "/scans": "Loading scans...",
1982
+ "/keys": "Loading API keys...",
1983
+ "/config": "Updating configuration...",
1984
+ "/compare": "Comparing scans...",
1985
+ "/whatif": "Running simulation...",
1986
+ "/report": "Fetching report...",
1987
+ "/auth": "Signing in...",
1988
+ "/auth-key": "Validating API key...",
1989
+ "/login": "Signing in...",
1990
+ "/changelog": "Loading changelog...",
1991
+ "/history": "Loading history...",
1992
+ "/palette": "Opening command palette...",
1993
+ "/commands": "Opening command palette..."
1994
+ };
1995
+ function spinnerLabel(cmd2) {
1996
+ return SPINNER_LABELS[cmd2.toLowerCase()] ?? null;
1997
+ }
1998
+ async function executeSlashCommand(cmd2, arg, ctx) {
1999
+ switch (cmd2.toLowerCase()) {
2000
+ case "/help":
2001
+ ctx.sink.write(renderHelpScreen());
2002
+ break;
2003
+ case "/palette":
2004
+ case "/commands": {
2005
+ const picked = await openCommandPalette();
2006
+ if (picked) return dispatchSlashCommand(picked, ctx);
2007
+ break;
2008
+ }
2009
+ case "/clear":
2010
+ return { redraw: true };
2011
+ case "/history": {
2012
+ const hist = await loadHistory();
2013
+ ctx.sink.write(renderHistoryScreen(hist));
2014
+ break;
2015
+ }
2016
+ case "/scan": {
2017
+ if (!arg) {
2018
+ ctx.sink.write(error("Usage: /scan <url>"));
2019
+ break;
2020
+ }
2021
+ const client = await getAuthenticatedClient();
2022
+ const progress = createStreamProgress(ctx.sink);
2023
+ const result = await runScanCommand(client, {
2024
+ url: normalizeScanUrl(arg),
2025
+ wait: true,
2026
+ onProgress: progress.update,
2027
+ streamProgress: true
2028
+ });
2029
+ progress.stop();
2030
+ ctx.sink.write(result.output);
2031
+ break;
2032
+ }
2033
+ case "/status": {
2034
+ const client = await getAuthenticatedClient();
2035
+ const status = await runStatusCommand(client);
2036
+ const rate = await runRateLimitStatus(client);
2037
+ ctx.sink.write(rate ? `${status}
2038
+
2039
+ ${muted("Rate limit")} ${rate}` : status);
2040
+ break;
2041
+ }
2042
+ case "/scans":
2043
+ await runScansInteractive(ctx);
2044
+ break;
2045
+ case "/keys":
2046
+ await runKeysInteractive(ctx);
2047
+ break;
2048
+ case "/config": {
2049
+ if (!arg.includes("=")) {
2050
+ ctx.sink.write(error("Usage: /config key=value"));
2051
+ break;
2052
+ }
2053
+ const [k, ...vParts] = arg.split("=");
2054
+ ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
2055
+ break;
2056
+ }
2057
+ case "/changelog": {
2058
+ const raw = await readAsset("changelog.json");
2059
+ ctx.sink.write(renderChangelog(JSON.parse(raw)));
2060
+ break;
2061
+ }
2062
+ case "/compare": {
2063
+ const [a, b] = arg.split(/\s+/);
2064
+ if (!a || !b) {
2065
+ ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
2066
+ break;
2067
+ }
2068
+ const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
2069
+ ctx.sink.write(await runCompareCommand2(a, b, "table"));
2070
+ break;
2071
+ }
2072
+ case "/whatif": {
2073
+ if (!arg) {
2074
+ ctx.sink.write(error("Usage: /whatif <scan-id>"));
2075
+ break;
2076
+ }
2077
+ const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
2078
+ ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
2079
+ break;
2080
+ }
2081
+ case "/report": {
2082
+ if (!arg) {
2083
+ ctx.sink.write(error("Usage: /report <scan-id>"));
2084
+ break;
2085
+ }
2086
+ const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
2087
+ const client = await getAuthenticatedClient();
2088
+ ctx.sink.write(await runReportCommand2(client, arg, "json"));
2089
+ break;
2090
+ }
2091
+ case "/login":
2092
+ case "/auth": {
2093
+ const result = await runAssistedBrowserLogin();
2094
+ ctx.sink.write(result.message);
2095
+ return { profile: result.profile, redraw: true };
2096
+ }
2097
+ case "/auth-key": {
2098
+ ctx.sink.write(await runApiKeyLogin({ interactive: true }));
2099
+ const client = await getAuthenticatedClient();
2100
+ return { profile: await client.get("/me"), redraw: true };
2101
+ }
2102
+ default:
2103
+ ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
2104
+ }
2105
+ return {};
2106
+ }
1904
2107
  async function dispatchSlashCommand(line, ctx) {
1905
2108
  const trimmed = line.trim();
1906
2109
  if (!trimmed) return {};
@@ -1922,112 +2125,11 @@ ${accent("/help")} lists everything`
1922
2125
  return {};
1923
2126
  }
1924
2127
  try {
1925
- switch (cmd2.toLowerCase()) {
1926
- case "/help":
1927
- ctx.sink.write(renderHelpScreen());
1928
- break;
1929
- case "/palette":
1930
- case "/commands": {
1931
- const picked = await openCommandPalette();
1932
- if (picked) return dispatchSlashCommand(picked, ctx);
1933
- break;
1934
- }
1935
- case "/clear":
1936
- return { redraw: true };
1937
- case "/history": {
1938
- const hist = await loadHistory();
1939
- ctx.sink.write(renderHistoryScreen(hist));
1940
- break;
1941
- }
1942
- case "/scan": {
1943
- if (!arg) {
1944
- ctx.sink.write(error("Usage: /scan <url>"));
1945
- break;
1946
- }
1947
- const client = await getAuthenticatedClient();
1948
- const progress = createStreamProgress(ctx.sink);
1949
- const result = await runScanCommand(client, {
1950
- url: normalizeScanUrl(arg),
1951
- wait: true,
1952
- onProgress: progress.update,
1953
- streamProgress: true
1954
- });
1955
- progress.stop();
1956
- ctx.sink.write(result.output);
1957
- break;
1958
- }
1959
- case "/status": {
1960
- const client = await getAuthenticatedClient();
1961
- const status = await runStatusCommand(client);
1962
- const rate = await runRateLimitStatus(client);
1963
- ctx.sink.write(rate ? `${status}
1964
-
1965
- ${muted("Rate limit")} ${rate}` : status);
1966
- break;
1967
- }
1968
- case "/scans":
1969
- await runScansInteractive(ctx);
1970
- break;
1971
- case "/keys":
1972
- await runKeysInteractive(ctx);
1973
- break;
1974
- case "/config": {
1975
- if (!arg.includes("=")) {
1976
- ctx.sink.write(error("Usage: /config key=value"));
1977
- break;
1978
- }
1979
- const [k, ...vParts] = arg.split("=");
1980
- ctx.sink.write(await runConfigSet(k.trim(), vParts.join("=").trim()));
1981
- break;
1982
- }
1983
- case "/changelog": {
1984
- const raw = await readAsset("changelog.json");
1985
- ctx.sink.write(renderChangelog(JSON.parse(raw)));
1986
- break;
1987
- }
1988
- case "/compare": {
1989
- const [a, b] = arg.split(/\s+/);
1990
- if (!a || !b) {
1991
- ctx.sink.write(error("Usage: /compare <scan-a> <scan-b>"));
1992
- break;
1993
- }
1994
- const { runCompareCommand: runCompareCommand2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1995
- ctx.sink.write(await runCompareCommand2(a, b, "table"));
1996
- break;
1997
- }
1998
- case "/whatif": {
1999
- if (!arg) {
2000
- ctx.sink.write(error("Usage: /whatif <scan-id>"));
2001
- break;
2002
- }
2003
- const { runWhatIfCommand: runWhatIfCommand2 } = await Promise.resolve().then(() => (init_whatif(), whatif_exports));
2004
- ctx.sink.write(await runWhatIfCommand2(arg, {}, "table"));
2005
- break;
2006
- }
2007
- case "/report": {
2008
- if (!arg) {
2009
- ctx.sink.write(error("Usage: /report <scan-id>"));
2010
- break;
2011
- }
2012
- const { runReportCommand: runReportCommand2 } = await Promise.resolve().then(() => (init_report(), report_exports));
2013
- const client = await getAuthenticatedClient();
2014
- ctx.sink.write(await runReportCommand2(client, arg, "json"));
2015
- break;
2016
- }
2017
- case "/login":
2018
- case "/auth": {
2019
- const result = await runAssistedBrowserLogin();
2020
- ctx.sink.write(result.message);
2021
- return { profile: result.profile, redraw: true };
2022
- }
2023
- case "/auth-key": {
2024
- ctx.sink.write(await runApiKeyLogin({ interactive: true }));
2025
- const client = await getAuthenticatedClient();
2026
- return { profile: await client.get("/me"), redraw: true };
2027
- }
2028
- default:
2029
- ctx.sink.write(errorBox(`Unknown command: ${cmd2}`, "Type /help"));
2128
+ const label = spinnerLabel(cmd2);
2129
+ if (label) {
2130
+ return await withSpinner(label, () => executeSlashCommand(cmd2, arg, ctx));
2030
2131
  }
2132
+ return await executeSlashCommand(cmd2, arg, ctx);
2031
2133
  } catch (e) {
2032
2134
  ctx.sink.error(formatApiError(e));
2033
2135
  }
@@ -2052,7 +2154,7 @@ async function runScansInteractive(ctx) {
2052
2154
  while (true) {
2053
2155
  const list = await runScansListCommand(client, "table", { limit: 20 });
2054
2156
  const data = await client.get("/scans?page=1&page_size=20");
2055
- const scans = data.results || [];
2157
+ const scans = asList(data);
2056
2158
  if (!scans.length) {
2057
2159
  ctx.sink.write(emptyState("No scans yet", "Run /scan <url> to create your first scan", "/scan https://example.com"));
2058
2160
  return;
@@ -2069,10 +2171,12 @@ async function runScansInteractive(ctx) {
2069
2171
  if (!scanId) return;
2070
2172
  const detail = await client.get(`/scan/${scanId}`);
2071
2173
  ctx.sink.write(renderScanResult(detail, "table"));
2072
- const cont = await input2({
2073
- message: muted("Enter = back to list \xB7 q = exit"),
2074
- default: ""
2075
- }).catch(() => "q");
2174
+ const cont = await runInteractive(
2175
+ () => input2({
2176
+ message: muted("Enter = back to list \xB7 q = exit"),
2177
+ default: ""
2178
+ })
2179
+ ).catch(() => "q");
2076
2180
  if (cont.toLowerCase() === "q") return;
2077
2181
  }
2078
2182
  }
@@ -2080,14 +2184,16 @@ async function runKeysInteractive(ctx) {
2080
2184
  const client = await getAuthenticatedClient();
2081
2185
  ctx.sink.write(await runKeysListCommand(client));
2082
2186
  ctx.sink.write(muted("\nActions: [r] revoke [n] new key [Enter] back"));
2083
- const action = await input2({ message: "Action", default: "" }).catch(() => "");
2187
+ const action = await runInteractive(() => input2({ message: "Action", default: "" })).catch(() => "");
2084
2188
  if (action.toLowerCase() === "r") {
2085
2189
  const keyId = await pickKeyForRevoke(client);
2086
2190
  if (!keyId) return;
2087
- const ok = await confirm2({ message: "Revoke this API key?", default: false }).catch(() => false);
2191
+ const ok = await runInteractive(
2192
+ () => confirm2({ message: "Revoke this API key?", default: false })
2193
+ ).catch(() => false);
2088
2194
  if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
2089
2195
  } else if (action.toLowerCase() === "n") {
2090
- const name = await promptNewKeyName();
2196
+ const name = await runInteractive(() => promptNewKeyName());
2091
2197
  const created = await client.post("/keys", { name });
2092
2198
  ctx.sink.write(success(`Created: ${created.name} (${created.prefix})`));
2093
2199
  ctx.sink.write(accent("Save this key \u2014 it won't be shown again:"));
@@ -2139,6 +2245,13 @@ async function runPromptLoop(initialProfile) {
2139
2245
  rl.close();
2140
2246
  process.exit(0);
2141
2247
  });
2248
+ setReadlineHooks(
2249
+ () => rl.pause(),
2250
+ () => {
2251
+ process.stdout.write("\n");
2252
+ rl.resume();
2253
+ }
2254
+ );
2142
2255
  const prompt = () => process.stdout.write(accent("> "));
2143
2256
  prompt();
2144
2257
  for await (const line of rl) {
@@ -2176,15 +2289,32 @@ init_theme();
2176
2289
  init_credentials();
2177
2290
  init_api_client();
2178
2291
  init_logger();
2292
+ function profileFromCredentials(creds) {
2293
+ return {
2294
+ id: "local",
2295
+ email: creds.email || "user@local",
2296
+ full_name: creds.full_name || null,
2297
+ role: "analyst",
2298
+ organization: "\u2014",
2299
+ key_expires_at: creds.key_expires_at ?? null,
2300
+ key_prefix: creds.key_prefix ?? null
2301
+ };
2302
+ }
2179
2303
  async function tryGetProfile() {
2304
+ const creds = await loadCredentials();
2305
+ if (!creds?.api_key) return null;
2180
2306
  try {
2181
- const creds = await loadCredentials();
2182
- if (!creds?.api_key) return null;
2183
- const client = await getAuthenticatedClient();
2184
- return await client.get("/me");
2307
+ const client = await FuzziApiClient.create();
2308
+ const profile = await client.get("/me");
2309
+ return profile;
2185
2310
  } catch (e) {
2186
- log.debug("profile bootstrap failed", e);
2187
- return null;
2311
+ if (e instanceof ApiError && e.status === 401) {
2312
+ log.debug("stored credentials invalid, clearing");
2313
+ await clearCredentials();
2314
+ return null;
2315
+ }
2316
+ log.debug("profile bootstrap failed, using cached credentials", e);
2317
+ return profileFromCredentials(creds);
2188
2318
  }
2189
2319
  }
2190
2320