lazyclaw 6.0.1 → 6.1.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.
@@ -158,7 +158,7 @@ export class MatrixChannel extends Channel {
158
158
  if (typeof opts.since === 'string') this._since = opts.since;
159
159
  this._timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : LONG_POLL_MS;
160
160
  const poll = opts.poll !== false; // default true
161
- if (poll) this._startPollLoop({ logger: this._logger });
161
+ if (poll) this._startPollLoop({ logger: this._logger, onDead: opts.onDead });
162
162
  return this;
163
163
  }
164
164
 
@@ -191,8 +191,9 @@ export class MatrixChannel extends Channel {
191
191
  // base.mjs's bucket gate reads req.token || req.key, so the sender
192
192
  // id rides under `key`: an authToken gate compares against it and a
193
193
  // rate-limit gate keys per-sender. We keep senderId for downstream
194
- // handler context too.
195
- gateInput: { key: evt.senderId, senderId: evt.senderId },
194
+ // handler context, and the globally-unique event_id for daemon-
195
+ // side dedup.
196
+ gateInput: { key: evt.senderId, senderId: evt.senderId, messageId: evt.eventId },
196
197
  });
197
198
  } catch (err) {
198
199
  if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
@@ -253,6 +254,7 @@ export class MatrixChannel extends Channel {
253
254
  threadId,
254
255
  text,
255
256
  senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
257
+ messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
256
258
  });
257
259
  }
258
260
 
@@ -320,8 +322,16 @@ export class MatrixChannel extends Channel {
320
322
  // backs off rather than crashing the daemon. The in-flight AbortController
321
323
  // is held so stop() can abort the ~30s held-open request for prompt
322
324
  // shutdown.
323
- _startPollLoop({ logger }) {
325
+ _startPollLoop({ logger, onDead }) {
324
326
  let stopped = false;
327
+ // Abnormal loop death must be VISIBLE to a live process: a gateway whose
328
+ // matrix poll silently exits looks healthy while being deaf on that
329
+ // channel. Default: throw from a fresh tick so the process crash
330
+ // handlers fire (and a service manager restarts it).
331
+ const dead = (err) => {
332
+ if (typeof onDead === 'function') return onDead(err);
333
+ setImmediate(() => { throw err; });
334
+ };
325
335
  const loop = async () => {
326
336
  while (!stopped) {
327
337
  try {
@@ -335,12 +345,20 @@ export class MatrixChannel extends Channel {
335
345
  }
336
346
  } catch (err) {
337
347
  if (stopped) break;
338
- if (err?.name === 'AbortError' || err?.code === 'MATRIX_ABORTED') break;
348
+ if (err?.name === 'AbortError' || err?.code === 'MATRIX_ABORTED') {
349
+ // Aborted while NOT stopping = some other actor killed the
350
+ // in-flight sync — that's a dead listener, not a clean exit.
351
+ logger(`[matrix] sync aborted outside shutdown — stopping listener\n`);
352
+ this._fatal = err;
353
+ dead(err);
354
+ break;
355
+ }
339
356
  // A dead/forbidden token will never recover — stop the listener
340
357
  // and surface it rather than spinning forever on a 500ms back-off.
341
358
  if (err?.code === 'MATRIX_AUTH_FATAL') {
342
359
  logger(`[matrix] FATAL: ${err.message} — stopping listener\n`);
343
360
  this._fatal = err;
361
+ dead(err);
344
362
  break;
345
363
  }
346
364
  logger(`[matrix] poll error: ${err?.message || err}\n`);
@@ -19,17 +19,10 @@
19
19
  // Phase 8 spec can point the adapter at a local mock HTTP server.
20
20
 
21
21
  import { Channel, ChannelGated } from './base.mjs';
22
+ import { SlackError, readSlackEnv, validateEnv } from './slack_env.mjs';
22
23
 
23
- const DEFAULT_API_BASE = 'https://slack.com/api';
24
-
25
- export class SlackError extends Error {
26
- constructor(message, code, missing) {
27
- super(message);
28
- this.name = 'SlackError';
29
- this.code = code || 'SLACK_ERR';
30
- if (Array.isArray(missing)) this.missing = missing;
31
- }
32
- }
24
+ // Re-exported for compatibility (split out under the file-size ratchet).
25
+ export { SlackError, readSlackEnv } from './slack_env.mjs';
33
26
 
34
27
  // Decide whether a Socket Mode event should drive a handler call.
35
28
  // Pulled out of dispatchEvent so we can unit-test the filter without
@@ -57,34 +50,6 @@ export function shouldDispatchEvent(event, { selfUserId = null, selfBotId = null
57
50
  return true;
58
51
  }
59
52
 
60
- export function readSlackEnv(env = process.env) {
61
- const out = {
62
- botToken: env.SLACK_BOT_TOKEN || null,
63
- appToken: env.SLACK_APP_TOKEN || null,
64
- signingSecret: env.SLACK_SIGNING_SECRET || null,
65
- apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
66
- };
67
- return out;
68
- }
69
-
70
- function validateEnv(env, { requireInbound = false } = {}) {
71
- const missing = [];
72
- if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
73
- else if (!env.botToken.startsWith('xoxb-')) {
74
- throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
75
- }
76
- if (requireInbound) {
77
- if (!env.appToken) missing.push('SLACK_APP_TOKEN');
78
- else if (!env.appToken.startsWith('xapp-')) {
79
- throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
80
- }
81
- if (!env.signingSecret) missing.push('SLACK_SIGNING_SECRET');
82
- }
83
- if (missing.length) {
84
- throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
85
- }
86
- }
87
-
88
53
  export class SlackChannel extends Channel {
89
54
  constructor(opts = {}) {
90
55
  super('slack');
@@ -112,10 +77,10 @@ export class SlackChannel extends Channel {
112
77
  // handler that decided to stay silent — e.g. the listener dropping
113
78
  // an empty-after-mention-strip inbound — doesn't leak a "(empty
114
79
  // reply)" placeholder into the channel.
115
- async _simulateInbound(text, threadId) {
80
+ async _simulateInbound(text, threadId, senderId = null, messageId = null) {
116
81
  let reply;
117
82
  try {
118
- reply = await this._processInbound({ threadId, text, gateInput: {} });
83
+ reply = await this._processInbound({ threadId, text, gateInput: { key: senderId, senderId, messageId } });
119
84
  } catch (err) {
120
85
  if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
121
86
  await this.send(threadId, `(gated: ${err.message})`);
@@ -128,6 +93,30 @@ export class SlackChannel extends Channel {
128
93
  await this.send(threadId, reply);
129
94
  }
130
95
 
96
+ // The base _processInbound forwards { channel, threadId, text }; we enrich
97
+ // the event with senderId (the Slack user id) so the listener bridge can
98
+ // pass it to the daemon's /inbound pairing gate — without it, Slack is the
99
+ // one channel that can never be pairing-gated. Mirrors telegram/matrix;
100
+ // adds a field, never drops one.
101
+ async _processInbound({ threadId, text, gateInput }) {
102
+ if (this._gate) {
103
+ const verdict = this._gate.check(gateInput || {});
104
+ if (!verdict.ok) {
105
+ const err = new Error(verdict.reason || 'denied');
106
+ err.code = 'CHANNEL_GATED';
107
+ throw err;
108
+ }
109
+ }
110
+ if (!this._handler) throw new Error(`channel "${this.name}" has no handler`);
111
+ return await this._handler({
112
+ channel: this.name,
113
+ threadId,
114
+ text,
115
+ senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
116
+ messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
117
+ });
118
+ }
119
+
131
120
  // Translate a target spec like `slack:#deploys` or `slack:U012345` into
132
121
  // a Slack `channel` string. Threads are addressed by a `threadId` of
133
122
  // shape `<channel>:<thread_ts>` or plain channel/user id.
@@ -184,7 +173,11 @@ export class SlackChannel extends Channel {
184
173
  // opts.logger?: (line: string) => void — diagnostic sink (stderr in
185
174
  // the CLI, no-op in tests).
186
175
  // opts.maxReconnects?: number — cap reconnect attempts (default ∞).
187
- async _connectSocketMode({ logger = () => {}, maxReconnects = Infinity } = {}) {
176
+ // opts.onDead?: (err) => void called when the reconnect chain gives up
177
+ // for good. Default THROWS on the next tick so an always-on process
178
+ // crashes loudly (and a service manager restarts it) instead of sitting
179
+ // alive with a permanently dead socket.
180
+ async _connectSocketMode({ logger = () => {}, maxReconnects = Infinity, onDead } = {}) {
188
181
  validateEnv(this._env, { requireInbound: true });
189
182
  if (typeof globalThis.WebSocket !== 'function') {
190
183
  throw new SlackError(
@@ -284,6 +277,7 @@ export class SlackChannel extends Channel {
284
277
  // get app_mention events. Either way we have channel + ts.
285
278
  const text = typeof event.text === 'string' ? event.text : '';
286
279
  const channel = event.channel;
280
+ const senderId = event.user != null ? String(event.user) : null; // the human who sent it
287
281
  const sourceTs = event.ts; // the message we react to
288
282
  const replyTs = event.thread_ts || event.ts; // the thread root for replies
289
283
  if (!channel || !sourceTs) return;
@@ -292,6 +286,10 @@ export class SlackChannel extends Channel {
292
286
  return;
293
287
  }
294
288
  const threadId = `${channel}:${replyTs}`;
289
+ // Native message id for daemon-side dedup: ts is unique per channel,
290
+ // so channel:ts is unique bot-wide (and identical for the app_mention
291
+ // and message events of the same message — exactly what dedup wants).
292
+ const messageId = `${channel}:${sourceTs}`;
295
293
  logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
296
294
 
297
295
  // Immediate acknowledgement. _ackInbound is silent when
@@ -300,7 +298,7 @@ export class SlackChannel extends Channel {
300
298
  const eyesOk = await this._ackInbound(channel, sourceTs, logger);
301
299
 
302
300
  try {
303
- await this._simulateInbound(text, threadId);
301
+ await this._simulateInbound(text, threadId, senderId, messageId);
304
302
  if (eyesOk) {
305
303
  // Swap the "working" reaction for a "done" one so the user can
306
304
  // tell at a glance which messages have been answered.
@@ -316,17 +314,51 @@ export class SlackChannel extends Channel {
316
314
  }
317
315
  };
318
316
 
317
+ const dead = (err) => {
318
+ if (typeof onDead === 'function') return onDead(err);
319
+ // Surface the permanent failure instead of staying alive-but-deaf:
320
+ // throwing from a fresh tick reaches the process crash handlers.
321
+ setImmediate(() => { throw err; });
322
+ };
323
+
324
+ // Reschedule after ANY reconnect failure — including apps.connections.open
325
+ // rejecting — so one bad negotiation can't permanently kill the chain.
326
+ const scheduleReconnect = () => {
327
+ if (closed) return;
328
+ attempts++;
329
+ if (attempts > maxReconnects) {
330
+ logger(`[slack] giving up after ${attempts - 1} reconnect attempts\n`);
331
+ dead(new SlackError(`socket-mode reconnect gave up after ${attempts - 1} attempts`, 'SLACK_SOCKET_DEAD'));
332
+ return;
333
+ }
334
+ const backoff = Math.min(30000, 1000 * Math.pow(2, Math.min(attempts, 5)));
335
+ logger(`[slack] reconnecting in ${backoff}ms (attempt ${attempts})\n`);
336
+ setTimeout(() => {
337
+ if (closed) return;
338
+ connectOnce().catch((e) => {
339
+ logger(`[slack] reconnect failed: ${e?.message || e}\n`);
340
+ scheduleReconnect();
341
+ });
342
+ }, backoff);
343
+ };
344
+
319
345
  const connectOnce = () => new Promise((resolve, reject) => {
346
+ // Settle exactly once: a socket that closes (or errors) before 'open'
347
+ // REJECTS instead of leaving the promise pending forever — the initial
348
+ // caller surfaces it as a start failure; the reconnect path's .catch
349
+ // schedules the next attempt.
350
+ let settled = false;
320
351
  let wsUrl;
321
352
  openConnection()
322
353
  .then((u) => { wsUrl = u; })
323
- .catch(reject)
354
+ .catch((e) => { settled = true; reject(e); })
324
355
  .then(() => {
325
356
  if (!wsUrl) return;
326
357
  logger(`[slack] socket-mode dialing wss gateway\n`);
327
358
  ws = new WebSocket(wsUrl);
328
359
  ws.addEventListener('open', () => {
329
360
  attempts = 0;
361
+ settled = true;
330
362
  logger(`[slack] socket-mode connected\n`);
331
363
  resolve();
332
364
  });
@@ -362,15 +394,16 @@ export class SlackChannel extends Channel {
362
394
  });
363
395
  ws.addEventListener('close', () => {
364
396
  logger(`[slack] socket closed\n`);
365
- if (closed) return;
366
- attempts++;
367
- if (attempts > maxReconnects) {
368
- logger(`[slack] giving up after ${attempts} reconnect attempts\n`);
397
+ if (!settled) {
398
+ // Closed before 'open': settle the pending connect attempt.
399
+ // The caller's .catch (initial start OR scheduleReconnect)
400
+ // decides what happens next never double-schedule here.
401
+ settled = true;
402
+ reject(new SlackError('socket closed before open', 'SLACK_SOCKET_CLOSED_EARLY'));
369
403
  return;
370
404
  }
371
- const backoff = Math.min(30000, 1000 * Math.pow(2, Math.min(attempts, 5)));
372
- logger(`[slack] reconnecting in ${backoff}ms (attempt ${attempts})\n`);
373
- setTimeout(() => { if (!closed) connectOnce().catch((e) => logger(`[slack] reconnect failed: ${e?.message || e}\n`)); }, backoff);
405
+ if (closed) return;
406
+ scheduleReconnect();
374
407
  });
375
408
  ws.addEventListener('error', (ev) => {
376
409
  // The 'error' event fires before 'close'; we let 'close' drive
@@ -0,0 +1,45 @@
1
+ // channels/slack_env.mjs — Slack env reading + validation, split out of
2
+ // slack.mjs (file-size ratchet) so the adapter file has room for the
3
+ // socket-mode transport. Re-exported from slack.mjs for compatibility.
4
+
5
+ const DEFAULT_API_BASE = 'https://slack.com/api';
6
+
7
+ export class SlackError extends Error {
8
+ constructor(message, code, missing) {
9
+ super(message);
10
+ this.name = 'SlackError';
11
+ this.code = code || 'SLACK_ERR';
12
+ if (Array.isArray(missing)) this.missing = missing;
13
+ }
14
+ }
15
+
16
+ export function readSlackEnv(env = process.env) {
17
+ const out = {
18
+ botToken: env.SLACK_BOT_TOKEN || null,
19
+ appToken: env.SLACK_APP_TOKEN || null,
20
+ signingSecret: env.SLACK_SIGNING_SECRET || null,
21
+ apiBase: env.SLACK_API_BASE || DEFAULT_API_BASE,
22
+ };
23
+ return out;
24
+ }
25
+
26
+ export function validateEnv(env, { requireInbound = false } = {}) {
27
+ const missing = [];
28
+ if (!env.botToken) missing.push('SLACK_BOT_TOKEN');
29
+ else if (!env.botToken.startsWith('xoxb-')) {
30
+ throw new SlackError('SLACK_BOT_TOKEN must start with "xoxb-"', 'SLACK_BAD_TOKEN', ['SLACK_BOT_TOKEN']);
31
+ }
32
+ if (requireInbound) {
33
+ // Socket Mode (the inbound path) authenticates the WebSocket with the
34
+ // app-level token; SLACK_SIGNING_SECRET is only needed for the HTTP Events
35
+ // API (request-signature verification), which this adapter does not use, so
36
+ // it is NOT required here — requiring it blocked socket-mode setups.
37
+ if (!env.appToken) missing.push('SLACK_APP_TOKEN');
38
+ else if (!env.appToken.startsWith('xapp-')) {
39
+ throw new SlackError('SLACK_APP_TOKEN must start with "xapp-"', 'SLACK_BAD_TOKEN', ['SLACK_APP_TOKEN']);
40
+ }
41
+ }
42
+ if (missing.length) {
43
+ throw new SlackError(`missing Slack env vars: ${missing.join(', ')}`, 'SLACK_MISSING_ENV', missing);
44
+ }
45
+ }
@@ -130,6 +130,7 @@ export class TelegramChannel extends Channel {
130
130
  // backoff between iterations (default 0 — no extra sleep).
131
131
  pollIntervalMs: typeof opts.pollIntervalMs === 'number' ? opts.pollIntervalMs : 0,
132
132
  logger: this._logger,
133
+ onDead: opts.onDead,
133
134
  });
134
135
  }
135
136
  return this;
@@ -157,8 +158,20 @@ export class TelegramChannel extends Channel {
157
158
  // base.mjs's bucket gate reads req.token || req.key, so the sender
158
159
  // id rides under `key`: an authToken gate compares against it and a
159
160
  // rate-limit gate keys per-sender. We also keep senderId for
160
- // downstream handler context.
161
- gateInput: { key: evt.senderId, senderId: evt.senderId },
161
+ // downstream handler context, plus a dedup id for the daemon.
162
+ // PREFER update_id: it is stable when getUpdates redelivers the same
163
+ // update (offset is memory-only, so a restart replays the batch) but
164
+ // DISTINCT for an edit — an edited_message carries the ORIGINAL
165
+ // message_id, so keying on message_id would replay the stale answer
166
+ // instead of processing the edit. chatId-scoped message_id is only
167
+ // the fallback for direct _simulateInbound callers without update_id.
168
+ gateInput: {
169
+ key: evt.senderId,
170
+ senderId: evt.senderId,
171
+ messageId: evt.updateId != null
172
+ ? `${evt.chatId}:u${evt.updateId}`
173
+ : (evt.messageId ? `${evt.chatId}:${evt.messageId}` : null),
174
+ },
162
175
  });
163
176
  } catch (err) {
164
177
  if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
@@ -201,6 +214,7 @@ export class TelegramChannel extends Channel {
201
214
  threadId,
202
215
  text,
203
216
  senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
217
+ messageId: gateInput && gateInput.messageId != null ? gateInput.messageId : null,
204
218
  });
205
219
  }
206
220
 
@@ -248,6 +262,12 @@ export class TelegramChannel extends Channel {
248
262
  const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 15000;
249
263
  const controller = new AbortController();
250
264
  const timer = setTimeout(() => controller.abort(), timeoutMs);
265
+ // opts.track exposes this call's AbortController so stop() can cut a
266
+ // held-open long-poll short instead of waiting out the ~60s window
267
+ // (which can overrun a service manager's SIGTERM grace and get us
268
+ // SIGKILLed mid-drain). Only getUpdates opts in — aborting an in-flight
269
+ // send would drop a reply.
270
+ if (opts.track) this._inflightAbort = controller;
251
271
  let res;
252
272
  try {
253
273
  res = await fetch(url, {
@@ -263,6 +283,11 @@ export class TelegramChannel extends Channel {
263
283
  throw new TelegramError(`telegram ${method} transport error: ${err?.message || err}`, 'TELEGRAM_TRANSPORT');
264
284
  } finally {
265
285
  clearTimeout(timer);
286
+ if (opts.track) this._inflightAbort = null;
287
+ }
288
+ if (res.status === 401 || res.status === 403) {
289
+ // A dead/revoked token never recovers — fatal, not retryable.
290
+ throw new TelegramError(`telegram ${method} auth failed: HTTP ${res.status} (check TELEGRAM_BOT_TOKEN)`, 'TELEGRAM_AUTH_FATAL');
266
291
  }
267
292
  if (!res.ok) {
268
293
  throw new TelegramError(`telegram ${method} failed: HTTP ${res.status}`, 'TELEGRAM_HTTP_FAIL');
@@ -280,15 +305,30 @@ export class TelegramChannel extends Channel {
280
305
  // hands the batch to _processBatch which advances the ack offset only
281
306
  // for updates it processes successfully. Errors are logged and the loop
282
307
  // backs off rather than crashing the daemon.
283
- _startPollLoop({ pollIntervalMs, logger }) {
308
+ _startPollLoop({ pollIntervalMs, logger, onDead }) {
284
309
  let stopped = false;
310
+ // Mirror matrix: abnormal loop death is surfaced (default: throw on a
311
+ // fresh tick -> crash handlers -> service-manager restart), never silent.
312
+ const dead = (err) => {
313
+ if (typeof onDead === 'function') return onDead(err);
314
+ setImmediate(() => { throw err; });
315
+ };
285
316
  const loop = async () => {
286
317
  while (!stopped) {
287
318
  try {
288
319
  const updates = await this._fetchUpdates();
289
320
  await this._processBatch(updates, () => stopped);
290
321
  } catch (err) {
291
- logger(`[telegram] poll error: ${err?.message || err}\n`);
322
+ // A dead/revoked token never recovers — stop and surface instead
323
+ // of hammering the API on a 500ms back-off forever.
324
+ if (!stopped && err?.code === 'TELEGRAM_AUTH_FATAL') {
325
+ logger(`[telegram] FATAL: ${err.message} — stopping listener\n`);
326
+ this._fatal = err;
327
+ dead(err);
328
+ break;
329
+ }
330
+ // A shutdown abort is a clean exit, not an error worth logging.
331
+ if (!stopped) logger(`[telegram] poll error: ${err?.message || err}\n`);
292
332
  // On a transport/API error, back off a beat so we don't spin
293
333
  // hot against a failing endpoint even when pollIntervalMs is 0.
294
334
  if (!stopped) await new Promise((r) => setTimeout(r, Math.max(pollIntervalMs, 500)));
@@ -299,11 +339,14 @@ export class TelegramChannel extends Channel {
299
339
  if (pollIntervalMs > 0) await new Promise((r) => setTimeout(r, pollIntervalMs));
300
340
  }
301
341
  };
302
- // Fire and forget — stop() flips `stopped` and the loop exits.
342
+ // Fire and forget — stop() flips `stopped`, aborts the held-open
343
+ // getUpdates (prompt shutdown instead of waiting out the ~60s
344
+ // long-poll), and the loop exits.
303
345
  const promise = loop();
304
346
  this._pollHandle = {
305
347
  stop: async () => {
306
348
  stopped = true;
349
+ try { this._inflightAbort?.abort(); } catch { /* already settled */ }
307
350
  try { await promise; } catch { /* best-effort */ }
308
351
  },
309
352
  };
@@ -347,7 +390,7 @@ export class TelegramChannel extends Channel {
347
390
  const json = await this._apiCall(
348
391
  'getUpdates',
349
392
  { offset: this._offset, timeout: LONG_POLL_SECONDS },
350
- { timeoutMs: (LONG_POLL_SECONDS + 10) * 1000 }
393
+ { timeoutMs: (LONG_POLL_SECONDS + 10) * 1000, track: true }
351
394
  );
352
395
  return Array.isArray(json.result) ? json.result : [];
353
396
  }
package/cli.mjs CHANGED
@@ -311,27 +311,16 @@ async function main() {
311
311
  }
312
312
  case 'channels': {
313
313
  const sub = (rest.positional[0] || 'list').toLowerCase();
314
- const { createLoader, listInstalled } = await import('./channels/loader.mjs');
315
- const cfgDir = path.dirname(configPath());
316
- const loader = createLoader({ configDir: cfgDir });
317
- if (sub === 'install') {
318
- const name = rest.positional[1];
319
- if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
320
- const info = await loader.install(name);
321
- process.stdout.write(`installed ${info.name}@${info.version}\n`);
322
- break;
323
- }
324
- if (sub === 'remove' || sub === 'uninstall') {
325
- const name = rest.positional[1];
326
- if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
327
- await loader.remove(name);
328
- process.stdout.write(`removed ${name}\n`);
329
- break;
330
- }
331
- // list
332
- const rows = listInstalled(cfgDir);
333
- if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
334
- for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
314
+ await (await import('./commands/channels.mjs')).cmdChannels(sub, rest.positional.slice(1), rest.flags);
315
+ break;
316
+ }
317
+ case 'service': {
318
+ const sub = rest.positional[0];
319
+ await (await import('./commands/service.mjs')).cmdService(sub, rest.positional.slice(1), rest.flags);
320
+ break;
321
+ }
322
+ case 'gateway': {
323
+ await (await import('./commands/gateway.mjs')).cmdGateway(rest.flags);
335
324
  break;
336
325
  }
337
326
  case 'daemon': {