lazyclaw 3.99.4 → 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 +1 -1
- package/cli.mjs +319 -24
- package/daemon.mjs +28 -0
- package/package.json +1 -1
- package/providers/registry.mjs +10 -5
- package/web/dashboard.html +459 -0
package/README.md
CHANGED
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
|
-
|
|
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)')}`,
|
|
@@ -1457,10 +1501,10 @@ async function _pickProviderInteractive() {
|
|
|
1457
1501
|
});
|
|
1458
1502
|
return { provider: ans || providers[0], model: null };
|
|
1459
1503
|
}
|
|
1460
|
-
// Default cursor:
|
|
1461
|
-
//
|
|
1462
|
-
|
|
1463
|
-
|
|
1504
|
+
// Default cursor: lands on item 0 (= the first row from PROVIDERS
|
|
1505
|
+
// insertion order, which registry.mjs deliberately curates as the
|
|
1506
|
+
// most user-familiar vendor — gemini at the time of writing).
|
|
1507
|
+
let idx = 0;
|
|
1464
1508
|
|
|
1465
1509
|
const readline = await import('node:readline');
|
|
1466
1510
|
readline.emitKeypressEvents(process.stdin);
|
|
@@ -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,10 +3189,199 @@ 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.
|
|
3085
|
-
|
|
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 = {}) {
|
|
3086
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
|
+
|
|
3087
3217
|
const cfg = readConfig();
|
|
3088
|
-
const
|
|
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
|
+
|
|
3330
|
+
// First-run welcome panel + delegated onboard. Drawn once before the
|
|
3331
|
+
// main launcher menu when the config has no provider yet. Walks the
|
|
3332
|
+
// user through the same arrow-key picker that `lazyclaw onboard`
|
|
3333
|
+
// uses; on success the launcher continues, on cancel the launcher
|
|
3334
|
+
// exits politely instead of dropping into a menu where every option
|
|
3335
|
+
// would error.
|
|
3336
|
+
async function _runFirstTimeOnboard() {
|
|
3337
|
+
const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
|
|
3338
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
3339
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
3340
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
3341
|
+
_renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
|
|
3342
|
+
process.stdout.write('\n');
|
|
3343
|
+
process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
|
|
3344
|
+
process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
|
|
3345
|
+
process.stdout.write(` ${dim('Pick a provider + model below; LazyClaw stores it in ~/.lazyclaw/config.json.')}\n\n`);
|
|
3346
|
+
process.stdout.write(` ${dim('Quick rule of thumb:')}\n`);
|
|
3347
|
+
process.stdout.write(` ${dim(' · gemini / openai / anthropic — need an API key (sk-... / paste during setup)')}\n`);
|
|
3348
|
+
process.stdout.write(` ${dim(' · claude-cli / ollama — keyless (use your existing Claude Code login or local Ollama)')}\n`);
|
|
3349
|
+
process.stdout.write(` ${dim(' · mock — offline echo, only useful for testing')}\n\n`);
|
|
3350
|
+
process.stdout.write(` ${dim('Press Enter to open the picker · Ctrl+C to abort.')}\n`);
|
|
3351
|
+
await _quickPrompt(' ▶ ');
|
|
3352
|
+
// Delegate to the real onboard flow with --pick so the picker UI
|
|
3353
|
+
// fires regardless of how this entry point was reached. cmdOnboard
|
|
3354
|
+
// owns config writing.
|
|
3355
|
+
try {
|
|
3356
|
+
await cmdOnboard({ pick: true });
|
|
3357
|
+
} catch (e) {
|
|
3358
|
+
process.stderr.write(`onboard error: ${e?.message || e}\n`);
|
|
3359
|
+
}
|
|
3360
|
+
process.stdout.write('\n');
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
async function cmdLauncher() {
|
|
3364
|
+
await ensureRegistry();
|
|
3365
|
+
let cfg = readConfig();
|
|
3366
|
+
// First-run guard: a fresh install has no `provider` set, so any
|
|
3367
|
+
// menu pick that calls a provider (Chat / Agent / Doctor / etc.)
|
|
3368
|
+
// would error halfway through with a confusing "missing api key"
|
|
3369
|
+
// or "unknown provider". Detect that state up front and walk the
|
|
3370
|
+
// user through onboard before showing the menu — once they've
|
|
3371
|
+
// picked, re-read the config and continue normally.
|
|
3372
|
+
if (!cfg.provider) {
|
|
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, [], {});
|
|
3378
|
+
cfg = readConfig();
|
|
3379
|
+
if (!cfg.provider) {
|
|
3380
|
+
process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
|
|
3381
|
+
process.exit(0);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
const provider = cfg.provider;
|
|
3089
3385
|
const model = cfg.model || '(default)';
|
|
3090
3386
|
const items = [
|
|
3091
3387
|
{ id: 'chat', label: 'Chat', desc: 'interactive REPL with the configured provider', argv: ['chat'] },
|
|
@@ -3118,16 +3414,7 @@ async function cmdLauncher() {
|
|
|
3118
3414
|
|
|
3119
3415
|
const draw = () => {
|
|
3120
3416
|
process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
|
|
3121
|
-
|
|
3122
|
-
accent(' ╭──────────────────────────────╮'),
|
|
3123
|
-
accent(' │ _ │'),
|
|
3124
|
-
accent(' │ | |__ _ _____ _ _ │'),
|
|
3125
|
-
accent(' │ | / _` |_ / || | \'_| │'),
|
|
3126
|
-
accent(' │ |_\\__,_/__\\_, |_| │'),
|
|
3127
|
-
accent(' │ LazyClaw |__/ ' + (readVersionFromRepo() || '').padEnd(10) + ' │'),
|
|
3128
|
-
accent(' ╰──────────────────────────────╯'),
|
|
3129
|
-
];
|
|
3130
|
-
banner.forEach((l) => process.stdout.write(l + '\n'));
|
|
3417
|
+
_renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
|
|
3131
3418
|
process.stdout.write('\n');
|
|
3132
3419
|
const provDisplay = provider === '(unset — pick during onboard)'
|
|
3133
3420
|
? warn(provider)
|
|
@@ -3385,6 +3672,14 @@ async function main() {
|
|
|
3385
3672
|
await cmdCron(sub, rest.positional.slice(1), rest.flags);
|
|
3386
3673
|
break;
|
|
3387
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
|
+
}
|
|
3388
3683
|
case 'daemon': {
|
|
3389
3684
|
await cmdDaemon(rest.flags);
|
|
3390
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.
|
|
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",
|
package/providers/registry.mjs
CHANGED
|
@@ -50,15 +50,20 @@ export const mockProvider = {
|
|
|
50
50
|
|
|
51
51
|
export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider, claudeCliProvider };
|
|
52
52
|
|
|
53
|
+
// Insertion order is the picker order. The list goes first-to-last in
|
|
54
|
+
// rough "user-familiar / popular" order so a first-time onboard lands
|
|
55
|
+
// the cursor on a vendor most users recognise. v3.99.5 reordered per
|
|
56
|
+
// user feedback ("gemini, codex 이런거 먼저 나오게끔").
|
|
53
57
|
export const PROVIDERS = {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
58
|
+
// Tier 1 — popular / brand-name vendors users come in looking for.
|
|
59
|
+
gemini: geminiProvider,
|
|
60
|
+
openai: openaiProvider, // surfaces gpt-5-codex / gpt-5 / o3-pro etc.
|
|
61
|
+
// Tier 2 — Claude. CLI variant first because it's keyless.
|
|
57
62
|
'claude-cli': claudeCliProvider,
|
|
58
63
|
anthropic: anthropicProvider,
|
|
59
|
-
|
|
60
|
-
gemini: geminiProvider,
|
|
64
|
+
// Tier 3 — local + dev/test.
|
|
61
65
|
ollama: ollamaProvider,
|
|
66
|
+
mock: mockProvider,
|
|
62
67
|
};
|
|
63
68
|
|
|
64
69
|
// Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
|
|
@@ -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 <id></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 <user>/<repo></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>
|