lazyclaw 3.99.5 → 3.99.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.
package/cli.mjs CHANGED
@@ -877,6 +877,8 @@ const SUBCOMMANDS = [
877
877
  'rates',
878
878
  // OpenClaw-parity subsurfaces (v3.93–v3.98)
879
879
  'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
880
+ // v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
881
+ 'setup', 'dashboard',
880
882
  ];
881
883
 
882
884
  const SUBCOMMAND_SUBS = {
@@ -1107,6 +1109,8 @@ const HELP_SUMMARIES = {
1107
1109
  workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
1108
1110
  browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
1109
1111
  cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
1112
+ setup: 'OpenClaw-style multi-step first-run wizard (provider + workspace + skill + webhook + ping)',
1113
+ dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
1110
1114
  inspect: 'Print persisted workflow state without executing',
1111
1115
  clear: 'Delete a persisted workflow state file (idempotent)',
1112
1116
  validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
@@ -1144,6 +1148,8 @@ const HELP_DETAILS = {
1144
1148
  workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
1145
1149
  browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
1146
1150
  cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
1151
+ setup: 'Usage: lazyclaw setup [--skip-test]\n OpenClaw-style multi-step first-run wizard. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick)\n 2. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 3. Optional skill bundle install from GitHub\n 4. Optional outbound webhook (Slack / Discord)\n 5. Reachability test against the picked provider\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
1152
+ dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n Tabs: Chat · Sessions · Skills · Workspace · Providers · Status. Each tab calls existing daemon endpoints.\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
1147
1153
  };
1148
1154
 
1149
1155
  function cmdHelp(name) {
@@ -1312,14 +1318,18 @@ async function cmdAgent(prompt, flags) {
1312
1318
  // (replaces rl.line with the full command). Tab still goes through
1313
1319
  // readline's tab-completer for cycling.
1314
1320
  function _attachGhostAutocomplete(rl) {
1315
- // Returns a `dispose()` callback that detaches the keypress listener
1316
- // and the rl 'line' listener installed below. Without disposal the
1317
- // process never exits Node keeps the event loop alive while
1318
- // process.stdin has a 'keypress' listener attached. (This was the
1319
- // root cause of the slow `/exit` users reported.)
1320
- if (!process.stdout.isTTY) return () => {};
1321
+ // Returns `{ dispose, suspend, resume }`. Dispose detaches the
1322
+ // keypress + rl 'line' listeners (failure to do so leaks the
1323
+ // event-loop ref, which is exactly the slow-exit bug v3.92
1324
+ // fixed). Suspend / resume gate the keypress handler so the
1325
+ // streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
1326
+ // ghost-render escapes that interleaving is what surfaces as
1327
+ // visible gaps between Korean characters in long replies.
1328
+ const noop = () => {};
1329
+ if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
1321
1330
  const cmds = SLASH_COMMANDS.map((c) => c.cmd);
1322
1331
  let lastGhost = '';
1332
+ let suspended = false;
1323
1333
  // Find the longest match for the current input. Returns '' when
1324
1334
  // nothing matches or when the input already equals a command.
1325
1335
  const findMatch = () => {
@@ -1356,6 +1366,10 @@ function _attachGhostAutocomplete(rl) {
1356
1366
  // _refreshLine, then return without forwarding the keypress.
1357
1367
  const onKeypress = (_str, key) => {
1358
1368
  if (!key) return;
1369
+ // While a streaming response is being printed, do nothing —
1370
+ // any ANSI cursor save / restore we emit would tear the wide-
1371
+ // character (CJK) output apart on the visible terminal.
1372
+ if (suspended) return;
1359
1373
  if (key.name === 'right' && lastGhost && rl.line === rl.line.trim() &&
1360
1374
  rl.cursor === (rl.line || '').length && (rl.line || '').length < lastGhost.length) {
1361
1375
  const accepted = lastGhost;
@@ -1381,33 +1395,81 @@ function _attachGhostAutocomplete(rl) {
1381
1395
  // over between turns.
1382
1396
  const onLine = () => { lastGhost = ''; };
1383
1397
  rl.on('line', onLine);
1384
- return () => {
1398
+ const dispose = () => {
1385
1399
  try { process.stdin.removeListener('keypress', onKeypress); } catch (_) {}
1386
1400
  try { rl.removeListener('line', onLine); } catch (_) {}
1387
1401
  // Wipe any leftover ghost on screen so the user's terminal doesn't
1388
1402
  // keep a dim suffix after we exit.
1389
1403
  try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
1390
1404
  };
1405
+ return {
1406
+ dispose,
1407
+ suspend: () => {
1408
+ suspended = true;
1409
+ // Wipe any half-rendered ghost before streaming starts so the
1410
+ // first chunk lands at the same column as the prompt.
1411
+ try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
1412
+ },
1413
+ resume: () => { suspended = false; },
1414
+ };
1391
1415
  }
1392
1416
 
1393
1417
  // LazyClaw banner — printed once at the top of every interactive chat
1394
1418
  // session so users see the active provider/model before they start
1395
1419
  // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
1396
1420
  // invocations stay clean for tests/scripts).
1421
+ // Single source of truth for the LazyClaw banner — used by the chat
1422
+ // REPL header, the no-arg launcher, and the first-run welcome panel.
1423
+ // Returns an array of pre-formatted lines (with ANSI colour) so the
1424
+ // caller can splice in additional rows without re-implementing the
1425
+ // alignment.
1426
+ //
1427
+ // Width-management rule: every inner line is forced through
1428
+ // `.padEnd(W)` so a stray width miscount can't punch the right
1429
+ // border off the box (which is exactly the bug v3.99.5 shipped:
1430
+ // two of the inner lines were 33 cols vs the others' 32, so the
1431
+ // ╮ rendered into the next line).
1432
+ function _renderBanner(version) {
1433
+ const W = 30;
1434
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1435
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1436
+ // Inner content of each banner row — DO NOT pad here, the wrapper
1437
+ // does it. Backslashes are JS-escaped so each `\\` renders as one
1438
+ // literal `\` in the output.
1439
+ const inner = [
1440
+ ' _',
1441
+ ' | |__ _ _____ _ _',
1442
+ " | / _` |_ / || | '_|",
1443
+ ' |_\\__,_/__\\_, |_|',
1444
+ ' LazyClaw |__/ ' + String(version || '?.?.?').padEnd(10).slice(0, 10),
1445
+ ];
1446
+ // Sleepy-cat mascot on the right, lined up with the busiest part
1447
+ // of the wordmark. Three rows of ASCII art + "zz" trail. Plain
1448
+ // ASCII (no box-drawing on the cat) so it lands well in any font.
1449
+ const mascot = [
1450
+ '',
1451
+ '',
1452
+ ' /\\_/\\',
1453
+ ' ( -.- ) ' + dim('z z'),
1454
+ ' > ^ < ' + dim('z'),
1455
+ '',
1456
+ '',
1457
+ ];
1458
+ const banner = [
1459
+ '╭' + '─'.repeat(W) + '╮',
1460
+ ...inner.map((s) => '│' + s.padEnd(W).slice(0, W) + '│'),
1461
+ '╰' + '─'.repeat(W) + '╯',
1462
+ ];
1463
+ return banner.map((l, i) => ' ' + accent(l) + (mascot[i] ? ' ' + mascot[i] : ''));
1464
+ }
1465
+
1397
1466
  function _printChatBanner(activeProvName, activeModel, version) {
1398
1467
  if (!process.stdout.isTTY) return;
1399
1468
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1400
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1401
1469
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1402
1470
  const lines = [
1403
1471
  '',
1404
- accent(' ╭──────────────────────────────╮'),
1405
- accent(' │ _ │'),
1406
- accent(' │ | |__ _ _____ _ _ │'),
1407
- accent(' │ | / _` |_ / || | \'_| │'),
1408
- accent(' │ |_\\__,_/__\\_, |_| │'),
1409
- accent(' │ LazyClaw |__/ ' + (version || '').padEnd(10) + ' │'),
1410
- accent(' ╰──────────────────────────────╯'),
1472
+ ..._renderBanner(version),
1411
1473
  '',
1412
1474
  ` ${dim('provider ·')} ${ok(activeProvName)}`,
1413
1475
  ` ${dim('model ·')} ${ok(activeModel || '(default)')}`,
@@ -1422,31 +1484,25 @@ function _printChatBanner(activeProvName, activeModel, version) {
1422
1484
  // when the user passes --pick. Falls back to plain stdin reads when
1423
1485
  // stdout isn't a TTY (CI/script callers should pass --non-interactive
1424
1486
  // equivalents instead).
1425
- async function _pickProviderInteractive() {
1426
- const providers = Object.keys(_registryMod.PROVIDERS);
1427
- if (!providers.length) return { provider: 'mock', model: null };
1428
- const info = _registryMod.PROVIDER_INFO || {};
1429
- // Build one row per (provider, model) pair using the static
1430
- // PROVIDER_INFO.suggestedModels list (registry.mjs is the single
1431
- // source of truth). When a provider has no models (mock), we emit
1432
- // one row for the provider itself.
1433
- const items = [];
1434
- for (const name of providers) {
1435
- const meta = info[name] || {};
1436
- const models = (Array.isArray(meta.suggestedModels) && meta.suggestedModels.length)
1437
- ? meta.suggestedModels
1438
- : [null];
1439
- const keyTag = meta.requiresApiKey
1440
- ? '\x1b[38;5;245m[api key]\x1b[0m'
1441
- : (name === 'claude-cli' ? '\x1b[38;5;208m[subscription]\x1b[0m' : '\x1b[38;5;245m[no key]\x1b[0m');
1442
- for (const m of models) {
1443
- const label = m ? `${name.padEnd(11)} ${m}` : `${name.padEnd(11)} (no model)`;
1444
- items.push({ provider: name, model: m, label, keyTag });
1445
- }
1446
- }
1487
+ // Generic arrow-key menu used by the multi-step provider/model
1488
+ // picker below. Returns the picked item, or one of the sentinel
1489
+ // strings 'BACK' (Esc caller should retry the previous step) or
1490
+ // 'CANCEL' (q — caller should bail entirely). Ctrl-C exits the
1491
+ // process directly, matching every other interactive prompt in the
1492
+ // CLI.
1493
+ //
1494
+ // `items` is an array of { id, label, desc, tag }. `tag` is an
1495
+ // optional pre-coloured pill (e.g. "[api key]") that lands on the
1496
+ // right side of the row. `defaultIdx` lets the caller pin where the
1497
+ // cursor lands; default 0.
1498
+ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
1447
1499
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
1448
- // Fall back to a single prompt the picker UI is purely cosmetic.
1449
- process.stdout.write(`provider [${providers.join('|')}]: `);
1500
+ // Non-TTY fallback: print the labels on stderr and read a single
1501
+ // line of stdin. Used when somebody pipes input to `lazyclaw
1502
+ // setup` — the wizard still works, just without arrows.
1503
+ process.stderr.write(`${title}\n`);
1504
+ items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
1505
+ process.stderr.write('pick (number / id, blank for first): ');
1450
1506
  const ans = await new Promise((resolve) => {
1451
1507
  let buf = '';
1452
1508
  const onData = (chunk) => {
@@ -1455,38 +1511,47 @@ async function _pickProviderInteractive() {
1455
1511
  };
1456
1512
  process.stdin.on('data', onData);
1457
1513
  });
1458
- return { provider: ans || providers[0], model: null };
1514
+ if (!ans) return items[0];
1515
+ const byNum = parseInt(ans, 10);
1516
+ if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
1517
+ const byId = items.find((it) => it.id === ans || it.label === ans);
1518
+ return byId || items[0];
1459
1519
  }
1460
- // Default cursor: lands on item 0 (= the first row from PROVIDERS
1461
- // insertion order, which registry.mjs deliberately curates as the
1462
- // most user-familiar vendor — gemini at the time of writing).
1463
- let idx = 0;
1464
1520
 
1465
1521
  const readline = await import('node:readline');
1466
1522
  readline.emitKeypressEvents(process.stdin);
1467
1523
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
1468
- // Long lists need scrolling so the picker fits the terminal height.
1469
- // Reserve 6 rows for header + footer + breathing room.
1524
+ let idx = Math.max(0, Math.min(items.length - 1, defaultIdx));
1525
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1526
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1527
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1528
+
1470
1529
  const draw = () => {
1471
- process.stdout.write('\x1b[?25l'); // hide cursor
1472
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1473
- process.stdout.write('\x1b[38;5;208mLazyClaw pick a provider/model\x1b[0m\n');
1474
- process.stdout.write('\x1b[2m↑/↓ to move · Enter to confirm · q to quit\x1b[0m\n');
1475
- process.stdout.write('\x1b[2m[subscription] = uses `claude` login (no key) · [api key] = needs sk-... key · [no key] = local\x1b[0m\n\n');
1476
- const rows = Math.max(6, (process.stdout.rows || 24) - 6);
1530
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
1531
+ process.stdout.write(accent(title) + '\n');
1532
+ if (subtitle) process.stdout.write(dim(subtitle) + '\n');
1533
+ process.stdout.write(dim('↑/↓ to move · Enter to confirm · Esc to back · q to quit') + '\n\n');
1534
+ const rows = Math.max(6, (process.stdout.rows || 24) - 8);
1477
1535
  let from = Math.max(0, idx - Math.floor(rows / 2));
1478
1536
  if (from + rows > items.length) from = Math.max(0, items.length - rows);
1479
1537
  const to = Math.min(items.length, from + rows);
1538
+ // Pre-compute label width so descriptions line up across rows.
1539
+ const labelW = items.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
1480
1540
  for (let i = from; i < to; i++) {
1481
1541
  const it = items[i];
1482
- const marker = i === idx ? '\x1b[38;5;208m❯\x1b[0m ' : ' ';
1483
- const text = i === idx ? `\x1b[1m${it.label}\x1b[0m` : it.label;
1484
- process.stdout.write(`${marker}${text} ${it.keyTag}\n`);
1542
+ const marker = i === idx ? accent(' ') : ' ';
1543
+ const lbl = (it.label || '').padEnd(labelW);
1544
+ const lblOut = i === idx ? bold(lbl) : lbl;
1545
+ const desc = it.desc ? ' ' + dim(it.desc) : '';
1546
+ const tag = it.tag ? ' ' + it.tag : '';
1547
+ process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
1485
1548
  }
1486
1549
  if (to < items.length) {
1487
- process.stdout.write(`\x1b[2m …(${items.length - to} more)\x1b[0m\n`);
1550
+ process.stdout.write(`${dim(` …(${items.length - to} more)`)}\n`);
1488
1551
  }
1552
+ if (footer) process.stdout.write('\n' + dim(footer) + '\n');
1489
1553
  };
1554
+
1490
1555
  draw();
1491
1556
  return await new Promise((resolve) => {
1492
1557
  const onKey = (_str, key) => {
@@ -1499,18 +1564,145 @@ async function _pickProviderInteractive() {
1499
1564
  else if (key.name === 'end') { idx = items.length - 1; draw(); }
1500
1565
  else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
1501
1566
  else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
1502
- else if (key.name === 'q' || key.name === 'escape') { cleanup(); resolve(items[idx]); }
1567
+ else if (key.name === 'escape') { cleanup(); resolve('BACK'); }
1568
+ else if (key.name === 'q') { cleanup(); resolve('CANCEL'); }
1503
1569
  };
1504
1570
  const cleanup = () => {
1505
1571
  process.stdin.off('keypress', onKey);
1506
1572
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
1507
- process.stdout.write('\x1b[?25h'); // show cursor
1508
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1573
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
1509
1574
  };
1510
1575
  process.stdin.on('keypress', onKey);
1511
1576
  });
1512
1577
  }
1513
1578
 
1579
+ // Bucket every registered provider into one of three auth-method
1580
+ // families. The picker's first step asks the user which family
1581
+ // they want before drilling into specific providers — much less
1582
+ // overwhelming than a flat 40-row list. Bucket assignment lives
1583
+ // here (rather than registry.mjs) because it's a UX concept, not
1584
+ // an intrinsic provider attribute.
1585
+ function _providerFamilies() {
1586
+ const info = _registryMod.PROVIDER_INFO || {};
1587
+ const all = Object.keys(_registryMod.PROVIDERS);
1588
+ const buckets = {
1589
+ api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: [] },
1590
+ cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: [] },
1591
+ mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: [] },
1592
+ };
1593
+ for (const name of all) {
1594
+ if (name === 'mock') buckets.mock.members.push(name);
1595
+ else if ((info[name] || {}).requiresApiKey) buckets.api.members.push(name);
1596
+ else buckets.cli.members.push(name);
1597
+ }
1598
+ return buckets;
1599
+ }
1600
+
1601
+ // Multi-step provider / model picker — replaces the flat 40-row
1602
+ // list of v3.99.5 with a drill-in:
1603
+ //
1604
+ // Step 1 — auth family (API key / CLI-Local / Mock)
1605
+ // Step 2 — provider in that family (gemini / openai / claude-cli / …)
1606
+ // Step 3 — model in that provider's suggestedModels
1607
+ //
1608
+ // Esc at any step goes back one. q or Ctrl-C cancels entirely.
1609
+ // Steps that have only one option auto-advance so the user doesn't
1610
+ // stare at a single-row menu (e.g. the Mock family has just `mock`).
1611
+ async function _pickProviderInteractive() {
1612
+ const providers = Object.keys(_registryMod.PROVIDERS);
1613
+ if (!providers.length) return { provider: 'mock', model: null };
1614
+ const info = _registryMod.PROVIDER_INFO || {};
1615
+ const families = _providerFamilies();
1616
+
1617
+ // Non-TTY fallback — single-prompt picker, identical to before.
1618
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
1619
+ process.stdout.write(`provider [${providers.join('|')}]: `);
1620
+ const ans = await new Promise((resolve) => {
1621
+ let buf = '';
1622
+ const onData = (chunk) => {
1623
+ buf += chunk.toString();
1624
+ if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
1625
+ };
1626
+ process.stdin.on('data', onData);
1627
+ });
1628
+ return { provider: ans || providers[0], model: null };
1629
+ }
1630
+
1631
+ // ── Step 1 — auth family ──────────────────────────────────────
1632
+ let family = null;
1633
+ while (!family) {
1634
+ const familyItems = Object.entries(families)
1635
+ .filter(([, b]) => b.members.length > 0)
1636
+ .map(([id, b]) => ({
1637
+ id,
1638
+ label: b.label,
1639
+ desc: `${b.desc} · ${b.members.join(' / ')}`,
1640
+ tag: b.tag,
1641
+ }));
1642
+ const picked = await _arrowMenu({
1643
+ title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
1644
+ subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
1645
+ items: familyItems,
1646
+ });
1647
+ if (picked === 'CANCEL' || picked === 'BACK') return null;
1648
+ family = picked;
1649
+ }
1650
+
1651
+ // ── Step 2 — provider in that family ──────────────────────────
1652
+ let provider = null;
1653
+ while (!provider) {
1654
+ const memberNames = families[family.id].members;
1655
+ if (memberNames.length === 1) {
1656
+ // Auto-advance — no point making the user pick from a single
1657
+ // row.
1658
+ provider = { id: memberNames[0] };
1659
+ break;
1660
+ }
1661
+ const provItems = memberNames.map((name) => {
1662
+ const meta = info[name] || {};
1663
+ const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
1664
+ return {
1665
+ id: name,
1666
+ label: name,
1667
+ desc: `models: ${models}`,
1668
+ tag: meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m',
1669
+ };
1670
+ });
1671
+ const picked = await _arrowMenu({
1672
+ title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
1673
+ subtitle: `Showing ${memberNames.length} ${family.label.toLowerCase()} provider(s).`,
1674
+ items: provItems,
1675
+ });
1676
+ if (picked === 'CANCEL') return null;
1677
+ if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
1678
+ provider = picked;
1679
+ }
1680
+
1681
+ // ── Step 3 — model ────────────────────────────────────────────
1682
+ const meta = info[provider.id] || {};
1683
+ const models = Array.isArray(meta.suggestedModels) ? meta.suggestedModels : [];
1684
+ if (!models.length) {
1685
+ // Provider has no curated models (mock) — return without a
1686
+ // model so the underlying call uses the provider default.
1687
+ return { provider: provider.id, model: null };
1688
+ }
1689
+ while (true) {
1690
+ const modelItems = models.map((m) => ({ id: m, label: m, desc: '' }));
1691
+ // Pin the cursor to the provider's defaultModel so Enter without
1692
+ // navigation picks the most-recommended one.
1693
+ const defaultIdx = Math.max(0, models.indexOf(meta.defaultModel || models[0]));
1694
+ const picked = await _arrowMenu({
1695
+ title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
1696
+ subtitle: `Showing ${models.length} suggested model(s). Type the model id directly later via /model in chat to use anything not listed here.`,
1697
+ items: modelItems,
1698
+ defaultIdx,
1699
+ });
1700
+ if (picked === 'CANCEL') return null;
1701
+ if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
1702
+ return { provider: provider.id, model: picked.id };
1703
+ }
1704
+ }
1705
+
1514
1706
  async function cmdChat(flags = {}) {
1515
1707
  await ensureRegistry();
1516
1708
  const sessionsMod = await import('./sessions.mjs');
@@ -1553,13 +1745,13 @@ async function cmdChat(flags = {}) {
1553
1745
  terminal: useTerminal,
1554
1746
  prompt: useTerminal ? '\x1b[38;5;208m›\x1b[0m ' : '',
1555
1747
  });
1556
- let _disposeGhost = () => {};
1748
+ let _ghost = { dispose: () => {}, suspend: () => {}, resume: () => {} };
1557
1749
  if (useTerminal) {
1558
1750
  // Cursor-style ghost autocomplete: when the buffer starts with `/`,
1559
1751
  // render the longest matching command after the cursor in dim grey.
1560
1752
  // Right-arrow at end-of-line accepts. Tab still cycles via the
1561
1753
  // existing handleSlash branch; this only adds the inline preview.
1562
- _disposeGhost = _attachGhostAutocomplete(rl) || (() => {});
1754
+ _ghost = _attachGhostAutocomplete(rl) || _ghost;
1563
1755
  rl.prompt();
1564
1756
  }
1565
1757
 
@@ -1811,6 +2003,32 @@ async function cmdChat(flags = {}) {
1811
2003
  process.stdout.write('\n^C interrupted — prompt is back\n');
1812
2004
  };
1813
2005
  process.on('SIGINT', onSigint);
2006
+ // Pause the ghost-autocomplete keypress handler while the
2007
+ // provider is streaming. Without this, every stale stdin event
2008
+ // would trigger `\x1b[s\x1b[K\x1b[u` cursor save/restore writes
2009
+ // that interleave with the streamed text and surface as visible
2010
+ // gaps between CJK characters (visible in user-reported screen
2011
+ // captures of Korean replies).
2012
+ if (useTerminal) _ghost.suspend();
2013
+ // Buffered writer — coalesce single-character streaming chunks
2014
+ // into ~30 ms windows. Two reasons:
2015
+ // 1. Korean / Japanese / Chinese tokens often arrive as one
2016
+ // character per chunk. Each individual `process.stdout.write`
2017
+ // can race against terminal redraw on a wide-cell character,
2018
+ // producing the same "visible space between every character"
2019
+ // symptom the suspend above also addresses.
2020
+ // 2. Far fewer syscalls. A 200-char Korean reply was ~200
2021
+ // separate writes; this collapses to ~7-10.
2022
+ let _writeBuf = '';
2023
+ let _writeTimer = null;
2024
+ const _flush = () => {
2025
+ if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
2026
+ _writeTimer = null;
2027
+ };
2028
+ const _writeChunk = (s) => {
2029
+ _writeBuf += s;
2030
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
2031
+ };
1814
2032
  try {
1815
2033
  for await (const chunk of prov.sendMessage(messages, {
1816
2034
  apiKey: _resolveAuthKey(cfg, activeProvName),
@@ -1819,13 +2037,21 @@ async function cmdChat(flags = {}) {
1819
2037
  signal: turnAc.signal,
1820
2038
  onUsage: accumulateUsage,
1821
2039
  })) {
1822
- process.stdout.write(chunk);
2040
+ _writeChunk(chunk);
1823
2041
  acc += chunk;
1824
2042
  }
2043
+ // Drain anything still buffered before the trailing newline so
2044
+ // the prompt lands on its own line cleanly.
2045
+ if (_writeTimer) clearTimeout(_writeTimer);
2046
+ _flush();
1825
2047
  process.stdout.write('\n');
1826
2048
  messages.push({ role: 'assistant', content: acc });
1827
2049
  persistTurn('assistant', acc);
1828
2050
  } catch (err) {
2051
+ // Drain pending buffer so partial reply stays on screen even
2052
+ // when the stream errors mid-flight.
2053
+ if (_writeTimer) clearTimeout(_writeTimer);
2054
+ _flush();
1829
2055
  // ABORT errors are user-initiated; partial assistant output is
1830
2056
  // discarded (we don't append a half-reply to the message history
1831
2057
  // because the next turn would treat it as a complete reply and
@@ -1835,6 +2061,7 @@ async function cmdChat(flags = {}) {
1835
2061
  }
1836
2062
  } finally {
1837
2063
  process.off('SIGINT', onSigint);
2064
+ if (useTerminal) _ghost.resume();
1838
2065
  }
1839
2066
  if (useTerminal) rl.prompt();
1840
2067
  } } finally {
@@ -1842,7 +2069,7 @@ async function cmdChat(flags = {}) {
1842
2069
  // hung for ~3-5 s while Node waited for stdin's keypress listener
1843
2070
  // and raw mode to release. Tearing them down explicitly drops the
1844
2071
  // exit time to <100 ms.
1845
- try { _disposeGhost(); } catch (_) {}
2072
+ try { _ghost.dispose(); } catch (_) {}
1846
2073
  try { rl.close(); } catch (_) {}
1847
2074
  if (useTerminal && process.stdin.isTTY && process.stdin.setRawMode) {
1848
2075
  try { process.stdin.setRawMode(false); } catch (_) {}
@@ -1855,6 +2082,69 @@ async function cmdChat(flags = {}) {
1855
2082
  }
1856
2083
  }
1857
2084
 
2085
+ // Light wrapper around the daemon — meant for users who installed
2086
+ // via npm and don't want to remember `daemon` flags. Boots the
2087
+ // daemon on a fixed default port (override with --port), then opens
2088
+ // the dashboard URL in the user's default browser.
2089
+ //
2090
+ // Why a separate command: typing `lazyclaw daemon` works too, but
2091
+ // `dashboard` is the discoverable name and it auto-opens the browser
2092
+ // (which the bare daemon doesn't, since most daemon callers are
2093
+ // scripts).
2094
+ async function cmdDashboard(flags = {}) {
2095
+ await ensureRegistry();
2096
+ const sessionsMod = await import('./sessions.mjs');
2097
+ const { startDaemon } = await import('./daemon.mjs');
2098
+ const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
2099
+ const cfgDir = path.dirname(configPath());
2100
+ const d = await startDaemon({
2101
+ port,
2102
+ once: false,
2103
+ readConfig,
2104
+ sessionsDirGetter: () => cfgDir,
2105
+ sessionsMod,
2106
+ version: () => readVersionFromRepo(),
2107
+ workflowStateDir: () => process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state',
2108
+ // No auth token by default — same loopback-only assumption the
2109
+ // bare daemon uses. Users who want to expose the dashboard set
2110
+ // LAZYCLAW_AUTH_TOKEN + --allow-origin via the daemon command.
2111
+ authToken: undefined,
2112
+ allowedOrigins: [],
2113
+ rateLimit: null,
2114
+ responseCache: null,
2115
+ logger: null,
2116
+ costCap: null,
2117
+ });
2118
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
2119
+ process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
2120
+ if (!flags['no-open']) {
2121
+ // macOS uses `open`; Linux generally `xdg-open`; Windows
2122
+ // `cmd /c start`. Detect by platform; bail silently if the
2123
+ // helper fails — the URL is already on stdout for fallback.
2124
+ const { spawn } = await import('node:child_process');
2125
+ let cmd, args;
2126
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
2127
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
2128
+ else { cmd = 'xdg-open'; args = [url]; }
2129
+ try {
2130
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
2131
+ } catch (_) { /* user can click the URL above */ }
2132
+ }
2133
+ // Forward SIGINT/SIGTERM to a graceful shutdown so Ctrl-C doesn't
2134
+ // strand a port-bound server. Same shape cmdDaemon uses.
2135
+ const { gracefulShutdown } = await import('./daemon.mjs');
2136
+ let shuttingDown = false;
2137
+ const shutdown = async () => {
2138
+ if (shuttingDown) return process.exit(1);
2139
+ shuttingDown = true;
2140
+ process.stdout.write('\n shutting down…\n');
2141
+ const result = await gracefulShutdown(d.server, 5_000);
2142
+ process.exit(result.forced ? 1 : 0);
2143
+ };
2144
+ process.on('SIGINT', shutdown);
2145
+ process.on('SIGTERM', shutdown);
2146
+ }
2147
+
1858
2148
  async function cmdDaemon(flags) {
1859
2149
  await ensureRegistry();
1860
2150
  const sessionsMod = await import('./sessions.mjs');
@@ -3082,6 +3372,144 @@ function parseArgs(argv) {
3082
3372
  // so chat / agent / etc. behave bit-identically to typing them
3083
3373
  // directly. Non-TTY (piped, scripted) callers still see the
3084
3374
  // classic "Usage: …" line so automation isn't surprised.
3375
+ // Multi-step setup wizard — OpenClaw-style first-run experience.
3376
+ // Provider/model/key + optional workspace + optional sample skill
3377
+ // + reachability ping. Each step can be skipped (Enter on prompt /
3378
+ // "n" on yes-no). Re-runnable safely: existing state is reused, not
3379
+ // clobbered, except when the user explicitly opts in.
3380
+ //
3381
+ // `lazyclaw setup` exposes this directly so users can re-run the
3382
+ // wizard any time. The first-run code path also funnels through it
3383
+ // so a fresh install sees the same flow whether they typed
3384
+ // `lazyclaw` or `lazyclaw setup`.
3385
+ async function cmdSetup(_sub, _positional, flags = {}) {
3386
+ await ensureRegistry();
3387
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
3388
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3389
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3390
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
3391
+ const warn = (s) => `\x1b[33m${s}\x1b[0m`;
3392
+
3393
+ // Header.
3394
+ if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
3395
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3396
+ process.stdout.write('\n');
3397
+ process.stdout.write(` ${bold('🔧 Setup wizard')}\n`);
3398
+ process.stdout.write(` ${dim('Five short steps. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
3399
+
3400
+ const cfg = readConfig();
3401
+ const cfgDir = path.dirname(configPath());
3402
+
3403
+ // ── Step 1: Provider + model (mandatory) ────────────────────
3404
+ process.stdout.write(` ${accent('Step 1/5 ·')} ${bold('Pick a provider + model')}\n`);
3405
+ process.stdout.write(` ${dim('Opens the arrow-key picker. The list leads with gemini / openai / claude-cli — pick the one you have an account or login for.')}\n\n`);
3406
+ await _quickPrompt(' ▶ press Enter to open the picker ');
3407
+ try {
3408
+ await cmdOnboard({ pick: true });
3409
+ } catch (e) {
3410
+ process.stderr.write(`onboard error: ${e?.message || e}\n`);
3411
+ process.exit(1);
3412
+ }
3413
+ // Re-read config after onboard wrote it. If the user aborted with
3414
+ // no provider set, bail out early — the rest of the wizard depends
3415
+ // on a provider being configured.
3416
+ const cfgAfterOnboard = readConfig();
3417
+ if (!cfgAfterOnboard.provider) {
3418
+ process.stdout.write(`\n ${warn('Setup aborted — no provider configured. Run `lazyclaw setup` again when ready.')}\n\n`);
3419
+ process.exit(0);
3420
+ }
3421
+ process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
3422
+
3423
+ // ── Step 2: Optional workspace ──────────────────────────────
3424
+ process.stdout.write(` ${accent('Step 2/5 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
3425
+ process.stdout.write(` ${dim('A workspace is a folder of AGENTS.md / SOUL.md / TOOLS.md prompt files that auto-inject into chat / agent. Skip if you don\'t need project-specific personas yet.')}\n\n`);
3426
+ const wsName = (await _quickPrompt(' workspace name (Enter to skip): ')).trim();
3427
+ if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
3428
+ try {
3429
+ const ws = await import('./workspace.mjs');
3430
+ const dir = ws.initWorkspace(cfgDir, wsName);
3431
+ process.stdout.write(` ${ok('✓ workspace created:')} ${dir}\n`);
3432
+ process.stdout.write(` ${dim('Edit AGENTS.md / SOUL.md / TOOLS.md any time. Use with: lazyclaw chat --workspace ' + wsName)}\n\n`);
3433
+ } catch (e) {
3434
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3435
+ }
3436
+ } else if (wsName) {
3437
+ process.stdout.write(` ${warn('skipped:')} workspace name must match [A-Za-z0-9_.-]+\n\n`);
3438
+ } else {
3439
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3440
+ }
3441
+
3442
+ // ── Step 3: Optional skill bundle install ───────────────────
3443
+ process.stdout.write(` ${accent('Step 3/5 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
3444
+ process.stdout.write(` ${dim('Format: <user>/<repo>[@<ref>]. Skills are .md prompt fragments that compose into the system prompt via --skill.')}\n\n`);
3445
+ const skillSpec = (await _quickPrompt(' github spec (Enter to skip): ')).trim();
3446
+ if (skillSpec) {
3447
+ try {
3448
+ const inst = await import('./skills_install.mjs');
3449
+ const r = await inst.installFromGithub(skillSpec, cfgDir, { force: false });
3450
+ process.stdout.write(` ${ok('✓ installed')} ${r.installed.length} ${dim('skill(s) from')} ${skillSpec}\n`);
3451
+ r.installed.forEach((s) => process.stdout.write(` · ${s.name} ${dim(`(${s.bytes} bytes)`)}\n`));
3452
+ if (r.skipped.length) {
3453
+ process.stdout.write(` ${dim('skipped (already installed):')} ${r.skipped.map((s) => s.name).join(', ')}\n`);
3454
+ }
3455
+ process.stdout.write('\n');
3456
+ } catch (e) {
3457
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3458
+ }
3459
+ } else {
3460
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3461
+ }
3462
+
3463
+ // ── Step 4: Optional outbound webhook ───────────────────────
3464
+ process.stdout.write(` ${accent('Step 4/5 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
3465
+ process.stdout.write(` ${dim('Use with: lazyclaw message send <name> <text>. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
3466
+ const hookName = (await _quickPrompt(' webhook name (Enter to skip): ')).trim();
3467
+ if (hookName) {
3468
+ const hookUrl = (await _quickPrompt(' webhook URL: ')).trim();
3469
+ if (!hookUrl) {
3470
+ process.stdout.write(` ${warn('skipped:')} URL required\n\n`);
3471
+ } else {
3472
+ try {
3473
+ const cf = await import('./config_features.mjs');
3474
+ const fresh = readConfig();
3475
+ cf.messageAdd(fresh, hookName, hookUrl);
3476
+ writeConfig(fresh);
3477
+ process.stdout.write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
3478
+ } catch (e) {
3479
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3480
+ }
3481
+ }
3482
+ } else {
3483
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3484
+ }
3485
+
3486
+ // ── Step 5: Reachability check ──────────────────────────────
3487
+ process.stdout.write(` ${accent('Step 5/5 ·')} ${bold('Verify the picked provider responds')}\n`);
3488
+ process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirms your key / subscription / local daemon is wired up.')}\n\n`);
3489
+ const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
3490
+ if (wantPing) {
3491
+ try {
3492
+ // Reuse the existing providers-test path so behaviour matches
3493
+ // a manual `lazyclaw providers test`.
3494
+ await cmdProviders('test', [cfgAfterOnboard.provider], {});
3495
+ } catch (e) {
3496
+ process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
3497
+ process.stdout.write(` ${dim('Setup still completed; you can retry with:')} lazyclaw providers test ${cfgAfterOnboard.provider}\n`);
3498
+ }
3499
+ } else {
3500
+ process.stdout.write(` ${dim('— skipped —')}\n`);
3501
+ }
3502
+
3503
+ // ── Wrap up ─────────────────────────────────────────────────
3504
+ process.stdout.write('\n');
3505
+ process.stdout.write(` ${ok(bold('🎉 Setup complete.'))}\n`);
3506
+ process.stdout.write(` ${dim('Run')} ${bold('lazyclaw')} ${dim('any time to open the menu, or jump in directly:')}\n`);
3507
+ process.stdout.write(` ${dim('•')} lazyclaw chat ${dim('— REPL with the configured provider')}\n`);
3508
+ process.stdout.write(` ${dim('•')} lazyclaw agent "..." ${dim('— one-shot prompt')}\n`);
3509
+ process.stdout.write(` ${dim('•')} lazyclaw doctor ${dim('— diagnostic JSON')}\n`);
3510
+ process.stdout.write(` ${dim('•')} lazyclaw setup ${dim('— re-run this wizard any time')}\n\n`);
3511
+ }
3512
+
3085
3513
  // First-run welcome panel + delegated onboard. Drawn once before the
3086
3514
  // main launcher menu when the config has no provider yet. Walks the
3087
3515
  // user through the same arrow-key picker that `lazyclaw onboard`
@@ -3093,16 +3521,7 @@ async function _runFirstTimeOnboard() {
3093
3521
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3094
3522
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3095
3523
  process.stdout.write('\x1b[2J\x1b[H');
3096
- const banner = [
3097
- accent(' ╭──────────────────────────────╮'),
3098
- accent(' │ _ │'),
3099
- accent(' │ | |__ _ _____ _ _ │'),
3100
- accent(' │ | / _` |_ / || | \'_| │'),
3101
- accent(' │ |_\\__,_/__\\_, |_| │'),
3102
- accent(' │ LazyClaw |__/ ' + (readVersionFromRepo() || '').padEnd(10) + ' │'),
3103
- accent(' ╰──────────────────────────────╯'),
3104
- ];
3105
- banner.forEach((l) => process.stdout.write(l + '\n'));
3524
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3106
3525
  process.stdout.write('\n');
3107
3526
  process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
3108
3527
  process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
@@ -3124,31 +3543,46 @@ async function _runFirstTimeOnboard() {
3124
3543
  process.stdout.write('\n');
3125
3544
  }
3126
3545
 
3546
+ // Direct dispatch from a launcher pick. Replaces the previous
3547
+ // `process.argv = [...]; await main()` round-trip so we can reuse
3548
+ // the launcher across multiple iterations without compounding
3549
+ // state. Each menu choice maps to its native cmd handler with the
3550
+ // same flag defaults the bare CLI would parse.
3551
+ async function _dispatchMenuChoice(argv) {
3552
+ const sub = argv[0];
3553
+ const rest = argv.slice(1);
3554
+ switch (sub) {
3555
+ case 'chat': return cmdChat({});
3556
+ case 'agent': return cmdAgent(rest[0] || '-', {});
3557
+ case 'onboard': return cmdOnboard({});
3558
+ case 'setup': return cmdSetup(undefined, rest, {});
3559
+ case 'workspace': return cmdWorkspace(rest[0], rest.slice(1), {});
3560
+ case 'browse': return cmdBrowse(rest[0], {});
3561
+ case 'skills': return cmdSkills(rest[0], rest.slice(1), {});
3562
+ case 'sessions': return cmdSessions(rest[0], rest.slice(1), {});
3563
+ case 'providers': return cmdProviders(rest[0], rest.slice(1), {});
3564
+ case 'cron': return cmdCron(rest[0], rest.slice(1), {});
3565
+ case 'auth': return cmdAuth(rest[0], rest.slice(1), {});
3566
+ case 'pairing': return cmdPairing(rest[0], rest.slice(1), {});
3567
+ case 'nodes': return cmdNodes(rest[0], rest.slice(1), {});
3568
+ case 'message': return cmdMessage(rest[0], rest.slice(1), {});
3569
+ case 'doctor': return cmdDoctor();
3570
+ case 'status': return cmdStatus();
3571
+ case 'help': return cmdHelp();
3572
+ case 'dashboard': return cmdDashboard({});
3573
+ default: throw new Error(`unknown menu choice: ${sub}`);
3574
+ }
3575
+ }
3576
+
3127
3577
  async function cmdLauncher() {
3128
3578
  await ensureRegistry();
3129
- let cfg = readConfig();
3130
- // First-run guard: a fresh install has no `provider` set, so any
3131
- // menu pick that calls a provider (Chat / Agent / Doctor / etc.)
3132
- // would error halfway through with a confusing "missing api key"
3133
- // or "unknown provider". Detect that state up front and walk the
3134
- // user through onboard before showing the menu — once they've
3135
- // picked, re-read the config and continue normally.
3136
- if (!cfg.provider) {
3137
- await _runFirstTimeOnboard();
3138
- cfg = readConfig();
3139
- // If they cancelled / aborted onboard we still don't have a
3140
- // provider — drop straight out instead of showing a menu where
3141
- // every item leads to the same error.
3142
- if (!cfg.provider) {
3143
- process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw onboard` when ready, then try `lazyclaw` again.\n\n');
3144
- process.exit(0);
3145
- }
3146
- }
3147
- const provider = cfg.provider;
3148
- const model = cfg.model || '(default)';
3579
+ // Item table is fixed across iterations — only the dispatcher and
3580
+ // the per-iteration draw redraw on each loop tick.
3149
3581
  const items = [
3150
3582
  { id: 'chat', label: 'Chat', desc: 'interactive REPL with the configured provider', argv: ['chat'] },
3151
3583
  { id: 'agent', label: 'Agent', desc: 'one-shot prompt — read text and exit', argv: ['agent'], promptForBody: true },
3584
+ { id: 'dashboard', label: 'Dashboard', desc: 'open the lazyclaw web UI in your browser', argv: ['dashboard'] },
3585
+ { id: 'setup', label: 'Setup', desc: 'multi-step provider / workspace / skill wizard', argv: ['setup'] },
3152
3586
  { id: 'onboard', label: 'Onboard', desc: 'pick provider / model / api-key', argv: ['onboard'] },
3153
3587
  { id: 'workspace', label: 'Workspace', desc: 'AGENTS.md / SOUL.md / TOOLS.md prompt bundles', argv: ['workspace', 'list'] },
3154
3588
  { id: 'browse', label: 'Browse', desc: 'fetch a URL → markdown', argv: ['browse'], promptForUrl: true },
@@ -3159,106 +3593,131 @@ async function cmdLauncher() {
3159
3593
  { id: 'doctor', label: 'Doctor', desc: 'diagnostic — config, providers, workflows', argv: ['doctor'] },
3160
3594
  { id: 'status', label: 'Status', desc: 'current provider / model / masked key', argv: ['status'] },
3161
3595
  { id: 'help', label: 'Help', desc: 'one-line summary of every subcommand', argv: ['help'] },
3162
- { id: 'quit', label: 'Quit', desc: 'exit without doing anything', argv: null },
3596
+ { id: 'quit', label: 'Quit', desc: 'exit lazyclaw', argv: null },
3163
3597
  ];
3164
3598
 
3165
- const readline = await import('node:readline');
3166
- readline.emitKeypressEvents(process.stdin);
3167
- if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3168
- let idx = 0;
3169
-
3170
- // Pretty header — same accent palette as _printChatBanner so
3171
- // returning users recognise it.
3172
3599
  const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
3173
3600
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3174
3601
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3175
3602
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
3176
3603
  const warn = (s) => `\x1b[33m${s}\x1b[0m`;
3177
3604
 
3178
- const draw = () => {
3179
- process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
3180
- const banner = [
3181
- accent(' ╭──────────────────────────────╮'),
3182
- accent(' │ _ │'),
3183
- accent(' │ | |__ _ _____ _ _ │'),
3184
- accent(' │ | / _` |_ / || | \'_| │'),
3185
- accent(' │ |_\\__,_/__\\_, |_| │'),
3186
- accent(' │ LazyClaw |__/ ' + (readVersionFromRepo() || '').padEnd(10) + ' │'),
3187
- accent(' ╰──────────────────────────────╯'),
3188
- ];
3189
- banner.forEach((l) => process.stdout.write(l + '\n'));
3190
- process.stdout.write('\n');
3191
- const provDisplay = provider === '(unset pick during onboard)'
3192
- ? warn(provider)
3193
- : ok(provider);
3194
- process.stdout.write(` ${dim('provider ·')} ${provDisplay}\n`);
3195
- process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
3196
- process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
3197
- process.stdout.write('\n');
3198
- process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
3199
-
3200
- // Trim list to terminal height so the menu still fits when
3201
- // someone shrinks the window or runs in a small split pane.
3202
- const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
3203
- const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
3204
- const toIdx = Math.min(items.length, fromIdx + rowsAvail);
3205
- for (let i = fromIdx; i < toIdx; i++) {
3206
- const it = items[i];
3207
- const marker = i === idx ? accent('❯ ') : ' ';
3208
- const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
3209
- process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
3605
+ let idx = 0;
3606
+ // Outer loop each iteration is one menu render → pick →
3607
+ // dispatch round. Subcommand return drops back here and the menu
3608
+ // is redrawn. Quit / Esc / Ctrl-C breaks the loop and returns,
3609
+ // which lets the calling main() exit naturally (no process.exit
3610
+ // so the buffered writer / open file descriptors close cleanly).
3611
+ while (true) {
3612
+ // First-run / config-missing guard: a fresh install has no
3613
+ // `provider` set, so any menu pick that calls a provider would
3614
+ // error halfway through. Funnel through cmdSetup before
3615
+ // rendering the menu the first time around.
3616
+ let cfg = readConfig();
3617
+ if (!cfg.provider) {
3618
+ try { await cmdSetup(undefined, [], {}); }
3619
+ catch (e) {
3620
+ process.stderr.write(`setup error: ${e?.message || e}\n`);
3621
+ }
3622
+ cfg = readConfig();
3623
+ if (!cfg.provider) {
3624
+ process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
3625
+ return;
3626
+ }
3210
3627
  }
3211
- process.stdout.write('\n');
3212
- };
3213
-
3214
- // Tear down raw mode + listeners cleanly so the next subcommand
3215
- // starts with a sane stdin (otherwise `chat` after launcher inherits
3216
- // the launcher's raw mode and behaves weirdly).
3217
- const teardown = (onKey) => {
3218
- if (onKey) process.stdin.off('keypress', onKey);
3219
- if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3220
- process.stdout.write('\x1b[?25h'); // show cursor
3221
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
3222
- };
3628
+ const provider = cfg.provider;
3629
+ const model = cfg.model || '(default)';
3223
3630
 
3224
- draw();
3225
- const picked = await new Promise((resolve) => {
3226
- const onKey = (_str, key) => {
3227
- if (!key) return;
3228
- if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
3229
- else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
3230
- else if (key.name === 'home') { idx = 0; draw(); }
3231
- else if (key.name === 'end') { idx = items.length - 1; draw(); }
3232
- else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
3233
- else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
3234
- else if (key.name === 'return') { teardown(onKey); resolve(items[idx]); }
3235
- else if (key.ctrl && key.name === 'c') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3236
- else if (key.name === 'escape' || key.name === 'q') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3631
+ // Re-establish stdin in raw / ref'd mode. A previous iteration
3632
+ // (e.g. `chat`) deliberately paused + unref'd stdin in its
3633
+ // exit-cleanup path so the process could end on /exit; now that
3634
+ // we want to keep going, re-attach.
3635
+ const readline = await import('node:readline');
3636
+ readline.emitKeypressEvents(process.stdin);
3637
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3638
+ process.stdin.resume();
3639
+ process.stdin.ref();
3640
+
3641
+ const draw = () => {
3642
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
3643
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3644
+ process.stdout.write('\n');
3645
+ process.stdout.write(` ${dim('provider ·')} ${ok(provider)}\n`);
3646
+ process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
3647
+ process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
3648
+ process.stdout.write('\n');
3649
+ process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
3650
+ const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
3651
+ const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
3652
+ const toIdx = Math.min(items.length, fromIdx + rowsAvail);
3653
+ for (let i = fromIdx; i < toIdx; i++) {
3654
+ const it = items[i];
3655
+ const marker = i === idx ? accent('❯ ') : ' ';
3656
+ const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
3657
+ process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
3658
+ }
3659
+ process.stdout.write('\n');
3237
3660
  };
3238
- process.stdin.on('keypress', onKey);
3239
- });
3240
3661
 
3241
- if (!picked || !picked.argv) {
3242
- process.exit(0);
3662
+ draw();
3663
+ const picked = await new Promise((resolve) => {
3664
+ const onKey = (_str, key) => {
3665
+ if (!key) return;
3666
+ if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
3667
+ else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
3668
+ else if (key.name === 'home') { idx = 0; draw(); }
3669
+ else if (key.name === 'end') { idx = items.length - 1; draw(); }
3670
+ else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
3671
+ else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
3672
+ else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
3673
+ else if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); }
3674
+ else if (key.name === 'escape' || key.name === 'q') { cleanup(); resolve({ id: 'quit', argv: null }); }
3675
+ function cleanup() {
3676
+ process.stdin.off('keypress', onKey);
3677
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3678
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
3679
+ }
3680
+ };
3681
+ process.stdin.on('keypress', onKey);
3682
+ });
3683
+
3684
+ if (!picked || picked.id === 'quit' || !picked.argv) {
3685
+ // Plain return so main() can exit naturally.
3686
+ return;
3687
+ }
3688
+
3689
+ // Two menu items need a follow-up question before they can run:
3690
+ // agent (prompt body), browse (URL). Ask once, then dispatch.
3691
+ let argv = picked.argv;
3692
+ if (picked.promptForBody) {
3693
+ const body = await _quickPrompt('prompt: ');
3694
+ if (!body) continue; // back to menu
3695
+ argv = ['agent', body];
3696
+ } else if (picked.promptForUrl) {
3697
+ const url = await _quickPrompt('url: ');
3698
+ if (!url) continue; // back to menu
3699
+ argv = ['browse', url];
3700
+ }
3701
+
3702
+ // Dispatch. Errors don't terminate the launcher — they're
3703
+ // surfaced as a stderr line and the menu redraws. Lets the
3704
+ // user recover from a transient API hiccup without a relaunch.
3705
+ try {
3706
+ await _dispatchMenuChoice(argv);
3707
+ } catch (e) {
3708
+ process.stderr.write(`\n ${accent('✗')} ${e?.message || String(e)}\n`);
3709
+ }
3710
+
3711
+ // Pause before re-drawing so the user can read the subcommand's
3712
+ // output. `chat` is the special case: its REPL has already kept
3713
+ // the user oriented for a long session, and they typed /exit
3714
+ // explicitly, so jumping straight back to the menu reads as
3715
+ // "ok, done with that conversation, back to the dashboard."
3716
+ if (picked.id !== 'chat') {
3717
+ process.stdout.write('\n');
3718
+ await _quickPrompt(` ${dim('Press Enter to return to the menu… ')}`);
3719
+ }
3243
3720
  }
3244
- // Two surfaces need a follow-up question before they can run:
3245
- // - `agent`: needs a prompt body
3246
- // - `browse`: needs a URL
3247
- // Ask via a simple readline prompt so the launcher stays
3248
- // self-contained instead of forwarding into a half-typed argv.
3249
- if (picked.promptForBody) {
3250
- const body = await _quickPrompt('prompt: ');
3251
- if (!body) process.exit(0);
3252
- picked.argv = ['agent', body];
3253
- } else if (picked.promptForUrl) {
3254
- const url = await _quickPrompt('url: ');
3255
- if (!url) process.exit(0);
3256
- picked.argv = ['browse', url];
3257
- }
3258
- // Replace argv and re-enter main(). The chosen subcommand sees
3259
- // the same parser surface as if the user had typed it directly.
3260
- process.argv = [process.argv[0], process.argv[1], ...picked.argv];
3261
- await main();
3262
3721
  }
3263
3722
 
3264
3723
  async function _quickPrompt(label) {
@@ -3444,6 +3903,14 @@ async function main() {
3444
3903
  await cmdCron(sub, rest.positional.slice(1), rest.flags);
3445
3904
  break;
3446
3905
  }
3906
+ case 'setup': {
3907
+ await cmdSetup(undefined, rest.positional, rest.flags);
3908
+ break;
3909
+ }
3910
+ case 'dashboard': {
3911
+ await cmdDashboard(rest.flags);
3912
+ break;
3913
+ }
3447
3914
  case 'daemon': {
3448
3915
  await cmdDaemon(rest.flags);
3449
3916
  break;