fuzzi-cli 0.1.0 → 0.1.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.
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ var __export = (target, all) => {
9
9
  };
10
10
 
11
11
  // src/types/brand.ts
12
- var BRAND, RISK_COLORS, VERSION, DEFAULT_API_URL;
12
+ var BRAND, RISK_COLORS, VERSION, APP_ORIGIN, DEFAULT_API_URL;
13
13
  var init_brand = __esm({
14
14
  "src/types/brand.ts"() {
15
15
  "use strict";
@@ -27,8 +27,9 @@ var init_brand = __esm({
27
27
  HIGH: "#EF4444",
28
28
  CRITICAL: "#A855F7"
29
29
  };
30
- VERSION = "0.1.0";
31
- DEFAULT_API_URL = "https://app.fuzzi.dev/api";
30
+ VERSION = "0.1.1";
31
+ APP_ORIGIN = "https://app.fuzzi.dev";
32
+ DEFAULT_API_URL = `${APP_ORIGIN}/api`;
32
33
  }
33
34
  });
34
35
 
@@ -396,6 +397,9 @@ function getCapabilities() {
396
397
  };
397
398
  return cached;
398
399
  }
400
+ function resetCapabilities() {
401
+ cached = null;
402
+ }
399
403
  var cached;
400
404
  var init_capabilities = __esm({
401
405
  "src/terminal/capabilities.ts"() {
@@ -434,6 +438,9 @@ function success(text) {
434
438
  function warn(text) {
435
439
  return color("#F59E0B", chalk.yellow)(text);
436
440
  }
441
+ function info(text) {
442
+ return color(BRAND.accent, chalk.cyan)(text);
443
+ }
437
444
  var accent, accentBold, muted, bold, dim;
438
445
  var init_theme = __esm({
439
446
  "src/terminal/theme.ts"() {
@@ -448,14 +455,6 @@ var init_theme = __esm({
448
455
  }
449
456
  });
450
457
 
451
- // src/lib/theme.ts
452
- var init_theme2 = __esm({
453
- "src/lib/theme.ts"() {
454
- "use strict";
455
- init_theme();
456
- }
457
- });
458
-
459
458
  // src/terminal/table.ts
460
459
  import Table from "cli-table3";
461
460
  function createTable(headers, rows) {
@@ -510,33 +509,52 @@ var init_strings = __esm({
510
509
  }
511
510
  });
512
511
 
512
+ // src/terminal/width.ts
513
+ import { stdout as stdout2 } from "process";
514
+ function terminalWidth() {
515
+ resetCapabilities();
516
+ return Math.max(64, (stdout2.columns ?? 80) - 2);
517
+ }
518
+ function contentWidth() {
519
+ return terminalWidth() - 4;
520
+ }
521
+ var init_width = __esm({
522
+ "src/terminal/width.ts"() {
523
+ "use strict";
524
+ init_capabilities();
525
+ }
526
+ });
527
+
513
528
  // src/terminal/layout.ts
514
529
  import boxen from "boxen";
515
530
  function panel(content, opts = {}) {
531
+ const width = opts.fullWidth !== false ? terminalWidth() : void 0;
516
532
  return boxen(content, {
517
533
  title: opts.title ? accentBold(opts.title) : void 0,
518
534
  padding: opts.padding ?? 1,
519
535
  margin: { top: 0, bottom: opts.marginBottom ?? 1, left: 0, right: 0 },
520
536
  borderStyle: "round",
521
537
  borderColor: getCapabilities().trueColor ? BRAND.accent : void 0,
522
- titleAlignment: "left"
538
+ titleAlignment: "left",
539
+ width
523
540
  });
524
541
  }
525
542
  function columns(left, right, leftWidth) {
526
- const width = leftWidth ?? Math.floor(getCapabilities().width * 0.45);
543
+ const total = contentWidth();
544
+ const split = leftWidth ?? Math.floor(total * 0.48);
527
545
  const leftLines = left.split("\n");
528
546
  const rightLines = right.split("\n");
529
547
  const rows = Math.max(leftLines.length, rightLines.length);
530
548
  const out = [];
531
549
  for (let i = 0; i < rows; i++) {
532
- const l = padEndVisible(leftLines[i] ?? "", width);
550
+ const l = padEndVisible(leftLines[i] ?? "", split);
533
551
  const r = rightLines[i] ?? "";
534
552
  out.push(`${l} ${r}`);
535
553
  }
536
554
  return out.join("\n");
537
555
  }
538
556
  function divider(char = "\u2500", width) {
539
- const w = width ?? getCapabilities().width - 4;
557
+ const w = width ?? contentWidth();
540
558
  return dim(char.repeat(Math.max(20, w)));
541
559
  }
542
560
  function statusBar(parts) {
@@ -547,6 +565,13 @@ function keyValue(rows, indent = 2) {
547
565
  const maxKey = Math.max(...rows.map(([k]) => k.length), 4);
548
566
  return rows.map(([k, v]) => `${pad}${muted(k.padEnd(maxKey))} ${v}`).join("\n");
549
567
  }
568
+ function centerBlock(text, width = contentWidth()) {
569
+ return text.split("\n").map((line) => {
570
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
571
+ const pad = Math.max(0, Math.floor((width - plain.length) / 2));
572
+ return " ".repeat(pad) + line;
573
+ }).join("\n");
574
+ }
550
575
  var init_layout = __esm({
551
576
  "src/terminal/layout.ts"() {
552
577
  "use strict";
@@ -554,6 +579,15 @@ var init_layout = __esm({
554
579
  init_theme();
555
580
  init_capabilities();
556
581
  init_strings();
582
+ init_width();
583
+ }
584
+ });
585
+
586
+ // src/lib/theme.ts
587
+ var init_theme2 = __esm({
588
+ "src/lib/theme.ts"() {
589
+ "use strict";
590
+ init_theme();
557
591
  }
558
592
  });
559
593
 
@@ -648,16 +682,125 @@ init_credentials();
648
682
  init_api_client();
649
683
  init_credentials();
650
684
  init_config();
651
- init_theme2();
652
- import { password, input } from "@inquirer/prompts";
685
+ init_theme();
686
+ import { password, input, confirm } from "@inquirer/prompts";
687
+
688
+ // src/lib/browser-auth.ts
689
+ init_config();
690
+ init_credentials();
691
+ init_api_client();
692
+ init_brand();
693
+ init_logger();
694
+ import { randomBytes } from "crypto";
695
+ import { createServer } from "http";
696
+ import { exec } from "child_process";
697
+ var TIMEOUT_MS = 5 * 60 * 1e3;
698
+ function generateState() {
699
+ return randomBytes(24).toString("base64url");
700
+ }
701
+ function openBrowser(url) {
702
+ const platform = process.platform;
703
+ const cmd = platform === "darwin" ? `open ${JSON.stringify(url)}` : platform === "win32" ? `start "" ${JSON.stringify(url)}` : `xdg-open ${JSON.stringify(url)}`;
704
+ exec(cmd, (err) => {
705
+ if (err) log.warn("could not open browser automatically", err.message);
706
+ });
707
+ }
708
+ function apiOrigin(apiUrl) {
709
+ return apiUrl.replace(/\/api\/?$/, "") || APP_ORIGIN;
710
+ }
711
+ async function runBrowserLogin() {
712
+ const config = await loadConfig();
713
+ const state = generateState();
714
+ const handoffToken = await new Promise((resolve, reject) => {
715
+ const server = createServer((req, res) => {
716
+ try {
717
+ const addr = server.address();
718
+ const port = typeof addr === "object" && addr ? addr.port : 0;
719
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
720
+ if (url.pathname !== "/callback") {
721
+ res.writeHead(404);
722
+ res.end();
723
+ return;
724
+ }
725
+ const token = url.searchParams.get("token");
726
+ const returnedState = url.searchParams.get("state");
727
+ if (!token || returnedState !== state) {
728
+ res.writeHead(400);
729
+ res.end("Invalid callback");
730
+ reject(new ApiError("Invalid sign-in callback.", 400, "invalid_callback", void 0, 2));
731
+ return;
732
+ }
733
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
734
+ res.end(`<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:48px">
735
+ <h1>Signed in to Fuzzi CLI</h1><p>Return to your terminal.</p>
736
+ <script>setTimeout(()=>window.close(),1200)</script></body></html>`);
737
+ server.close();
738
+ resolve(token);
739
+ } catch (e) {
740
+ server.close();
741
+ reject(e);
742
+ }
743
+ });
744
+ const timer = setTimeout(() => {
745
+ server.close();
746
+ reject(new ApiError("Sign-in timed out after 5 minutes.", 408, "auth_timeout", void 0, 2));
747
+ }, TIMEOUT_MS);
748
+ server.listen(0, "127.0.0.1", () => {
749
+ const addr = server.address();
750
+ const port = typeof addr === "object" && addr ? addr.port : 0;
751
+ const loginUrl = `${apiOrigin(config.api_url)}/cli-auth?state=${encodeURIComponent(state)}&callback_port=${port}`;
752
+ openBrowser(loginUrl);
753
+ log.debug("browser auth", loginUrl);
754
+ });
755
+ server.on("error", (e) => {
756
+ clearTimeout(timer);
757
+ reject(e);
758
+ });
759
+ });
760
+ const client = new FuzziApiClient(config.api_url);
761
+ const handoff = await client.post("/cli/handoff", {
762
+ handoff_token: handoffToken,
763
+ state
764
+ });
765
+ if (!handoff.api_key) {
766
+ throw new ApiError("Sign-in failed: no API key returned.", 500, "handoff_failed", void 0, 2);
767
+ }
768
+ client.setToken(handoff.api_key);
769
+ const profile = await client.get("/me");
770
+ await saveCredentials({
771
+ api_key: handoff.api_key,
772
+ auth_method: "api_key",
773
+ key_prefix: handoff.prefix || profile.key_prefix || maskApiKey(handoff.api_key),
774
+ key_expires_at: handoff.expires_at || profile.key_expires_at || void 0,
775
+ email: profile.email,
776
+ full_name: profile.full_name || void 0,
777
+ saved_at: (/* @__PURE__ */ new Date()).toISOString()
778
+ });
779
+ const name = profile.full_name || profile.email;
780
+ return { message: `Signed in as ${name}`, profile };
781
+ }
782
+
783
+ // src/commands/auth.ts
653
784
  async function runAuthLogin(opts = {}) {
785
+ if (opts.browser || opts.interactive !== false && !opts.apiKey && !opts.apiKeyOnly) {
786
+ try {
787
+ const result = await runBrowserLogin();
788
+ return success(result.message);
789
+ } catch (e) {
790
+ if (opts.browser) throw e;
791
+ if (opts.apiKeyOnly) throw e;
792
+ }
793
+ }
794
+ return runApiKeyLogin(opts);
795
+ }
796
+ async function runApiKeyLogin(opts = {}) {
654
797
  const config = await loadConfig();
655
798
  const client = new FuzziApiClient(config.api_url);
656
799
  let apiKey = opts.apiKey?.trim();
657
800
  if (!apiKey) {
658
801
  if (opts.interactive === false) {
659
802
  throw new ApiError(
660
- "No API key provided. Generate one at https://app.fuzzi.dev/settings/api-keys",
803
+ "No API key provided. Run fuzzi auth login or sign in via browser.",
661
804
  401,
662
805
  "missing_key",
663
806
  void 0,
@@ -909,9 +1052,9 @@ function parseTomlSimple(raw) {
909
1052
  }
910
1053
  return config;
911
1054
  }
912
- async function loadProjectConfig(cwd5) {
913
- const fuzzirc = join3(cwd5, ".fuzzirc");
914
- const fuzzitoml = join3(cwd5, "fuzzi.toml");
1055
+ async function loadProjectConfig(cwd4) {
1056
+ const fuzzirc = join3(cwd4, ".fuzzirc");
1057
+ const fuzzitoml = join3(cwd4, "fuzzi.toml");
915
1058
  if (existsSync(fuzzirc)) {
916
1059
  try {
917
1060
  const raw = await readFile3(fuzzirc, "utf8");
@@ -1079,8 +1222,8 @@ async function runScanCommand(client, opts) {
1079
1222
  log.debug("creating scan", { url: opts.url, env });
1080
1223
  const created = await withRetry(() => client.post("/scan", body));
1081
1224
  if (!shouldWait) {
1082
- const output2 = format === "json" ? JSON.stringify(created, null, 2) : [success("Scan started"), muted(`ID: ${created.scan_id}`), muted(created.message)].join("\n");
1083
- return { output: output2, exitCode: 0 };
1225
+ const output3 = format === "json" ? JSON.stringify(created, null, 2) : [success("Scan started"), muted(`ID: ${created.scan_id}`), muted(created.message)].join("\n");
1226
+ return { output: output3, exitCode: 0 };
1084
1227
  }
1085
1228
  const host = hostnameFromUrl(opts.url);
1086
1229
  const progress = opts.onProgress ? { update: opts.onProgress, stop: () => {
@@ -1189,9 +1332,17 @@ function exitWith(code) {
1189
1332
  function buildProgram() {
1190
1333
  const program = new Command("fuzzi").name("fuzzi").description("Fuzzi security scanner CLI \u2014 interactive shell and scriptable commands").version(VERSION);
1191
1334
  const auth = program.command("auth").description("Authentication");
1192
- auth.command("login").description("Authenticate with an API key from https://app.fuzzi.dev/settings/api-keys").option("--api-key <key>", "API key (fz_live_...)").action(async (opts) => {
1335
+ auth.command("login").description("Sign in via browser (default) or API key").option("--browser", "Sign in via browser (default when interactive)").option("--api-key <key>", "Paste an API key (fz_live_...)").action(async (opts) => {
1193
1336
  try {
1194
- console.log(await runAuthLogin({ apiKey: opts.apiKey, interactive: !opts.apiKey }));
1337
+ const useApiKey = !!opts.apiKey;
1338
+ console.log(
1339
+ await runAuthLogin({
1340
+ apiKey: opts.apiKey,
1341
+ interactive: !opts.apiKey,
1342
+ browser: !useApiKey,
1343
+ apiKeyOnly: useApiKey
1344
+ })
1345
+ );
1195
1346
  } catch (e) {
1196
1347
  handleCommandError(e);
1197
1348
  }
@@ -1300,11 +1451,6 @@ function buildProgram() {
1300
1451
  return program;
1301
1452
  }
1302
1453
 
1303
- // src/cli/bootstrap.ts
1304
- init_credentials();
1305
- init_api_client();
1306
- import { cwd as cwd4 } from "process";
1307
-
1308
1454
  // src/shell/prompt-loop.ts
1309
1455
  import * as readline from "readline/promises";
1310
1456
  import { stdin as input3, stdout as output } from "process";
@@ -1326,6 +1472,7 @@ function renderFuzziMark() {
1326
1472
  init_brand();
1327
1473
  init_theme();
1328
1474
  init_layout();
1475
+ init_width();
1329
1476
 
1330
1477
  // src/lib/assets.ts
1331
1478
  import { readFile as readFile4 } from "fs/promises";
@@ -1352,33 +1499,61 @@ async function readAsset(name) {
1352
1499
  }
1353
1500
 
1354
1501
  // src/shell/home-screen.ts
1355
- async function fetchHomeData(profile, cwd5) {
1502
+ async function fetchHomeData(profile, cwd4) {
1356
1503
  let changelog = [];
1357
1504
  try {
1358
1505
  changelog = JSON.parse(await readAsset("changelog.json"));
1359
1506
  } catch {
1360
1507
  changelog = [];
1361
1508
  }
1362
- return { profile, cwd: cwd5, changelog };
1509
+ return { profile, cwd: cwd4, changelog };
1363
1510
  }
1364
1511
  function renderHomeScreen(data) {
1512
+ const w = contentWidth();
1365
1513
  const name = data.profile?.full_name || data.profile?.email?.split("@")[0] || "there";
1366
1514
  const org = data.profile?.organization?.trim();
1367
- const welcome = data.profile ? accentBold(`Welcome back, ${name}!`) : muted("Welcome to Fuzzi");
1368
- const mark = accent(renderFuzziMark());
1369
- const connected = data.profile ? statusBar([accent("Connected"), org ?? null].filter(Boolean)) : muted("Not connected");
1370
- const headerBody = ["", welcome, "", mark, "", connected, muted(data.cwd), ""].join("\n");
1515
+ const welcome = data.profile ? accentBold(`Welcome back, ${name}!`) : accentBold("Welcome to Fuzzi");
1516
+ const mark = centerBlock(accent(renderFuzziMark()), w);
1517
+ const statusLine = data.profile ? [accent("\u25CF Connected"), org, muted(data.profile.email)].filter(Boolean).join(muted(" \xB7 ")) : muted("Not connected") + muted(" \xB7 ") + info("Press Enter to sign in via browser");
1518
+ const cwdLine = centerBlock(muted(data.cwd), w);
1371
1519
  const latest = data.changelog[0];
1372
- const whatsNew = latest ? [...latest.highlights.slice(0, 2).map((h) => muted(`\xB7 ${h}`)), muted("/changelog for more")].join("\n") : muted("Stay tuned for updates");
1520
+ const whatsNewTitle = accentBold("What's new");
1521
+ const whatsNew = latest ? [
1522
+ whatsNewTitle,
1523
+ ...latest.highlights.slice(0, 3).map((h) => muted(` \xB7 ${h}`)),
1524
+ muted(" /changelog for more")
1525
+ ].join("\n") : [whatsNewTitle, muted(" Stay tuned for updates")].join("\n");
1526
+ const quickTitle = accentBold("Quick actions");
1373
1527
  const quickActions = [
1374
- accent("/scan") + muted(" <url>") + muted(" scan a target"),
1375
- accent("/scans") + muted(" browse history"),
1376
- accent("/status") + muted(" account info"),
1377
- accent("/palette") + muted(" search commands"),
1378
- accent("/help") + muted(" all commands")
1528
+ quickTitle,
1529
+ accent("/scan") + muted(" <url> ") + muted("security scan"),
1530
+ accent("/scans") + muted(" ") + muted("browse history"),
1531
+ accent("/status") + muted(" ") + muted("account info"),
1532
+ accent("/auth") + muted(" ") + muted("sign in (browser)"),
1533
+ accent("/auth-key") + muted(" ") + muted("paste API key"),
1534
+ accent("/palette") + muted(" ") + muted("search commands"),
1535
+ accent("/help") + muted(" ") + muted("all commands")
1379
1536
  ].join("\n");
1380
- const panels = panel(columns(quickActions, whatsNew, 38), { title: "Quick actions" });
1381
- return panel(headerBody, { title: `Fuzzi CLI v${VERSION}`, marginBottom: 0 }) + "\n" + panels;
1537
+ const footer = data.profile ? muted("Type a command below \xB7 /palette to search \xB7 Ctrl+C to exit") : muted("Press Enter at startup to sign in via browser \xB7 or /auth-key");
1538
+ const body = [
1539
+ "",
1540
+ centerBlock(welcome, w),
1541
+ "",
1542
+ mark,
1543
+ "",
1544
+ centerBlock(statusLine, w),
1545
+ cwdLine,
1546
+ "",
1547
+ divider(),
1548
+ "",
1549
+ columns(quickActions, whatsNew),
1550
+ "",
1551
+ divider(),
1552
+ "",
1553
+ centerBlock(footer, w),
1554
+ ""
1555
+ ].join("\n");
1556
+ return panel(body, { title: `Fuzzi CLI v${VERSION}`, marginBottom: 0 });
1382
1557
  }
1383
1558
  function renderChangelog(entries) {
1384
1559
  if (!entries.length) return muted("No changelog entries.");
@@ -1393,7 +1568,7 @@ function renderChangelog(entries) {
1393
1568
 
1394
1569
  // src/shell/slash-commands.ts
1395
1570
  init_api_client();
1396
- import { confirm, input as input2 } from "@inquirer/prompts";
1571
+ import { confirm as confirm2, input as input2 } from "@inquirer/prompts";
1397
1572
 
1398
1573
  // src/commands/keys.ts
1399
1574
  init_table();
@@ -1422,9 +1597,9 @@ async function searchPalette(message, choices) {
1422
1597
  try {
1423
1598
  return await search({
1424
1599
  message,
1425
- source: async (input4) => {
1426
- if (!input4) return choices;
1427
- const q = input4.toLowerCase();
1600
+ source: async (input5) => {
1601
+ if (!input5) return choices;
1602
+ const q = input5.toLowerCase();
1428
1603
  return choices.filter((c) => c.value.includes(q) || c.description?.includes(q));
1429
1604
  }
1430
1605
  });
@@ -1482,13 +1657,14 @@ var SLASH_COMMANDS = [
1482
1657
  { name: "/palette", description: "Open command palette", aliases: ["/commands"] },
1483
1658
  { name: "/changelog", description: "View release notes" },
1484
1659
  { name: "/help", description: "Show all commands" },
1485
- { name: "/auth", description: "Log in with API key", aliases: ["/login"] },
1660
+ { name: "/auth", description: "Sign in via browser", aliases: ["/login"] },
1661
+ { name: "/auth-key", description: "Paste an API key instead", usage: "/auth-key" },
1486
1662
  { name: "/clear", description: "Clear screen and refresh home" },
1487
1663
  { name: "/history", description: "Show recent commands" },
1488
1664
  { name: "/exit", description: "Exit the shell", aliases: ["/quit"] }
1489
1665
  ];
1490
- function findCommand(input4) {
1491
- const cmd = input4.trim().split(/\s/)[0].toLowerCase();
1666
+ function findCommand(input5) {
1667
+ const cmd = input5.trim().split(/\s/)[0].toLowerCase();
1492
1668
  return SLASH_COMMANDS.find(
1493
1669
  (c) => c.name === cmd || c.aliases?.some((a) => a === cmd)
1494
1670
  );
@@ -1569,14 +1745,59 @@ function successBox(message) {
1569
1745
 
1570
1746
  // src/shell/slash-commands.ts
1571
1747
  init_strings();
1748
+ function normalizeInput(line) {
1749
+ let t = line.trim();
1750
+ if (!t || t.startsWith("/")) return t;
1751
+ if (t.toLowerCase().startsWith("fuzzi ")) t = t.slice(6).trim();
1752
+ const lower = t.toLowerCase();
1753
+ const aliases = {
1754
+ "auth login": "/auth",
1755
+ auth: "/auth",
1756
+ login: "/auth",
1757
+ "auth-key": "/auth-key",
1758
+ logout: "/exit",
1759
+ help: "/help",
1760
+ exit: "/exit",
1761
+ quit: "/exit",
1762
+ clear: "/clear",
1763
+ changelog: "/changelog",
1764
+ status: "/status",
1765
+ scans: "/scans",
1766
+ keys: "/keys",
1767
+ palette: "/palette"
1768
+ };
1769
+ if (aliases[lower]) return aliases[lower];
1770
+ if (lower.startsWith("scan ")) return `/scan ${t.slice(5).trim()}`;
1771
+ if (lower.startsWith("config set ")) {
1772
+ const parts = t.slice(11).trim().split(/\s+/);
1773
+ if (parts.length >= 2) return `/config ${parts[0]}=${parts.slice(1).join(" ")}`;
1774
+ }
1775
+ if (lower.startsWith("config ")) return `/config ${t.slice(7).trim().replace(/\s+/, "=")}`;
1776
+ if (!t.includes(" ") && t.includes(".")) return `/scan ${t}`;
1777
+ return t;
1778
+ }
1779
+ function normalizeScanUrl(url) {
1780
+ const u = url.trim();
1781
+ if (!/^https?:\/\//i.test(u)) return `https://${u}`;
1782
+ return u;
1783
+ }
1572
1784
  async function dispatchSlashCommand(line, ctx) {
1573
1785
  const trimmed = line.trim();
1574
1786
  if (!trimmed) return {};
1575
1787
  if (trimmed === "/exit" || trimmed === "/quit") return { exit: true };
1576
1788
  const [cmd, ...rest] = trimmed.split(/\s+/);
1577
1789
  const arg = rest.join(" ").trim();
1578
- const def = findCommand(cmd);
1579
- if (!def && cmd.startsWith("/")) {
1790
+ if (!cmd.startsWith("/")) {
1791
+ ctx.sink.write(
1792
+ errorBox(
1793
+ `Not a shell command: ${trimmed}`,
1794
+ `Use slash commands here \u2014 e.g. ${accent("/auth")} not "fuzzi auth login"
1795
+ ${accent("/help")} lists everything`
1796
+ )
1797
+ );
1798
+ return {};
1799
+ }
1800
+ if (!findCommand(cmd) && cmd.startsWith("/")) {
1580
1801
  ctx.sink.write(errorBox(`Unknown command: ${cmd}`, "Type /help or /palette"));
1581
1802
  return {};
1582
1803
  }
@@ -1606,9 +1827,10 @@ async function dispatchSlashCommand(line, ctx) {
1606
1827
  const client = await getAuthenticatedClient();
1607
1828
  const progress = createStreamProgress(ctx.sink);
1608
1829
  const result = await runScanCommand(client, {
1609
- url: arg,
1830
+ url: normalizeScanUrl(arg),
1610
1831
  wait: true,
1611
- onProgress: progress.update
1832
+ onProgress: progress.update,
1833
+ streamProgress: true
1612
1834
  });
1613
1835
  progress.stop();
1614
1836
  ctx.sink.write(result.output);
@@ -1674,7 +1896,12 @@ ${muted("Rate limit")} ${rate}` : status);
1674
1896
  }
1675
1897
  case "/login":
1676
1898
  case "/auth": {
1677
- ctx.sink.write(await runAuthLogin({ interactive: true }));
1899
+ ctx.sink.write(await runAuthLogin({ interactive: true, browser: true }));
1900
+ const client = await getAuthenticatedClient();
1901
+ return { profile: await client.get("/me"), redraw: true };
1902
+ }
1903
+ case "/auth-key": {
1904
+ ctx.sink.write(await runApiKeyLogin({ interactive: true }));
1678
1905
  const client = await getAuthenticatedClient();
1679
1906
  return { profile: await client.get("/me"), redraw: true };
1680
1907
  }
@@ -1737,7 +1964,7 @@ async function runKeysInteractive(ctx) {
1737
1964
  if (action.toLowerCase() === "r") {
1738
1965
  const keyId = await pickKeyForRevoke(client);
1739
1966
  if (!keyId) return;
1740
- const ok = await confirm({ message: "Revoke this API key?", default: false }).catch(() => false);
1967
+ const ok = await confirm2({ message: "Revoke this API key?", default: false }).catch(() => false);
1741
1968
  if (ok) ctx.sink.write(successBox(await runKeyRevoke(client, keyId)));
1742
1969
  } else if (action.toLowerCase() === "n") {
1743
1970
  const name = await promptNewKeyName();
@@ -1775,15 +2002,16 @@ async function runPromptLoop(initialProfile) {
1775
2002
  const workDir = cwd3();
1776
2003
  const history = await loadHistory();
1777
2004
  const refresh = async () => {
1778
- if (getCapabilities().interactive) console.clear();
2005
+ if (getCapabilities().interactive) {
2006
+ process.stdout.write("\x1B[2J\x1B[H");
2007
+ }
1779
2008
  const data = await fetchHomeData(profile, workDir);
1780
2009
  console.log(renderHomeScreen(data));
1781
- const bar = statusBar([
2010
+ console.log(statusBar([
1782
2011
  profile ? muted(profile.email) : muted("guest"),
1783
2012
  dim(workDir),
1784
2013
  isDebugMode() ? muted("debug") : null
1785
- ].filter(Boolean));
1786
- console.log(bar);
2014
+ ].filter(Boolean)));
1787
2015
  console.log("");
1788
2016
  };
1789
2017
  await refresh();
@@ -1809,7 +2037,8 @@ async function runPromptLoop(initialProfile) {
1809
2037
  error: (text) => console.error(text),
1810
2038
  clearLine: () => process.stdout.write("\r\x1B[K")
1811
2039
  };
1812
- const result = await dispatchSlashCommand(line, {
2040
+ const normalized = normalizeInput(line);
2041
+ const result = await dispatchSlashCommand(normalized, {
1813
2042
  cwd: workDir,
1814
2043
  profile,
1815
2044
  sink,
@@ -1826,22 +2055,16 @@ async function runPromptLoop(initialProfile) {
1826
2055
  }
1827
2056
  }
1828
2057
 
1829
- // src/shell/onboarding.ts
2058
+ // src/shell/auth-gate.ts
2059
+ init_layout();
1830
2060
  init_theme();
1831
- function renderOnboarding() {
1832
- return [
1833
- muted("Authenticate to run scans and browse results."),
1834
- "",
1835
- ` 1. Generate an API key at ${accent("app.fuzzi.dev/settings/api-keys")}`,
1836
- ` 2. Run ${accent("/auth")} or ${accent("fuzzi auth login")}`,
1837
- ` 3. Scan a URL with ${accent("/scan https://example.com")}`,
1838
- "",
1839
- muted("Type /help for all commands \xB7 /palette to search")
1840
- ].join("\n");
1841
- }
2061
+ init_width();
2062
+ import * as readline2 from "readline";
2063
+ import { stdin as input4, stdout as output2 } from "process";
1842
2064
 
1843
- // src/cli/bootstrap.ts
1844
- init_layout();
2065
+ // src/cli/profile.ts
2066
+ init_credentials();
2067
+ init_api_client();
1845
2068
  init_logger();
1846
2069
  async function tryGetProfile() {
1847
2070
  try {
@@ -1854,13 +2077,61 @@ async function tryGetProfile() {
1854
2077
  return null;
1855
2078
  }
1856
2079
  }
2080
+
2081
+ // src/shell/auth-gate.ts
2082
+ function renderAuthGate() {
2083
+ const w = contentWidth();
2084
+ const body = [
2085
+ "",
2086
+ centerBlock(accent(renderFuzziMark()), w),
2087
+ "",
2088
+ centerBlock(accentBold("Sign in to continue"), w),
2089
+ "",
2090
+ centerBlock(muted("The CLI needs your Fuzzi account to run scans."), w),
2091
+ "",
2092
+ divider(),
2093
+ "",
2094
+ centerBlock(info("Press Enter to open your browser and sign in"), w),
2095
+ centerBlock(muted("Or type /auth-key later to paste an API key instead"), w),
2096
+ ""
2097
+ ].join("\n");
2098
+ return panel(body, { title: "Fuzzi CLI", marginBottom: 1 });
2099
+ }
2100
+ function waitForEnter() {
2101
+ return new Promise((resolve) => {
2102
+ const rl = readline2.createInterface({ input: input4, output: output2, terminal: true });
2103
+ output2.write(accent("\n \u203A Press Enter to open browser... "));
2104
+ rl.once("line", () => {
2105
+ rl.close();
2106
+ resolve();
2107
+ });
2108
+ });
2109
+ }
2110
+ async function runAuthGate() {
2111
+ const existing = await tryGetProfile();
2112
+ if (existing) return existing;
2113
+ if (!output2.isTTY) return null;
2114
+ console.log(renderAuthGate());
2115
+ await waitForEnter();
2116
+ const progress = createProgress("Opening browser...");
2117
+ try {
2118
+ const result = await runBrowserLogin();
2119
+ progress.succeed("Signed in");
2120
+ console.log(accent(result.message));
2121
+ return result.profile;
2122
+ } catch (e) {
2123
+ progress.fail("Sign-in failed");
2124
+ console.log(muted(formatApiError(e)));
2125
+ console.log(muted("You can still use /auth-key to paste an API key, or /auth to retry browser login."));
2126
+ return null;
2127
+ }
2128
+ }
2129
+
2130
+ // src/cli/bootstrap.ts
1857
2131
  async function runInteractiveMode() {
1858
- const profile = await tryGetProfile();
1859
- const workDir = cwd4();
1860
- const data = await fetchHomeData(profile, workDir);
1861
- console.log(renderHomeScreen(data));
2132
+ let profile = await tryGetProfile();
1862
2133
  if (!profile) {
1863
- console.log(panel(renderOnboarding(), { title: "Get started" }));
2134
+ profile = await runAuthGate();
1864
2135
  }
1865
2136
  await runPromptLoop(profile);
1866
2137
  }