lazyclaw 6.0.1 → 6.2.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/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/cli.mjs +10 -21
- package/commands/channels.mjs +106 -107
- package/commands/chat.mjs +81 -45
- package/commands/daemon.mjs +15 -0
- package/commands/gateway.mjs +194 -0
- package/commands/providers.mjs +17 -38
- package/commands/service.mjs +113 -0
- package/commands/setup.mjs +55 -54
- package/commands/setup_channels.mjs +207 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/routes/_deps.mjs +6 -0
- package/daemon/routes/conversation.mjs +68 -4
- package/daemon/routes/ops.mjs +9 -29
- package/dotenv_min.mjs +28 -0
- package/first_run.mjs +9 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +2 -3
- package/providers/codex_cli.mjs +17 -1
- package/providers/compat_vendors.mjs +161 -0
- package/providers/gemini_cli.mjs +19 -3
- package/providers/model_catalogue.mjs +142 -9
- package/providers/probe.mjs +28 -0
- package/providers/registry.mjs +31 -162
- package/tui/editor.mjs +18 -2
- package/tui/pickers.mjs +3 -3
- package/tui/provider_families.mjs +8 -6
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// commands/gateway.mjs — `lazyclaw gateway`: the single-process always-on
|
|
2
|
+
// agent (Phase 5, "approach B").
|
|
3
|
+
//
|
|
4
|
+
// One process owns everything: it starts the HTTP daemon in-process (the
|
|
5
|
+
// session-bearing core) AND the configured channel transports (Slack Socket
|
|
6
|
+
// Mode / Telegram long-poll / Matrix /sync), feeding every inbound through
|
|
7
|
+
// the same POST /inbound the standalone listeners use — so the bridge
|
|
8
|
+
// contract, pairing gate, dedup, cost cap, and learning hook are identical.
|
|
9
|
+
// Because the channels live in-process, each registers a live sender into
|
|
10
|
+
// ctx.channelSenders, which finally gives POST /handoff a notifier: the
|
|
11
|
+
// target channel receives a resume marker and a failed notify rolls the
|
|
12
|
+
// binding back (channels/handoff.mjs). `slack|telegram|matrix listen` remain
|
|
13
|
+
// supported as standalone single-channel forwarders.
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
|
|
18
|
+
import { ensureRegistry } from '../lib/registry_boot.mjs';
|
|
19
|
+
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
20
|
+
import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
|
|
21
|
+
import { makeInboundHandler } from '../lib/inbound_client.mjs';
|
|
22
|
+
|
|
23
|
+
export const GATEWAY_CHANNELS = ['slack', 'telegram', 'matrix'];
|
|
24
|
+
|
|
25
|
+
// Default per-channel constructors. Each receives { handler, logger,
|
|
26
|
+
// allowlist }, must return a started channel exposing send()/stop().
|
|
27
|
+
// Injectable so tests can run the full gateway with stub transports.
|
|
28
|
+
const DEFAULT_FACTORIES = {
|
|
29
|
+
async slack({ handler, logger }) {
|
|
30
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
31
|
+
const ch = new SlackChannel();
|
|
32
|
+
await ch.start(handler);
|
|
33
|
+
await ch._connectSocketMode({ logger });
|
|
34
|
+
return ch;
|
|
35
|
+
},
|
|
36
|
+
async telegram({ handler, logger, allowlist }) {
|
|
37
|
+
const { TelegramChannel } = await import('../channels/telegram.mjs');
|
|
38
|
+
const ch = new TelegramChannel({ allowlist });
|
|
39
|
+
await ch.start(handler, { poll: true, logger });
|
|
40
|
+
return ch;
|
|
41
|
+
},
|
|
42
|
+
async matrix({ handler, logger, allowlist }) {
|
|
43
|
+
const { MatrixChannel } = await import('../channels/matrix.mjs');
|
|
44
|
+
const ch = new MatrixChannel({ allowlist });
|
|
45
|
+
await ch.start(handler, { poll: true, logger });
|
|
46
|
+
return ch;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Which channels should the gateway run? --channels a,b wins; otherwise the
|
|
51
|
+
// enabled cfg.channels sections that we have a built-in transport for.
|
|
52
|
+
export function _selectChannels(cfg, flags = {}) {
|
|
53
|
+
if (flags.channels) {
|
|
54
|
+
return String(flags.channels).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
55
|
+
.filter((n) => GATEWAY_CHANNELS.includes(n));
|
|
56
|
+
}
|
|
57
|
+
const configured = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
58
|
+
return GATEWAY_CHANNELS.filter((n) => configured[n] && configured[n].enabled !== false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The whole gateway, with injectable deps so tests can drive it end-to-end
|
|
62
|
+
// (stub transports, ephemeral port) without Slack/Telegram/Matrix creds.
|
|
63
|
+
// Throws GatewayGuardError on an unsafe config. Returns { port, channels,
|
|
64
|
+
// channelSenders, skipped, stop }.
|
|
65
|
+
export async function runGateway(flags = {}, deps = {}) {
|
|
66
|
+
await ensureRegistry();
|
|
67
|
+
const log = deps.log || ((s) => process.stderr.write(s));
|
|
68
|
+
const cfg = deps.readConfig ? deps.readConfig() : readConfig();
|
|
69
|
+
assertUnattendedSafe(cfg, { surface: 'gateway' });
|
|
70
|
+
const cfgDir = path.dirname(configPath());
|
|
71
|
+
loadDotenvIfAny(cfgDir);
|
|
72
|
+
|
|
73
|
+
// The gateway is session-bearing AND sits on a well-known always-on port,
|
|
74
|
+
// so unlike the ad-hoc daemon it does not run unauthenticated by default:
|
|
75
|
+
// resolve --auth-token > env > the persisted gateway.token, else mint one
|
|
76
|
+
// and persist it 0600 (so `service install gateway` restarts keep the same
|
|
77
|
+
// token and the operator can read it from the file — it is never logged).
|
|
78
|
+
// --no-auth opts back into the historical open-loopback posture.
|
|
79
|
+
let authToken = flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null;
|
|
80
|
+
const tokenFile = path.join(cfgDir, 'gateway.token');
|
|
81
|
+
if (!authToken && !flags['no-auth']) {
|
|
82
|
+
try { authToken = fs.readFileSync(tokenFile, 'utf8').trim() || null; } catch { /* not minted yet */ }
|
|
83
|
+
if (!authToken) {
|
|
84
|
+
authToken = randomBytes(24).toString('hex');
|
|
85
|
+
fs.writeFileSync(tokenFile, authToken + '\n', { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
log(`[gateway] auth token active (read it from ${tokenFile}; external callers need Authorization: Bearer)\n`);
|
|
88
|
+
}
|
|
89
|
+
if (!authToken) log('[gateway] warning: --no-auth — any local process can drive the agent on this port.\n');
|
|
90
|
+
const allowlistArr = (cfg.pairing || []).map((p) => String(p.id));
|
|
91
|
+
if (allowlistArr.length === 0) {
|
|
92
|
+
log('[gateway] warning: pairing allowlist is empty — the agent will answer ANYONE who can reach a connected channel. Pair senders with `lazyclaw pairing add <id>`.\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Live sender map: channels register here as they come up; the daemon's
|
|
96
|
+
// handoff route reads it per-request (live Map, registrations visible).
|
|
97
|
+
const channelSenders = new Map();
|
|
98
|
+
|
|
99
|
+
const sessionsMod = await import('../sessions.mjs');
|
|
100
|
+
const { startDaemon } = await import('../daemon.mjs');
|
|
101
|
+
const startDaemonImpl = deps.startDaemonImpl || startDaemon;
|
|
102
|
+
const d = await startDaemonImpl({
|
|
103
|
+
port: flags.port !== undefined ? parseInt(flags.port, 10) : 19600,
|
|
104
|
+
once: false,
|
|
105
|
+
readConfig,
|
|
106
|
+
writeConfig: authToken ? writeConfig : undefined,
|
|
107
|
+
sessionsDirGetter: () => cfgDir,
|
|
108
|
+
sessionsMod,
|
|
109
|
+
version: () => readVersionFromRepo(),
|
|
110
|
+
authToken: authToken || undefined,
|
|
111
|
+
allowedOrigins: [],
|
|
112
|
+
logger: deps.logger || null,
|
|
113
|
+
channelSenders,
|
|
114
|
+
});
|
|
115
|
+
log(`[gateway] daemon core on http://127.0.0.1:${d.port}\n`);
|
|
116
|
+
|
|
117
|
+
const daemonUrl = `http://127.0.0.1:${d.port}`;
|
|
118
|
+
const factories = { ...DEFAULT_FACTORIES, ...(deps.channelFactories || {}) };
|
|
119
|
+
const wanted = _selectChannels(cfg, flags);
|
|
120
|
+
if (wanted.length === 0) {
|
|
121
|
+
log('[gateway] no channels configured/enabled — running the daemon core only. Add one with `lazyclaw setup` or pass --channels slack,telegram,matrix.\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const channels = [];
|
|
125
|
+
const skipped = [];
|
|
126
|
+
for (const name of wanted) {
|
|
127
|
+
const handler = makeInboundHandler({
|
|
128
|
+
channel: name, daemonUrl, daemonToken: authToken,
|
|
129
|
+
provider: flags.provider, model: flags.model,
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
const ch = await factories[name]({
|
|
133
|
+
handler,
|
|
134
|
+
logger: (line) => log(line),
|
|
135
|
+
allowlist: allowlistArr.length ? allowlistArr : null,
|
|
136
|
+
});
|
|
137
|
+
channels.push({ name, ch });
|
|
138
|
+
channelSenders.set(name, (externalId, text) => ch.send(externalId, text));
|
|
139
|
+
log(`[gateway] ${name}: connected\n`);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// One channel missing creds must not take down the others.
|
|
142
|
+
skipped.push({ name, error: err?.message || String(err) });
|
|
143
|
+
log(`[gateway] ${name}: skipped (${err?.message || err})\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stop = async () => {
|
|
148
|
+
for (const { name, ch } of channels) {
|
|
149
|
+
try { await ch.stop(); } catch { /* best-effort drain */ }
|
|
150
|
+
channelSenders.delete(name);
|
|
151
|
+
}
|
|
152
|
+
// A wedged in-flight request must not hang shutdown forever — the crash
|
|
153
|
+
// handler awaits stop() before exiting, and a hang there would leave a
|
|
154
|
+
// crashed gateway alive-but-dead with no service-manager restart.
|
|
155
|
+
await Promise.race([
|
|
156
|
+
d.close(),
|
|
157
|
+
new Promise((r) => setTimeout(r, 8_000)),
|
|
158
|
+
]);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return { port: d.port, channels, channelSenders, skipped, stop, authToken };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function cmdGateway(flags = {}) {
|
|
165
|
+
let gw;
|
|
166
|
+
try {
|
|
167
|
+
gw = await runGateway(flags);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error(`gateway: ${err?.message || err}`);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write(JSON.stringify({
|
|
173
|
+
ok: true,
|
|
174
|
+
url: `http://127.0.0.1:${gw.port}`,
|
|
175
|
+
port: gw.port,
|
|
176
|
+
channels: gw.channels.map((c) => c.name),
|
|
177
|
+
skipped: gw.skipped,
|
|
178
|
+
}) + '\n');
|
|
179
|
+
process.stderr.write('[gateway] running. Ctrl-C to stop.\n');
|
|
180
|
+
|
|
181
|
+
installCrashHandlers({ label: 'gateway', stop: () => gw.stop() });
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
let shuttingDown = false;
|
|
184
|
+
const onSig = async () => {
|
|
185
|
+
if (shuttingDown) return process.exit(1);
|
|
186
|
+
shuttingDown = true;
|
|
187
|
+
process.stderr.write('\n[gateway] shutting down…\n');
|
|
188
|
+
try { await gw.stop(); } catch { /* best-effort */ }
|
|
189
|
+
resolve();
|
|
190
|
+
};
|
|
191
|
+
process.once('SIGINT', onSig);
|
|
192
|
+
process.once('SIGTERM', onSig);
|
|
193
|
+
});
|
|
194
|
+
}
|
package/commands/providers.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { readConfig, writeConfig } from '../lib/config.mjs';
|
|
4
4
|
import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
5
5
|
import { _fetchModelsForProvider } from '../tui/pickers.mjs';
|
|
6
|
+
import { probeProvider } from '../providers/probe.mjs';
|
|
6
7
|
|
|
7
8
|
export async function cmdRates(sub, positional, flags = {}) {
|
|
8
9
|
// Manage cfg.rates without hand-editing JSON. Same shape as
|
|
@@ -234,47 +235,15 @@ export async function cmdProviders(sub, positional, flags = {}) {
|
|
|
234
235
|
}
|
|
235
236
|
// cfg already declared above for the all-mode branch; reuse it.
|
|
236
237
|
const meta = getRegistry().PROVIDER_INFO[name] || {};
|
|
237
|
-
// --model
|
|
238
|
-
// lifted them out of positional). --model wins over config.model
|
|
239
|
-
// wins over PROVIDER_INFO.defaultModel.
|
|
238
|
+
// --model wins over config.model wins over PROVIDER_INFO.defaultModel.
|
|
240
239
|
const model = flags.model || cfg.model || meta.defaultModel || 'unknown';
|
|
241
240
|
const prompt = flags.prompt || 'ping';
|
|
242
241
|
const apiKey = cfg['api-key'] || '';
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// support a timeout flag here — the user can SIGINT if a
|
|
249
|
-
// provider hangs.
|
|
250
|
-
let reply = '';
|
|
251
|
-
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
252
|
-
for await (const chunk of stream) {
|
|
253
|
-
if (typeof chunk === 'string') reply += chunk;
|
|
254
|
-
}
|
|
255
|
-
const durationMs = Date.now() - t0;
|
|
256
|
-
const ok = reply.length > 0;
|
|
257
|
-
console.log(JSON.stringify({
|
|
258
|
-
ok,
|
|
259
|
-
provider: name,
|
|
260
|
-
model,
|
|
261
|
-
durationMs,
|
|
262
|
-
replyLength: reply.length,
|
|
263
|
-
reply: reply.slice(0, 200) + (reply.length > 200 ? '…' : ''),
|
|
264
|
-
}, null, 2));
|
|
265
|
-
process.exit(ok ? 0 : 1);
|
|
266
|
-
} catch (err) {
|
|
267
|
-
const durationMs = Date.now() - t0;
|
|
268
|
-
console.log(JSON.stringify({
|
|
269
|
-
ok: false,
|
|
270
|
-
provider: name,
|
|
271
|
-
model,
|
|
272
|
-
durationMs,
|
|
273
|
-
error: err?.message || String(err),
|
|
274
|
-
code: err?.code || null,
|
|
275
|
-
}, null, 2));
|
|
276
|
-
process.exit(1);
|
|
277
|
-
}
|
|
242
|
+
// Shared, no-exit probe (providers/probe.mjs). The CLI prints JSON and
|
|
243
|
+
// exits; the setup wizard renders one line and keeps going.
|
|
244
|
+
const result = await probeProvider({ name, model, prompt, apiKey });
|
|
245
|
+
console.log(JSON.stringify(result, null, 2));
|
|
246
|
+
process.exit(result.ok ? 0 : 1);
|
|
278
247
|
}
|
|
279
248
|
case 'add': {
|
|
280
249
|
// Register an OpenAI-compatible custom endpoint non-interactively.
|
|
@@ -437,6 +406,16 @@ export async function cmdOrchestrator(sub, positional, _flags = {}) {
|
|
|
437
406
|
}, null, 2));
|
|
438
407
|
return;
|
|
439
408
|
}
|
|
409
|
+
case 'on':
|
|
410
|
+
case 'off': {
|
|
411
|
+
// Route cfg.provider to/from 'orchestrator' (shared with /orchestrator).
|
|
412
|
+
const cf = await import('../config_features.mjs');
|
|
413
|
+
cf.orchestratorEnable(cfg, sub === 'on');
|
|
414
|
+
writeConfig(cfg);
|
|
415
|
+
const w = Array.isArray(orch.workers) ? orch.workers.length : 0;
|
|
416
|
+
console.log(JSON.stringify({ ok: true, enabled: sub === 'on', provider: cfg.provider, ...(sub === 'on' && w === 0 ? { warning: 'no workers configured — add one: lazyclaw orchestrator workers add <provider[:model]>' } : {}) }, null, 2));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
440
419
|
case 'set-planner': {
|
|
441
420
|
try {
|
|
442
421
|
const spec = validateSpec(positional[0]);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// commands/service.mjs — `lazyclaw service <install|uninstall|status|restart>`.
|
|
2
|
+
// Wraps a lazyclaw long-running command as an always-on OS service via
|
|
3
|
+
// lib/service_install.mjs (launchd / systemd / detached-fallback).
|
|
4
|
+
//
|
|
5
|
+
// v1 manages the DAEMON — the single always-on agent core. Once the channel
|
|
6
|
+
// listeners forward into the daemon's /inbound (next phase), the daemon is
|
|
7
|
+
// the one process that has to stay up; channel surfaces graduate to their own
|
|
8
|
+
// service targets in a later phase.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { configPath, readConfig } from '../lib/config.mjs';
|
|
13
|
+
import { assertUnattendedSafe, assertServicePairing } from '../lib/gateway_guard.mjs';
|
|
14
|
+
import { detectBackend, installService, uninstallService, serviceStatus } from '../lib/service_install.mjs';
|
|
15
|
+
|
|
16
|
+
const SUPPORTED_SURFACES = new Set(['daemon', 'gateway']);
|
|
17
|
+
|
|
18
|
+
function hasSystemctl() {
|
|
19
|
+
try {
|
|
20
|
+
const r = spawnSync('systemctl', ['--version'], { stdio: 'ignore' });
|
|
21
|
+
return !r.error && r.status === 0;
|
|
22
|
+
} catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cliPath() {
|
|
26
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.mjs');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Exported for tests: builds the service spec (wrapped argv + backend) without
|
|
30
|
+
// any side effects.
|
|
31
|
+
export function _buildSpec(surface, flags = {}, cfgDir = '', deps = {}) {
|
|
32
|
+
const hasSystemd = typeof deps.hasSystemctl === 'function' ? deps.hasSystemctl() : hasSystemctl();
|
|
33
|
+
const args = [deps.cliPath ? deps.cliPath() : cliPath(), surface];
|
|
34
|
+
// Default the service to the well-known port the channel listeners dial
|
|
35
|
+
// (the LAZYCLAW_DAEMON_URL default), so `service install` + `* listen`
|
|
36
|
+
// work together out of the box. Bare `lazyclaw daemon` still defaults to a
|
|
37
|
+
// random port for ad-hoc / scripted use.
|
|
38
|
+
args.push('--port', flags.port !== undefined ? String(flags.port) : '19600');
|
|
39
|
+
if (flags['auth-token']) args.push('--auth-token', String(flags['auth-token']));
|
|
40
|
+
if (flags.log) args.push('--log', String(flags.log));
|
|
41
|
+
if (flags.channels) args.push('--channels', String(flags.channels));
|
|
42
|
+
return {
|
|
43
|
+
name: surface,
|
|
44
|
+
surface,
|
|
45
|
+
execPath: process.execPath,
|
|
46
|
+
args,
|
|
47
|
+
workingDir: deps.cwd || process.cwd(),
|
|
48
|
+
configDir: cfgDir,
|
|
49
|
+
description: `lazyclaw always-on ${surface}`,
|
|
50
|
+
env: { LAZYCLAW_CONFIG_DIR: cfgDir },
|
|
51
|
+
backend: detectBackend({ override: flags.backend || null, hasSystemctl: hasSystemd }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function cmdService(sub, positional = [], flags = {}) {
|
|
56
|
+
const surface = (positional[0] || 'daemon').toLowerCase();
|
|
57
|
+
if (!SUPPORTED_SURFACES.has(surface)) {
|
|
58
|
+
console.error(`service: only '${[...SUPPORTED_SURFACES].join("', '")}' is supported in this version (channel gateway lands in a later phase). Got: ${surface}`);
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
const cfg = readConfig();
|
|
62
|
+
const cfgDir = path.dirname(configPath());
|
|
63
|
+
|
|
64
|
+
// Fail closed before installing: never wrap a remote surface as a service
|
|
65
|
+
// while the global unattended-sensitive tool override is on (RCE). Channel
|
|
66
|
+
// surfaces additionally require a non-empty pairing allowlist.
|
|
67
|
+
try {
|
|
68
|
+
assertUnattendedSafe(cfg, { surface });
|
|
69
|
+
if (surface !== 'daemon') assertServicePairing(cfg, { service: true, surface });
|
|
70
|
+
} catch (e) { console.error(e.message); process.exit(2); }
|
|
71
|
+
|
|
72
|
+
const spec = _buildSpec(surface, flags, cfgDir);
|
|
73
|
+
const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (sub === 'install') {
|
|
77
|
+
const r = installService(spec);
|
|
78
|
+
emit({ ok: true, action: 'install', ...r });
|
|
79
|
+
if (r.backend === 'fallback') {
|
|
80
|
+
process.stderr.write('note: no service manager detected — running detached with a pidfile. This survives the terminal but NOT a reboot. Re-run `lazyclaw service install` after reboot, or install launchd/systemd.\n');
|
|
81
|
+
// The detached child's fate is invisible (stdio ignored), so verify it
|
|
82
|
+
// actually stayed up — a daemon that hit EADDRINUSE (port already in
|
|
83
|
+
// use) exits within ~200ms. Surface that instead of a false "ok".
|
|
84
|
+
await new Promise((s) => setTimeout(s, 800));
|
|
85
|
+
const st = serviceStatus(spec);
|
|
86
|
+
if (!st.running) {
|
|
87
|
+
const port = flags.port !== undefined ? String(flags.port) : '19600';
|
|
88
|
+
process.stderr.write(`warning: the daemon did not stay up — port ${port} may already be in use (a daemon is already running?). Check: lsof -ti tcp:${port}. Free it or pass --port, then re-run.\n`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (sub === 'uninstall' || sub === 'remove') {
|
|
94
|
+
emit({ ok: true, action: 'uninstall', ...uninstallService(spec) });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (sub === 'status') {
|
|
98
|
+
emit({ ok: true, action: 'status', ...serviceStatus(spec) });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (sub === 'restart') {
|
|
102
|
+
try { uninstallService(spec); } catch { /* not installed yet */ }
|
|
103
|
+
emit({ ok: true, action: 'restart', ...installService(spec) });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
console.error(`service: ${e.message}`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.error('Usage: lazyclaw service <install|uninstall|status|restart> [daemon|gateway] [--port N] [--auth-token T] [--log LEVEL] [--channels a,b] [--backend launchd|systemd|fallback]');
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
package/commands/setup.mjs
CHANGED
|
@@ -22,6 +22,8 @@ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOK
|
|
|
22
22
|
import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
|
|
23
23
|
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from '../tui/slash_dispatcher.mjs';
|
|
24
24
|
import { SLASH_COMMANDS } from '../tui/slash_commands.mjs';
|
|
25
|
+
import { runChannelStep, runWebhookStep, runOrchestratorStep, runContextStep } from './setup_channels.mjs';
|
|
26
|
+
import { splashPropsForSetup, renderSplashToString } from '../tui/splash_props.mjs';
|
|
25
27
|
|
|
26
28
|
function applyOnboardConfig(currentCfg, flags) {
|
|
27
29
|
// Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
|
|
@@ -60,7 +62,7 @@ export async function cmdOnboard(flags) {
|
|
|
60
62
|
const ask = q => new Promise(resolve => rl.question(q, resolve));
|
|
61
63
|
if (!flags.provider) {
|
|
62
64
|
const provs = Object.keys(getRegistry().PROVIDERS).join('|');
|
|
63
|
-
const noKeyHint = '\x1b[38;
|
|
65
|
+
const noKeyHint = '\x1b[38;2;217;179;90mclaude-cli\x1b[0m (subscription, no key) is the default';
|
|
64
66
|
process.stdout.write(`hint: ${noKeyHint}\n`);
|
|
65
67
|
flags.provider = (await ask(`provider [${provs}]: `)).trim() || 'claude-cli';
|
|
66
68
|
}
|
|
@@ -117,7 +119,7 @@ const HELP_SUMMARIES = {
|
|
|
117
119
|
workspace: 'AGENTS.md / SOUL.md / TOOLS.md system-prompt convention (workspace list|init|show|remove|path)',
|
|
118
120
|
browse: 'Fetch a URL and emit Markdown on stdout (browse <url> [--max-bytes <N>])',
|
|
119
121
|
cron: 'Schedule recurring agent runs via launchd / crontab (cron list|add|remove|show|sync|run)',
|
|
120
|
-
setup: '
|
|
122
|
+
setup: 'Hermes-style phased first-run wizard (provider + verify chat + channel + workspace + skill + webhook)',
|
|
121
123
|
dashboard: 'Launch the lazyclaw-only web UI (lighter than the full lazyclaude dashboard)',
|
|
122
124
|
inspect: 'Print persisted workflow state without executing',
|
|
123
125
|
clear: 'Delete a persisted workflow state file (idempotent)',
|
|
@@ -158,7 +160,7 @@ const HELP_DETAILS = {
|
|
|
158
160
|
workspace: 'Usage: lazyclaw workspace <list | init <name> | show <name> [<file>] | remove <name> | path <name>>\n Workspace = a directory under <configDir>/workspaces/<name>/ containing AGENTS.md, SOUL.md, TOOLS.md.\n When `chat` or `agent` is invoked with --workspace <name>, the three files are stitched into a single system prompt at the head of the conversation. Missing files are skipped silently.\n init scaffolds the three files with short stubs you replace.\n show prints the composed prompt; show <name> AGENTS.md (etc) prints just one file.',
|
|
159
161
|
browse: 'Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--user-agent <ua>] [--meta]\n Fetches the URL and emits Markdown on stdout. Pipes cleanly into `agent`:\n lazyclaw browse https://example.com/docs | lazyclaw agent -\n Strips <script>/<style>/<svg>/comments, prefers <main>/<article>, falls back to <body>.\n --max-bytes caps the body read (default 2 MB) so a misconfigured upstream can\'t OOM the process.\n --meta prints { url, title, bytes, truncated } as JSON to stderr alongside the markdown on stdout.',
|
|
160
162
|
cron: 'Usage: lazyclaw cron <list | add <name> "<cron-spec>" -- <cmd> ... | remove <name> | show <name> | sync | run <name>>\n Schedule recurring agent runs. macOS uses launchd (~/Library/LaunchAgents/com.lazyclaw.<name>.plist); Linux / WSL uses the user crontab.\n Cron spec is the standard 5-field form (minute hour dom month dow). Supports *, range a-b, list a,b,c, step */N.\n add: pass the command after `--`. Typical use:\n lazyclaw cron add daily-summary "0 9 * * 1-5" -- lazyclaw agent "Summarise today\'s TODOs"\n list / show: read from cfg.cron[name] (config is the source of truth).\n sync: re-installs every job in cfg.cron into the system scheduler — handy after a reinstall.\n run: one-shot in-process execution of the named job; the OS scheduler does the same thing on its trigger.\n Logs: ~/.lazyclaw/logs/cron-<name>.{out,err}.log (macOS launchd path).',
|
|
161
|
-
setup: 'Usage: lazyclaw setup [--skip-test]\n
|
|
163
|
+
setup: 'Usage: lazyclaw setup [--skip-test]\n Hermes-style phased first-run wizard — get one clean chat working first,\n then optionally add the rest. Walks through:\n 1. Provider + model + api-key (delegates to onboard --pick; ≥64k-context tip)\n 2. Verify the provider responds (1-token ping; --skip-test bypasses)\n 3. Optional channel / gateway (Slack / Telegram / Matrix / HTTP built in;\n Discord / Email / Signal / Voice / WhatsApp via plugin packages)\n 4. Optional workspace init (AGENTS.md / SOUL.md / TOOLS.md)\n 5. Optional skill bundle install from GitHub\n 6. Optional outbound webhook (Slack / Discord)\n 7. Optional multi-agent orchestration (planner + workers)\n Each optional step takes Enter or "skip" to bypass. Re-runnable safely.\n Also fires automatically on first run when `lazyclaw` is invoked with no config.',
|
|
162
164
|
dashboard: 'Usage: lazyclaw dashboard [--port <N>] [--no-open]\n Launches the lazyclaw-only web UI on http://127.0.0.1:<port> (default 19600) and opens it in the default browser.\n Wraps `lazyclaw daemon` + a static HTML; no Python / lazyclaude dashboard required.\n See web/dashboard.html for the current tab set (v5: Chat / Sessions / Workflows / Skills / Providers / Rates / Metrics / Doctor / Config / Status / Agents / Teams / Tasks / Trainer / Recall / Sandbox / Channels).\n --no-open keeps the browser closed (handy for SSH / headless / dev). The bound URL is always printed to stdout.',
|
|
163
165
|
orchestrator: 'Usage: lazyclaw orchestrator <status | set-planner <provider[:model]> | workers add <spec> | workers remove <spec> | workers set <spec,spec,...> | workers clear | set-max-subtasks <N> | clear>\n Read/write cfg.orchestrator without editing config.json by hand.\n status — print {planner, workers, maxSubtasks} as JSON; lists registered providers for reference.\n set-planner — replace the planner spec ("provider" or "provider:model"). "orchestrator" itself is rejected (self-recursion).\n workers add — append a worker (idempotent — duplicates skipped).\n workers remove — drop a worker by exact match. Idempotent.\n workers set — replace the whole list (comma-separated specs).\n workers clear — empty the workers list.\n set-max-subtasks <N> — cap subtasks per request, clamped 1..10 (default 5).\n clear — delete the cfg.orchestrator block entirely.\n Pair with: `lazyclaw config set provider orchestrator` to route chats through it.',
|
|
164
166
|
};
|
|
@@ -187,7 +189,7 @@ export function cmdHelp(name) {
|
|
|
187
189
|
|
|
188
190
|
export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
189
191
|
await ensureRegistry();
|
|
190
|
-
const accent = (s) => `\x1b[38;
|
|
192
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
191
193
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
192
194
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
193
195
|
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
@@ -195,17 +197,18 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
195
197
|
|
|
196
198
|
// Header.
|
|
197
199
|
if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
|
|
198
|
-
|
|
200
|
+
process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
|
|
199
201
|
process.stdout.write('\n');
|
|
200
202
|
process.stdout.write(` ${bold('🔧 Setup wizard')}\n`);
|
|
201
|
-
process.stdout.write(` ${dim('
|
|
203
|
+
process.stdout.write(` ${dim('Get one clean chat working first, then optionally add a channel, workspace, or skills. Press Enter to accept the default; type "skip" or "n" to bypass an optional step.')}\n\n`);
|
|
202
204
|
|
|
203
205
|
const cfg = readConfig();
|
|
204
206
|
const cfgDir = path.dirname(configPath());
|
|
207
|
+
const colors = { accent, bold, dim, ok, warn };
|
|
205
208
|
|
|
206
|
-
// ── Step 1: Provider + model (mandatory)
|
|
207
|
-
process.stdout.write(` ${accent('Step 1/
|
|
208
|
-
process.stdout.write(` ${dim('Opens the arrow-key picker.
|
|
209
|
+
// ── Step 1/7: Provider + model (mandatory) ──────────────────
|
|
210
|
+
process.stdout.write(` ${accent('Step 1/7 ·')} ${bold('Pick a provider + model')}\n`);
|
|
211
|
+
process.stdout.write(` ${dim('Opens the arrow-key picker. Tip: pick a model with ≥64k context — small windows can\'t hold multi-step tool-calling state.')}\n\n`);
|
|
209
212
|
await _quickPrompt(' ▶ press Enter to open the picker ');
|
|
210
213
|
try {
|
|
211
214
|
await cmdOnboard({ pick: true });
|
|
@@ -230,8 +233,38 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
230
233
|
}
|
|
231
234
|
process.stdout.write(`\n ${ok('✓ provider:')} ${cfgAfterOnboard.provider} ${dim('model:')} ${cfgAfterOnboard.model || '(default)'}\n\n`);
|
|
232
235
|
|
|
233
|
-
//
|
|
234
|
-
|
|
236
|
+
// Context window — asked right after the model pick (optional; Enter keeps
|
|
237
|
+
// defaults). Not a numbered step: it's part of the core model setup.
|
|
238
|
+
await runContextStep({ prompt: _quickPrompt, colors });
|
|
239
|
+
|
|
240
|
+
// ── Step 2/7: Verify one clean chat works ───────────────────
|
|
241
|
+
// Hermes rule: get a clean reply before layering on channels/skills.
|
|
242
|
+
process.stdout.write(` ${accent('Step 2/7 ·')} ${bold('Verify the provider responds')}\n`);
|
|
243
|
+
process.stdout.write(` ${dim('Sends a 1-token "ping" via `lazyclaw providers test`. Confirm a clean reply before layering on channels/skills.')}\n\n`);
|
|
244
|
+
const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
|
|
245
|
+
if (wantPing) {
|
|
246
|
+
try {
|
|
247
|
+
// No-exit probe (providers/probe.mjs) — the CLI `providers test` calls
|
|
248
|
+
// process.exit, which would kill the rest of this wizard. Render one
|
|
249
|
+
// concise line instead of the full JSON dump and keep going.
|
|
250
|
+
const { probeProvider } = await import('../providers/probe.mjs');
|
|
251
|
+
const r = await probeProvider({ name: cfgAfterOnboard.provider, model: cfgAfterOnboard.model || undefined });
|
|
252
|
+
if (r.ok) process.stdout.write(` ${ok('✓ ' + (r.reply || 'ok'))} ${dim(`· ${r.model || cfgAfterOnboard.provider} · ${r.durationMs}ms`)}\n`);
|
|
253
|
+
else process.stdout.write(` ${warn('✗ ' + (r.error || 'no reply'))} ${dim(`· retry: lazyclaw providers test ${cfgAfterOnboard.provider}`)}\n`);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
|
|
256
|
+
}
|
|
257
|
+
process.stdout.write('\n');
|
|
258
|
+
} else {
|
|
259
|
+
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Step 3/7: Channel / gateway (optional) ──────────────────
|
|
263
|
+
process.stdout.write(` ${accent('Step 3/7 ·')} ${bold('Where will you run it?')} ${dim('(optional)')}\n`);
|
|
264
|
+
await runChannelStep({ cfgDir, prompt: _quickPrompt, colors });
|
|
265
|
+
|
|
266
|
+
// ── Step 4/7: Optional workspace ────────────────────────────
|
|
267
|
+
process.stdout.write(` ${accent('Step 4/7 ·')} ${bold('Initialise a workspace?')} ${dim('(optional)')}\n`);
|
|
235
268
|
process.stdout.write(` ${dim('A workspace is a folder of AGENTS.md / SOUL.md / TOOLS.md prompt files that auto-inject into chat / agent. Skip if you don\'t need project-specific personas yet.')}\n\n`);
|
|
236
269
|
const wsName = (await _quickPrompt(' workspace name (Enter to skip): ')).trim();
|
|
237
270
|
if (wsName && /^[A-Za-z0-9_.-]+$/.test(wsName)) {
|
|
@@ -249,8 +282,8 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
249
282
|
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
250
283
|
}
|
|
251
284
|
|
|
252
|
-
// ── Step
|
|
253
|
-
process.stdout.write(` ${accent('Step
|
|
285
|
+
// ── Step 5/7: Optional skill bundle install ─────────────────
|
|
286
|
+
process.stdout.write(` ${accent('Step 5/7 ·')} ${bold('Install a skill bundle from GitHub?')} ${dim('(optional)')}\n`);
|
|
254
287
|
process.stdout.write(` ${dim('Format: <user>/<repo>[@<ref>]. Skills are .md prompt fragments that compose into the system prompt via --skill.')}\n\n`);
|
|
255
288
|
const skillSpec = (await _quickPrompt(' github spec (Enter to skip): ')).trim();
|
|
256
289
|
if (skillSpec) {
|
|
@@ -270,45 +303,13 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
270
303
|
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
271
304
|
}
|
|
272
305
|
|
|
273
|
-
// ── Step
|
|
274
|
-
process.stdout.write(` ${accent('Step
|
|
275
|
-
|
|
276
|
-
const hookName = (await _quickPrompt(' webhook name (Enter to skip): ')).trim();
|
|
277
|
-
if (hookName) {
|
|
278
|
-
const hookUrl = (await _quickPrompt(' webhook URL: ')).trim();
|
|
279
|
-
if (!hookUrl) {
|
|
280
|
-
process.stdout.write(` ${warn('skipped:')} URL required\n\n`);
|
|
281
|
-
} else {
|
|
282
|
-
try {
|
|
283
|
-
const cf = await import('../config_features.mjs');
|
|
284
|
-
const fresh = readConfig();
|
|
285
|
-
cf.messageAdd(fresh, hookName, hookUrl);
|
|
286
|
-
writeConfig(fresh);
|
|
287
|
-
process.stdout.write(` ${ok('✓ webhook saved:')} ${hookName}\n\n`);
|
|
288
|
-
} catch (e) {
|
|
289
|
-
process.stdout.write(` ${warn('skipped:')} ${e?.message || e}\n\n`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
} else {
|
|
293
|
-
process.stdout.write(` ${dim('— skipped —')}\n\n`);
|
|
294
|
-
}
|
|
306
|
+
// ── Step 6/7: Optional outbound webhook ─────────────────────
|
|
307
|
+
process.stdout.write(` ${accent('Step 6/7 ·')} ${bold('Add an outbound webhook?')} ${dim('(optional)')}\n`);
|
|
308
|
+
await runWebhookStep({ prompt: _quickPrompt, colors });
|
|
295
309
|
|
|
296
|
-
// ── Step
|
|
297
|
-
process.stdout.write(` ${accent('Step
|
|
298
|
-
|
|
299
|
-
const wantPing = !flags['skip-test'] && (await _quickPrompt(' test now? [Y/n] ')).trim().toLowerCase() !== 'n';
|
|
300
|
-
if (wantPing) {
|
|
301
|
-
try {
|
|
302
|
-
// Reuse the existing providers-test path so behaviour matches
|
|
303
|
-
// a manual `lazyclaw providers test`.
|
|
304
|
-
await (await import('../commands/providers.mjs')).cmdProviders('test', [cfgAfterOnboard.provider], {});
|
|
305
|
-
} catch (e) {
|
|
306
|
-
process.stdout.write(` ${warn('test errored:')} ${e?.message || e}\n`);
|
|
307
|
-
process.stdout.write(` ${dim('Setup still completed; you can retry with:')} lazyclaw providers test ${cfgAfterOnboard.provider}\n`);
|
|
308
|
-
}
|
|
309
|
-
} else {
|
|
310
|
-
process.stdout.write(` ${dim('— skipped —')}\n`);
|
|
311
|
-
}
|
|
310
|
+
// ── Step 7/7: Optional multi-agent orchestration ────────────
|
|
311
|
+
process.stdout.write(` ${accent('Step 7/7 ·')} ${bold('Enable multi-agent orchestration?')} ${dim('(optional)')}\n`);
|
|
312
|
+
await runOrchestratorStep({ prompt: _quickPrompt, colors });
|
|
312
313
|
|
|
313
314
|
// ── Wrap up ─────────────────────────────────────────────────
|
|
314
315
|
process.stdout.write('\n');
|
|
@@ -327,11 +328,11 @@ export async function cmdSetup(_sub, _positional, flags = {}) {
|
|
|
327
328
|
// exits politely instead of dropping into a menu where every option
|
|
328
329
|
// would error.
|
|
329
330
|
async function _runFirstTimeOnboard() {
|
|
330
|
-
const accent = (s) => `\x1b[38;
|
|
331
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
331
332
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
332
333
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
333
334
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
334
|
-
|
|
335
|
+
process.stdout.write(renderSplashToString(await splashPropsForSetup({ version: readVersionFromRepo() })) + '\n');
|
|
335
336
|
process.stdout.write('\n');
|
|
336
337
|
process.stdout.write(` ${bold('👋 Welcome — first-time setup')}\n\n`);
|
|
337
338
|
process.stdout.write(` ${dim('No provider configured yet at')} ${configPath()}\n`);
|
|
@@ -513,7 +514,7 @@ export async function cmdLauncher() {
|
|
|
513
514
|
{ id: 'quit', label: 'Quit', desc: 'exit lazyclaw', argv: null },
|
|
514
515
|
];
|
|
515
516
|
|
|
516
|
-
const accent = (s) => `\x1b[38;
|
|
517
|
+
const accent = (s) => `\x1b[38;2;217;179;90m${s}\x1b[0m`;
|
|
517
518
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
518
519
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
519
520
|
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|