lazyclaw 3.99.5 → 3.99.6

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/README.md CHANGED
@@ -71,7 +71,7 @@ What you see on launch (TTY only):
71
71
  │ | |__ _ _____ _ _ │
72
72
  │ | / _` |_ / || | '_| │
73
73
  │ |_\__,_/__\_, |_| │
74
- │ LazyClaw |__/ 3.92.0
74
+ │ LazyClaw |__/ 3.99.6
75
75
  ╰──────────────────────────────╯
76
76
 
77
77
  provider · anthropic
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) {
@@ -1394,20 +1400,58 @@ function _attachGhostAutocomplete(rl) {
1394
1400
  // session so users see the active provider/model before they start
1395
1401
  // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
1396
1402
  // invocations stay clean for tests/scripts).
1403
+ // Single source of truth for the LazyClaw banner — used by the chat
1404
+ // REPL header, the no-arg launcher, and the first-run welcome panel.
1405
+ // Returns an array of pre-formatted lines (with ANSI colour) so the
1406
+ // caller can splice in additional rows without re-implementing the
1407
+ // alignment.
1408
+ //
1409
+ // Width-management rule: every inner line is forced through
1410
+ // `.padEnd(W)` so a stray width miscount can't punch the right
1411
+ // border off the box (which is exactly the bug v3.99.5 shipped:
1412
+ // two of the inner lines were 33 cols vs the others' 32, so the
1413
+ // ╮ rendered into the next line).
1414
+ function _renderBanner(version) {
1415
+ const W = 30;
1416
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1417
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1418
+ // Inner content of each banner row — DO NOT pad here, the wrapper
1419
+ // does it. Backslashes are JS-escaped so each `\\` renders as one
1420
+ // literal `\` in the output.
1421
+ const inner = [
1422
+ ' _',
1423
+ ' | |__ _ _____ _ _',
1424
+ " | / _` |_ / || | '_|",
1425
+ ' |_\\__,_/__\\_, |_|',
1426
+ ' LazyClaw |__/ ' + String(version || '?.?.?').padEnd(10).slice(0, 10),
1427
+ ];
1428
+ // Sleepy-cat mascot on the right, lined up with the busiest part
1429
+ // of the wordmark. Three rows of ASCII art + "zz" trail. Plain
1430
+ // ASCII (no box-drawing on the cat) so it lands well in any font.
1431
+ const mascot = [
1432
+ '',
1433
+ '',
1434
+ ' /\\_/\\',
1435
+ ' ( -.- ) ' + dim('z z'),
1436
+ ' > ^ < ' + dim('z'),
1437
+ '',
1438
+ '',
1439
+ ];
1440
+ const banner = [
1441
+ '╭' + '─'.repeat(W) + '╮',
1442
+ ...inner.map((s) => '│' + s.padEnd(W).slice(0, W) + '│'),
1443
+ '╰' + '─'.repeat(W) + '╯',
1444
+ ];
1445
+ return banner.map((l, i) => ' ' + accent(l) + (mascot[i] ? ' ' + mascot[i] : ''));
1446
+ }
1447
+
1397
1448
  function _printChatBanner(activeProvName, activeModel, version) {
1398
1449
  if (!process.stdout.isTTY) return;
1399
1450
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1400
- const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1401
1451
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1402
1452
  const lines = [
1403
1453
  '',
1404
- accent(' ╭──────────────────────────────╮'),
1405
- accent(' │ _ │'),
1406
- accent(' │ | |__ _ _____ _ _ │'),
1407
- accent(' │ | / _` |_ / || | \'_| │'),
1408
- accent(' │ |_\\__,_/__\\_, |_| │'),
1409
- accent(' │ LazyClaw |__/ ' + (version || '').padEnd(10) + ' │'),
1410
- accent(' ╰──────────────────────────────╯'),
1454
+ ..._renderBanner(version),
1411
1455
  '',
1412
1456
  ` ${dim('provider ·')} ${ok(activeProvName)}`,
1413
1457
  ` ${dim('model ·')} ${ok(activeModel || '(default)')}`,
@@ -1855,6 +1899,69 @@ async function cmdChat(flags = {}) {
1855
1899
  }
1856
1900
  }
1857
1901
 
1902
+ // Light wrapper around the daemon — meant for users who installed
1903
+ // via npm and don't want to remember `daemon` flags. Boots the
1904
+ // daemon on a fixed default port (override with --port), then opens
1905
+ // the dashboard URL in the user's default browser.
1906
+ //
1907
+ // Why a separate command: typing `lazyclaw daemon` works too, but
1908
+ // `dashboard` is the discoverable name and it auto-opens the browser
1909
+ // (which the bare daemon doesn't, since most daemon callers are
1910
+ // scripts).
1911
+ async function cmdDashboard(flags = {}) {
1912
+ await ensureRegistry();
1913
+ const sessionsMod = await import('./sessions.mjs');
1914
+ const { startDaemon } = await import('./daemon.mjs');
1915
+ const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
1916
+ const cfgDir = path.dirname(configPath());
1917
+ const d = await startDaemon({
1918
+ port,
1919
+ once: false,
1920
+ readConfig,
1921
+ sessionsDirGetter: () => cfgDir,
1922
+ sessionsMod,
1923
+ version: () => readVersionFromRepo(),
1924
+ workflowStateDir: () => process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state',
1925
+ // No auth token by default — same loopback-only assumption the
1926
+ // bare daemon uses. Users who want to expose the dashboard set
1927
+ // LAZYCLAW_AUTH_TOKEN + --allow-origin via the daemon command.
1928
+ authToken: undefined,
1929
+ allowedOrigins: [],
1930
+ rateLimit: null,
1931
+ responseCache: null,
1932
+ logger: null,
1933
+ costCap: null,
1934
+ });
1935
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
1936
+ process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
1937
+ if (!flags['no-open']) {
1938
+ // macOS uses `open`; Linux generally `xdg-open`; Windows
1939
+ // `cmd /c start`. Detect by platform; bail silently if the
1940
+ // helper fails — the URL is already on stdout for fallback.
1941
+ const { spawn } = await import('node:child_process');
1942
+ let cmd, args;
1943
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
1944
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
1945
+ else { cmd = 'xdg-open'; args = [url]; }
1946
+ try {
1947
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
1948
+ } catch (_) { /* user can click the URL above */ }
1949
+ }
1950
+ // Forward SIGINT/SIGTERM to a graceful shutdown so Ctrl-C doesn't
1951
+ // strand a port-bound server. Same shape cmdDaemon uses.
1952
+ const { gracefulShutdown } = await import('./daemon.mjs');
1953
+ let shuttingDown = false;
1954
+ const shutdown = async () => {
1955
+ if (shuttingDown) return process.exit(1);
1956
+ shuttingDown = true;
1957
+ process.stdout.write('\n shutting down…\n');
1958
+ const result = await gracefulShutdown(d.server, 5_000);
1959
+ process.exit(result.forced ? 1 : 0);
1960
+ };
1961
+ process.on('SIGINT', shutdown);
1962
+ process.on('SIGTERM', shutdown);
1963
+ }
1964
+
1858
1965
  async function cmdDaemon(flags) {
1859
1966
  await ensureRegistry();
1860
1967
  const sessionsMod = await import('./sessions.mjs');
@@ -3082,6 +3189,144 @@ function parseArgs(argv) {
3082
3189
  // so chat / agent / etc. behave bit-identically to typing them
3083
3190
  // directly. Non-TTY (piped, scripted) callers still see the
3084
3191
  // classic "Usage: …" line so automation isn't surprised.
3192
+ // Multi-step setup wizard — OpenClaw-style first-run experience.
3193
+ // Provider/model/key + optional workspace + optional sample skill
3194
+ // + reachability ping. Each step can be skipped (Enter on prompt /
3195
+ // "n" on yes-no). Re-runnable safely: existing state is reused, not
3196
+ // clobbered, except when the user explicitly opts in.
3197
+ //
3198
+ // `lazyclaw setup` exposes this directly so users can re-run the
3199
+ // wizard any time. The first-run code path also funnels through it
3200
+ // so a fresh install sees the same flow whether they typed
3201
+ // `lazyclaw` or `lazyclaw setup`.
3202
+ async function cmdSetup(_sub, _positional, flags = {}) {
3203
+ await ensureRegistry();
3204
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
3205
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3206
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3207
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
3208
+ const warn = (s) => `\x1b[33m${s}\x1b[0m`;
3209
+
3210
+ // Header.
3211
+ if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
3212
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3213
+ process.stdout.write('\n');
3214
+ process.stdout.write(` ${bold('🔧 Setup wizard')}\n`);
3215
+ process.stdout.write(` ${dim('Five short steps. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
3216
+
3217
+ const cfg = readConfig();
3218
+ const cfgDir = path.dirname(configPath());
3219
+
3220
+ // ── Step 1: Provider + model (mandatory) ────────────────────
3221
+ process.stdout.write(` ${accent('Step 1/5 ·')} ${bold('Pick a provider + model')}\n`);
3222
+ 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`);
3223
+ await _quickPrompt(' ▶ press Enter to open the picker ');
3224
+ try {
3225
+ await cmdOnboard({ pick: true });
3226
+ } catch (e) {
3227
+ process.stderr.write(`onboard error: ${e?.message || e}\n`);
3228
+ process.exit(1);
3229
+ }
3230
+ // Re-read config after onboard wrote it. If the user aborted with
3231
+ // no provider set, bail out early — the rest of the wizard depends
3232
+ // on a provider being configured.
3233
+ const cfgAfterOnboard = readConfig();
3234
+ if (!cfgAfterOnboard.provider) {
3235
+ process.stdout.write(`\n ${warn('Setup aborted — no provider configured. Run `lazyclaw setup` again when ready.')}\n\n`);
3236
+ process.exit(0);
3237
+ }
3238
+ process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
3239
+
3240
+ // ── Step 2: Optional workspace ──────────────────────────────
3241
+ process.stdout.write(` ${accent('Step 2/5 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
3242
+ 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`);
3243
+ const wsName = (await _quickPrompt(' workspace name (Enter to skip): ')).trim();
3244
+ if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
3245
+ try {
3246
+ const ws = await import('./workspace.mjs');
3247
+ const dir = ws.initWorkspace(cfgDir, wsName);
3248
+ process.stdout.write(` ${ok('✓ workspace created:')} ${dir}\n`);
3249
+ process.stdout.write(` ${dim('Edit AGENTS.md / SOUL.md / TOOLS.md any time. Use with: lazyclaw chat --workspace ' + wsName)}\n\n`);
3250
+ } catch (e) {
3251
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3252
+ }
3253
+ } else if (wsName) {
3254
+ process.stdout.write(` ${warn('skipped:')} workspace name must match [A-Za-z0-9_.-]+\n\n`);
3255
+ } else {
3256
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3257
+ }
3258
+
3259
+ // ── Step 3: Optional skill bundle install ───────────────────
3260
+ process.stdout.write(` ${accent('Step 3/5 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
3261
+ process.stdout.write(` ${dim('Format: <user>/<repo>[@<ref>]. Skills are .md prompt fragments that compose into the system prompt via --skill.')}\n\n`);
3262
+ const skillSpec = (await _quickPrompt(' github spec (Enter to skip): ')).trim();
3263
+ if (skillSpec) {
3264
+ try {
3265
+ const inst = await import('./skills_install.mjs');
3266
+ const r = await inst.installFromGithub(skillSpec, cfgDir, { force: false });
3267
+ process.stdout.write(` ${ok('✓ installed')} ${r.installed.length} ${dim('skill(s) from')} ${skillSpec}\n`);
3268
+ r.installed.forEach((s) => process.stdout.write(` · ${s.name} ${dim(`(${s.bytes} bytes)`)}\n`));
3269
+ if (r.skipped.length) {
3270
+ process.stdout.write(` ${dim('skipped (already installed):')} ${r.skipped.map((s) => s.name).join(', ')}\n`);
3271
+ }
3272
+ process.stdout.write('\n');
3273
+ } catch (e) {
3274
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3275
+ }
3276
+ } else {
3277
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3278
+ }
3279
+
3280
+ // ── Step 4: Optional outbound webhook ───────────────────────
3281
+ process.stdout.write(` ${accent('Step 4/5 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
3282
+ process.stdout.write(` ${dim('Use with: lazyclaw message send <name> <text>. Slack / Discord Incoming Webhook URLs work as-is.')}\n\n`);
3283
+ const hookName = (await _quickPrompt(' webhook name (Enter to skip): ')).trim();
3284
+ if (hookName) {
3285
+ const hookUrl = (await _quickPrompt(' webhook URL: ')).trim();
3286
+ if (!hookUrl) {
3287
+ process.stdout.write(` ${warn('skipped:')} URL required\n\n`);
3288
+ } else {
3289
+ try {
3290
+ const cf = await import('./config_features.mjs');
3291
+ const fresh = readConfig();
3292
+ cf.messageAdd(fresh, hookName, hookUrl);
3293
+ writeConfig(fresh);
3294
+ process.stdout.write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
3295
+ } catch (e) {
3296
+ process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
3297
+ }
3298
+ }
3299
+ } else {
3300
+ process.stdout.write(` ${dim('— skipped —')}\n\n`);
3301
+ }
3302
+
3303
+ // ── Step 5: Reachability check ──────────────────────────────
3304
+ process.stdout.write(` ${accent('Step 5/5 ·')} ${bold('Verify the picked provider responds')}\n`);
3305
+ process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirms your key / subscription / local daemon is wired up.')}\n\n`);
3306
+ const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
3307
+ if (wantPing) {
3308
+ try {
3309
+ // Reuse the existing providers-test path so behaviour matches
3310
+ // a manual `lazyclaw providers test`.
3311
+ await cmdProviders('test', [cfgAfterOnboard.provider], {});
3312
+ } catch (e) {
3313
+ process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
3314
+ process.stdout.write(` ${dim('Setup still completed; you can retry with:')} lazyclaw providers test ${cfgAfterOnboard.provider}\n`);
3315
+ }
3316
+ } else {
3317
+ process.stdout.write(` ${dim('— skipped —')}\n`);
3318
+ }
3319
+
3320
+ // ── Wrap up ─────────────────────────────────────────────────
3321
+ process.stdout.write('\n');
3322
+ process.stdout.write(` ${ok(bold('🎉 Setup complete.'))}\n`);
3323
+ process.stdout.write(` ${dim('Run')} ${bold('lazyclaw')} ${dim('any time to open the menu, or jump in directly:')}\n`);
3324
+ process.stdout.write(` ${dim('•')} lazyclaw chat ${dim('— REPL with the configured provider')}\n`);
3325
+ process.stdout.write(` ${dim('•')} lazyclaw agent "..." ${dim('— one-shot prompt')}\n`);
3326
+ process.stdout.write(` ${dim('•')} lazyclaw doctor ${dim('— diagnostic JSON')}\n`);
3327
+ process.stdout.write(` ${dim('•')} lazyclaw setup ${dim('— re-run this wizard any time')}\n\n`);
3328
+ }
3329
+
3085
3330
  // First-run welcome panel + delegated onboard. Drawn once before the
3086
3331
  // main launcher menu when the config has no provider yet. Walks the
3087
3332
  // user through the same arrow-key picker that `lazyclaw onboard`
@@ -3093,16 +3338,7 @@ async function _runFirstTimeOnboard() {
3093
3338
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3094
3339
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3095
3340
  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'));
3341
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3106
3342
  process.stdout.write('\n');
3107
3343
  process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
3108
3344
  process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
@@ -3134,13 +3370,14 @@ async function cmdLauncher() {
3134
3370
  // user through onboard before showing the menu — once they've
3135
3371
  // picked, re-read the config and continue normally.
3136
3372
  if (!cfg.provider) {
3137
- await _runFirstTimeOnboard();
3373
+ // Delegate to the full setup wizard rather than the bare onboard
3374
+ // picker — first-time users benefit from the workspace / skill /
3375
+ // ping steps too. cmdSetup exits the process on abort, so the
3376
+ // re-read below only fires when the wizard completed successfully.
3377
+ await cmdSetup(undefined, [], {});
3138
3378
  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
3379
  if (!cfg.provider) {
3143
- process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw onboard` when ready, then try `lazyclaw` again.\n\n');
3380
+ process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
3144
3381
  process.exit(0);
3145
3382
  }
3146
3383
  }
@@ -3177,16 +3414,7 @@ async function cmdLauncher() {
3177
3414
 
3178
3415
  const draw = () => {
3179
3416
  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'));
3417
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3190
3418
  process.stdout.write('\n');
3191
3419
  const provDisplay = provider === '(unset — pick during onboard)'
3192
3420
  ? warn(provider)
@@ -3444,6 +3672,14 @@ async function main() {
3444
3672
  await cmdCron(sub, rest.positional.slice(1), rest.flags);
3445
3673
  break;
3446
3674
  }
3675
+ case 'setup': {
3676
+ await cmdSetup(undefined, rest.positional, rest.flags);
3677
+ break;
3678
+ }
3679
+ case 'dashboard': {
3680
+ await cmdDashboard(rest.flags);
3681
+ break;
3682
+ }
3447
3683
  case 'daemon': {
3448
3684
  await cmdDaemon(rest.flags);
3449
3685
  break;
package/daemon.mjs CHANGED
@@ -378,6 +378,34 @@ export function makeHandler(ctx) {
378
378
  const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
379
379
  const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
380
380
  switch (true) {
381
+ case route === 'GET /' || route === 'GET /dashboard': {
382
+ // Serve the lazyclaw-only web dashboard (a single static
383
+ // HTML in src/lazyclaw/web/). Co-resident with the JSON
384
+ // API so a single port handles both — no CORS song and
385
+ // dance, no separate static server. Falls back to a
386
+ // helpful text response when the file is missing (someone
387
+ // ran the daemon out of a partial install).
388
+ try {
389
+ const fs = await import('node:fs');
390
+ const path = await import('node:path');
391
+ const url = await import('node:url');
392
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
393
+ const htmlPath = path.join(here, 'web', 'dashboard.html');
394
+ const body = fs.readFileSync(htmlPath, 'utf8');
395
+ res.writeHead(200, {
396
+ 'content-type': 'text/html; charset=utf-8',
397
+ 'cache-control': 'no-cache',
398
+ });
399
+ return res.end(body);
400
+ } catch (e) {
401
+ res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
402
+ return res.end(
403
+ `lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
404
+ `Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
405
+ `error: ${e?.message || e}\n`,
406
+ );
407
+ }
408
+ }
381
409
  case route === 'GET /version':
382
410
  return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
383
411
  case route === 'GET /health':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.5",
3
+ "version": "3.99.6",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,459 @@
1
+ <!doctype html>
2
+ <!--
3
+ lazyclaw dashboard — a single-file SPA served by `lazyclaw daemon`.
4
+ Deliberately framework-free: vanilla DOM, vanilla fetch, no build
5
+ step. The full lazyclaude dashboard is gigantic; this is the
6
+ lazyclaw-only slice (chat / sessions / skills / providers /
7
+ status), aimed at users who installed via `npm i -g lazyclaw` and
8
+ don't want to clone the whole monorepo just to see a UI.
9
+ -->
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1">
14
+ <title>LazyClaw</title>
15
+ <style>
16
+ :root {
17
+ --bg: #0a0a0a;
18
+ --card: #14141c;
19
+ --border: #2a2a36;
20
+ --text: #e8e8ea;
21
+ --dim: #a8a8b8;
22
+ --accent: #d97757;
23
+ --ok: #4ade80;
24
+ --warn: #f59e0b;
25
+ --err: #ef4444;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ body {
29
+ margin: 0;
30
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
31
+ background: var(--bg);
32
+ color: var(--text);
33
+ min-height: 100vh;
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+ header {
38
+ padding: 14px 22px;
39
+ border-bottom: 1px solid var(--border);
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 14px;
43
+ }
44
+ .logo { font-weight: 700; font-size: 16px; color: var(--accent); }
45
+ .ver { color: var(--dim); font-size: 11px; }
46
+ nav.tabs {
47
+ display: flex;
48
+ gap: 2px;
49
+ padding: 0 14px;
50
+ border-bottom: 1px solid var(--border);
51
+ overflow-x: auto;
52
+ }
53
+ nav.tabs button {
54
+ background: none;
55
+ border: 0;
56
+ color: var(--dim);
57
+ padding: 12px 16px;
58
+ cursor: pointer;
59
+ font-size: 13px;
60
+ border-bottom: 2px solid transparent;
61
+ white-space: nowrap;
62
+ }
63
+ nav.tabs button:hover { color: var(--text); }
64
+ nav.tabs button.active {
65
+ color: var(--accent);
66
+ border-bottom-color: var(--accent);
67
+ }
68
+ main {
69
+ flex: 1;
70
+ padding: 22px;
71
+ overflow-y: auto;
72
+ }
73
+ section { display: none; }
74
+ section.active { display: block; }
75
+ h2 { margin: 0 0 14px; font-size: 18px; }
76
+ .card {
77
+ background: var(--card);
78
+ border: 1px solid var(--border);
79
+ border-radius: 8px;
80
+ padding: 14px 16px;
81
+ margin-bottom: 12px;
82
+ }
83
+ .row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
84
+ .row + .row { border-top: 1px solid var(--border); }
85
+ .name { font-weight: 600; }
86
+ .dim { color: var(--dim); font-size: 12px; }
87
+ button.btn {
88
+ background: var(--accent);
89
+ color: #fff;
90
+ border: 0;
91
+ border-radius: 6px;
92
+ padding: 8px 14px;
93
+ cursor: pointer;
94
+ font-size: 13px;
95
+ font-weight: 500;
96
+ }
97
+ button.btn:hover { filter: brightness(1.1); }
98
+ button.btn-secondary {
99
+ background: transparent;
100
+ border: 1px solid var(--border);
101
+ color: var(--text);
102
+ }
103
+ button.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
104
+ .empty { color: var(--dim); padding: 24px; text-align: center; font-style: italic; }
105
+ pre {
106
+ background: #06060c;
107
+ padding: 12px 14px;
108
+ border-radius: 6px;
109
+ overflow-x: auto;
110
+ font-size: 12px;
111
+ color: #cfcfd6;
112
+ border: 1px solid var(--border);
113
+ }
114
+ /* Chat */
115
+ #chat-stream {
116
+ background: var(--card);
117
+ border: 1px solid var(--border);
118
+ border-radius: 8px;
119
+ padding: 12px;
120
+ height: 50vh;
121
+ overflow-y: auto;
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: 14px;
125
+ }
126
+ .msg { padding: 8px 12px; border-radius: 6px; max-width: 90%; white-space: pre-wrap; word-wrap: break-word; }
127
+ .msg.user { align-self: flex-end; background: rgba(217, 119, 87, 0.15); border: 1px solid rgba(217, 119, 87, 0.3); }
128
+ .msg.assistant { align-self: flex-start; background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
129
+ .msg.error { align-self: stretch; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.3); color: #ffd3d3; }
130
+ .input-row {
131
+ display: flex;
132
+ gap: 8px;
133
+ margin-top: 12px;
134
+ }
135
+ .input-row textarea {
136
+ flex: 1;
137
+ background: var(--card);
138
+ border: 1px solid var(--border);
139
+ border-radius: 6px;
140
+ padding: 10px 12px;
141
+ color: var(--text);
142
+ resize: vertical;
143
+ min-height: 60px;
144
+ font: inherit;
145
+ }
146
+ .pill {
147
+ display: inline-block;
148
+ padding: 2px 8px;
149
+ border-radius: 999px;
150
+ font-size: 10px;
151
+ background: var(--border);
152
+ color: var(--dim);
153
+ margin-left: 6px;
154
+ }
155
+ .pill.ok { background: rgba(74, 222, 128, 0.15); color: var(--ok); }
156
+ .pill.warn { background: rgba(245, 158, 11, 0.15); color: var(--warn); }
157
+ .pill.err { background: rgba(239, 68, 68, 0.15); color: var(--err); }
158
+ .toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
159
+ .toolbar select {
160
+ background: var(--card);
161
+ border: 1px solid var(--border);
162
+ color: var(--text);
163
+ padding: 6px 10px;
164
+ border-radius: 6px;
165
+ font: inherit;
166
+ }
167
+ footer {
168
+ padding: 10px 22px;
169
+ border-top: 1px solid var(--border);
170
+ color: var(--dim);
171
+ font-size: 11px;
172
+ display: flex;
173
+ justify-content: space-between;
174
+ }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <header>
179
+ <div class="logo">🦞 LazyClaw</div>
180
+ <div class="ver" id="version">…</div>
181
+ </header>
182
+
183
+ <nav class="tabs">
184
+ <button data-tab="chat" class="active">Chat</button>
185
+ <button data-tab="sessions">Sessions</button>
186
+ <button data-tab="skills">Skills</button>
187
+ <button data-tab="providers">Providers</button>
188
+ <button data-tab="status">Status</button>
189
+ </nav>
190
+
191
+ <main>
192
+ <section id="tab-chat" class="active">
193
+ <h2>Chat</h2>
194
+ <div class="toolbar">
195
+ <select id="chat-assignee"><option value="">(loading…)</option></select>
196
+ <button class="btn btn-secondary" onclick="resetChat()">Clear</button>
197
+ <span class="dim" id="chat-meta"></span>
198
+ </div>
199
+ <div id="chat-stream"><div class="empty">Type below to start.</div></div>
200
+ <div class="input-row">
201
+ <textarea id="chat-input" placeholder="Send a message — Enter to send, Shift+Enter for newline."></textarea>
202
+ <button class="btn" onclick="sendChat()">Send</button>
203
+ </div>
204
+ </section>
205
+
206
+ <section id="tab-sessions">
207
+ <h2>Sessions</h2>
208
+ <div id="sessions-list"><div class="empty">Loading…</div></div>
209
+ </section>
210
+
211
+ <section id="tab-skills">
212
+ <h2>Skills</h2>
213
+ <div id="skills-list"><div class="empty">Loading…</div></div>
214
+ </section>
215
+
216
+ <section id="tab-providers">
217
+ <h2>Providers</h2>
218
+ <div id="providers-list"><div class="empty">Loading…</div></div>
219
+ </section>
220
+
221
+ <section id="tab-status">
222
+ <h2>Status</h2>
223
+ <div id="status-card"><div class="empty">Loading…</div></div>
224
+ </section>
225
+ </main>
226
+
227
+ <footer>
228
+ <span>lazyclaw dashboard</span>
229
+ <span id="footer-url"></span>
230
+ </footer>
231
+
232
+ <script>
233
+ // Tab switching ────────────────────────────────────────────────
234
+ const tabs = document.querySelectorAll('nav.tabs button');
235
+ const sections = document.querySelectorAll('main section');
236
+ tabs.forEach((b) => b.addEventListener('click', () => {
237
+ tabs.forEach((x) => x.classList.toggle('active', x === b));
238
+ sections.forEach((s) => s.classList.toggle('active', s.id === 'tab-' + b.dataset.tab));
239
+ const loader = LOADERS[b.dataset.tab];
240
+ if (loader) loader();
241
+ }));
242
+
243
+ document.getElementById('footer-url').textContent = location.href;
244
+
245
+ // Tiny fetch helper that surfaces errors as toasts on the page.
246
+ async function api(path, opts = {}) {
247
+ const r = await fetch(path, opts);
248
+ if (!r.ok && r.status !== 200) {
249
+ let body = '';
250
+ try { body = JSON.stringify(await r.json()); } catch {}
251
+ throw new Error(`${r.status} ${r.statusText}${body ? ' — ' + body : ''}`);
252
+ }
253
+ return r.json();
254
+ }
255
+
256
+ // ── Status / version (always shown in header) ────────────────
257
+ api('/version').then((v) => {
258
+ document.getElementById('version').textContent = `v${v.version}`;
259
+ }).catch(() => {});
260
+
261
+ // ── Loaders per tab ──────────────────────────────────────────
262
+ const LOADERS = {};
263
+
264
+ LOADERS.chat = async function loadChat() {
265
+ try {
266
+ const r = await api('/providers');
267
+ const sel = document.getElementById('chat-assignee');
268
+ sel.innerHTML = '';
269
+ for (const p of r.providers || []) {
270
+ const ms = (p.suggestedModels || []);
271
+ if (!ms.length) {
272
+ const opt = document.createElement('option');
273
+ opt.value = p.name; opt.textContent = p.name;
274
+ sel.appendChild(opt);
275
+ continue;
276
+ }
277
+ for (const m of ms.slice(0, 6)) {
278
+ const opt = document.createElement('option');
279
+ opt.value = `${p.name}:${m}`;
280
+ opt.textContent = `${p.name} · ${m}`;
281
+ sel.appendChild(opt);
282
+ }
283
+ }
284
+ } catch (e) {
285
+ document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
286
+ }
287
+ };
288
+
289
+ LOADERS.sessions = async function loadSessions() {
290
+ const root = document.getElementById('sessions-list');
291
+ root.innerHTML = '<div class="empty">Loading…</div>';
292
+ try {
293
+ const r = await api('/sessions');
294
+ const arr = r.sessions || r;
295
+ if (!Array.isArray(arr) || arr.length === 0) {
296
+ root.innerHTML = '<div class="empty">No persisted sessions yet. Start one with <code>lazyclaw chat --session &lt;id&gt;</code>.</div>';
297
+ return;
298
+ }
299
+ root.innerHTML = '';
300
+ arr.forEach((s) => {
301
+ const div = document.createElement('div');
302
+ div.className = 'card row';
303
+ const id = s.id || s.sessionId || s.name || JSON.stringify(s);
304
+ const turns = s.turns ?? s.turnCount ?? '';
305
+ const updated = s.updatedAt || s.mtime || '';
306
+ div.innerHTML = `<div class="name">${id}</div>
307
+ <div class="dim">${turns ? turns + ' turns' : ''}</div>
308
+ <div class="dim" style="margin-left:auto;">${updated}</div>`;
309
+ root.appendChild(div);
310
+ });
311
+ } catch (e) {
312
+ root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
313
+ }
314
+ };
315
+
316
+ LOADERS.skills = async function loadSkills() {
317
+ const root = document.getElementById('skills-list');
318
+ root.innerHTML = '<div class="empty">Loading…</div>';
319
+ try {
320
+ const r = await api('/skills');
321
+ const arr = r.skills || r;
322
+ if (!Array.isArray(arr) || arr.length === 0) {
323
+ root.innerHTML = '<div class="empty">No skills yet. Install one: <code>lazyclaw skills install &lt;user&gt;/&lt;repo&gt;</code>.</div>';
324
+ return;
325
+ }
326
+ root.innerHTML = '';
327
+ arr.forEach((s) => {
328
+ const div = document.createElement('div');
329
+ div.className = 'card';
330
+ div.innerHTML = `<div class="row" style="border:0;padding:0;">
331
+ <div class="name">${s.name}</div>
332
+ <div class="dim" style="margin-left:auto;">${(s.bytes ?? '')} bytes</div>
333
+ </div>
334
+ <div class="dim" style="margin-top:6px;">${s.summary || ''}</div>`;
335
+ root.appendChild(div);
336
+ });
337
+ } catch (e) {
338
+ root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
339
+ }
340
+ };
341
+
342
+ LOADERS.providers = async function loadProviders() {
343
+ const root = document.getElementById('providers-list');
344
+ root.innerHTML = '<div class="empty">Loading…</div>';
345
+ try {
346
+ const r = await api('/providers');
347
+ const arr = r.providers || r;
348
+ root.innerHTML = '';
349
+ arr.forEach((p) => {
350
+ const div = document.createElement('div');
351
+ div.className = 'card';
352
+ const tag = p.requiresApiKey
353
+ ? '<span class="pill warn">api key</span>'
354
+ : '<span class="pill ok">no key</span>';
355
+ const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
356
+ div.innerHTML = `<div class="row" style="border:0;padding:0;">
357
+ <div class="name">${p.name}</div>${tag}
358
+ <div class="dim" style="margin-left:auto;">${p.endpoint || ''}</div>
359
+ </div>
360
+ <div class="dim" style="margin-top:6px;">${p.docs || ''}</div>
361
+ <div style="margin-top:8px;font-size:12px;">${models}</div>`;
362
+ root.appendChild(div);
363
+ });
364
+ } catch (e) {
365
+ root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
366
+ }
367
+ };
368
+
369
+ LOADERS.status = async function loadStatus() {
370
+ const root = document.getElementById('status-card');
371
+ root.innerHTML = '<div class="empty">Loading…</div>';
372
+ try {
373
+ const r = await api('/status');
374
+ root.innerHTML = `<div class="card"><pre>${JSON.stringify(r, null, 2)}</pre></div>`;
375
+ } catch (e) {
376
+ root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
377
+ }
378
+ };
379
+
380
+ // First load = chat tab.
381
+ LOADERS.chat();
382
+
383
+ // ── Chat send ─────────────────────────────────────────────────
384
+ let chatHistory = []; // [{role, text}]
385
+ function resetChat() {
386
+ chatHistory = [];
387
+ const stream = document.getElementById('chat-stream');
388
+ stream.innerHTML = '<div class="empty">Type below to start.</div>';
389
+ document.getElementById('chat-meta').textContent = '';
390
+ }
391
+ function appendMsg(role, text) {
392
+ const stream = document.getElementById('chat-stream');
393
+ // First message kicks the empty placeholder.
394
+ if (stream.querySelector('.empty')) stream.innerHTML = '';
395
+ const div = document.createElement('div');
396
+ div.className = 'msg ' + role;
397
+ div.textContent = text;
398
+ stream.appendChild(div);
399
+ stream.scrollTop = stream.scrollHeight;
400
+ return div;
401
+ }
402
+ document.getElementById('chat-input').addEventListener('keydown', (e) => {
403
+ if (e.key === 'Enter' && !e.shiftKey) {
404
+ e.preventDefault();
405
+ sendChat();
406
+ }
407
+ });
408
+ async function sendChat() {
409
+ const ta = document.getElementById('chat-input');
410
+ const text = ta.value.trim();
411
+ if (!text) return;
412
+ const assignee = document.getElementById('chat-assignee').value;
413
+ if (!assignee) {
414
+ appendMsg('error', 'No provider selected. Run `lazyclaw onboard` first.');
415
+ return;
416
+ }
417
+ ta.value = '';
418
+ appendMsg('user', text);
419
+ chatHistory.push({ role: 'user', text });
420
+ const meta = document.getElementById('chat-meta');
421
+ meta.textContent = '⏳ thinking…';
422
+ const t0 = Date.now();
423
+ try {
424
+ // Daemon's POST /agent: { prompt, provider, model, ... }
425
+ // Returns { text, provider, model, durationMs, ... }.
426
+ const [provName, modelName] = assignee.includes(':') ? assignee.split(':', 2) : [assignee, ''];
427
+ const body = { prompt: buildAgentPrompt(text), provider: provName };
428
+ if (modelName) body.model = modelName;
429
+ const r = await api('/agent', {
430
+ method: 'POST',
431
+ headers: { 'content-type': 'application/json' },
432
+ body: JSON.stringify(body),
433
+ });
434
+ const reply = r.text || r.output || '(empty)';
435
+ appendMsg('assistant', reply);
436
+ chatHistory.push({ role: 'assistant', text: reply });
437
+ const dur = ((Date.now() - t0) / 1000).toFixed(1);
438
+ meta.textContent = `${r.provider || provName} · ${r.model || modelName || '(default)'} · ${dur}s`;
439
+ } catch (e) {
440
+ appendMsg('error', '⚠ ' + (e.message || String(e)));
441
+ meta.textContent = '';
442
+ }
443
+ }
444
+ function buildAgentPrompt(latestUserText) {
445
+ // Flat conversation prompt: previous turns + the new user message.
446
+ // The daemon's /agent endpoint is one-shot, so we stuff prior
447
+ // turns into the prompt body. Keeps the dashboard stateless.
448
+ if (chatHistory.length <= 1) return latestUserText;
449
+ const lines = [];
450
+ for (const m of chatHistory.slice(-12, -1)) {
451
+ lines.push((m.role === 'user' ? 'User:' : 'Assistant:') + ' ' + m.text);
452
+ }
453
+ lines.push('User: ' + latestUserText);
454
+ lines.push('Assistant:');
455
+ return lines.join('\n\n');
456
+ }
457
+ </script>
458
+ </body>
459
+ </html>