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/LICENSE +21 -0
- package/README.md +186 -0
- package/cli.mjs +2648 -0
- package/config-validate.mjs +61 -0
- package/daemon.mjs +1451 -0
- package/logger.mjs +55 -0
- package/package.json +55 -0
- package/providers/anthropic.mjs +313 -0
- package/providers/cache.mjs +132 -0
- package/providers/fallback.mjs +90 -0
- package/providers/gemini.mjs +187 -0
- package/providers/ollama.mjs +148 -0
- package/providers/openai.mjs +243 -0
- package/providers/rates.mjs +85 -0
- package/providers/registry.mjs +144 -0
- package/providers/retry.mjs +103 -0
- package/ratelimit.mjs +65 -0
- package/rates-validate.mjs +58 -0
- package/sessions.mjs +177 -0
- package/skills.mjs +97 -0
- package/web/server.mjs +33 -0
- package/workflow/executor.mjs +358 -0
- package/workflow/persistent.mjs +369 -0
- package/workflow/summary.mjs +318 -0
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); });
|