lazyclaw 3.88.0

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 ADDED
@@ -0,0 +1,2648 @@
1
+ #!/usr/bin/env node
2
+ // LazyClaw CLI — workflow + config commands.
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ async function loadEngine() {
9
+ return import('./workflow/persistent.mjs');
10
+ }
11
+
12
+ function configPath() {
13
+ const override = process.env.LAZYCLAW_CONFIG_DIR;
14
+ const dir = override ? override : path.join(os.homedir(), '.lazyclaw');
15
+ return path.join(dir, 'config.json');
16
+ }
17
+
18
+ function readConfig() {
19
+ const p = configPath();
20
+ if (!fs.existsSync(p)) return {};
21
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
22
+ catch { return {}; }
23
+ }
24
+
25
+ function writeConfig(cfg) {
26
+ const p = configPath();
27
+ fs.mkdirSync(path.dirname(p), { recursive: true });
28
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
29
+ }
30
+
31
+ async function importWorkflow(file) {
32
+ const abs = path.resolve(file);
33
+ const url = pathToFileURL(abs).href;
34
+ const mod = await import(url);
35
+ if (!mod.nodes || !Array.isArray(mod.nodes)) {
36
+ throw new Error(`Workflow file ${file} must export 'nodes' array`);
37
+ }
38
+ return mod.nodes;
39
+ }
40
+
41
+ // Wire SIGINT/SIGTERM to an AbortController so a workflow run aborts
42
+ // at the next node/level boundary (or sooner if execute() subscribed
43
+ // to the signal). Returns { signal, dispose } — the caller MUST call
44
+ // dispose() in a finally so we don't leak listeners across REPL turns.
45
+ //
46
+ // Exit-code semantics:
47
+ // - normal success → 0
48
+ // - normal failure → 1
49
+ // - ABORT (signal-driven cancellation) → 130 (conventional Ctrl+C)
50
+ function makeRunSignal() {
51
+ const ac = new AbortController();
52
+ let received = null;
53
+ const onSig = (sig) => {
54
+ if (!received) {
55
+ received = sig;
56
+ ac.abort();
57
+ } else {
58
+ // Second signal: bail immediately without waiting for the engine.
59
+ // Same "I really mean it" semantic the daemon uses.
60
+ process.exit(130);
61
+ }
62
+ };
63
+ const onSigint = () => onSig('SIGINT');
64
+ const onSigterm = () => onSig('SIGTERM');
65
+ process.on('SIGINT', onSigint);
66
+ process.on('SIGTERM', onSigterm);
67
+ return {
68
+ signal: ac.signal,
69
+ dispose() {
70
+ process.off('SIGINT', onSigint);
71
+ process.off('SIGTERM', onSigterm);
72
+ },
73
+ wasAborted() { return ac.signal.aborted; },
74
+ };
75
+ }
76
+
77
+ function exitCodeFor(result, sig) {
78
+ if (sig.wasAborted() || result?.code === 'ABORT' || result?.error?.code === 'ABORT') return 130;
79
+ return result?.success ? 0 : 1;
80
+ }
81
+
82
+ async function cmdRun(sessionId, file, opts = {}) {
83
+ const nodes = await importWorkflow(file);
84
+ const dir = opts.dir || '.workflow-state';
85
+ const sig = makeRunSignal();
86
+ try {
87
+ if (opts['parallel-persistent']) {
88
+ // --parallel-persistent: DAG with checkpoint + resume. Same state
89
+ // file shape as the sequential path so a session id collision is
90
+ // observable, not silently corrupting.
91
+ const { runPersistentDag } = await loadEngine();
92
+ const r = await runPersistentDag(nodes, { sessionId, dir, timeoutMs: opts.timeoutMs, signal: sig.signal, concurrency: opts.concurrency });
93
+ console.log(JSON.stringify({
94
+ success: r.success,
95
+ executedNodes: r.executedNodes || [],
96
+ failedAt: r.failedAt || null,
97
+ mode: 'parallel-persistent',
98
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
99
+ error: r.error || null,
100
+ }));
101
+ process.exit(exitCodeFor(r, sig));
102
+ }
103
+ if (opts.parallel) {
104
+ // --parallel: schedule by `deps`. No state persistence — `runParallel`
105
+ // is a one-shot DAG run; resume semantics belong to runPersistent or
106
+ // runPersistentDag. failedAt + executedNodes are derived from results
107
+ // so the JSON shape stays compatible with the sequential path.
108
+ const { runParallel } = await import('./workflow/executor.mjs');
109
+ const r = await runParallel(nodes, { signal: sig.signal, concurrency: opts.concurrency });
110
+ const executedNodes = r.results.filter(x => x.status === 'success').map(x => x.id);
111
+ console.log(JSON.stringify({
112
+ success: r.success,
113
+ executedNodes,
114
+ failedAt: r.failedAt || null,
115
+ mode: 'parallel',
116
+ aborted: r.error?.code === 'ABORT' || sig.wasAborted() || undefined,
117
+ error: r.error?.message || null,
118
+ }));
119
+ process.exit(exitCodeFor(r, sig));
120
+ }
121
+ const { runPersistent } = await loadEngine();
122
+ const r = await runPersistent(nodes, { sessionId, dir, maxRetries: opts.maxRetries ?? 3, signal: sig.signal });
123
+ console.log(JSON.stringify({
124
+ success: r.success,
125
+ executedNodes: r.executedNodes,
126
+ failedAt: r.failedAt,
127
+ mode: 'sequential',
128
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
129
+ }));
130
+ process.exit(exitCodeFor(r, sig));
131
+ } finally {
132
+ sig.dispose();
133
+ }
134
+ }
135
+
136
+ // Pure transformation over a persisted state file — no execution.
137
+ // The shape mirrors the on-disk state plus a derived `summary` block
138
+ // so a script can decide "should I resume?" without parsing per-node
139
+ // statuses itself.
140
+ //
141
+ // With no sessionId, lists every state file in `dir` with a summary
142
+ // block per session — sorted by updatedAt descending so the most
143
+ // recently touched run sits at the top.
144
+ //
145
+ // Exit codes (single-session mode):
146
+ // 0 — state found and printed
147
+ // 1 — workflow completed (all nodes success, no work left to resume)
148
+ // 2 — state file not found
149
+ // 3 — workflow failed and is NOT resumable as-is (terminal failure
150
+ // with retries exhausted; user must edit the workflow or state)
151
+ //
152
+ // Exit codes (list mode):
153
+ // 0 — listing produced (even if empty — empty dir is valid state)
154
+ // 2 — `dir` does not exist
155
+ async function cmdInspect(sessionId, opts = {}) {
156
+ const dir = opts.dir || '.workflow-state';
157
+ const { loadState } = await loadEngine();
158
+ const { summarizeState, listSessions, aggregateNodeStats } = await import('./workflow/summary.mjs');
159
+
160
+ // --aggregate (list mode): per-node statistics across every
161
+ // session in the state dir — count, success/failed/pending/running
162
+ // counts, and min/max/avg/total durations. Answers "which node
163
+ // tends to be slow or fail across all my runs?" — a question
164
+ // single-session inspect can't answer.
165
+ if (!sessionId && opts.aggregate) {
166
+ let stats;
167
+ try {
168
+ stats = aggregateNodeStats(dir, { filter: opts.filter });
169
+ } catch (e) {
170
+ if (e?.code === 'ENOENT') {
171
+ console.error(`State directory ${dir} does not exist`);
172
+ process.exit(2);
173
+ }
174
+ throw e;
175
+ }
176
+ // --aggregate --node <id>: drill into one node's cross-session
177
+ // stats. Useful when you've already identified the bottleneck
178
+ // and want to track its trend across runs without scrolling
179
+ // the full table.
180
+ if (opts.node) {
181
+ const nodeStat = stats.nodeStats[opts.node];
182
+ if (!nodeStat) {
183
+ console.error(`No node "${opts.node}" found across sessions in ${dir} (known: ${Object.keys(stats.nodeStats).join(', ') || 'none'})`);
184
+ process.exit(2);
185
+ }
186
+ console.log(JSON.stringify({
187
+ dir,
188
+ filter: opts.filter || null,
189
+ sessionCount: stats.sessionCount,
190
+ nodeId: opts.node,
191
+ ...nodeStat,
192
+ }, null, 2));
193
+ process.exit(0);
194
+ }
195
+ console.log(JSON.stringify({ dir, filter: opts.filter || null, ...stats }, null, 2));
196
+ process.exit(0);
197
+ }
198
+
199
+ // List mode — no sessionId given. Walks the state directory and
200
+ // emits a summary per session. Per-node `nodes` map is omitted —
201
+ // run with a session id for full detail.
202
+ //
203
+ // --status filters the listing by lifecycle: done, resumable,
204
+ // failed, or running. Mutually exclusive — passing more than one
205
+ // is an error rather than silent overlap so a script can rely on
206
+ // the predicate it asked for.
207
+ if (!sessionId) {
208
+ let sessions;
209
+ try {
210
+ sessions = listSessions(dir);
211
+ } catch (e) {
212
+ if (e?.code === 'ENOENT') {
213
+ console.error(`State directory ${dir} does not exist`);
214
+ process.exit(2);
215
+ }
216
+ throw e;
217
+ }
218
+ const status = opts.status;
219
+ if (status) {
220
+ const valid = new Set(['done', 'resumable', 'failed', 'running']);
221
+ if (!valid.has(status)) {
222
+ console.error(`invalid --status: ${status} (expected one of: ${[...valid].join(', ')})`);
223
+ process.exit(2);
224
+ }
225
+ sessions = sessions.filter(s => {
226
+ if (status === 'done') return s.summary.done;
227
+ if (status === 'resumable') return s.summary.resumable;
228
+ if (status === 'failed') return s.summary.failed > 0;
229
+ if (status === 'running') return s.summary.running > 0;
230
+ return true;
231
+ });
232
+ }
233
+ // --filter <substr>: case-insensitive sessionId substring (same
234
+ // semantic as v3.33's sessions/skills list filter).
235
+ // --limit <N>: post-filter cap. Composes with --status (status
236
+ // first, then filter, then limit).
237
+ if (opts.filter) {
238
+ const f = String(opts.filter).toLowerCase();
239
+ sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
240
+ }
241
+ if (opts.limit !== undefined) {
242
+ const n = parseInt(opts.limit, 10);
243
+ if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
244
+ }
245
+ console.log(JSON.stringify({ dir, status: status || null, sessions }, null, 2));
246
+ process.exit(0);
247
+ }
248
+
249
+ const state = loadState(sessionId, dir);
250
+ if (!state) {
251
+ console.error(`No state for session ${sessionId} in ${dir}`);
252
+ process.exit(2);
253
+ }
254
+ // --node <id>: drill into one node's state. Useful for scripts
255
+ // checking a specific node ("did node 'classify' succeed?")
256
+ // without reading the full state body. Exit codes mirror the
257
+ // node's status:
258
+ // 0 — node exists and status is success or pending or running
259
+ // 1 — node exists and status is failed (script-friendly red)
260
+ // 2 — node doesn't exist in this session (typo or wrong workflow)
261
+ if (opts.node) {
262
+ const ns = state.nodes?.[opts.node];
263
+ if (!ns) {
264
+ console.error(`No node "${opts.node}" in session ${sessionId} (known: ${Object.keys(state.nodes || {}).join(', ')})`);
265
+ process.exit(2);
266
+ }
267
+ console.log(JSON.stringify({
268
+ sessionId: state.sessionId,
269
+ nodeId: opts.node,
270
+ ...ns,
271
+ }, null, 2));
272
+ process.exit(ns.status === 'failed' ? 1 : 0);
273
+ }
274
+ // --slowest <N>: top N nodes by durationMs. Pure state-file
275
+ // analysis — no workflow file needed (deps are irrelevant to
276
+ // "which node took the longest"). Sorted descending; ties
277
+ // broken by id ascending so the output is deterministic.
278
+ if (opts.slowest !== undefined) {
279
+ const n = parseInt(opts.slowest, 10);
280
+ if (!Number.isFinite(n) || n <= 0) {
281
+ console.error(`--slowest must be a positive integer (got ${JSON.stringify(opts.slowest)})`);
282
+ process.exit(2);
283
+ }
284
+ const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
285
+ id,
286
+ status: ns?.status || 'pending',
287
+ durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
288
+ attempts: ns?.attempts ?? 0,
289
+ }));
290
+ entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
291
+ console.log(JSON.stringify({
292
+ sessionId: state.sessionId,
293
+ top: entries.slice(0, n),
294
+ }, null, 2));
295
+ process.exit(0);
296
+ }
297
+ // --critical-path <workflow.mjs>: compute the longest weighted path
298
+ // through the DAG using each node's recorded durationMs. Useful for
299
+ // "where's the bottleneck" analysis after a slow run. Requires the
300
+ // workflow file because the state file doesn't persist deps.
301
+ if (opts.criticalPath) {
302
+ let workflowNodes;
303
+ try {
304
+ workflowNodes = await importWorkflow(opts.criticalPath);
305
+ } catch (e) {
306
+ console.error(`critical-path: ${e?.message || e}`);
307
+ process.exit(2);
308
+ }
309
+ const { criticalPath } = await import('./workflow/summary.mjs');
310
+ const result = criticalPath(workflowNodes, state.nodes || {});
311
+ console.log(JSON.stringify({
312
+ sessionId: state.sessionId,
313
+ ...result,
314
+ }, null, 2));
315
+ process.exit(0);
316
+ }
317
+ const { summary, failedNodes } = summarizeState(state);
318
+ // --summary trims the per-node `nodes` map and `order` from the
319
+ // single-session output, leaving only `summary` + `failedNodes` +
320
+ // timestamps. Useful for "I just want the headline" — the same
321
+ // shape list-mode produces per session, so a script can normalize
322
+ // output across both modes by passing --summary in single mode.
323
+ const compact = !!opts.summary;
324
+ const out = compact
325
+ ? {
326
+ sessionId: state.sessionId,
327
+ dir,
328
+ summary,
329
+ failedNodes,
330
+ startedAt: state.startedAt,
331
+ updatedAt: state.updatedAt,
332
+ }
333
+ : {
334
+ sessionId: state.sessionId,
335
+ dir,
336
+ summary,
337
+ failedNodes,
338
+ order: state.order,
339
+ nodes: state.nodes,
340
+ startedAt: state.startedAt,
341
+ updatedAt: state.updatedAt,
342
+ };
343
+ console.log(JSON.stringify(out, null, 2));
344
+ if (summary.done) process.exit(1);
345
+ if (summary.failed > 0) process.exit(3);
346
+ process.exit(0);
347
+ }
348
+
349
+ // Delete a persisted workflow state file. Idempotent — same shape
350
+ // as DELETE /workflows/<id> on the daemon. Confined to the state
351
+ // dir; a sessionId that resolves outside is rejected.
352
+ //
353
+ // Exit codes:
354
+ // 0 — file existed and was deleted (or didn't exist; either way ok)
355
+ // 1 — sessionId escapes the state dir / unsafe (refused)
356
+ // 2 — state directory does not exist (nothing to clear)
357
+ async function cmdClear(sessionId, opts = {}) {
358
+ const dir = opts.dir || '.workflow-state';
359
+ if (!fs.existsSync(dir)) {
360
+ console.error(`State directory ${dir} does not exist`);
361
+ process.exit(2);
362
+ }
363
+ const file = path.join(dir, `${sessionId}.json`);
364
+ const resolvedDir = path.resolve(dir);
365
+ const resolvedFile = path.resolve(file);
366
+ if (!resolvedFile.startsWith(resolvedDir + path.sep) && resolvedFile !== resolvedDir) {
367
+ console.error(`invalid sessionId: ${sessionId}`);
368
+ process.exit(1);
369
+ }
370
+ const existed = fs.existsSync(resolvedFile);
371
+ if (existed) fs.unlinkSync(resolvedFile);
372
+ console.log(JSON.stringify({ ok: true, sessionId, removed: existed }));
373
+ process.exit(0);
374
+ }
375
+
376
+ // Static validation of a workflow file. No execution — pure shape +
377
+ // topology check. Useful for CI:
378
+ // $ lazyclaw validate ./flow.mjs && lazyclaw run job ./flow.mjs
379
+ //
380
+ // Checks (in order; the first hard failure short-circuits the rest
381
+ // for a fast CI signal, but soft warnings collect into `warnings`):
382
+ // 1. file imports cleanly and exports `nodes` (hard)
383
+ // 2. each node has a string `id` and an `execute` function (hard)
384
+ // 3. ids are unique (hard — duplicate is a silent bug)
385
+ // 4. deps reference known ids (warn — unknown deps are treated as
386
+ // satisfied edges by topologicalLevels, so this is not fatal
387
+ // but almost always a typo)
388
+ // 5. no cycles (hard — `topologicalLevels` returns `leftover` non-empty)
389
+ //
390
+ // Output JSON includes:
391
+ // - ok: bool
392
+ // - issues: hard-failure messages
393
+ // - warnings: soft messages (still ok=true)
394
+ // - levels: topological levels (one per concurrent batch)
395
+ // - maxParallelism: max level width (informational — what the user's
396
+ // `--concurrency` flag should at most be set to)
397
+ //
398
+ // Exit codes:
399
+ // 0 — valid (warnings ok)
400
+ // 1 — hard failure
401
+ // 2 — file path / import error (couldn't read or eval the file)
402
+ async function cmdValidate(file) {
403
+ if (!file) { console.error('Usage: lazyclaw validate <workflow.mjs>'); process.exit(2); }
404
+ let nodes;
405
+ try {
406
+ nodes = await importWorkflow(file);
407
+ } catch (e) {
408
+ console.error(`validate: ${e?.message || e}`);
409
+ process.exit(2);
410
+ }
411
+ const issues = [];
412
+ const warnings = [];
413
+ // Per-node shape validation. We continue past per-node failures so
414
+ // the user sees every issue at once, not one-per-edit-cycle.
415
+ const ids = new Set();
416
+ for (let i = 0; i < nodes.length; i++) {
417
+ const n = nodes[i];
418
+ const where = `nodes[${i}]`;
419
+ if (!n || typeof n !== 'object') { issues.push(`${where}: must be an object`); continue; }
420
+ if (typeof n.id !== 'string' || n.id.length === 0) { issues.push(`${where}: missing or non-string id`); continue; }
421
+ if (typeof n.execute !== 'function') issues.push(`${where} (id=${n.id}): execute is not a function`);
422
+ if (ids.has(n.id)) issues.push(`${where}: duplicate id "${n.id}"`);
423
+ ids.add(n.id);
424
+ if (n.deps !== undefined && !Array.isArray(n.deps)) {
425
+ issues.push(`${where} (id=${n.id}): deps must be an array of strings`);
426
+ }
427
+ }
428
+ // Dep reference check (warnings — topologicalLevels tolerates them).
429
+ for (const n of nodes) {
430
+ for (const d of n?.deps || []) {
431
+ if (!ids.has(d)) warnings.push(`node "${n.id}": dep "${d}" not found in this workflow (will be treated as satisfied)`);
432
+ }
433
+ }
434
+ // Topology / cycle check — only meaningful when shape passed.
435
+ let levels = null;
436
+ let maxParallelism = 0;
437
+ if (issues.length === 0) {
438
+ const { topologicalLevels } = await import('./workflow/executor.mjs');
439
+ const { levels: lvls, leftover } = topologicalLevels(nodes);
440
+ levels = lvls;
441
+ maxParallelism = lvls.reduce((m, l) => Math.max(m, l.length), 0);
442
+ if (leftover.length > 0) {
443
+ issues.push(`workflow has a cycle or unreachable nodes: ${leftover.join(', ')}`);
444
+ }
445
+ }
446
+ const ok = issues.length === 0;
447
+ console.log(JSON.stringify({
448
+ ok, file, nodeCount: nodes.length, issues, warnings,
449
+ levels, maxParallelism,
450
+ }, null, 2));
451
+ process.exit(ok ? 0 : 1);
452
+ }
453
+
454
+ // Emit a workflow's DAG as Mermaid syntax. Useful for docs, code
455
+ // review, and quick visual debugging — Mermaid renders inline in
456
+ // GitHub markdown, GitLab, Notion, Obsidian, and most modern note
457
+ // tools, so the output is paste-ready.
458
+ //
459
+ // Direction is top-down (`graph TD`) by default; --lr flag flips it
460
+ // to left-right which is more readable for wide DAGs.
461
+ //
462
+ // Output goes to stdout as plain text (the Mermaid block contents,
463
+ // no fenced ```mermaid wrapper). The user adds the fence when
464
+ // embedding so the same output works for the editors that DON'T
465
+ // render markdown.
466
+ //
467
+ // Each node id is sanitized to a Mermaid-safe identifier (letters,
468
+ // digits, underscores) for the LHS reference, with the original id
469
+ // in brackets as the visible label. So `fetch-data` becomes
470
+ // `fetch_data[fetch-data]` in the output — Mermaid's id rules are
471
+ // stricter than ours.
472
+ async function cmdGraph(file, opts = {}) {
473
+ if (!file) { console.error('Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]'); process.exit(2); }
474
+ let nodes;
475
+ try {
476
+ nodes = await importWorkflow(file);
477
+ } catch (e) {
478
+ console.error(`graph: ${e?.message || e}`);
479
+ process.exit(2);
480
+ }
481
+ // --state <session-id> overlays current run status onto each node
482
+ // (success/running/failed/pending). Without a state, every node
483
+ // gets a neutral declaration. With state, nodes are tagged with a
484
+ // CSS class via Mermaid's classDef + class syntax — paste-able
485
+ // straight into a render, and renders that don't support classDef
486
+ // (rare) just ignore the styling and show the raw graph.
487
+ let state = null;
488
+ if (opts.state) {
489
+ const dir = opts.dir || '.workflow-state';
490
+ const { loadState } = await loadEngine();
491
+ state = loadState(opts.state, dir);
492
+ if (!state) {
493
+ console.error(`graph: no state for session ${opts.state} in ${dir}`);
494
+ process.exit(2);
495
+ }
496
+ }
497
+ const direction = opts.lr ? 'LR' : 'TD';
498
+ const lines = [`graph ${direction}`];
499
+ // Mermaid node ids must match /[a-zA-Z][a-zA-Z0-9_]*/ — anything
500
+ // else needs the bracketed-label form. We always emit the bracket
501
+ // label so the visible text is the user's actual id (no ambiguity)
502
+ // while the LHS identifier is always Mermaid-safe.
503
+ const safeId = (id) => {
504
+ const s = String(id).replace(/[^a-zA-Z0-9_]/g, '_');
505
+ return /^[a-zA-Z]/.test(s) ? s : `n_${s}`;
506
+ };
507
+ // Per-status visual cues. Unicode glyph in the label + classDef
508
+ // class for color. The glyph alone works in plain markdown
509
+ // viewers; the classDef adds color for Mermaid renders.
510
+ const statusGlyph = {
511
+ success: ' ✓',
512
+ running: ' ⏳',
513
+ failed: ' ✗',
514
+ pending: '',
515
+ };
516
+ const declared = new Set();
517
+ const classedNodes = { success: [], running: [], failed: [], pending: [] };
518
+ const declare = (id) => {
519
+ if (declared.has(id)) return;
520
+ let label = id;
521
+ let cls = null;
522
+ if (state) {
523
+ const ns = state.nodes?.[id];
524
+ const st = ns?.status || 'pending';
525
+ label = id + (statusGlyph[st] || '');
526
+ cls = st;
527
+ classedNodes[st]?.push(safeId(id));
528
+ }
529
+ lines.push(` ${safeId(id)}[${label}]`);
530
+ declared.add(id);
531
+ };
532
+ for (const n of nodes) declare(n.id);
533
+ for (const n of nodes) {
534
+ for (const d of n.deps || []) {
535
+ // Edge: dep → node. Mermaid syntax `a --> b`.
536
+ lines.push(` ${safeId(d)} --> ${safeId(n.id)}`);
537
+ }
538
+ }
539
+ if (state) {
540
+ // GitHub's Mermaid theme renders these well in both light/dark
541
+ // mode. Operators rendering in their own theme can override.
542
+ lines.push(' classDef success fill:#9f6,stroke:#363,stroke-width:1px;');
543
+ lines.push(' classDef running fill:#fc6,stroke:#963,stroke-width:1px;');
544
+ lines.push(' classDef failed fill:#f66,stroke:#933,stroke-width:1px;');
545
+ lines.push(' classDef pending fill:#ddd,stroke:#666,stroke-width:1px;');
546
+ for (const [cls, ids] of Object.entries(classedNodes)) {
547
+ if (ids.length === 0) continue;
548
+ // `class id1,id2,id3 className` — Mermaid syntax for batch class assignment.
549
+ lines.push(` class ${ids.join(',')} ${cls};`);
550
+ }
551
+ }
552
+ console.log(lines.join('\n'));
553
+ process.exit(0);
554
+ }
555
+
556
+ async function cmdResume(sessionId, file, opts = {}) {
557
+ const { runPersistent, runPersistentDag, loadState } = await loadEngine();
558
+ const dir = opts.dir || '.workflow-state';
559
+ const prior = loadState(sessionId, dir);
560
+ if (!prior) {
561
+ console.error(`No state for session ${sessionId} in ${dir}`);
562
+ process.exit(2);
563
+ }
564
+ const nodes = await importWorkflow(file);
565
+ const sig = makeRunSignal();
566
+ try {
567
+ // --parallel-persistent picks the DAG engine. Sequential by default
568
+ // — same flag the run command uses, so the resume invocation
569
+ // mirrors the original run invocation. (We can't auto-detect the
570
+ // engine from the state file alone; both engines write the same
571
+ // shape. The user knows which mode they originally ran.)
572
+ if (opts['parallel-persistent']) {
573
+ const r = await runPersistentDag(nodes, {
574
+ sessionId, dir, timeoutMs: opts.timeoutMs,
575
+ signal: sig.signal, concurrency: opts.concurrency,
576
+ });
577
+ console.log(JSON.stringify({
578
+ success: r.success,
579
+ executedNodes: r.executedNodes || [],
580
+ failedAt: r.failedAt || null,
581
+ resumed: true,
582
+ mode: 'parallel-persistent',
583
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
584
+ error: r.error || null,
585
+ }));
586
+ process.exit(exitCodeFor(r, sig));
587
+ }
588
+ const r = await runPersistent(nodes, { sessionId, dir, maxRetries: opts.maxRetries ?? 3, signal: sig.signal });
589
+ console.log(JSON.stringify({
590
+ success: r.success,
591
+ executedNodes: r.executedNodes,
592
+ failedAt: r.failedAt,
593
+ resumed: true,
594
+ mode: 'sequential',
595
+ aborted: r.code === 'ABORT' || sig.wasAborted() || undefined,
596
+ }));
597
+ process.exit(exitCodeFor(r, sig));
598
+ } finally {
599
+ sig.dispose();
600
+ }
601
+ }
602
+
603
+ async function cmdConfigEdit() {
604
+ // Open config.json in $EDITOR (or sensible default), then validate
605
+ // the result before letting the user walk away believing the edit
606
+ // landed. A bad JSON syntax error here would silently break every
607
+ // future invocation, so we re-parse the file post-edit and refuse
608
+ // to leave it broken.
609
+ const p = configPath();
610
+ // Ensure the file exists with at least an empty object so $EDITOR
611
+ // doesn't open a blank scratch buffer the user accidentally saves
612
+ // as nothing.
613
+ fs.mkdirSync(path.dirname(p), { recursive: true });
614
+ if (!fs.existsSync(p)) fs.writeFileSync(p, '{}\n');
615
+ const editor = process.env.LAZYCLAW_EDITOR || process.env.VISUAL || process.env.EDITOR || 'vi';
616
+ const { spawn } = await import('node:child_process');
617
+ await new Promise((resolve, reject) => {
618
+ const child = spawn(editor, [p], { stdio: 'inherit' });
619
+ child.on('exit', code => {
620
+ if (code === 0) resolve();
621
+ else reject(new Error(`editor exited ${code}`));
622
+ });
623
+ child.on('error', reject);
624
+ });
625
+ // Validate the result. If JSON.parse throws, restore from a backup
626
+ // we made before the edit (the original content if the file existed,
627
+ // or an empty {} otherwise — the file always has SOME valid JSON).
628
+ try {
629
+ const txt = fs.readFileSync(p, 'utf8');
630
+ JSON.parse(txt);
631
+ console.log(JSON.stringify({ ok: true, path: p }));
632
+ } catch (e) {
633
+ console.error(`config: edit produced invalid JSON: ${e.message}`);
634
+ console.error(`Re-run \`lazyclaw config edit\` to fix; nothing else has been touched.`);
635
+ process.exit(1);
636
+ }
637
+ }
638
+
639
+ function cmdConfigSet(key, value) {
640
+ const cfg = readConfig();
641
+ cfg[key] = value;
642
+ writeConfig(cfg);
643
+ console.log(JSON.stringify({ ok: true, key, value }));
644
+ }
645
+
646
+ function applyOnboardConfig(currentCfg, flags) {
647
+ // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
648
+ // by splitting it, but explicit --provider always wins.
649
+ const { parseProviderModel } = require_registry_sync();
650
+ const next = { ...currentCfg };
651
+ if (flags.model) {
652
+ const parsed = parseProviderModel(flags.model);
653
+ if (parsed.provider && !flags.provider) next.provider = parsed.provider;
654
+ next.model = parsed.model || flags.model;
655
+ }
656
+ if (flags.provider) next.provider = flags.provider;
657
+ if (flags['api-key']) next['api-key'] = flags['api-key'];
658
+ return next;
659
+ }
660
+
661
+ // Module is ESM but we want a synchronous-looking helper for the CLI flow.
662
+ // Cache the import on first use so we don't pay for it on every config call.
663
+ let _registryMod = null;
664
+ function require_registry_sync() {
665
+ if (!_registryMod) {
666
+ // eslint-disable-next-line no-undef
667
+ throw new Error('registry module not pre-loaded — call ensureRegistry() first');
668
+ }
669
+ return _registryMod;
670
+ }
671
+ async function ensureRegistry() {
672
+ if (!_registryMod) _registryMod = await import('./providers/registry.mjs');
673
+ return _registryMod;
674
+ }
675
+
676
+ async function cmdOnboard(flags) {
677
+ await ensureRegistry();
678
+ if (!flags['non-interactive']) {
679
+ // Interactive onboarding is a single guided prompt sequence — kept tiny.
680
+ // For automation always use --non-interactive plus the value flags.
681
+ const readline = await import('node:readline');
682
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
683
+ const ask = q => new Promise(resolve => rl.question(q, resolve));
684
+ flags.provider = flags.provider || (await ask('provider [mock|anthropic]: ')).trim();
685
+ flags.model = flags.model || (await ask('model (or "anthropic/claude-opus-4-7"): ')).trim();
686
+ flags['api-key'] = flags['api-key'] || (await ask('api-key (leave blank for mock): ')).trim();
687
+ rl.close();
688
+ }
689
+ const next = applyOnboardConfig(readConfig(), flags);
690
+ if (!next.provider) { console.error('onboard: provider is required'); process.exit(2); }
691
+ writeConfig(next);
692
+ console.log(JSON.stringify({ ok: true, written: configPath(), provider: next.provider, model: next.model || null, hasApiKey: !!next['api-key'] }));
693
+ }
694
+
695
+ async function cmdDoctor() {
696
+ await ensureRegistry();
697
+ const cfg = readConfig();
698
+ const issues = [];
699
+ if (!cfg.provider) issues.push('config.provider is missing — run `lazyclaw onboard`');
700
+ if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
701
+ issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
702
+ }
703
+ if (cfg.provider && !PROVIDERS_HAS(_registryMod.PROVIDERS, cfg.provider)) {
704
+ issues.push(`unknown provider "${cfg.provider}" — registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')}`);
705
+ }
706
+ // Workflow state health — informational counters that show whether
707
+ // the user has any failed or stuck workflow runs to attend to. We
708
+ // don't push these to `issues` (a stuck workflow doesn't break the
709
+ // CLI) but they surface in the output so `lazyclaw doctor | jq` can
710
+ // surface them in dashboards.
711
+ const stateDir = process.env.LAZYCLAW_WORKFLOW_STATE_DIR || '.workflow-state';
712
+ let workflows = null;
713
+ try {
714
+ const { listSessions } = await import('./workflow/summary.mjs');
715
+ if (fs.existsSync(stateDir)) {
716
+ const sessions = listSessions(stateDir);
717
+ const counts = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
718
+ for (const s of sessions) {
719
+ if (s.summary.done) counts.done++;
720
+ if (s.summary.resumable) counts.resumable++;
721
+ if (s.summary.failed > 0) counts.failed++;
722
+ if (s.summary.running > 0) counts.running++;
723
+ }
724
+ workflows = { dir: stateDir, ...counts };
725
+ // Surface a hint when there are stuck runs that the engine will
726
+ // demote to pending on next load — this often signals a process
727
+ // that crashed; the user should at least know.
728
+ if (counts.running > 0) {
729
+ issues.push(`${counts.running} workflow session(s) have 'running' nodes from a prior interrupted run — they will be demoted to pending on next resume.`);
730
+ }
731
+ } else {
732
+ workflows = { dir: stateDir, present: false };
733
+ }
734
+ } catch (e) {
735
+ workflows = { dir: stateDir, error: e?.message || String(e) };
736
+ }
737
+ const ok = issues.length === 0;
738
+ const out = {
739
+ ok,
740
+ configPath: configPath(),
741
+ provider: cfg.provider || null,
742
+ model: cfg.model || null,
743
+ hasApiKey: !!cfg['api-key'],
744
+ nodeVersion: process.version,
745
+ platform: `${process.platform}-${process.arch}`,
746
+ issues,
747
+ knownProviders: Object.keys(_registryMod.PROVIDERS),
748
+ workflows,
749
+ timestamp: new Date().toISOString(),
750
+ };
751
+ console.log(JSON.stringify(out, null, 2));
752
+ process.exit(ok ? 0 : 1);
753
+ }
754
+
755
+ function PROVIDERS_HAS(map, name) {
756
+ return Object.prototype.hasOwnProperty.call(map, name);
757
+ }
758
+
759
+ async function cmdStatus() {
760
+ await ensureRegistry();
761
+ const cfg = readConfig();
762
+ const out = {
763
+ configPath: configPath(),
764
+ provider: cfg.provider || null,
765
+ model: cfg.model || null,
766
+ keyMasked: _registryMod.maskApiKey(cfg['api-key']),
767
+ };
768
+ console.log(JSON.stringify(out, null, 2));
769
+ }
770
+
771
+ const SLASH_COMMANDS = [
772
+ { cmd: '/help', help: 'list available slash commands' },
773
+ { cmd: '/status', help: 'print current provider, model, masked key' },
774
+ { cmd: '/new', help: 'clear conversation and start over' },
775
+ { cmd: '/reset', help: 'alias for /new' },
776
+ { cmd: '/usage', help: 'show message count + chars sent so far' },
777
+ { cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
778
+ { cmd: '/provider', help: 'switch provider: /provider openai (no arg → print current)' },
779
+ { cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
780
+ { cmd: '/exit', help: 'leave the chat' },
781
+ ];
782
+
783
+ function readVersionFromRepo() {
784
+ // Two source-of-truth lookups, in order:
785
+ // 1. The npm-published package's own package.json (sits next to
786
+ // cli.mjs once installed via `npm i -g lazyclaw`).
787
+ // 2. The monorepo's VERSION file at the repo root (one or two
788
+ // levels up depending on how the file is symlinked / copied).
789
+ // Either one wins on first hit. Falls back to '0.0.0' so the CLI
790
+ // never crashes on a stripped-down install.
791
+ const here = path.dirname(new URL(import.meta.url).pathname);
792
+ const candidates = [
793
+ { kind: 'pkg', path: path.resolve(here, './package.json') },
794
+ { kind: 'pkg', path: path.resolve(here, '../package.json') },
795
+ { kind: 'version', path: path.resolve(here, '../../VERSION') },
796
+ { kind: 'version', path: path.resolve(here, '../../../VERSION') },
797
+ ];
798
+ for (const c of candidates) {
799
+ try {
800
+ const raw = fs.readFileSync(c.path, 'utf8').trim();
801
+ if (!raw) continue;
802
+ if (c.kind === 'pkg') {
803
+ const v = JSON.parse(raw).version;
804
+ if (v) return v;
805
+ } else {
806
+ return raw;
807
+ }
808
+ } catch { /* keep trying */ }
809
+ }
810
+ return '0.0.0';
811
+ }
812
+
813
+ async function cmdVersion() {
814
+ const out = {
815
+ version: readVersionFromRepo(),
816
+ nodeVersion: process.version,
817
+ platform: `${process.platform}-${process.arch}`,
818
+ };
819
+ console.log(JSON.stringify(out));
820
+ }
821
+
822
+ // Subcommand inventory used by `lazyclaw completion`. Single source of
823
+ // truth so adding a subcommand updates the completion script too. The
824
+ // dispatcher in main() is the runtime authority; this list mirrors it.
825
+ const SUBCOMMANDS = [
826
+ 'run', 'resume', 'inspect', 'clear', 'validate', 'graph',
827
+ 'config', 'chat', 'agent',
828
+ 'doctor', 'status', 'onboard',
829
+ 'sessions', 'skills', 'providers',
830
+ 'daemon', 'version', 'completion', 'help',
831
+ 'export', 'import',
832
+ 'rates',
833
+ ];
834
+
835
+ const SUBCOMMAND_SUBS = {
836
+ config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
837
+ sessions: ['list', 'show', 'clear', 'export', 'search'],
838
+ skills: ['list', 'show', 'install', 'remove', 'search'],
839
+ providers: ['list', 'info', 'test'],
840
+ rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
841
+ completion: ['bash', 'zsh'],
842
+ };
843
+
844
+ function bashCompletion() {
845
+ // Standard bash COMPREPLY pattern. We split COMP_WORDS into:
846
+ // [0] = lazyclaw, [1] = subcommand, [2+] = subcommand args.
847
+ // Two-level completion: word index 1 → top subcommands; index 2 → the
848
+ // sub-subcommand list (if defined for that subcommand). Beyond index 2
849
+ // we don't try to enumerate dynamic items (session ids etc.) — that
850
+ // would require running the CLI on every <Tab>, which is too slow.
851
+ const subs = SUBCOMMANDS.join(' ');
852
+ const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
853
+ .map(([name, list]) => ` ${name})\n COMPREPLY=( $(compgen -W "${list.join(' ')}" -- "$cur") )\n ;;`)
854
+ .join('\n');
855
+ return `# lazyclaw bash completion. Source from your shell:
856
+ # eval "$(node /path/to/cli.mjs completion bash)"
857
+ _lazyclaw_completion() {
858
+ local cur prev words cword
859
+ _init_completion 2>/dev/null || {
860
+ cur="\${COMP_WORDS[COMP_CWORD]}"
861
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
862
+ cword=$COMP_CWORD
863
+ }
864
+ if [ "$cword" -eq 1 ]; then
865
+ COMPREPLY=( $(compgen -W "${subs}" -- "$cur") )
866
+ return 0
867
+ fi
868
+ if [ "$cword" -eq 2 ]; then
869
+ case "\${COMP_WORDS[1]}" in
870
+ ${subSubsCases}
871
+ esac
872
+ return 0
873
+ fi
874
+ return 0
875
+ }
876
+ complete -F _lazyclaw_completion lazyclaw
877
+ `;
878
+ }
879
+
880
+ function zshCompletion() {
881
+ // _arguments-style. We list subcommands then dispatch on the first
882
+ // positional via a single `_describe`. Sub-subcommands handled by a
883
+ // case inside the function. Same coverage rationale as bash.
884
+ const subs = SUBCOMMANDS.map(s => ` '${s}'`).join('\n');
885
+ const subSubsCases = Object.entries(SUBCOMMAND_SUBS)
886
+ .map(([name, list]) => ` (${name}) _values 'sub' ${list.map(v => `'${v}'`).join(' ')} ;;`)
887
+ .join('\n');
888
+ return `#compdef lazyclaw
889
+ # lazyclaw zsh completion. Add to fpath, or eval inline:
890
+ # eval "$(node /path/to/cli.mjs completion zsh)"
891
+ _lazyclaw() {
892
+ local subs=(
893
+ ${subs}
894
+ )
895
+ if (( CURRENT == 2 )); then
896
+ _values 'subcommand' \${subs[@]}
897
+ return
898
+ fi
899
+ if (( CURRENT == 3 )); then
900
+ case \${words[2]} in
901
+ ${subSubsCases}
902
+ esac
903
+ return
904
+ fi
905
+ }
906
+ compdef _lazyclaw lazyclaw
907
+ _lazyclaw "$@"
908
+ `;
909
+ }
910
+
911
+ async function cmdCompletion(shell) {
912
+ if (shell === 'bash') { process.stdout.write(bashCompletion()); return; }
913
+ if (shell === 'zsh') { process.stdout.write(zshCompletion()); return; }
914
+ console.error('Usage: lazyclaw completion <bash|zsh>');
915
+ process.exit(2);
916
+ }
917
+
918
+ const BUNDLE_VERSION = 1;
919
+
920
+ async function cmdExport(flags) {
921
+ // Portable bundle: config + every installed skill + (optionally) every
922
+ // persisted session. Writes JSON to stdout so the caller pipes it
923
+ // wherever they want — disk, scp, gist, encrypted vault.
924
+ //
925
+ // Secrets default to redacted because a bundle on a teammate's laptop
926
+ // shouldn't carry your API keys. --include-secrets flips that behavior
927
+ // for the use case of "back up MY laptop to MY external drive".
928
+ const skillsMod = await import('./skills.mjs');
929
+ const sessionsMod = await import('./sessions.mjs');
930
+ const cfgDir = path.dirname(configPath());
931
+ const cfg = readConfig();
932
+ const safeCfg = { ...cfg };
933
+ if (!flags['include-secrets']) {
934
+ if (safeCfg['api-key']) safeCfg['api-key'] = '***REDACTED***';
935
+ }
936
+ const skills = skillsMod.listSkills(cfgDir).map(s => ({
937
+ name: s.name,
938
+ content: skillsMod.loadSkill(s.name, cfgDir),
939
+ }));
940
+ const includeSessions = !!flags['include-sessions'];
941
+ const sessions = sessionsMod.listSessions(cfgDir).map(s => {
942
+ const base = { id: s.id, mtime: new Date(s.mtimeMs).toISOString(), bytes: s.bytes };
943
+ if (includeSessions) base.turns = sessionsMod.loadTurns(s.id, cfgDir);
944
+ return base;
945
+ });
946
+ const bundle = {
947
+ bundleVersion: BUNDLE_VERSION,
948
+ exportedAt: new Date().toISOString(),
949
+ config: safeCfg,
950
+ skills,
951
+ sessions,
952
+ secretsIncluded: !!flags['include-secrets'],
953
+ sessionContentIncluded: includeSessions,
954
+ };
955
+ process.stdout.write(JSON.stringify(bundle, null, 2) + '\n');
956
+ }
957
+
958
+ async function cmdImport(flags) {
959
+ // Read JSON bundle from stdin (or --from <path>). Apply with these rules:
960
+ // - config keys land via writeConfig; existing keys are overwritten
961
+ // UNLESS --no-overwrite-config is set.
962
+ // - skills land via installSkill; existing names are skipped UNLESS
963
+ // --overwrite-skills is set.
964
+ // - sessions land only when the bundle carried turn content AND
965
+ // --import-sessions is set; existing session files are NEVER
966
+ // overwritten (we don't want to clobber active conversations).
967
+ // - REDACTED api-key in the bundle is dropped (never written).
968
+ const skillsMod = await import('./skills.mjs');
969
+ const sessionsMod = await import('./sessions.mjs');
970
+ const cfgDir = path.dirname(configPath());
971
+ let raw;
972
+ if (flags.from) raw = fs.readFileSync(flags.from, 'utf8');
973
+ else {
974
+ raw = await new Promise(resolve => {
975
+ let buf = '';
976
+ process.stdin.setEncoding('utf8');
977
+ process.stdin.on('data', d => { buf += d; });
978
+ process.stdin.on('end', () => resolve(buf));
979
+ });
980
+ }
981
+ let bundle;
982
+ try { bundle = JSON.parse(raw); }
983
+ catch (e) { console.error(`import: invalid JSON: ${e.message}`); process.exit(2); }
984
+ if (!bundle || typeof bundle !== 'object' || bundle.bundleVersion !== BUNDLE_VERSION) {
985
+ console.error(`import: unsupported bundleVersion (got ${bundle?.bundleVersion}, expected ${BUNDLE_VERSION})`);
986
+ process.exit(2);
987
+ }
988
+ const stats = { configKeys: 0, skillsAdded: 0, skillsSkipped: 0, sessionsAdded: 0, sessionsSkipped: 0 };
989
+ // Config
990
+ if (bundle.config && typeof bundle.config === 'object') {
991
+ const existing = readConfig();
992
+ const next = flags['no-overwrite-config']
993
+ ? { ...bundle.config, ...existing } // existing wins
994
+ : { ...existing, ...bundle.config }; // bundle wins (default)
995
+ // Drop redacted secrets so we never write the placeholder string.
996
+ if (next['api-key'] === '***REDACTED***') delete next['api-key'];
997
+ writeConfig(next);
998
+ stats.configKeys = Object.keys(bundle.config).length;
999
+ }
1000
+ // Skills
1001
+ for (const s of bundle.skills || []) {
1002
+ if (!s?.name || typeof s.content !== 'string') continue;
1003
+ const file = skillsMod.skillPath(s.name, cfgDir);
1004
+ if (fs.existsSync(file) && !flags['overwrite-skills']) {
1005
+ stats.skillsSkipped += 1;
1006
+ continue;
1007
+ }
1008
+ skillsMod.installSkill(s.name, s.content, cfgDir);
1009
+ stats.skillsAdded += 1;
1010
+ }
1011
+ // Sessions — never overwrite, only add new
1012
+ if (flags['import-sessions']) {
1013
+ for (const sess of bundle.sessions || []) {
1014
+ if (!sess?.id || !Array.isArray(sess.turns)) continue;
1015
+ try {
1016
+ const file = sessionsMod.sessionPath(sess.id, cfgDir);
1017
+ if (fs.existsSync(file)) { stats.sessionsSkipped += 1; continue; }
1018
+ for (const t of sess.turns) {
1019
+ if (t?.role && typeof t.content === 'string') {
1020
+ sessionsMod.appendTurn(sess.id, t.role, t.content, cfgDir);
1021
+ }
1022
+ }
1023
+ stats.sessionsAdded += 1;
1024
+ } catch { stats.sessionsSkipped += 1; }
1025
+ }
1026
+ }
1027
+ console.log(JSON.stringify({ ok: true, ...stats }));
1028
+ }
1029
+
1030
+ // One-line summaries used by `lazyclaw help`. Format keeps it scan-friendly
1031
+ // in a 80-column terminal: subcommand padded to 12 chars, then the summary.
1032
+ const HELP_SUMMARIES = {
1033
+ run: 'Execute a workflow file (run <session-id> <workflow.mjs>)',
1034
+ resume: 'Resume a workflow from its last persisted checkpoint',
1035
+ config: 'Manage local config (get|set|list|delete <key>)',
1036
+ chat: 'Interactive REPL with the configured provider',
1037
+ agent: 'One-shot prompt: streams a single response, exits',
1038
+ doctor: 'Print diagnostic JSON; exits non-zero on issues',
1039
+ status: 'Print current provider/model/masked key as JSON',
1040
+ onboard: 'Guided setup (use --non-interactive for scripts)',
1041
+ sessions: 'Persistent chat sessions (list|show|clear|export)',
1042
+ skills: 'Markdown skill bundles (list|show|install|remove)',
1043
+ providers: 'Inspect registered providers (list|info <name>)',
1044
+ daemon: 'Run the local HTTP gateway (--port, --auth-token, --allow-origin)',
1045
+ version: 'Print VERSION + node + platform as JSON',
1046
+ completion: 'Emit shell completion script (completion <bash|zsh>)',
1047
+ export: 'Dump config + skills (+ optional sessions) as a JSON bundle',
1048
+ import: 'Apply a JSON bundle from stdin or --from <path>',
1049
+ rates: 'Manage cost rate-cards in config (rates list|set <provider/model>|delete|shape)',
1050
+ inspect: 'Print persisted workflow state without executing',
1051
+ clear: 'Delete a persisted workflow state file (idempotent)',
1052
+ validate: 'Static-check a workflow file: shape, deps, cycles, parallelism',
1053
+ graph: 'Emit workflow DAG as Mermaid syntax (paste-ready for docs)',
1054
+ };
1055
+
1056
+ // Detailed usage per subcommand for `lazyclaw help <name>`. Kept as flat
1057
+ // strings so the help output is identical in every terminal.
1058
+ const HELP_DETAILS = {
1059
+ run: 'Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]\n Default: runPersistent — sequential, persists state, resumable via `lazyclaw resume`.\n --parallel: runParallel — topological-level DAG, in-memory only, NOT resumable.\n --parallel-persistent: runPersistentDag — DAG + checkpoint + resume.\n --concurrency <N>: cap in-flight nodes within a level (DAG modes only). 0/missing → unbounded.\n Workflow file exports `nodes`; deps: string[] declares dependencies for both DAG modes.',
1060
+ resume: 'Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]\n Re-enters a previously persisted run; succeeds nodes are skipped.\n Pass --parallel-persistent to resume a DAG run (must match the original run\'s mode).\n --concurrency <N>: cap in-flight nodes per level (DAG mode only).',
1061
+ inspect: 'Usage: lazyclaw inspect [<session-id>] [--dir <state-dir>] [--status done|resumable|failed|running] [--summary] [--filter <substr>] [--limit <N>] [--node <node-id>] [--slowest <N>] [--critical-path <workflow.mjs>] [--aggregate]\n With no session-id: list every persisted session in the state dir, sorted by recency.\n --aggregate (list mode): per-node stats across all sessions (count, success/failed/pending/running, min/max/avg/total duration).\n --status filters the listing to a single lifecycle bucket.\n --filter / --limit refine list-mode further (case-insensitive sessionId substring + post-filter cap).\n --summary trims per-node detail in single-session mode (matches list-mode shape).\n --node <id>: print just that node\'s state. Exit 0 success/pending/running, 1 failed, 2 no such node.\n --slowest <N>: top N nodes by durationMs (descending, ties broken by id).\n --critical-path <workflow.mjs>: longest-weighted-path analysis using each node\'s recorded durationMs (bottleneck finder).\n With a session-id (no per-node flag): print full state. Exit code: 0=resumable, 1=fully done, 2=no state, 3=terminal failure.',
1062
+ clear: 'Usage: lazyclaw clear <session-id> [--dir <state-dir>]\n Delete the state file for <session-id>. Idempotent — exits 0 whether the file existed or not.\n Refuses sessionIds that resolve outside <state-dir>. Mirrors DELETE /workflows/<id> on the daemon.',
1063
+ validate: 'Usage: lazyclaw validate <workflow.mjs>\n Static check: load + shape + dep + cycle + parallelism estimate.\n Exit 0 valid · 1 hard failure (issues populated) · 2 file/import error.',
1064
+ graph: 'Usage: lazyclaw graph <workflow.mjs> [--lr] [--state <session-id>] [--dir <state-dir>]\n Emit the workflow DAG as Mermaid syntax (graph TD by default; --lr for left-right).\n --state overlays a persisted run\'s status (success ✓ / running ⏳ / failed ✗ / pending) with classDef styling.\n Output is paste-ready for GitHub markdown / Notion / Obsidian.',
1065
+ config: 'Usage: lazyclaw config <get|set|list|delete|path|edit|validate> [key] [value]\n Local key-value config at $LAZYCLAW_CONFIG_DIR/config.json (default ~/.lazyclaw).\n `path` prints the file location; `edit` opens it in $EDITOR (or $LAZYCLAW_EDITOR / $VISUAL / vi) and validates JSON on save.\n `validate` checks the structural integrity of the whole config file (typed values, known providers, rate-card shape).',
1066
+ chat: 'Usage: lazyclaw chat [--session <id>] [--skill name1,name2] [--pick]\n --session persists turns to <configDir>/sessions/<id>.jsonl across invocations.\n --skill composes named skills into a system message at the head of the conversation.\n --pick opens an interactive provider/model picker before the prompt (also auto-fires on first run).',
1067
+ agent: 'Usage: lazyclaw agent <prompt|-> [--provider X] [--model Y] [--skill list] [--thinking N] [--show-thinking] [--usage] [--cost]\n One-shot non-interactive call. Pass "-" as the prompt to read from stdin.\n --usage prints normalized {inputTokens, outputTokens, ...} to stderr after the response.\n --cost adds a cost line on stderr when config.rates has a card for the active provider/model.',
1068
+ doctor: 'Usage: lazyclaw doctor\n Validates configuration and registered providers. Exits 0 only when no issues.',
1069
+ status: 'Usage: lazyclaw status\n Provider, model, and masked API key. Never prints the raw key.',
1070
+ onboard: 'Usage: lazyclaw onboard [--non-interactive] [--provider X] [--model Y] [--api-key Z]\n --model accepts the unified "provider/model" string (e.g. anthropic/claude-opus-4-7).',
1071
+ sessions: 'Usage: lazyclaw sessions <list [--filter <substr>] [--limit <N>]|show <id>|clear <id>|export <id> [--format md|json|text]|search <query> [--regex]>\n list — recent sessions by mtime; --filter caps to ids containing substring (case-insensitive); --limit caps result count.\n export — render in chosen format (md default for human sharing, json for tooling, text for paste).\n search — case-insensitive substring (or --regex pattern) match across all session content; returns first excerpt + match count per matching session.',
1072
+ skills: 'Usage: lazyclaw skills <list [--filter <substr>] [--limit <N>]|show <name>|install <name> [--from <path> | --from-url <https://...>]|remove <name>|search <query> [--regex]>\n list — installed skills; --filter caps to names containing substring (case-insensitive); --limit caps result count.\n --from-url fetches over HTTPS only; 1 MiB body cap.\n search — case-insensitive substring (or --regex) match across all skill markdown bodies; returns first excerpt + match count per skill.',
1073
+ providers: 'Usage: lazyclaw providers <list [--filter <substr>] [--limit <N>] | info <name> | test <name> [--model X] [--prompt T] | test [--all] [--prompt T]>\n list — registered providers (--filter case-insensitive name substring; --limit caps post-filter count).\n info — static metadata: requiresApiKey, defaultModel, suggestedModels, endpoint.\n test — send a 1-token "ping" through the provider and report ok/error + duration.\n Useful after configuring an API key to verify it works before relying on it.\n No name OR --all: tests every registered provider in parallel; exits 0 only when ALL pass.',
1074
+ daemon: 'Usage: lazyclaw daemon [--port <N>] [--once] [--auth-token <token>] [--allow-origin <origin>] [--rate-limit <N>] [--response-cache] [--log <level>] [--shutdown-timeout-ms <N>] [--cost-cap-<currency> <N> ...] [--workflow-state-dir <dir>]\n Always binds 127.0.0.1. --port 0 picks a random port and prints the URL.\n --auth-token also reads $LAZYCLAW_AUTH_TOKEN; --allow-origin also reads $LAZYCLAW_ALLOW_ORIGINS.\n --rate-limit <N> caps each remote IP at N requests / 60 s.\n --response-cache enables process-scoped memoization; per-request opt-in via body.cache.\n --log <debug|info|warn|error> emits JSON-line access logs on stderr (also reads $LAZYCLAW_LOG_LEVEL).\n --shutdown-timeout-ms <N> caps graceful drain on SIGINT/SIGTERM (default 10000). Second signal forces immediate exit.\n --cost-cap-usd 100 (or any currency code in lowercase) rejects POST /agent + /chat with 402 once cumulative cost reaches the cap.\n --workflow-state-dir <dir> backs GET /workflows + GET /workflows/<id> (default .workflow-state, also reads $LAZYCLAW_WORKFLOW_STATE_DIR).',
1075
+ version: 'Usage: lazyclaw version\n Aliases: --version, -v.',
1076
+ completion: 'Usage: lazyclaw completion <bash|zsh>\n bash: eval "$(lazyclaw completion bash)"\n zsh: lazyclaw completion zsh > "${fpath[1]}/_lazyclaw"',
1077
+ export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
1078
+ import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
1079
+ rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
1080
+ };
1081
+
1082
+ function cmdHelp(name) {
1083
+ if (!name) {
1084
+ process.stdout.write('lazyclaw — terminal AI assistant + workflow engine\n\n');
1085
+ process.stdout.write('Subcommands:\n');
1086
+ for (const sub of SUBCOMMANDS) {
1087
+ const summary = HELP_SUMMARIES[sub] || '';
1088
+ process.stdout.write(` ${sub.padEnd(12)}${summary}\n`);
1089
+ }
1090
+ process.stdout.write('\nlazyclaw help <subcommand> detailed usage\n');
1091
+ return;
1092
+ }
1093
+ const detail = HELP_DETAILS[name];
1094
+ if (!detail) {
1095
+ process.stderr.write(`unknown subcommand: ${name}\n`);
1096
+ process.stderr.write(`run \`lazyclaw help\` to see the list\n`);
1097
+ process.exit(2);
1098
+ }
1099
+ process.stdout.write(detail + '\n');
1100
+ }
1101
+
1102
+ async function cmdAgent(prompt, flags) {
1103
+ // OpenClaw-style one-shot: send a single prompt, stream the response,
1104
+ // exit. Useful in scripts and pipelines. Honors --provider and --model
1105
+ // flags as overrides over config.json. Reads stdin when prompt is "-"
1106
+ // so callers can pipe input.
1107
+ await ensureRegistry();
1108
+ const skillsMod = await import('./skills.mjs');
1109
+ const cfg = readConfig();
1110
+ const provName = flags.provider || cfg.provider || 'mock';
1111
+ let prov = _registryMod.PROVIDERS[provName];
1112
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
1113
+ // --fallback "openai,ollama" wraps the primary in a withFallback chain so
1114
+ // RATE_LIMIT/CONNECTION_REFUSED/5xx on the primary trips through to the
1115
+ // listed providers in order. Unknown names exit 2 — better than a silent
1116
+ // skip, the chain lengths matter for user expectations.
1117
+ const fallbackList = (flags.fallback ? String(flags.fallback) : '')
1118
+ .split(',').map(s => s.trim()).filter(Boolean);
1119
+ if (fallbackList.length > 0) {
1120
+ const chain = [prov];
1121
+ for (const fb of fallbackList) {
1122
+ const fp = _registryMod.PROVIDERS[fb];
1123
+ if (!fp) { console.error(`unknown fallback provider: ${fb}`); process.exit(2); }
1124
+ chain.push(fp);
1125
+ }
1126
+ const { withFallback } = await import('./providers/fallback.mjs');
1127
+ prov = withFallback(chain);
1128
+ }
1129
+ // --retry N wraps the chosen provider with the rate-limit-aware retry
1130
+ // helper. N is exclusive of the initial call (--retry 3 = up to 4 tries).
1131
+ // Default 0 keeps behavior identical to before for callers that don't
1132
+ // explicitly opt in.
1133
+ const retryN = flags.retry !== undefined ? parseInt(flags.retry, 10) : 0;
1134
+ if (Number.isFinite(retryN) && retryN > 0) {
1135
+ const { withRateLimitRetry } = await import('./providers/retry.mjs');
1136
+ prov = withRateLimitRetry(prov, { attempts: retryN });
1137
+ }
1138
+
1139
+ // --skill resolves a comma-separated list to a composed system prompt.
1140
+ // Defaults from config.skills (same shape) if --skill not passed.
1141
+ const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
1142
+ .split(',').map(s => s.trim()).filter(Boolean);
1143
+ let systemPrompt = null;
1144
+ if (skillNames.length > 0) {
1145
+ try { systemPrompt = skillsMod.composeSystemPrompt(skillNames, path.dirname(configPath())); }
1146
+ catch (e) { console.error(`skill error: ${e.message}`); process.exit(2); }
1147
+ }
1148
+
1149
+ let text = prompt;
1150
+ if (text === '-' || text === undefined) {
1151
+ text = await new Promise(resolve => {
1152
+ let buf = '';
1153
+ process.stdin.setEncoding('utf8');
1154
+ process.stdin.on('data', d => { buf += d; });
1155
+ process.stdin.on('end', () => resolve(buf));
1156
+ });
1157
+ }
1158
+ if (!text || !String(text).trim()) {
1159
+ console.error('agent: empty prompt'); process.exit(2);
1160
+ }
1161
+ const messages = [];
1162
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
1163
+ messages.push({ role: 'user', content: String(text) });
1164
+ // --thinking <budgetTokens> enables Anthropic extended thinking. Other
1165
+ // providers ignore the flag silently because their opts shape doesn't
1166
+ // carry it.
1167
+ const thinkingBudget = flags.thinking ? parseInt(flags.thinking, 10) : 0;
1168
+ // --show-thinking prints thinking deltas to stderr while text deltas
1169
+ // continue to stream to stdout. This keeps stdout clean for piping.
1170
+ const showThinking = flags['show-thinking'];
1171
+ // --usage prints normalized token totals to stderr after the response
1172
+ // streams. --cost adds a cost line when cfg.rates has a card matching
1173
+ // the active provider/model. Both write to stderr so piping the answer
1174
+ // text downstream isn't polluted with metadata.
1175
+ const showUsage = flags.usage;
1176
+ const showCost = flags.cost;
1177
+ // Loading rates is lazy: only when --cost is on, and we resolve once
1178
+ // up-front so the onUsage callback below doesn't need to import on a
1179
+ // hot path.
1180
+ let costFromUsage = null;
1181
+ if (showCost) {
1182
+ const ratesMod = await import('./providers/rates.mjs');
1183
+ costFromUsage = ratesMod.costFromUsage;
1184
+ }
1185
+ try {
1186
+ for await (const chunk of prov.sendMessage(messages, {
1187
+ apiKey: cfg['api-key'],
1188
+ model: flags.model || cfg.model,
1189
+ thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
1190
+ onThinking: showThinking ? t => process.stderr.write(t) : undefined,
1191
+ onUsage: (showUsage || showCost) ? (u) => {
1192
+ if (showUsage) process.stderr.write('usage: ' + JSON.stringify(u) + '\n');
1193
+ if (showCost && cfg.rates) {
1194
+ const c = costFromUsage(
1195
+ { provider: flags.provider || cfg.provider, model: flags.model || cfg.model, usage: u },
1196
+ cfg.rates,
1197
+ );
1198
+ if (c) process.stderr.write('cost: ' + JSON.stringify(c) + '\n');
1199
+ }
1200
+ } : undefined,
1201
+ })) {
1202
+ process.stdout.write(chunk);
1203
+ }
1204
+ process.stdout.write('\n');
1205
+ } catch (err) {
1206
+ process.stderr.write(`error: ${err?.message || String(err)}\n`);
1207
+ process.exit(1);
1208
+ }
1209
+ }
1210
+
1211
+ // Cursor-style ghost autocomplete for the chat prompt. When the
1212
+ // current readline buffer starts with `/` and prefix-matches a known
1213
+ // slash command, the rest of the command is rendered in dim grey
1214
+ // after the cursor. Right-arrow at end-of-line accepts the suggestion
1215
+ // (replaces rl.line with the full command). Tab still goes through
1216
+ // readline's tab-completer for cycling.
1217
+ function _attachGhostAutocomplete(rl) {
1218
+ if (!process.stdout.isTTY) return;
1219
+ const cmds = SLASH_COMMANDS.map((c) => c.cmd);
1220
+ let lastGhost = '';
1221
+ // Find the longest match for the current input. Returns '' when
1222
+ // nothing matches or when the input already equals a command.
1223
+ const findMatch = () => {
1224
+ const buf = rl.line || '';
1225
+ if (!buf.startsWith('/')) return '';
1226
+ const exact = cmds.find((c) => c === buf);
1227
+ if (exact) return '';
1228
+ const hits = cmds.filter((c) => c.startsWith(buf) && c.length > buf.length);
1229
+ if (!hits.length) return '';
1230
+ return hits[0]; // first match is the shortest matching command
1231
+ };
1232
+ // Render the ghost after the user's cursor. We use ANSI save/restore
1233
+ // (\x1b[s / \x1b[u) so writing the suggestion doesn't move readline's
1234
+ // notion of where the cursor is; we just paint the dim text and snap
1235
+ // back. \x1b[K clears any leftover ghost from the previous keystroke.
1236
+ const render = () => {
1237
+ if (!process.stdout.isTTY) return;
1238
+ const match = findMatch();
1239
+ const buf = rl.line || '';
1240
+ // Always clear leftover ghost first.
1241
+ process.stdout.write('\x1b[s\x1b[K');
1242
+ if (match && match.length > buf.length) {
1243
+ const tail = match.slice(buf.length);
1244
+ process.stdout.write(`\x1b[2m${tail}\x1b[0m`);
1245
+ lastGhost = match;
1246
+ } else {
1247
+ lastGhost = '';
1248
+ }
1249
+ process.stdout.write('\x1b[u');
1250
+ };
1251
+ // Intercept Right-arrow at end-of-line to accept the suggestion.
1252
+ // We attach as a prependListener so we run before readline's own
1253
+ // handler — when we accept, we mutate rl.line ourselves and call
1254
+ // _refreshLine, then return without forwarding the keypress.
1255
+ const onKeypress = (_str, key) => {
1256
+ if (!key) return;
1257
+ if (key.name === 'right' && lastGhost && rl.line === rl.line.trim() &&
1258
+ rl.cursor === (rl.line || '').length && (rl.line || '').length < lastGhost.length) {
1259
+ const accepted = lastGhost;
1260
+ // Clear the dim ghost before redrawing the line (otherwise the
1261
+ // residue overlaps the new line content).
1262
+ process.stdout.write('\x1b[s\x1b[K\x1b[u');
1263
+ rl.line = accepted;
1264
+ rl.cursor = accepted.length;
1265
+ // _refreshLine is private but stable across Node 18+ readline
1266
+ // implementations. Falls back to manual redraw if it ever changes.
1267
+ if (typeof rl._refreshLine === 'function') rl._refreshLine();
1268
+ else { process.stdout.write('\r\x1b[K' + (rl._prompt || '') + accepted); }
1269
+ lastGhost = '';
1270
+ return;
1271
+ }
1272
+ // For any other key, schedule the ghost re-render after readline
1273
+ // has updated rl.line. setImmediate runs after readline's keypress
1274
+ // handler completes.
1275
+ setImmediate(render);
1276
+ };
1277
+ process.stdin.on('keypress', onKeypress);
1278
+ // Clear ghost on each new prompt so a stale dim hint doesn't carry
1279
+ // over between turns.
1280
+ rl.on('line', () => { lastGhost = ''; });
1281
+ }
1282
+
1283
+ // LazyClaw banner — printed once at the top of every interactive chat
1284
+ // session so users see the active provider/model before they start
1285
+ // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
1286
+ // invocations stay clean for tests/scripts).
1287
+ function _printChatBanner(activeProvName, activeModel, version) {
1288
+ if (!process.stdout.isTTY) return;
1289
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1290
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1291
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1292
+ const lines = [
1293
+ '',
1294
+ accent(' ╭──────────────────────────────╮'),
1295
+ accent(' │ _ │'),
1296
+ accent(' │ | |__ _ _____ _ _ │'),
1297
+ accent(' │ | / _` |_ / || | \'_| │'),
1298
+ accent(' │ |_\\__,_/__\\_, |_| │'),
1299
+ accent(' │ LazyClaw |__/ ' + (version || '').padEnd(10) + ' │'),
1300
+ accent(' ╰──────────────────────────────╯'),
1301
+ '',
1302
+ ` ${dim('provider ·')} ${ok(activeProvName)}`,
1303
+ ` ${dim('model ·')} ${ok(activeModel || '(default)')}`,
1304
+ ` ${dim('slash ·')} /help · /model · /provider · /exit`,
1305
+ ` ${dim('hint ·')} → ${dim('to accept the suggested command,')} Tab ${dim('to cycle')}`,
1306
+ '',
1307
+ ];
1308
+ process.stdout.write(lines.join('\n') + '\n');
1309
+ }
1310
+
1311
+ // Interactive provider/model picker. Used on first run (no config) or
1312
+ // when the user passes --pick. Falls back to plain stdin reads when
1313
+ // stdout isn't a TTY (CI/script callers should pass --non-interactive
1314
+ // equivalents instead).
1315
+ async function _pickProviderInteractive() {
1316
+ const providers = Object.keys(_registryMod.PROVIDERS);
1317
+ if (!providers.length) return { provider: 'mock', model: null };
1318
+ // Build the picker list: one row per provider, models surfaced inline
1319
+ // when the provider exposes them via a `models` array (anthropic/openai/
1320
+ // gemini/ollama do; mock doesn't).
1321
+ const items = [];
1322
+ for (const name of providers) {
1323
+ const p = _registryMod.PROVIDERS[name];
1324
+ const ms = (p && Array.isArray(p.models) && p.models.length) ? p.models : [null];
1325
+ for (const m of ms) {
1326
+ items.push({ provider: name, model: m, label: m ? `${name} ${m}` : name });
1327
+ }
1328
+ }
1329
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
1330
+ // Fall back to a single prompt — the picker UI is purely cosmetic.
1331
+ process.stdout.write(`provider [${providers.join('|')}]: `);
1332
+ const ans = await new Promise((resolve) => {
1333
+ let buf = '';
1334
+ const onData = (chunk) => {
1335
+ buf += chunk.toString();
1336
+ if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
1337
+ };
1338
+ process.stdin.on('data', onData);
1339
+ });
1340
+ return { provider: ans || providers[0], model: null };
1341
+ }
1342
+ const readline = await import('node:readline');
1343
+ readline.emitKeypressEvents(process.stdin);
1344
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
1345
+ let idx = 0;
1346
+ const draw = () => {
1347
+ // Move to top of picker block, redraw rows, leave cursor at the bottom.
1348
+ process.stdout.write('\x1b[?25l'); // hide cursor
1349
+ process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1350
+ process.stdout.write('\x1b[38;5;208mLazyClaw — pick a provider/model\x1b[0m\n');
1351
+ process.stdout.write('\x1b[2m↑/↓ to move · Enter to confirm · q to quit\x1b[0m\n\n');
1352
+ items.forEach((it, i) => {
1353
+ const marker = i === idx ? '\x1b[38;5;208m❯\x1b[0m ' : ' ';
1354
+ const text = i === idx ? `\x1b[1m${it.label}\x1b[0m` : it.label;
1355
+ process.stdout.write(`${marker}${text}\n`);
1356
+ });
1357
+ };
1358
+ draw();
1359
+ return await new Promise((resolve) => {
1360
+ const onKey = (_str, key) => {
1361
+ if (!key) return;
1362
+ if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
1363
+ else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
1364
+ else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
1365
+ else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
1366
+ else if (key.name === 'q' || key.name === 'escape') { cleanup(); resolve(items[idx]); }
1367
+ };
1368
+ const cleanup = () => {
1369
+ process.stdin.off('keypress', onKey);
1370
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
1371
+ process.stdout.write('\x1b[?25h'); // show cursor
1372
+ process.stdout.write('\x1b[2J\x1b[H');
1373
+ };
1374
+ process.stdin.on('keypress', onKey);
1375
+ });
1376
+ }
1377
+
1378
+ async function cmdChat(flags = {}) {
1379
+ await ensureRegistry();
1380
+ const sessionsMod = await import('./sessions.mjs');
1381
+ const skillsMod = await import('./skills.mjs');
1382
+ const cfg = readConfig();
1383
+ // Mutable in-REPL state: /provider and /model edit these without
1384
+ // touching config.json on disk. The CLI flag form (`chat --provider X`)
1385
+ // would normally seed these via cfg, but we leave that to a future
1386
+ // iteration; today the slash commands work against the on-disk default.
1387
+ let activeProvName = cfg.provider || '';
1388
+ let activeModel = cfg.model || null;
1389
+ const lookupProv = (name) => _registryMod.PROVIDERS[name];
1390
+ // Interactive picker fires when --pick is set OR no provider is
1391
+ // configured yet (first run). Skipped when stdin isn't a TTY so
1392
+ // automation stays predictable.
1393
+ const shouldPick = (!!flags.pick) || (!activeProvName && process.stdin.isTTY);
1394
+ if (shouldPick) {
1395
+ const picked = await _pickProviderInteractive();
1396
+ if (picked && picked.provider) {
1397
+ activeProvName = picked.provider;
1398
+ if (picked.model) activeModel = picked.model;
1399
+ }
1400
+ }
1401
+ if (!activeProvName) activeProvName = 'mock';
1402
+ let prov = lookupProv(activeProvName);
1403
+ if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
1404
+
1405
+ // Top-of-session banner so the user can see at a glance what they're
1406
+ // talking to. Cheap (no provider call) and TTY-only.
1407
+ _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
1408
+
1409
+ const readline = await import('node:readline');
1410
+ // Use terminal:true when we're attached to a TTY so the prompt shows
1411
+ // and ghost-text autocomplete (below) can render. Falls back to the
1412
+ // plain non-terminal mode for piped/non-TTY callers.
1413
+ const useTerminal = !!process.stdin.isTTY;
1414
+ const rl = readline.createInterface({
1415
+ input: process.stdin,
1416
+ output: useTerminal ? process.stdout : undefined,
1417
+ terminal: useTerminal,
1418
+ prompt: useTerminal ? '\x1b[38;5;208m›\x1b[0m ' : '',
1419
+ });
1420
+ if (useTerminal) {
1421
+ // Cursor-style ghost autocomplete: when the buffer starts with `/`,
1422
+ // render the longest matching command after the cursor in dim grey.
1423
+ // Right-arrow at end-of-line accepts. Tab still cycles via the
1424
+ // existing handleSlash branch; this only adds the inline preview.
1425
+ _attachGhostAutocomplete(rl);
1426
+ rl.prompt();
1427
+ }
1428
+
1429
+ // Persistent session ID. When --session is set we hydrate prior turns from
1430
+ // <configDir>/sessions/<id>.jsonl and append every new turn back to it.
1431
+ // Without --session, chat is in-memory only (matches phase 4 behavior).
1432
+ const sessionId = flags.session || null;
1433
+ const cfgDir = path.dirname(configPath());
1434
+ let messages = sessionId
1435
+ ? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
1436
+ : [];
1437
+
1438
+ // --skill (comma-separated names) composes into a system message at the
1439
+ // head of the conversation. Same shape as `agent --skill`. Defaults from
1440
+ // config.skills array when --skill not passed. We only inject if no
1441
+ // system message is already present (so resuming a session doesn't
1442
+ // double-prepend skills that the prior invocation already added).
1443
+ const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
1444
+ .split(',').map(s => s.trim()).filter(Boolean);
1445
+ if (skillNames.length > 0 && !messages.some(m => m.role === 'system')) {
1446
+ try {
1447
+ const sys = skillsMod.composeSystemPrompt(skillNames, cfgDir);
1448
+ if (sys) {
1449
+ messages.unshift({ role: 'system', content: sys });
1450
+ if (sessionId) sessionsMod.appendTurn(sessionId, 'system', sys, cfgDir);
1451
+ }
1452
+ } catch (e) {
1453
+ console.error(`skill error: ${e.message}`);
1454
+ process.exit(2);
1455
+ }
1456
+ }
1457
+
1458
+ let charsSent = messages.reduce((n, m) => n + (m.role === 'user' ? String(m.content || '').length : 0), 0);
1459
+ if (sessionId && messages.length > (skillNames.length > 0 ? 1 : 0)) {
1460
+ process.stdout.write(`resumed session ${sessionId} with ${messages.length} prior turn(s)\n`);
1461
+ }
1462
+ // Running usage accumulator. /usage reports both the cheap local
1463
+ // estimate (messageCount + charsSent) AND the provider-reported
1464
+ // totals when the provider emits them on each turn. Mock provider
1465
+ // doesn't emit usage, so usage stays null there — no surprise.
1466
+ /** @type {{ inputTokens: number, outputTokens: number, totalTokens: number, turnsWithUsage: number } | null} */
1467
+ let runningUsage = null;
1468
+ const accumulateUsage = (u) => {
1469
+ if (!u) return;
1470
+ if (!runningUsage) runningUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, turnsWithUsage: 0 };
1471
+ runningUsage.inputTokens += Number(u.inputTokens) || 0;
1472
+ runningUsage.outputTokens += Number(u.outputTokens) || 0;
1473
+ runningUsage.totalTokens += Number(u.totalTokens) || ((Number(u.inputTokens) || 0) + (Number(u.outputTokens) || 0));
1474
+ runningUsage.turnsWithUsage += 1;
1475
+ };
1476
+ const persistTurn = (role, content) => {
1477
+ if (!sessionId) return;
1478
+ sessionsMod.appendTurn(sessionId, role, content, cfgDir);
1479
+ };
1480
+
1481
+ const handleSlash = async (line) => {
1482
+ const cmd = line.split(/\s+/)[0];
1483
+ switch (cmd) {
1484
+ case '/help': {
1485
+ process.stdout.write('slash commands:\n');
1486
+ for (const c of SLASH_COMMANDS) process.stdout.write(` ${c.cmd.padEnd(8)} — ${c.help}\n`);
1487
+ return true;
1488
+ }
1489
+ case '/status': {
1490
+ const out = {
1491
+ provider: activeProvName,
1492
+ model: activeModel,
1493
+ keyMasked: _registryMod.maskApiKey(cfg['api-key']),
1494
+ messageCount: messages.length,
1495
+ };
1496
+ process.stdout.write(JSON.stringify(out) + '\n');
1497
+ return true;
1498
+ }
1499
+ case '/provider': {
1500
+ // `/provider <name>` switches the active provider for subsequent
1501
+ // turns. The conversation history stays put — the next user
1502
+ // message goes to the new provider with the existing context.
1503
+ // `/provider` (no arg) prints the current name.
1504
+ const arg = line.slice('/provider'.length).trim();
1505
+ if (!arg) {
1506
+ process.stdout.write(`provider: ${activeProvName}\n`);
1507
+ return true;
1508
+ }
1509
+ const next = lookupProv(arg);
1510
+ if (!next) {
1511
+ process.stdout.write(`unknown provider: ${arg} (known: ${Object.keys(_registryMod.PROVIDERS).join(', ')})\n`);
1512
+ return true;
1513
+ }
1514
+ activeProvName = arg;
1515
+ prov = next;
1516
+ process.stdout.write(`provider → ${arg}\n`);
1517
+ return true;
1518
+ }
1519
+ case '/model': {
1520
+ // `/model <name>` updates the active model without touching the
1521
+ // provider. `/model` (no arg) prints the current value.
1522
+ const arg = line.slice('/model'.length).trim();
1523
+ if (!arg) {
1524
+ process.stdout.write(`model: ${activeModel || '(default)'}\n`);
1525
+ return true;
1526
+ }
1527
+ // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
1528
+ // splits and switches both.
1529
+ const { parseProviderModel } = _registryMod;
1530
+ const parsed = parseProviderModel(arg);
1531
+ if (parsed.provider) {
1532
+ const next = lookupProv(parsed.provider);
1533
+ if (!next) {
1534
+ process.stdout.write(`unknown provider: ${parsed.provider}\n`);
1535
+ return true;
1536
+ }
1537
+ activeProvName = parsed.provider;
1538
+ prov = next;
1539
+ }
1540
+ activeModel = parsed.model || arg;
1541
+ process.stdout.write(`model → ${activeModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
1542
+ return true;
1543
+ }
1544
+ case '/new':
1545
+ case '/reset': {
1546
+ messages = [];
1547
+ charsSent = 0;
1548
+ runningUsage = null;
1549
+ if (sessionId) {
1550
+ const sm = await import('./sessions.mjs');
1551
+ sm.resetSession(sessionId, cfgDir);
1552
+ }
1553
+ process.stdout.write('cleared — new conversation\n');
1554
+ return true;
1555
+ }
1556
+ case '/usage': {
1557
+ const out = { messageCount: messages.length, charsSent };
1558
+ if (runningUsage) out.tokens = runningUsage;
1559
+ // When cfg.rates has a card for the active provider/model AND
1560
+ // we accumulated real usage, surface the running cost too. The
1561
+ // computation is local (pure arithmetic), no extra network.
1562
+ if (runningUsage && cfg.rates && typeof cfg.rates === 'object') {
1563
+ try {
1564
+ const { costFromUsage } = await import('./providers/rates.mjs');
1565
+ const r = costFromUsage(
1566
+ { provider: activeProvName, model: activeModel, usage: runningUsage },
1567
+ cfg.rates,
1568
+ );
1569
+ if (r) out.cost = r;
1570
+ } catch { /* never let cost-card lookup fail the slash */ }
1571
+ }
1572
+ process.stdout.write(JSON.stringify(out) + '\n');
1573
+ return true;
1574
+ }
1575
+ case '/skill': {
1576
+ // `/skill name1,name2` — replace the active system message with a
1577
+ // composition of the named skills. `/skill` (no arg) clears the
1578
+ // system message. The replacement happens in-place on the
1579
+ // messages array; the prior system turn (if any) is dropped so
1580
+ // we don't end up with two stacked system messages talking past
1581
+ // each other. When --session is set we persist the new system
1582
+ // message so the next invocation resumes with the same context.
1583
+ const arg = line.slice('/skill'.length).trim();
1584
+ const names = arg.split(',').map(s => s.trim()).filter(Boolean);
1585
+ const sysIdx = messages.findIndex(m => m.role === 'system');
1586
+ if (names.length === 0) {
1587
+ if (sysIdx >= 0) messages.splice(sysIdx, 1);
1588
+ if (sessionId) {
1589
+ // Persistent session: rewrite the file from scratch so the
1590
+ // dropped system turn doesn't linger as a stale entry.
1591
+ const sm = await import('./sessions.mjs');
1592
+ sm.resetSession(sessionId, cfgDir);
1593
+ for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
1594
+ }
1595
+ process.stdout.write('cleared system prompt (no active skills)\n');
1596
+ return true;
1597
+ }
1598
+ try {
1599
+ const sys = await (async () => {
1600
+ const mod = await import('./skills.mjs');
1601
+ return mod.composeSystemPrompt(names, cfgDir);
1602
+ })();
1603
+ if (!sys) {
1604
+ process.stdout.write('no skill content composed (empty input?)\n');
1605
+ return true;
1606
+ }
1607
+ if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: sys };
1608
+ else messages.unshift({ role: 'system', content: sys });
1609
+ if (sessionId) {
1610
+ const sm = await import('./sessions.mjs');
1611
+ sm.resetSession(sessionId, cfgDir);
1612
+ for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
1613
+ }
1614
+ process.stdout.write(`active skills: ${names.join(', ')}\n`);
1615
+ } catch (e) {
1616
+ process.stdout.write(`skill error: ${e?.message || e}\n`);
1617
+ }
1618
+ return true;
1619
+ }
1620
+ case '/exit': {
1621
+ return 'EXIT';
1622
+ }
1623
+ default:
1624
+ process.stdout.write(`unknown slash: ${cmd} (try /help)\n`);
1625
+ return true;
1626
+ }
1627
+ };
1628
+
1629
+ for await (const line of rl) {
1630
+ const text = line.trim();
1631
+ if (!text) { if (useTerminal) rl.prompt(); continue; }
1632
+ if (text.startsWith('/')) {
1633
+ const r = await handleSlash(text);
1634
+ if (r === 'EXIT') break;
1635
+ if (useTerminal) rl.prompt();
1636
+ continue;
1637
+ }
1638
+ messages.push({ role: 'user', content: text });
1639
+ charsSent += text.length;
1640
+ persistTurn('user', text);
1641
+ let acc = '';
1642
+ // Per-turn AbortController. Ctrl+C during a stream aborts THIS turn
1643
+ // and returns to the prompt instead of killing the process. Outside
1644
+ // a stream, Ctrl+C still terminates (we restore the default handler
1645
+ // below, after the try/finally).
1646
+ const turnAc = new AbortController();
1647
+ const onSigint = () => {
1648
+ turnAc.abort();
1649
+ process.stdout.write('\n^C interrupted — prompt is back\n');
1650
+ };
1651
+ process.on('SIGINT', onSigint);
1652
+ try {
1653
+ for await (const chunk of prov.sendMessage(messages, {
1654
+ apiKey: cfg['api-key'],
1655
+ model: activeModel,
1656
+ signal: turnAc.signal,
1657
+ onUsage: accumulateUsage,
1658
+ })) {
1659
+ process.stdout.write(chunk);
1660
+ acc += chunk;
1661
+ }
1662
+ process.stdout.write('\n');
1663
+ messages.push({ role: 'assistant', content: acc });
1664
+ persistTurn('assistant', acc);
1665
+ } catch (err) {
1666
+ // ABORT errors are user-initiated; partial assistant output is
1667
+ // discarded (we don't append a half-reply to the message history
1668
+ // because the next turn would treat it as a complete reply and
1669
+ // give odd context to the model).
1670
+ if (err?.code !== 'ABORT' && !turnAc.signal.aborted) {
1671
+ process.stdout.write(`error: ${err?.message || String(err)}\n`);
1672
+ }
1673
+ } finally {
1674
+ process.off('SIGINT', onSigint);
1675
+ }
1676
+ if (useTerminal) rl.prompt();
1677
+ }
1678
+ }
1679
+
1680
+ async function cmdDaemon(flags) {
1681
+ await ensureRegistry();
1682
+ const sessionsMod = await import('./sessions.mjs');
1683
+ const { startDaemon } = await import('./daemon.mjs');
1684
+ const port = flags.port !== undefined ? parseInt(flags.port, 10) : 0;
1685
+ const once = !!flags.once;
1686
+ // --auth-token wins over the env var so a per-invocation override works.
1687
+ // When neither is set, the daemon runs unauthenticated (the historical
1688
+ // single-user-loopback default).
1689
+ const authToken = flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null;
1690
+ // --allow-origin accepts a comma-separated list (also reads
1691
+ // LAZYCLAW_ALLOW_ORIGINS env). When neither is set, any request that
1692
+ // carries an `Origin` header is rejected with 403 — the browser-CSRF
1693
+ // / DNS-rebinding default. CLI/script callers don't send Origin so
1694
+ // they're unaffected.
1695
+ const originSrc = flags['allow-origin'] || process.env.LAZYCLAW_ALLOW_ORIGINS || '';
1696
+ const allowedOrigins = String(originSrc).split(',').map(s => s.trim()).filter(Boolean);
1697
+ // --rate-limit <capacity> sets a token-bucket cap per remote IP.
1698
+ // refillPerSec defaults to capacity/60 so the bucket sustains the
1699
+ // same long-run rate (a bucket of 60 / 1 per second == 60 req/min).
1700
+ // Pass 0 (or omit) to leave the daemon unlimited.
1701
+ const rlCap = flags['rate-limit'] ? parseInt(flags['rate-limit'], 10) : 0;
1702
+ const rateLimit = (Number.isFinite(rlCap) && rlCap > 0)
1703
+ ? { capacity: rlCap, refillPerSec: rlCap / 60 }
1704
+ : null;
1705
+ // --response-cache flips the daemon-scope cache on (no value form ⇒ true).
1706
+ // Per-request opt-in still happens via body.cache; this just allocates
1707
+ // the shared map so the cache state actually persists.
1708
+ const responseCache = flags['response-cache'] ? true : null;
1709
+ // --log <level> enables structured access logging. Also reads
1710
+ // LAZYCLAW_LOG_LEVEL. When set, every request emits a JSON line on
1711
+ // stderr at info level: {ts, level, msg:'access', method, path, status,
1712
+ // durationMs, remote}. Default is silent.
1713
+ const logLevel = flags.log || process.env.LAZYCLAW_LOG_LEVEL || null;
1714
+ const { createLogger } = await import('./logger.mjs');
1715
+ const logger = logLevel ? createLogger({ level: logLevel }) : null;
1716
+ // Cost cap parsing: any --cost-cap-<currency> <amount> flag pair
1717
+ // contributes one entry to the costCap map. Currency codes are upper-
1718
+ // cased to match what costFromUsage's rate cards produce. Bad/zero
1719
+ // values are silently skipped — the daemon should never reject a
1720
+ // request because the operator typo'd the limit.
1721
+ const costCap = {};
1722
+ for (const [k, v] of Object.entries(flags)) {
1723
+ if (!k.startsWith('cost-cap-')) continue;
1724
+ const cur = k.slice('cost-cap-'.length).toUpperCase();
1725
+ const amt = Number(v);
1726
+ if (Number.isFinite(amt) && amt > 0) costCap[cur] = amt;
1727
+ }
1728
+ const costCapOrNull = Object.keys(costCap).length > 0 ? costCap : null;
1729
+ // Workflow state dir: --workflow-state-dir flag wins, then env, then
1730
+ // the CLI's default of `.workflow-state` (cwd-relative). Mirrors the
1731
+ // CLI's `lazyclaw run --dir` resolution so `inspect` and the daemon
1732
+ // see the same files.
1733
+ const workflowStateDirValue = flags['workflow-state-dir']
1734
+ || process.env.LAZYCLAW_WORKFLOW_STATE_DIR
1735
+ || '.workflow-state';
1736
+ const cfgDir = path.dirname(configPath());
1737
+ const d = await startDaemon({
1738
+ port: Number.isFinite(port) ? port : 0,
1739
+ once,
1740
+ readConfig,
1741
+ sessionsDirGetter: () => cfgDir,
1742
+ sessionsMod,
1743
+ version: () => readVersionFromRepo(),
1744
+ workflowStateDir: () => workflowStateDirValue,
1745
+ authToken: authToken || undefined,
1746
+ allowedOrigins,
1747
+ rateLimit,
1748
+ responseCache,
1749
+ logger,
1750
+ costCap: costCapOrNull,
1751
+ });
1752
+ // Print the bound port immediately so test/script callers can pick it up
1753
+ // even when we asked for port 0. Indicate auth presence (not the token)
1754
+ // and the allowed-origin count (not the values, just whether browser
1755
+ // access has been opened).
1756
+ process.stdout.write(JSON.stringify({
1757
+ ok: true, url: `http://127.0.0.1:${d.port}`, port: d.port, once,
1758
+ auth: !!authToken,
1759
+ allowedOriginCount: allowedOrigins.length,
1760
+ rateLimit: rateLimit ? { capacity: rateLimit.capacity, refillPerSec: rateLimit.refillPerSec } : null,
1761
+ responseCache: !!responseCache,
1762
+ log: logLevel || null,
1763
+ costCap: costCapOrNull,
1764
+ }) + '\n');
1765
+ if (!once) {
1766
+ // Forward SIGINT/SIGTERM to a graceful shutdown with a hard timeout
1767
+ // (default 10 s, override with --shutdown-timeout-ms). Second signal
1768
+ // bypasses the wait and exits immediately — the orchestrator's "I
1769
+ // mean it" signal.
1770
+ const { gracefulShutdown } = await import('./daemon.mjs');
1771
+ const timeoutMs = flags['shutdown-timeout-ms'] ? parseInt(flags['shutdown-timeout-ms'], 10) : 10_000;
1772
+ let shuttingDown = false;
1773
+ const shutdown = async () => {
1774
+ if (shuttingDown) {
1775
+ if (logger) logger.warn('shutdown.force', { reason: 'second signal' });
1776
+ return process.exit(1);
1777
+ }
1778
+ shuttingDown = true;
1779
+ if (logger) logger.info('shutdown.begin', { timeoutMs });
1780
+ const result = await gracefulShutdown(d.server, timeoutMs);
1781
+ if (logger) logger.info('shutdown.end', result);
1782
+ process.exit(result.forced ? 1 : 0);
1783
+ };
1784
+ process.on('SIGINT', shutdown);
1785
+ process.on('SIGTERM', shutdown);
1786
+ } else {
1787
+ // In once mode, exit naturally after the server closes.
1788
+ d.server.on('close', () => process.exit(0));
1789
+ }
1790
+ }
1791
+
1792
+ async function cmdRates(sub, positional, flags = {}) {
1793
+ // Manage cfg.rates without hand-editing JSON. Same shape as
1794
+ // RATE_CARD_SHAPE in providers/rates.mjs:
1795
+ // { 'provider/model': { inputPer1M, outputPer1M, cacheReadPer1M?, cacheCreatePer1M?, currency? } }
1796
+ switch (sub) {
1797
+ case undefined:
1798
+ case 'list': {
1799
+ const cfg = readConfig();
1800
+ const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
1801
+ // Same --filter / --limit pattern as v3.33-v3.36 across
1802
+ // sessions/skills/workflows. Filter on key (provider/model)
1803
+ // case-insensitive, then post-filter cap.
1804
+ let entries = Object.entries(rates);
1805
+ if (flags.filter) {
1806
+ const f = String(flags.filter).toLowerCase();
1807
+ entries = entries.filter(([key]) => key.toLowerCase().includes(f));
1808
+ }
1809
+ if (flags.limit !== undefined) {
1810
+ const n = parseInt(flags.limit, 10);
1811
+ if (Number.isFinite(n) && n > 0) entries = entries.slice(0, n);
1812
+ }
1813
+ console.log(JSON.stringify(Object.fromEntries(entries), null, 2));
1814
+ return;
1815
+ }
1816
+ case 'set': {
1817
+ const key = positional[0];
1818
+ if (!key || !key.includes('/')) {
1819
+ console.error('Usage: lazyclaw rates set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD]');
1820
+ process.exit(2);
1821
+ }
1822
+ const inputPer1M = flags.input !== undefined ? Number(flags.input) : null;
1823
+ const outputPer1M = flags.output !== undefined ? Number(flags.output) : null;
1824
+ if (!Number.isFinite(inputPer1M) || !Number.isFinite(outputPer1M) || inputPer1M < 0 || outputPer1M < 0) {
1825
+ console.error('rates set: --input and --output must be non-negative numbers (per million tokens)');
1826
+ process.exit(2);
1827
+ }
1828
+ const card = { inputPer1M, outputPer1M };
1829
+ if (flags['cache-read'] !== undefined) card.cacheReadPer1M = Number(flags['cache-read']);
1830
+ if (flags['cache-create'] !== undefined) card.cacheCreatePer1M = Number(flags['cache-create']);
1831
+ if (flags.currency) card.currency = String(flags.currency);
1832
+ else card.currency = 'USD';
1833
+ const cfg = readConfig();
1834
+ cfg.rates = cfg.rates || {};
1835
+ cfg.rates[key] = card;
1836
+ writeConfig(cfg);
1837
+ console.log(JSON.stringify({ ok: true, key, card }));
1838
+ return;
1839
+ }
1840
+ case 'delete':
1841
+ case 'unset': {
1842
+ const key = positional[0];
1843
+ if (!key) { console.error('Usage: lazyclaw rates delete <provider/model>'); process.exit(2); }
1844
+ const cfg = readConfig();
1845
+ const had = !!(cfg.rates && cfg.rates[key]);
1846
+ if (cfg.rates) delete cfg.rates[key];
1847
+ writeConfig(cfg);
1848
+ console.log(JSON.stringify({ ok: true, key, removed: had }));
1849
+ return;
1850
+ }
1851
+ case 'shape': {
1852
+ // Print the reference shape so users can copy-paste into config.
1853
+ const mod = await import('./providers/rates.mjs');
1854
+ console.log(JSON.stringify(mod.RATE_CARD_SHAPE, null, 2));
1855
+ return;
1856
+ }
1857
+ case 'copy': {
1858
+ // Clone a rate card from <src/model> to <dst/model>. Useful when
1859
+ // a new model launches at the same price as a known one and you
1860
+ // don't want to retype every field.
1861
+ //
1862
+ // Refuses to overwrite an existing destination unless --force is
1863
+ // passed (a rate card is operator-curated; silent overwrite is
1864
+ // exactly the wrong default).
1865
+ const src = positional[0];
1866
+ const dst = positional[1];
1867
+ if (!src || !dst || !src.includes('/') || !dst.includes('/')) {
1868
+ console.error('Usage: lazyclaw rates copy <src-provider/model> <dst-provider/model> [--force]');
1869
+ process.exit(2);
1870
+ }
1871
+ const cfg = readConfig();
1872
+ const rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
1873
+ if (!rates[src]) {
1874
+ console.error(`rates copy: source key "${src}" not found in cfg.rates`);
1875
+ process.exit(1);
1876
+ }
1877
+ if (rates[dst] && !flags.force) {
1878
+ console.error(`rates copy: destination "${dst}" already exists (pass --force to overwrite)`);
1879
+ process.exit(1);
1880
+ }
1881
+ // Deep clone (small object) so a later edit to one doesn't
1882
+ // mutate the other.
1883
+ cfg.rates = rates;
1884
+ cfg.rates[dst] = JSON.parse(JSON.stringify(rates[src]));
1885
+ writeConfig(cfg);
1886
+ console.log(JSON.stringify({ ok: true, src, dst, card: cfg.rates[dst] }));
1887
+ return;
1888
+ }
1889
+ case 'validate': {
1890
+ // Shape check shared with daemon's GET /rates/validate via
1891
+ // rates-validate.mjs. Single source of truth.
1892
+ const cfg = readConfig();
1893
+ await ensureRegistry();
1894
+ const { validateRates } = await import('./rates-validate.mjs');
1895
+ const result = validateRates(cfg.rates, _registryMod.PROVIDERS);
1896
+ console.log(JSON.stringify(result, null, 2));
1897
+ process.exit(result.ok ? 0 : 1);
1898
+ }
1899
+ default:
1900
+ console.error('Usage: lazyclaw rates <list|set <key>|delete <key>|shape|validate>');
1901
+ process.exit(2);
1902
+ }
1903
+ }
1904
+
1905
+ async function cmdSkills(sub, positional, flags = {}) {
1906
+ const skillsMod = await import('./skills.mjs');
1907
+ const cfgDir = path.dirname(configPath());
1908
+ switch (sub) {
1909
+ case undefined:
1910
+ case 'list': {
1911
+ // Same --filter / --limit semantic as v3.33's sessions list:
1912
+ // case-insensitive name substring, then post-filter cap.
1913
+ let items = skillsMod.listSkills(cfgDir);
1914
+ if (flags.filter) {
1915
+ const f = String(flags.filter).toLowerCase();
1916
+ items = items.filter(s => s.name.toLowerCase().includes(f));
1917
+ }
1918
+ if (flags.limit !== undefined) {
1919
+ const n = parseInt(flags.limit, 10);
1920
+ if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
1921
+ }
1922
+ console.log(JSON.stringify(items.map(s => ({ name: s.name, bytes: s.bytes, summary: s.summary })), null, 2));
1923
+ return;
1924
+ }
1925
+ case 'show': {
1926
+ const name = positional[0];
1927
+ if (!name) { console.error('Usage: lazyclaw skills show <name>'); process.exit(2); }
1928
+ try { process.stdout.write(skillsMod.loadSkill(name, cfgDir)); }
1929
+ catch (e) { console.error(e.message); process.exit(1); }
1930
+ return;
1931
+ }
1932
+ case 'install': {
1933
+ // Three forms: --from <path>, --from-url <https://...>, or stdin.
1934
+ const name = positional[0];
1935
+ if (!name) { console.error('Usage: lazyclaw skills install <name> [--from <path> | --from-url <https://...>]'); process.exit(2); }
1936
+ let content;
1937
+ if (flags['from-url']) {
1938
+ const url = String(flags['from-url']);
1939
+ // Refuse http/file/data — only https. The skill content goes
1940
+ // straight into the system prompt, so source authenticity matters.
1941
+ if (!url.startsWith('https://')) {
1942
+ console.error('skills install --from-url requires an https:// URL');
1943
+ process.exit(2);
1944
+ }
1945
+ const fetchFn = globalThis.fetch;
1946
+ if (!fetchFn) { console.error('fetch is not available in this Node runtime'); process.exit(1); }
1947
+ // Configurable max size — protect against pathological responses
1948
+ // that would balloon the prompt and the disk file. 1 MiB cap.
1949
+ const MAX_BYTES = 1_048_576;
1950
+ try {
1951
+ const res = await fetchFn(url, { redirect: 'follow' });
1952
+ if (!res.ok) { console.error(`fetch ${url} → ${res.status}`); process.exit(1); }
1953
+ // Stream the body so we can stop at the cap rather than loading
1954
+ // an arbitrarily large response into memory.
1955
+ const reader = res.body?.getReader?.();
1956
+ if (!reader) { content = await res.text(); }
1957
+ else {
1958
+ const chunks = [];
1959
+ let total = 0;
1960
+ while (true) {
1961
+ const { value, done } = await reader.read();
1962
+ if (done) break;
1963
+ total += value.length;
1964
+ if (total > MAX_BYTES) {
1965
+ console.error(`skills install: response exceeds ${MAX_BYTES} bytes; refusing`);
1966
+ process.exit(1);
1967
+ }
1968
+ chunks.push(value);
1969
+ }
1970
+ content = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks.map(c => Buffer.from(c))));
1971
+ }
1972
+ } catch (e) {
1973
+ console.error(`skills install fetch failed: ${e?.message || e}`);
1974
+ process.exit(1);
1975
+ }
1976
+ } else if (flags.from) {
1977
+ content = fs.readFileSync(flags.from, 'utf8');
1978
+ } else {
1979
+ content = await new Promise(resolve => {
1980
+ let buf = '';
1981
+ process.stdin.setEncoding('utf8');
1982
+ process.stdin.on('data', d => { buf += d; });
1983
+ process.stdin.on('end', () => resolve(buf));
1984
+ });
1985
+ }
1986
+ const written = skillsMod.installSkill(name, content, cfgDir);
1987
+ console.log(JSON.stringify({ ok: true, name, path: written, bytes: content.length }));
1988
+ return;
1989
+ }
1990
+ case 'remove': {
1991
+ const name = positional[0];
1992
+ if (!name) { console.error('Usage: lazyclaw skills remove <name>'); process.exit(2); }
1993
+ skillsMod.removeSkill(name, cfgDir);
1994
+ console.log(JSON.stringify({ ok: true, removed: name }));
1995
+ return;
1996
+ }
1997
+ case 'search': {
1998
+ // Mirror of `lazyclaw sessions search` — case-insensitive substring
1999
+ // by default, --regex for pattern mode. Returns per-skill match
2000
+ // count + first-excerpt window (40 chars before/after match).
2001
+ // The skill body IS markdown so users typically search for terms
2002
+ // mentioned in instructions or examples.
2003
+ const query = positional[0];
2004
+ if (!query) { console.error('Usage: lazyclaw skills search <query> [--regex]'); process.exit(2); }
2005
+ const useRegex = !!flags.regex;
2006
+ let matcher;
2007
+ if (useRegex) {
2008
+ try { matcher = new RegExp(query, 'i'); }
2009
+ catch (e) { console.error(`invalid regex: ${e.message}`); process.exit(2); }
2010
+ } else {
2011
+ const q = query.toLowerCase();
2012
+ matcher = { test: (s) => String(s).toLowerCase().includes(q) };
2013
+ }
2014
+ const items = skillsMod.listSkills(cfgDir);
2015
+ const matches = [];
2016
+ for (const s of items) {
2017
+ let body;
2018
+ try { body = skillsMod.loadSkill(s.name, cfgDir); }
2019
+ catch { continue; } // file may have been removed mid-listing
2020
+ // Count matches across the whole body, not per-line. For a
2021
+ // skill body that's a few KB this is plenty fast and the count
2022
+ // matches the user's intuition of "how many times does it
2023
+ // mention X."
2024
+ let matchCount = 0;
2025
+ let firstExcerpt = null;
2026
+ if (useRegex) {
2027
+ // Re-anchor the regex with /gi so we can iterate; the original
2028
+ // matcher was /i for boolean test() above. Rebuild here.
2029
+ const gFlag = new RegExp(query, 'gi');
2030
+ for (const m of body.matchAll(gFlag)) {
2031
+ matchCount++;
2032
+ if (firstExcerpt === null) {
2033
+ const pos = m.index ?? 0;
2034
+ const start = Math.max(0, pos - 40);
2035
+ const end = Math.min(body.length, pos + m[0].length + 40);
2036
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
2037
+ }
2038
+ }
2039
+ } else {
2040
+ const lower = body.toLowerCase();
2041
+ const q = query.toLowerCase();
2042
+ let pos = 0;
2043
+ while (true) {
2044
+ const i = lower.indexOf(q, pos);
2045
+ if (i < 0) break;
2046
+ matchCount++;
2047
+ if (firstExcerpt === null) {
2048
+ const start = Math.max(0, i - 40);
2049
+ const end = Math.min(body.length, i + q.length + 40);
2050
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
2051
+ }
2052
+ pos = i + q.length;
2053
+ }
2054
+ }
2055
+ if (matchCount > 0) {
2056
+ matches.push({
2057
+ name: s.name,
2058
+ bytes: s.bytes,
2059
+ matchCount,
2060
+ excerpt: firstExcerpt,
2061
+ });
2062
+ }
2063
+ }
2064
+ console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
2065
+ return;
2066
+ }
2067
+ default:
2068
+ console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]>');
2069
+ process.exit(2);
2070
+ }
2071
+ }
2072
+
2073
+ async function cmdProviders(sub, positional, flags = {}) {
2074
+ await ensureRegistry();
2075
+ switch (sub) {
2076
+ case undefined:
2077
+ case 'list': {
2078
+ // Defensive: if metadata is missing for a registered provider, fall back
2079
+ // to a minimal shape so this never crashes the CLI even mid-refactor.
2080
+ // --filter / --limit pattern matches v3.33-v3.46 across the other
2081
+ // list surfaces. Filter on provider name, case-insensitive.
2082
+ let out = Object.keys(_registryMod.PROVIDERS).map(name => {
2083
+ const meta = _registryMod.PROVIDER_INFO[name] || { name, requiresApiKey: false, docs: '' };
2084
+ return {
2085
+ name,
2086
+ requiresApiKey: !!meta.requiresApiKey,
2087
+ defaultModel: meta.defaultModel || null,
2088
+ suggestedModels: meta.suggestedModels || [],
2089
+ };
2090
+ });
2091
+ if (flags.filter) {
2092
+ const f = String(flags.filter).toLowerCase();
2093
+ out = out.filter(p => p.name.toLowerCase().includes(f));
2094
+ }
2095
+ if (flags.limit !== undefined) {
2096
+ const n = parseInt(flags.limit, 10);
2097
+ if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
2098
+ }
2099
+ console.log(JSON.stringify(out, null, 2));
2100
+ return;
2101
+ }
2102
+ case 'info': {
2103
+ const name = positional[0];
2104
+ if (!name) { console.error('Usage: lazyclaw providers info <name>'); process.exit(2); }
2105
+ const meta = _registryMod.PROVIDER_INFO[name];
2106
+ if (!meta) {
2107
+ console.error(`unknown provider: ${name} (registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')})`);
2108
+ process.exit(2);
2109
+ }
2110
+ console.log(JSON.stringify(meta, null, 2));
2111
+ return;
2112
+ }
2113
+ case 'test': {
2114
+ // Smoke-test a provider with a tiny ("ping") prompt. Useful after
2115
+ // configuring a new API key — surfaces auth errors fast without
2116
+ // waiting for the next real call to fail.
2117
+ //
2118
+ // Output:
2119
+ // { ok: bool, provider, model, durationMs, [reply | error, code] }
2120
+ //
2121
+ // Exit codes:
2122
+ // 0 — provider returned a non-empty reply
2123
+ // 1 — provider returned an error (auth failure, rate limit, ...)
2124
+ // 2 — invalid invocation (unknown name)
2125
+ //
2126
+ // No name OR --all: smoke-test every registered provider in
2127
+ // parallel. Output is `{ ok, results: [...] }` where ok is true
2128
+ // iff every entry passed. Exit 0 when all pass, 1 otherwise.
2129
+ const name = positional[0];
2130
+ const cfg = readConfig();
2131
+ const promptIdx = positional.indexOf('--prompt');
2132
+ const sharedPrompt = flags.prompt || (promptIdx >= 0 ? positional[promptIdx + 1] : null) || 'ping';
2133
+ if (!name || flags.all) {
2134
+ const apiKey = cfg['api-key'] || '';
2135
+ const t0all = Date.now();
2136
+ const results = await Promise.all(
2137
+ Object.entries(_registryMod.PROVIDERS).map(async ([pid, provider]) => {
2138
+ const meta = _registryMod.PROVIDER_INFO[pid] || {};
2139
+ const model = flags.model || cfg.model || meta.defaultModel || 'unknown';
2140
+ const t0 = Date.now();
2141
+ try {
2142
+ let reply = '';
2143
+ const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
2144
+ for await (const chunk of stream) {
2145
+ if (typeof chunk === 'string') reply += chunk;
2146
+ }
2147
+ return {
2148
+ name: pid, ok: reply.length > 0, model,
2149
+ durationMs: Date.now() - t0,
2150
+ replyLength: reply.length,
2151
+ };
2152
+ } catch (err) {
2153
+ return {
2154
+ name: pid, ok: false, model,
2155
+ durationMs: Date.now() - t0,
2156
+ error: err?.message || String(err),
2157
+ code: err?.code || null,
2158
+ };
2159
+ }
2160
+ }),
2161
+ );
2162
+ const allOk = results.every(r => r.ok);
2163
+ console.log(JSON.stringify({
2164
+ ok: allOk,
2165
+ totalDurationMs: Date.now() - t0all,
2166
+ results,
2167
+ }, null, 2));
2168
+ process.exit(allOk ? 0 : 1);
2169
+ }
2170
+ const provider = _registryMod.PROVIDERS[name];
2171
+ if (!provider) {
2172
+ console.error(`unknown provider: ${name} (registered: ${Object.keys(_registryMod.PROVIDERS).join(', ')})`);
2173
+ process.exit(2);
2174
+ }
2175
+ // cfg already declared above for the all-mode branch; reuse it.
2176
+ const meta = _registryMod.PROVIDER_INFO[name] || {};
2177
+ // --model / --prompt come in via the parsed flags map (parseArgs
2178
+ // lifted them out of positional). --model wins over config.model
2179
+ // wins over PROVIDER_INFO.defaultModel.
2180
+ const model = flags.model || cfg.model || meta.defaultModel || 'unknown';
2181
+ const prompt = flags.prompt || 'ping';
2182
+ const apiKey = cfg['api-key'] || '';
2183
+ const t0 = Date.now();
2184
+ try {
2185
+ // Drain the streaming response (every provider yields chunks of
2186
+ // string). For mock this is instant; for real providers it's
2187
+ // bounded by the prompt length and provider latency. We don't
2188
+ // support a timeout flag here — the user can SIGINT if a
2189
+ // provider hangs.
2190
+ let reply = '';
2191
+ const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
2192
+ for await (const chunk of stream) {
2193
+ if (typeof chunk === 'string') reply += chunk;
2194
+ }
2195
+ const durationMs = Date.now() - t0;
2196
+ const ok = reply.length > 0;
2197
+ console.log(JSON.stringify({
2198
+ ok,
2199
+ provider: name,
2200
+ model,
2201
+ durationMs,
2202
+ replyLength: reply.length,
2203
+ reply: reply.slice(0, 200) + (reply.length > 200 ? '…' : ''),
2204
+ }, null, 2));
2205
+ process.exit(ok ? 0 : 1);
2206
+ } catch (err) {
2207
+ const durationMs = Date.now() - t0;
2208
+ console.log(JSON.stringify({
2209
+ ok: false,
2210
+ provider: name,
2211
+ model,
2212
+ durationMs,
2213
+ error: err?.message || String(err),
2214
+ code: err?.code || null,
2215
+ }, null, 2));
2216
+ process.exit(1);
2217
+ }
2218
+ }
2219
+ default:
2220
+ console.error('Usage: lazyclaw providers <list|info <name>|test <name> [--model X] [--prompt T]>');
2221
+ process.exit(2);
2222
+ }
2223
+ }
2224
+
2225
+ async function cmdSessions(sub, positional, flags = {}) {
2226
+ const sessionsMod = await import('./sessions.mjs');
2227
+ const cfgDir = path.dirname(configPath());
2228
+ switch (sub) {
2229
+ case 'list': {
2230
+ // --filter <substring> applies a case-insensitive id substring
2231
+ // filter (no regex, deliberately — filtering on session ids is
2232
+ // typically about prefixes or fragments).
2233
+ // --limit <N> caps the result count after filter+sort. Negative
2234
+ // or zero values are ignored so a script can pass `--limit 0`
2235
+ // explicitly to opt out without special-casing.
2236
+ // --with-turn-count: opt-in flag that adds `turnCount` per
2237
+ // session. Loads each session file (one fs.read each) — opt-in
2238
+ // because the default `list` should be fast even with thousands
2239
+ // of sessions.
2240
+ let items = sessionsMod.listSessions(cfgDir);
2241
+ if (flags.filter) {
2242
+ const f = String(flags.filter).toLowerCase();
2243
+ items = items.filter(s => s.id.toLowerCase().includes(f));
2244
+ }
2245
+ if (flags.limit !== undefined) {
2246
+ const n = parseInt(flags.limit, 10);
2247
+ if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
2248
+ }
2249
+ let out = items.map(s => {
2250
+ const base = { id: s.id, bytes: s.bytes, mtime: new Date(s.mtimeMs).toISOString(), _mtimeMs: s.mtimeMs };
2251
+ if (flags['with-turn-count'] || flags['sort-by'] === 'turn-count') {
2252
+ try { base.turnCount = sessionsMod.loadTurns(s.id, cfgDir).length; }
2253
+ catch { base.turnCount = null; }
2254
+ }
2255
+ return base;
2256
+ });
2257
+ // --sort-by mtime|turn-count|bytes|id. Default is mtime descending
2258
+ // (matches the underlying listSessions behavior). turn-count
2259
+ // implicitly enables turnCount loading above.
2260
+ if (flags['sort-by']) {
2261
+ const valid = new Set(['mtime', 'turn-count', 'bytes', 'id']);
2262
+ if (!valid.has(flags['sort-by'])) {
2263
+ console.error(`invalid --sort-by: ${flags['sort-by']} (expected: mtime, turn-count, bytes, id)`);
2264
+ process.exit(2);
2265
+ }
2266
+ const cmp = {
2267
+ mtime: (a, b) => b._mtimeMs - a._mtimeMs,
2268
+ 'turn-count': (a, b) => (b.turnCount ?? 0) - (a.turnCount ?? 0),
2269
+ bytes: (a, b) => b.bytes - a.bytes,
2270
+ id: (a, b) => a.id.localeCompare(b.id),
2271
+ };
2272
+ out.sort(cmp[flags['sort-by']]);
2273
+ }
2274
+ // Strip the internal helper field before serializing.
2275
+ out = out.map(({ _mtimeMs, ...rest }) => rest);
2276
+ console.log(JSON.stringify(out, null, 2));
2277
+ return;
2278
+ }
2279
+ case 'show': {
2280
+ const id = positional[0];
2281
+ if (!id) { console.error('Usage: lazyclaw sessions show <id>'); process.exit(2); }
2282
+ const turns = sessionsMod.loadTurns(id, cfgDir);
2283
+ console.log(JSON.stringify(turns, null, 2));
2284
+ return;
2285
+ }
2286
+ case 'clear': {
2287
+ const id = positional[0];
2288
+ if (!id) { console.error('Usage: lazyclaw sessions clear <id>'); process.exit(2); }
2289
+ sessionsMod.clearSession(id, cfgDir);
2290
+ console.log(JSON.stringify({ ok: true, cleared: id }));
2291
+ return;
2292
+ }
2293
+ case 'export': {
2294
+ const id = positional[0];
2295
+ if (!id) { console.error('Usage: lazyclaw sessions export <id> [--format md|json|text]'); process.exit(2); }
2296
+ const format = (flags.format || 'md').toLowerCase();
2297
+ const formatters = {
2298
+ md: sessionsMod.exportMarkdown,
2299
+ markdown: sessionsMod.exportMarkdown,
2300
+ json: sessionsMod.exportJson,
2301
+ text: sessionsMod.exportText,
2302
+ txt: sessionsMod.exportText,
2303
+ };
2304
+ const fn = formatters[format];
2305
+ if (!fn) {
2306
+ console.error(`unknown export format: ${format} (expected: md, json, text)`);
2307
+ process.exit(2);
2308
+ }
2309
+ try { process.stdout.write(fn(id, cfgDir)); }
2310
+ catch (e) { console.error(e.message); process.exit(1); }
2311
+ return;
2312
+ }
2313
+ case 'search': {
2314
+ const query = positional[0];
2315
+ if (!query) { console.error('Usage: lazyclaw sessions search <query> [--regex]'); process.exit(2); }
2316
+ // --regex came in via the parsed flags map (parseArgs lifted it
2317
+ // out of positional). 'regex' is also in BOOLEAN_FLAGS so it
2318
+ // never consumes the next argument.
2319
+ const useRegex = !!flags.regex;
2320
+ let matcher;
2321
+ if (useRegex) {
2322
+ try { matcher = new RegExp(query, 'i'); }
2323
+ catch (e) { console.error(`invalid regex: ${e.message}`); process.exit(2); }
2324
+ } else {
2325
+ // Case-insensitive substring search. The naive `s.includes(q)`
2326
+ // pattern is exactly what the user wants — same shape they'd
2327
+ // get from `grep -i`.
2328
+ const q = query.toLowerCase();
2329
+ matcher = { test: (s) => String(s).toLowerCase().includes(q) };
2330
+ }
2331
+ const items = sessionsMod.listSessions(cfgDir);
2332
+ const matches = [];
2333
+ for (const s of items) {
2334
+ const turns = sessionsMod.loadTurns(s.id, cfgDir);
2335
+ let matchCount = 0;
2336
+ let firstExcerpt = null;
2337
+ for (const t of turns) {
2338
+ if (typeof t?.content !== 'string') continue;
2339
+ if (matcher.test(t.content)) {
2340
+ matchCount++;
2341
+ if (firstExcerpt === null) {
2342
+ // Excerpt: 40 chars before/after first match, clamped at
2343
+ // string boundaries. For regex matches we need to find
2344
+ // the actual position; for substring use indexOf.
2345
+ const c = t.content;
2346
+ let pos;
2347
+ if (useRegex) {
2348
+ pos = c.search(matcher);
2349
+ } else {
2350
+ pos = c.toLowerCase().indexOf(query.toLowerCase());
2351
+ }
2352
+ if (pos < 0) pos = 0;
2353
+ const start = Math.max(0, pos - 40);
2354
+ const end = Math.min(c.length, pos + query.length + 40);
2355
+ firstExcerpt = (start > 0 ? '…' : '') + c.slice(start, end) + (end < c.length ? '…' : '');
2356
+ }
2357
+ }
2358
+ }
2359
+ if (matchCount > 0) {
2360
+ matches.push({
2361
+ id: s.id,
2362
+ mtime: new Date(s.mtimeMs).toISOString(),
2363
+ matchCount,
2364
+ excerpt: firstExcerpt,
2365
+ });
2366
+ }
2367
+ }
2368
+ console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
2369
+ // Exit 0 even on no matches — `grep` convention is exit 1, but
2370
+ // a CLI tool that returns JSON should always exit 0 on a
2371
+ // successful search; the caller checks `matches.length` for
2372
+ // emptiness.
2373
+ return;
2374
+ }
2375
+ default:
2376
+ console.error('Usage: lazyclaw sessions <list|show <id>|clear <id>|export <id>|search <query> [--regex]>');
2377
+ process.exit(2);
2378
+ }
2379
+ }
2380
+
2381
+ function cmdConfigGet(key) {
2382
+ const cfg = readConfig();
2383
+ if (key) console.log(JSON.stringify({ key, value: cfg[key] ?? null }));
2384
+ else console.log(JSON.stringify(cfg));
2385
+ }
2386
+
2387
+ // Structural integrity check across the whole config. Distinct from
2388
+ // `lazyclaw doctor` (runtime checks: provider available, key present
2389
+ // for the active provider). Validate is purely about *shape* — does
2390
+ // every value have the right type, is `provider` known, are rates
2391
+ // well-formed.
2392
+ //
2393
+ // Hard issues exit 1; unknown top-level keys produce warnings (kept
2394
+ // exit 0 so a forward-compatible config from a newer CLI doesn't
2395
+ // fail validate on an older CLI).
2396
+ async function cmdConfigValidate() {
2397
+ const cfg = readConfig();
2398
+ await ensureRegistry();
2399
+ const { validateConfig } = await import('./config-validate.mjs');
2400
+ const { ok, issues, warnings } = validateConfig(cfg, _registryMod.PROVIDERS);
2401
+ console.log(JSON.stringify({
2402
+ ok,
2403
+ configPath: configPath(),
2404
+ keys: Object.keys(cfg),
2405
+ issues,
2406
+ warnings,
2407
+ }, null, 2));
2408
+ process.exit(ok ? 0 : 1);
2409
+ }
2410
+
2411
+ // Flags whose presence is the signal — they don't consume the next arg
2412
+ // even when one is available. Without this allow-list,
2413
+ // `lazyclaw run --parallel demo wf.mjs` would set `flags.parallel='demo'`
2414
+ // and silently lose the session id; the user would only see a
2415
+ // "missing positional" error after the dispatcher rejected it.
2416
+ const BOOLEAN_FLAGS = new Set([
2417
+ 'parallel',
2418
+ 'parallel-persistent',
2419
+ 'once',
2420
+ 'non-interactive',
2421
+ 'include-secrets',
2422
+ 'include-sessions',
2423
+ 'overwrite-skills',
2424
+ 'no-overwrite-config',
2425
+ 'import-sessions',
2426
+ 'show-thinking',
2427
+ 'usage',
2428
+ 'cost',
2429
+ 'response-cache',
2430
+ 'help', // also handled as a subcommand alias
2431
+ 'version',
2432
+ 'summary', // inspect: trim per-node detail
2433
+ 'regex', // sessions search: treat query as a regex
2434
+ 'lr', // graph: emit Mermaid `graph LR` (left-right)
2435
+ 'force', // rates copy: overwrite existing destination
2436
+ 'aggregate', // inspect (list mode): per-node stats across sessions
2437
+ 'all', // providers test: run all providers in parallel
2438
+ 'with-turn-count', // sessions list: include turn count per session
2439
+ ]);
2440
+
2441
+ function parseArgs(argv) {
2442
+ const out = { positional: [], flags: {} };
2443
+ for (let i = 0; i < argv.length; i++) {
2444
+ const a = argv[i];
2445
+ if (a.startsWith('--')) {
2446
+ const eq = a.indexOf('=');
2447
+ if (eq >= 0) {
2448
+ out.flags[a.slice(2, eq)] = a.slice(eq + 1);
2449
+ } else {
2450
+ const name = a.slice(2);
2451
+ if (BOOLEAN_FLAGS.has(name)) {
2452
+ // Known boolean — never consumes the next arg.
2453
+ out.flags[name] = true;
2454
+ continue;
2455
+ }
2456
+ const next = argv[i + 1];
2457
+ if (next === undefined || next.startsWith('--')) {
2458
+ // Unknown flag at end-of-args or before another --flag: still boolean.
2459
+ out.flags[name] = true;
2460
+ } else {
2461
+ out.flags[name] = next;
2462
+ i += 1;
2463
+ }
2464
+ }
2465
+ } else out.positional.push(a);
2466
+ }
2467
+ return out;
2468
+ }
2469
+
2470
+ async function main() {
2471
+ const argv = process.argv.slice(2);
2472
+ const cmd = argv[0];
2473
+ const rest = parseArgs(argv.slice(1));
2474
+ switch (cmd) {
2475
+ case 'run': {
2476
+ const [sessionId, file] = rest.positional;
2477
+ if (!sessionId || !file) { console.error('Usage: lazyclaw run <session-id> <workflow.mjs> [--parallel | --parallel-persistent] [--concurrency <N>]'); process.exit(2); }
2478
+ // --concurrency caps in-flight nodes within a single level for
2479
+ // both --parallel and --parallel-persistent. Sequential mode
2480
+ // ignores it (only one node runs at a time anyway).
2481
+ const concurrency = rest.flags.concurrency !== undefined
2482
+ ? Math.max(0, parseInt(rest.flags.concurrency, 10) || 0)
2483
+ : undefined;
2484
+ await cmdRun(sessionId, file, {
2485
+ dir: rest.flags.dir,
2486
+ parallel: !!rest.flags.parallel,
2487
+ 'parallel-persistent': !!rest.flags['parallel-persistent'],
2488
+ concurrency,
2489
+ });
2490
+ break;
2491
+ }
2492
+ case 'resume': {
2493
+ const [sessionId, file] = rest.positional;
2494
+ if (!sessionId || !file) { console.error('Usage: lazyclaw resume <session-id> <workflow.mjs> [--parallel-persistent] [--concurrency <N>]'); process.exit(2); }
2495
+ const concurrency = rest.flags.concurrency !== undefined
2496
+ ? Math.max(0, parseInt(rest.flags.concurrency, 10) || 0)
2497
+ : undefined;
2498
+ await cmdResume(sessionId, file, {
2499
+ dir: rest.flags.dir,
2500
+ 'parallel-persistent': !!rest.flags['parallel-persistent'],
2501
+ concurrency,
2502
+ });
2503
+ break;
2504
+ }
2505
+ case 'inspect': {
2506
+ // No-arg form lists every persisted session in the state dir.
2507
+ // Pass the empty positional through; cmdInspect's list mode
2508
+ // handles it.
2509
+ const [sessionId] = rest.positional;
2510
+ await cmdInspect(sessionId, {
2511
+ dir: rest.flags.dir,
2512
+ status: rest.flags.status,
2513
+ summary: !!rest.flags.summary,
2514
+ filter: rest.flags.filter,
2515
+ limit: rest.flags.limit,
2516
+ node: rest.flags.node,
2517
+ criticalPath: rest.flags['critical-path'],
2518
+ slowest: rest.flags.slowest,
2519
+ aggregate: !!rest.flags.aggregate,
2520
+ });
2521
+ break;
2522
+ }
2523
+ case 'clear': {
2524
+ const [sessionId] = rest.positional;
2525
+ if (!sessionId) { console.error('Usage: lazyclaw clear <session-id> [--dir <state-dir>]'); process.exit(2); }
2526
+ await cmdClear(sessionId, { dir: rest.flags.dir });
2527
+ break;
2528
+ }
2529
+ case 'validate': {
2530
+ const [file] = rest.positional;
2531
+ await cmdValidate(file);
2532
+ break;
2533
+ }
2534
+ case 'graph': {
2535
+ const [file] = rest.positional;
2536
+ await cmdGraph(file, {
2537
+ lr: !!rest.flags.lr,
2538
+ state: rest.flags.state,
2539
+ dir: rest.flags.dir,
2540
+ });
2541
+ break;
2542
+ }
2543
+ case 'config': {
2544
+ const sub = rest.positional[0];
2545
+ if (sub === 'set') {
2546
+ const [, key, ...valueParts] = rest.positional;
2547
+ cmdConfigSet(key, valueParts.join(' '));
2548
+ } else if (sub === 'get') {
2549
+ cmdConfigGet(rest.positional[1]);
2550
+ } else if (sub === 'list') {
2551
+ cmdConfigGet(undefined);
2552
+ } else if (sub === 'delete' || sub === 'unset') {
2553
+ const key = rest.positional[1];
2554
+ if (!key) { console.error('Usage: lazyclaw config delete <key>'); process.exit(2); }
2555
+ const cfg = readConfig();
2556
+ const had = Object.prototype.hasOwnProperty.call(cfg, key);
2557
+ delete cfg[key];
2558
+ writeConfig(cfg);
2559
+ console.log(JSON.stringify({ ok: true, key, removed: had }));
2560
+ } else if (sub === 'path') {
2561
+ // Useful for shell pipelines: `cat $(lazyclaw config path)`.
2562
+ console.log(configPath());
2563
+ } else if (sub === 'edit') {
2564
+ await cmdConfigEdit();
2565
+ } else if (sub === 'validate') {
2566
+ await cmdConfigValidate();
2567
+ } else {
2568
+ console.error('Usage: lazyclaw config set|get|list|delete|path|edit|validate <key> [value]'); process.exit(2);
2569
+ }
2570
+ break;
2571
+ }
2572
+ case 'chat': {
2573
+ await cmdChat(rest.flags);
2574
+ break;
2575
+ }
2576
+ case 'sessions': {
2577
+ const sub = rest.positional[0];
2578
+ await cmdSessions(sub, rest.positional.slice(1), rest.flags);
2579
+ break;
2580
+ }
2581
+ case 'providers': {
2582
+ const sub = rest.positional[0];
2583
+ await cmdProviders(sub, rest.positional.slice(1), rest.flags);
2584
+ break;
2585
+ }
2586
+ case 'skills': {
2587
+ const sub = rest.positional[0];
2588
+ await cmdSkills(sub, rest.positional.slice(1), rest.flags);
2589
+ break;
2590
+ }
2591
+ case 'rates': {
2592
+ const sub = rest.positional[0];
2593
+ await cmdRates(sub, rest.positional.slice(1), rest.flags);
2594
+ break;
2595
+ }
2596
+ case 'daemon': {
2597
+ await cmdDaemon(rest.flags);
2598
+ break;
2599
+ }
2600
+ case 'agent': {
2601
+ const prompt = rest.positional[0];
2602
+ await cmdAgent(prompt, rest.flags);
2603
+ break;
2604
+ }
2605
+ case 'doctor': {
2606
+ await cmdDoctor();
2607
+ break;
2608
+ }
2609
+ case 'status': {
2610
+ await cmdStatus();
2611
+ break;
2612
+ }
2613
+ case 'onboard': {
2614
+ await cmdOnboard(rest.flags);
2615
+ break;
2616
+ }
2617
+ case 'version':
2618
+ case '--version':
2619
+ case '-v': {
2620
+ await cmdVersion();
2621
+ break;
2622
+ }
2623
+ case 'completion': {
2624
+ await cmdCompletion(rest.positional[0]);
2625
+ break;
2626
+ }
2627
+ case 'export': {
2628
+ await cmdExport(rest.flags);
2629
+ break;
2630
+ }
2631
+ case 'import': {
2632
+ await cmdImport(rest.flags);
2633
+ break;
2634
+ }
2635
+ case 'help':
2636
+ case '--help':
2637
+ case '-h': {
2638
+ cmdHelp(rest.positional[0]);
2639
+ break;
2640
+ }
2641
+ default:
2642
+ console.error('Usage: lazyclaw <' + SUBCOMMANDS.join('|') + '> ...');
2643
+ console.error('Run `lazyclaw help` for a one-line summary of each subcommand.');
2644
+ process.exit(2);
2645
+ }
2646
+ }
2647
+
2648
+ main().catch(e => { console.error(e?.stack || e?.message || String(e)); process.exit(1); });