opencode-router 0.11.77 → 0.11.79

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/dist/cli.js ADDED
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { Command } from "commander";
4
+ import { Bot } from "grammy";
5
+ import { WebClient } from "@slack/web-api";
6
+ import { startBridge } from "./bridge.js";
7
+ import { loadConfig, readConfigFile, writeConfigFile, } from "./config.js";
8
+ import { BridgeStore } from "./db.js";
9
+ import { createLogger } from "./logger.js";
10
+ import { createClient } from "./opencode.js";
11
+ import { parseSlackPeerId } from "./slack.js";
12
+ import { truncateText } from "./text.js";
13
+ const VERSION = (() => {
14
+ if (typeof __OPENCODE_ROUTER_VERSION__ === "string" && __OPENCODE_ROUTER_VERSION__.trim()) {
15
+ return __OPENCODE_ROUTER_VERSION__.trim();
16
+ }
17
+ try {
18
+ const pkgPath = new URL("../package.json", import.meta.url);
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
20
+ if (typeof pkg.version === "string" && pkg.version.trim()) {
21
+ return pkg.version.trim();
22
+ }
23
+ }
24
+ catch {
25
+ // ignore
26
+ }
27
+ return "0.0.0";
28
+ })();
29
+ function outputJson(data) {
30
+ console.log(JSON.stringify(data, null, 2));
31
+ }
32
+ function outputError(message, exitCode = 1) {
33
+ if (program.opts().json) {
34
+ outputJson({ error: message });
35
+ }
36
+ else {
37
+ console.error(`Error: ${message}`);
38
+ }
39
+ process.exit(exitCode);
40
+ }
41
+ function createAppLogger(config) {
42
+ return createLogger(config.logLevel, { logFile: config.logFile });
43
+ }
44
+ function createConsoleReporter() {
45
+ const formatChannel = (channel, identityId) => {
46
+ const name = channel === "telegram" ? "Telegram" : "Slack";
47
+ return `${name}/${identityId}`;
48
+ };
49
+ const printBlock = (prefix, text) => {
50
+ const lines = text.split(/\r?\n/).map((line) => truncateText(line.trim(), 240));
51
+ const [first, ...rest] = lines.length ? lines : ["(empty)"];
52
+ console.log(`${prefix} ${first}`);
53
+ for (const line of rest) {
54
+ console.log(`${" ".repeat(prefix.length)} ${line}`);
55
+ }
56
+ };
57
+ return {
58
+ onStatus(message) {
59
+ console.log(message);
60
+ },
61
+ onInbound({ channel, identityId, peerId, text, fromMe }) {
62
+ const base = fromMe ? `${peerId} (me)` : peerId;
63
+ const prefix = `[${formatChannel(channel, identityId)}] ${base} >`;
64
+ printBlock(prefix, text);
65
+ },
66
+ onOutbound({ channel, identityId, peerId, text, kind }) {
67
+ const marker = kind === "reply" ? "<" : kind === "tool" ? "*" : "!";
68
+ const prefix = `[${formatChannel(channel, identityId)}] ${peerId} ${marker}`;
69
+ printBlock(prefix, text);
70
+ },
71
+ };
72
+ }
73
+ function updateConfig(configPath, updater) {
74
+ const { config } = readConfigFile(configPath);
75
+ const base = config ?? { version: 1 };
76
+ const next = updater(base);
77
+ next.version = next.version ?? 1;
78
+ writeConfigFile(configPath, next);
79
+ return next;
80
+ }
81
+ function normalizeIdentityId(value) {
82
+ const trimmed = (value ?? "").trim();
83
+ if (!trimmed)
84
+ return "default";
85
+ const safe = trimmed.replace(/[^a-zA-Z0-9_.-]+/g, "-");
86
+ const cleaned = safe.replace(/^-+|-+$/g, "").slice(0, 48);
87
+ return cleaned || "default";
88
+ }
89
+ function upsertTelegramBot(cfg, identity) {
90
+ const next = { ...cfg };
91
+ next.channels = next.channels ?? {};
92
+ const existing = next.channels.telegram ?? {};
93
+ const bots = Array.isArray(existing.bots) ? existing.bots.slice() : [];
94
+ const id = normalizeIdentityId(identity.id);
95
+ const filtered = bots.filter((b) => normalizeIdentityId(b.id) !== id);
96
+ filtered.push({ id, token: identity.token, enabled: identity.enabled !== false });
97
+ next.channels.telegram = { ...existing, enabled: true, bots: filtered };
98
+ return next;
99
+ }
100
+ function deleteTelegramBot(cfg, idRaw) {
101
+ const id = normalizeIdentityId(idRaw);
102
+ const next = { ...cfg };
103
+ next.channels = next.channels ?? {};
104
+ const existing = next.channels.telegram ?? {};
105
+ const bots = Array.isArray(existing.bots) ? existing.bots.slice() : [];
106
+ const filtered = bots.filter((b) => normalizeIdentityId(b.id) !== id);
107
+ const deleted = filtered.length !== bots.length;
108
+ next.channels.telegram = { ...existing, bots: filtered };
109
+ return { next, deleted };
110
+ }
111
+ function upsertSlackApp(cfg, identity) {
112
+ const next = { ...cfg };
113
+ next.channels = next.channels ?? {};
114
+ const existing = next.channels.slack ?? {};
115
+ const apps = Array.isArray(existing.apps) ? existing.apps.slice() : [];
116
+ const id = normalizeIdentityId(identity.id);
117
+ const filtered = apps.filter((a) => normalizeIdentityId(a.id) !== id);
118
+ filtered.push({ id, botToken: identity.botToken, appToken: identity.appToken, enabled: identity.enabled !== false });
119
+ next.channels.slack = { ...existing, enabled: true, apps: filtered };
120
+ return next;
121
+ }
122
+ function deleteSlackApp(cfg, idRaw) {
123
+ const id = normalizeIdentityId(idRaw);
124
+ const next = { ...cfg };
125
+ next.channels = next.channels ?? {};
126
+ const existing = next.channels.slack ?? {};
127
+ const apps = Array.isArray(existing.apps) ? existing.apps.slice() : [];
128
+ const filtered = apps.filter((a) => normalizeIdentityId(a.id) !== id);
129
+ const deleted = filtered.length !== apps.length;
130
+ next.channels.slack = { ...existing, apps: filtered };
131
+ return { next, deleted };
132
+ }
133
+ async function runStart(pathOverride, options) {
134
+ if (pathOverride?.trim()) {
135
+ process.env.OPENCODE_DIRECTORY = pathOverride.trim();
136
+ }
137
+ if (options?.opencodeUrl?.trim()) {
138
+ process.env.OPENCODE_URL = options.opencodeUrl.trim();
139
+ }
140
+ const config = loadConfig();
141
+ const logger = createAppLogger(config);
142
+ const reporter = createConsoleReporter();
143
+ if (!process.env.OPENCODE_DIRECTORY) {
144
+ process.env.OPENCODE_DIRECTORY = config.opencodeDirectory;
145
+ }
146
+ const bridge = await startBridge(config, logger, reporter);
147
+ if (process.stdout.isTTY) {
148
+ reporter.onStatus?.("Commands: opencode-router identities, opencode-router bindings, opencode-router status");
149
+ }
150
+ const shutdown = async () => {
151
+ logger.info("shutting down");
152
+ await bridge.stop();
153
+ process.exit(0);
154
+ };
155
+ process.on("SIGINT", shutdown);
156
+ process.on("SIGTERM", shutdown);
157
+ }
158
+ const program = new Command();
159
+ program
160
+ .name("opencode-router")
161
+ .version(VERSION)
162
+ .description("opencode-router: Slack + Telegram bridge + directory routing")
163
+ .option("--json", "Output in JSON format", false);
164
+ program
165
+ .command("start")
166
+ .description("Start the bridge")
167
+ .argument("[path]", "opencode workspace path")
168
+ .option("--opencode-url <url>", "opencode server URL")
169
+ .action((pathArg, options) => runStart(pathArg, options));
170
+ program
171
+ .command("serve")
172
+ .description("Start the bridge (headless)")
173
+ .argument("[path]", "opencode workspace path")
174
+ .option("--opencode-url <url>", "opencode server URL")
175
+ .action((pathArg, options) => runStart(pathArg, options));
176
+ program
177
+ .command("health")
178
+ .description("Check opencode health (exit 0 if healthy, 1 if not)")
179
+ .action(async () => {
180
+ const useJson = program.opts().json;
181
+ const config = loadConfig(process.env, { requireOpencode: false });
182
+ try {
183
+ const client = createClient(config);
184
+ const health = await client.global.health();
185
+ const healthy = Boolean(health.healthy);
186
+ if (useJson) {
187
+ outputJson({
188
+ healthy,
189
+ opencodeUrl: config.opencodeUrl,
190
+ identities: {
191
+ telegram: config.telegramBots.map((b) => ({ id: b.id, enabled: b.enabled !== false })),
192
+ slack: config.slackApps.map((a) => ({ id: a.id, enabled: a.enabled !== false })),
193
+ },
194
+ });
195
+ }
196
+ else {
197
+ console.log(`Healthy: ${healthy ? "yes" : "no"}`);
198
+ console.log(`opencode URL: ${config.opencodeUrl}`);
199
+ }
200
+ process.exit(healthy ? 0 : 1);
201
+ }
202
+ catch (error) {
203
+ if (useJson) {
204
+ outputJson({
205
+ healthy: false,
206
+ error: String(error),
207
+ opencodeUrl: config.opencodeUrl,
208
+ });
209
+ }
210
+ else {
211
+ console.log("Healthy: no");
212
+ console.log(`Error: ${String(error)}`);
213
+ }
214
+ process.exit(1);
215
+ }
216
+ });
217
+ program
218
+ .command("status")
219
+ .description("Show identity and opencode status")
220
+ .action(() => {
221
+ const useJson = program.opts().json;
222
+ const config = loadConfig(process.env, { requireOpencode: false });
223
+ const telegram = config.telegramBots.map((b) => ({ id: b.id, enabled: b.enabled !== false }));
224
+ const slack = config.slackApps.map((a) => ({ id: a.id, enabled: a.enabled !== false }));
225
+ if (useJson) {
226
+ outputJson({
227
+ config: config.configPath,
228
+ healthPort: config.healthPort ?? null,
229
+ telegram,
230
+ slack,
231
+ opencode: { url: config.opencodeUrl, directory: config.opencodeDirectory },
232
+ });
233
+ return;
234
+ }
235
+ console.log(`Config: ${config.configPath}`);
236
+ console.log(`Health port: ${config.healthPort ?? "(not set)"}`);
237
+ console.log(`Telegram bots: ${telegram.length}`);
238
+ console.log(`Slack apps: ${slack.length}`);
239
+ console.log(`opencode URL: ${config.opencodeUrl}`);
240
+ });
241
+ // -----------------------------------------------------------------------------
242
+ // Config helpers
243
+ // -----------------------------------------------------------------------------
244
+ const configCmd = program.command("config").description("Manage configuration");
245
+ configCmd
246
+ .command("get")
247
+ .argument("[key]", "Config key to get (dot notation)")
248
+ .description("Get config value(s)")
249
+ .action((key) => {
250
+ const useJson = program.opts().json;
251
+ const config = loadConfig(process.env, { requireOpencode: false });
252
+ const { config: configFile } = readConfigFile(config.configPath);
253
+ if (!key) {
254
+ if (useJson)
255
+ outputJson(configFile);
256
+ else
257
+ console.log(JSON.stringify(configFile, null, 2));
258
+ return;
259
+ }
260
+ const keys = key.split(".");
261
+ let current = configFile;
262
+ for (const k of keys) {
263
+ if (current === null || current === undefined || typeof current !== "object") {
264
+ current = undefined;
265
+ break;
266
+ }
267
+ current = current[k];
268
+ }
269
+ if (useJson)
270
+ outputJson({ [key]: current });
271
+ else
272
+ console.log(`${key}: ${current === undefined ? "(not set)" : typeof current === "object" ? JSON.stringify(current, null, 2) : current}`);
273
+ });
274
+ configCmd
275
+ .command("set")
276
+ .argument("<key>", "Config key to set (dot notation)")
277
+ .argument("<value>", "Value to set (JSON for arrays/objects)")
278
+ .description("Set config value")
279
+ .action((key, value) => {
280
+ const useJson = program.opts().json;
281
+ const config = loadConfig(process.env, { requireOpencode: false });
282
+ const parsed = (() => {
283
+ try {
284
+ return JSON.parse(value);
285
+ }
286
+ catch {
287
+ return value;
288
+ }
289
+ })();
290
+ const updated = updateConfig(config.configPath, (cfg) => {
291
+ const next = { ...cfg };
292
+ const keys = key.split(".");
293
+ let cur = next;
294
+ for (let i = 0; i < keys.length - 1; i++) {
295
+ const k = keys[i];
296
+ if (cur[k] === undefined || cur[k] === null || typeof cur[k] !== "object")
297
+ cur[k] = {};
298
+ cur = cur[k];
299
+ }
300
+ cur[keys[keys.length - 1]] = parsed;
301
+ return next;
302
+ });
303
+ if (useJson)
304
+ outputJson({ success: true, key, value: parsed, config: updated });
305
+ else
306
+ console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
307
+ });
308
+ // -----------------------------------------------------------------------------
309
+ // Identities
310
+ // -----------------------------------------------------------------------------
311
+ const telegram = program.command("telegram").description("Telegram identities");
312
+ telegram
313
+ .command("list")
314
+ .description("List Telegram bot identities")
315
+ .action(() => {
316
+ const useJson = program.opts().json;
317
+ const config = loadConfig(process.env, { requireOpencode: false });
318
+ const items = config.telegramBots.map((b) => ({ id: b.id, enabled: b.enabled !== false }));
319
+ if (useJson)
320
+ outputJson({ items });
321
+ else
322
+ for (const item of items)
323
+ console.log(`${item.id} ${item.enabled ? "enabled" : "disabled"}`);
324
+ });
325
+ telegram
326
+ .command("add")
327
+ .argument("<token>", "Telegram bot token")
328
+ .option("--id <id>", "Identity id (default: default)")
329
+ .option("--disabled", "Add identity but disable it", false)
330
+ .description("Add or update a Telegram bot identity")
331
+ .action((token, opts) => {
332
+ const useJson = program.opts().json;
333
+ const config = loadConfig(process.env, { requireOpencode: false });
334
+ const id = normalizeIdentityId(opts.id);
335
+ const enabled = !opts.disabled;
336
+ updateConfig(config.configPath, (cfg) => upsertTelegramBot(cfg, { id, token: token.trim(), enabled }));
337
+ if (useJson)
338
+ outputJson({ success: true, id, enabled });
339
+ else
340
+ console.log(`Saved Telegram identity: ${id}`);
341
+ });
342
+ telegram
343
+ .command("remove")
344
+ .argument("<id>", "Identity id")
345
+ .description("Remove a Telegram bot identity")
346
+ .action((idRaw) => {
347
+ const useJson = program.opts().json;
348
+ const config = loadConfig(process.env, { requireOpencode: false });
349
+ const { next, deleted } = deleteTelegramBot(readConfigFile(config.configPath).config, idRaw);
350
+ writeConfigFile(config.configPath, next);
351
+ if (useJson)
352
+ outputJson({ success: deleted, id: normalizeIdentityId(idRaw) });
353
+ else
354
+ console.log(deleted ? `Removed Telegram identity: ${normalizeIdentityId(idRaw)}` : "Identity not found.");
355
+ process.exit(deleted ? 0 : 1);
356
+ });
357
+ const slack = program.command("slack").description("Slack identities");
358
+ slack
359
+ .command("list")
360
+ .description("List Slack app identities")
361
+ .action(() => {
362
+ const useJson = program.opts().json;
363
+ const config = loadConfig(process.env, { requireOpencode: false });
364
+ const items = config.slackApps.map((a) => ({ id: a.id, enabled: a.enabled !== false }));
365
+ if (useJson)
366
+ outputJson({ items });
367
+ else
368
+ for (const item of items)
369
+ console.log(`${item.id} ${item.enabled ? "enabled" : "disabled"}`);
370
+ });
371
+ slack
372
+ .command("add")
373
+ .argument("<botToken>", "Slack bot token (xoxb-...)")
374
+ .argument("<appToken>", "Slack app token (xapp-...)")
375
+ .option("--id <id>", "Identity id (default: default)")
376
+ .option("--disabled", "Add identity but disable it", false)
377
+ .description("Add or update a Slack app identity")
378
+ .action((botToken, appToken, opts) => {
379
+ const useJson = program.opts().json;
380
+ const config = loadConfig(process.env, { requireOpencode: false });
381
+ const id = normalizeIdentityId(opts.id);
382
+ const enabled = !opts.disabled;
383
+ updateConfig(config.configPath, (cfg) => upsertSlackApp(cfg, { id, botToken: botToken.trim(), appToken: appToken.trim(), enabled }));
384
+ if (useJson)
385
+ outputJson({ success: true, id, enabled });
386
+ else
387
+ console.log(`Saved Slack identity: ${id}`);
388
+ });
389
+ slack
390
+ .command("remove")
391
+ .argument("<id>", "Identity id")
392
+ .description("Remove a Slack identity")
393
+ .action((idRaw) => {
394
+ const useJson = program.opts().json;
395
+ const config = loadConfig(process.env, { requireOpencode: false });
396
+ const { next, deleted } = deleteSlackApp(readConfigFile(config.configPath).config, idRaw);
397
+ writeConfigFile(config.configPath, next);
398
+ if (useJson)
399
+ outputJson({ success: deleted, id: normalizeIdentityId(idRaw) });
400
+ else
401
+ console.log(deleted ? `Removed Slack identity: ${normalizeIdentityId(idRaw)}` : "Identity not found.");
402
+ process.exit(deleted ? 0 : 1);
403
+ });
404
+ // -----------------------------------------------------------------------------
405
+ // Bindings
406
+ // -----------------------------------------------------------------------------
407
+ const bindings = program.command("bindings").description("Manage identity-scoped bindings");
408
+ bindings
409
+ .command("list")
410
+ .option("--channel <channel>", "telegram|slack")
411
+ .option("--identity <id>", "Identity id")
412
+ .description("List bindings")
413
+ .action((opts) => {
414
+ const useJson = program.opts().json;
415
+ const config = loadConfig(process.env, { requireOpencode: false });
416
+ const store = new BridgeStore(config.dbPath);
417
+ const channelRaw = opts.channel?.trim().toLowerCase();
418
+ const identityId = opts.identity?.trim() ? normalizeIdentityId(opts.identity) : undefined;
419
+ const channel = channelRaw === "telegram" || channelRaw === "slack" ? channelRaw : channelRaw ? (outputError("Invalid channel"), undefined) : undefined;
420
+ const items = store
421
+ .listBindings({ ...(channel ? { channel } : {}), ...(identityId ? { identityId } : {}) })
422
+ .map((b) => ({
423
+ channel: b.channel,
424
+ identityId: b.identity_id,
425
+ peerId: b.peer_id,
426
+ directory: b.directory,
427
+ updatedAt: b.updated_at,
428
+ }));
429
+ store.close();
430
+ if (useJson)
431
+ outputJson({ items });
432
+ else
433
+ for (const item of items)
434
+ console.log(`${item.channel}/${item.identityId} ${item.peerId} -> ${item.directory}`);
435
+ });
436
+ bindings
437
+ .command("set")
438
+ .requiredOption("--channel <channel>", "telegram|slack")
439
+ .requiredOption("--identity <id>", "Identity id")
440
+ .requiredOption("--peer <peerId>", "Peer id")
441
+ .requiredOption("--dir <directory>", "Directory")
442
+ .description("Set a binding")
443
+ .action((opts) => {
444
+ const useJson = program.opts().json;
445
+ const config = loadConfig(process.env, { requireOpencode: false });
446
+ const store = new BridgeStore(config.dbPath);
447
+ const channelRaw = opts.channel.trim().toLowerCase();
448
+ if (channelRaw !== "telegram" && channelRaw !== "slack")
449
+ outputError("Invalid channel");
450
+ const identityId = normalizeIdentityId(opts.identity);
451
+ const peerId = opts.peer.trim();
452
+ const directory = opts.dir.trim();
453
+ if (!peerId || !directory)
454
+ outputError("peer and dir are required");
455
+ store.upsertBinding(channelRaw, identityId, peerId, directory);
456
+ store.deleteSession(channelRaw, identityId, peerId);
457
+ store.close();
458
+ if (useJson)
459
+ outputJson({ success: true });
460
+ else
461
+ console.log("Binding saved.");
462
+ });
463
+ bindings
464
+ .command("clear")
465
+ .requiredOption("--channel <channel>", "telegram|slack")
466
+ .requiredOption("--identity <id>", "Identity id")
467
+ .requiredOption("--peer <peerId>", "Peer id")
468
+ .description("Clear a binding")
469
+ .action((opts) => {
470
+ const useJson = program.opts().json;
471
+ const config = loadConfig(process.env, { requireOpencode: false });
472
+ const store = new BridgeStore(config.dbPath);
473
+ const channelRaw = opts.channel.trim().toLowerCase();
474
+ if (channelRaw !== "telegram" && channelRaw !== "slack")
475
+ outputError("Invalid channel");
476
+ const identityId = normalizeIdentityId(opts.identity);
477
+ const peerId = opts.peer.trim();
478
+ const ok = store.deleteBinding(channelRaw, identityId, peerId);
479
+ store.deleteSession(channelRaw, identityId, peerId);
480
+ store.close();
481
+ if (useJson)
482
+ outputJson({ success: ok });
483
+ else
484
+ console.log(ok ? "Binding removed." : "Binding not found.");
485
+ process.exit(ok ? 0 : 1);
486
+ });
487
+ // -----------------------------------------------------------------------------
488
+ // Send helper
489
+ // -----------------------------------------------------------------------------
490
+ program
491
+ .command("send")
492
+ .description("Send a test message")
493
+ .requiredOption("--channel <channel>", "telegram or slack")
494
+ .requiredOption("--identity <id>", "Identity id")
495
+ .requiredOption("--to <recipient>", "Recipient ID (chat ID or peerId)")
496
+ .requiredOption("--message <text>", "Message text to send")
497
+ .action(async (opts) => {
498
+ const useJson = program.opts().json;
499
+ const channelRaw = opts.channel.trim().toLowerCase();
500
+ if (channelRaw !== "telegram" && channelRaw !== "slack") {
501
+ outputError("Invalid channel. Must be 'telegram' or 'slack'.");
502
+ }
503
+ const config = loadConfig(process.env, { requireOpencode: false });
504
+ const identityId = normalizeIdentityId(opts.identity);
505
+ const to = opts.to.trim();
506
+ const message = opts.message;
507
+ try {
508
+ if (channelRaw === "telegram") {
509
+ const bot = config.telegramBots.find((b) => b.id === identityId);
510
+ if (!bot)
511
+ throw new Error(`Telegram identity not found: ${identityId}`);
512
+ const tg = new Bot(bot.token);
513
+ await tg.api.sendMessage(Number(to), message);
514
+ }
515
+ else {
516
+ const app = config.slackApps.find((a) => a.id === identityId);
517
+ if (!app)
518
+ throw new Error(`Slack identity not found: ${identityId}`);
519
+ const web = new WebClient(app.botToken);
520
+ const peer = parseSlackPeerId(to);
521
+ if (!peer.channelId)
522
+ throw new Error("Invalid recipient for Slack.");
523
+ await web.chat.postMessage({
524
+ channel: peer.channelId,
525
+ text: message,
526
+ ...(peer.threadTs ? { thread_ts: peer.threadTs } : {}),
527
+ });
528
+ }
529
+ if (useJson)
530
+ outputJson({ success: true });
531
+ else
532
+ console.log("Message sent.");
533
+ process.exit(0);
534
+ }
535
+ catch (error) {
536
+ if (useJson)
537
+ outputJson({ success: false, error: String(error) });
538
+ else
539
+ console.error(`Failed to send message: ${String(error)}`);
540
+ process.exit(1);
541
+ }
542
+ });
543
+ program.action(() => {
544
+ program.outputHelp();
545
+ });
546
+ program.parseAsync(process.argv).catch((error) => {
547
+ const useJson = program.opts().json;
548
+ if (useJson)
549
+ outputJson({ error: String(error) });
550
+ else
551
+ console.error(error);
552
+ process.exitCode = 1;
553
+ });