@thesammykins/tether 1.0.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/bin/tether.ts ADDED
@@ -0,0 +1,1010 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Tether CLI - Manage your Discord-AI Agent bridge
4
+ *
5
+ * Management Commands:
6
+ * tether start - Start bot and worker
7
+ * tether stop - Stop all processes
8
+ * tether status - Show running status
9
+ * tether health - Check Distether connection
10
+ * tether setup - Interactive setup wizard
11
+ *
12
+ * Distether Commands:
13
+ * tether send <channel> "message"
14
+ * tether embed <channel> "description" [--title, --color, --field, etc.]
15
+ * tether file <channel> <filepath> "message"
16
+ * tether buttons <channel> "prompt" --button label="..." id="..." [style, reply, webhook]
17
+ * tether ask <channel> "question" --option "A" --option "B" [--timeout 300]
18
+ * tether typing <channel>
19
+ * tether edit <channel> <messageId> "content"
20
+ * tether delete <channel> <messageId>
21
+ * tether rename <threadId> "name"
22
+ * tether reply <channel> <messageId> "message"
23
+ * tether thread <channel> <messageId> "name"
24
+ * tether react <channel> <messageId> "emoji"
25
+ * tether state <channel> <messageId> <state> (processing, done, error, or custom)
26
+ *
27
+ * DM Commands:
28
+ * tether dm <user-id> "message"
29
+ * tether dm <user-id> --embed "description" [--title, --color, --field, etc.]
30
+ * tether dm <user-id> --file <filepath> ["message"]
31
+ */
32
+
33
+ import { spawn, spawnSync } from 'bun';
34
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync } from 'fs';
35
+ import { join, dirname } from 'path';
36
+ import * as readline from 'readline';
37
+ import { homedir } from 'os';
38
+ import { randomUUID } from 'crypto';
39
+
40
+ const PID_FILE = join(process.cwd(), '.tether.pid');
41
+ const API_BASE = process.env.TETHER_API_URL || 'http://localhost:2643';
42
+
43
+ const command = process.argv[2];
44
+ const args = process.argv.slice(3);
45
+
46
+ // Color name to Distether color int mapping
47
+ const COLORS: Record<string, number> = {
48
+ red: 15158332, // 0xE74C3C
49
+ green: 3066993, // 0x2ECC71
50
+ blue: 3447003, // 0x3498DB
51
+ yellow: 16776960, // 0xFFFF00
52
+ purple: 10181046, // 0x9B59B6
53
+ orange: 15105570, // 0xE67E22
54
+ gray: 9807270, // 0x95A5A6
55
+ grey: 9807270, // 0x95A5A6
56
+ };
57
+
58
+ // Button style name to Distether style int mapping
59
+ const BUTTON_STYLES: Record<string, number> = {
60
+ primary: 1,
61
+ secondary: 2,
62
+ success: 3,
63
+ danger: 4,
64
+ };
65
+
66
+ async function prompt(question: string): Promise<string> {
67
+ const rl = readline.createInterface({
68
+ input: process.stdin,
69
+ output: process.stdout,
70
+ });
71
+ return new Promise((resolve) => {
72
+ rl.question(question, (answer) => {
73
+ rl.close();
74
+ resolve(answer.trim());
75
+ });
76
+ });
77
+ }
78
+
79
+ // ============ API Helper ============
80
+
81
+ async function apiCall(endpoint: string, body: any): Promise<any> {
82
+ try {
83
+ const response = await fetch(`${API_BASE}${endpoint}`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify(body),
87
+ });
88
+ const data = await response.json();
89
+ if (!response.ok || data.error) {
90
+ console.error('Error:', data.error || 'Request failed');
91
+ process.exit(1);
92
+ }
93
+ return data;
94
+ } catch (error: any) {
95
+ if (error.code === 'ECONNREFUSED') {
96
+ console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
97
+ } else {
98
+ console.error('Error:', error.message);
99
+ }
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ // ============ Distether Commands ============
105
+
106
+ async function sendMessage() {
107
+ const channel = args[0];
108
+ const message = args[1];
109
+ if (!channel || !message) {
110
+ console.error('Usage: tether send <channel> "message"');
111
+ process.exit(1);
112
+ }
113
+ const result = await apiCall('/command', {
114
+ command: 'send-to-thread',
115
+ args: { thread: channel, message },
116
+ });
117
+ console.log(`Sent message: ${result.messageId}`);
118
+ }
119
+
120
+ async function sendEmbed() {
121
+ const channel = args[0];
122
+ if (!channel) {
123
+ console.error('Usage: tether embed <channel> "description" [--title "..." --color green ...]');
124
+ process.exit(1);
125
+ }
126
+
127
+ // Parse flags and find positional description
128
+ const embed: any = {};
129
+ const fields: any[] = [];
130
+ let description = '';
131
+ let i = 1;
132
+
133
+ while (i < args.length) {
134
+ const arg = args[i];
135
+ if (arg === '--title' && args[i + 1]) {
136
+ embed.title = args[++i];
137
+ } else if (arg === '--url' && args[i + 1]) {
138
+ embed.url = args[++i];
139
+ } else if (arg === '--color' && args[i + 1]) {
140
+ const colorArg = args[++i].toLowerCase();
141
+ embed.color = COLORS[colorArg] || parseInt(colorArg.replace('0x', ''), 16) || 0;
142
+ } else if (arg === '--author' && args[i + 1]) {
143
+ embed.author = embed.author || {};
144
+ embed.author.name = args[++i];
145
+ } else if (arg === '--author-url' && args[i + 1]) {
146
+ embed.author = embed.author || {};
147
+ embed.author.url = args[++i];
148
+ } else if (arg === '--author-icon' && args[i + 1]) {
149
+ embed.author = embed.author || {};
150
+ embed.author.icon_url = args[++i];
151
+ } else if (arg === '--thumbnail' && args[i + 1]) {
152
+ embed.thumbnail = { url: args[++i] };
153
+ } else if (arg === '--image' && args[i + 1]) {
154
+ embed.image = { url: args[++i] };
155
+ } else if (arg === '--footer' && args[i + 1]) {
156
+ embed.footer = embed.footer || {};
157
+ embed.footer.text = args[++i];
158
+ } else if (arg === '--footer-icon' && args[i + 1]) {
159
+ embed.footer = embed.footer || {};
160
+ embed.footer.icon_url = args[++i];
161
+ } else if (arg === '--timestamp') {
162
+ embed.timestamp = new Date().toISOString();
163
+ } else if (arg === '--field' && args[i + 1]) {
164
+ const fieldStr = args[++i];
165
+ const parts = fieldStr.split(':');
166
+ if (parts.length >= 2) {
167
+ fields.push({
168
+ name: parts[0],
169
+ value: parts[1],
170
+ inline: parts[2]?.toLowerCase() === 'inline',
171
+ });
172
+ }
173
+ } else if (!arg.startsWith('--')) {
174
+ description = arg;
175
+ }
176
+ i++;
177
+ }
178
+
179
+ if (description) embed.description = description;
180
+ if (fields.length > 0) embed.fields = fields;
181
+
182
+ const result = await apiCall('/command', {
183
+ command: 'send-to-thread',
184
+ args: { thread: channel, embeds: [embed] },
185
+ });
186
+ console.log(`Sent embed: ${result.messageId}`);
187
+ }
188
+
189
+ async function sendFile() {
190
+ const channel = args[0];
191
+ const filepath = args[1];
192
+ const message = args[2] || '';
193
+
194
+ if (!channel || !filepath) {
195
+ console.error('Usage: tether file <channel> <filepath> ["message"]');
196
+ process.exit(1);
197
+ }
198
+
199
+ if (!existsSync(filepath)) {
200
+ console.error(`Error: File not found: ${filepath}`);
201
+ process.exit(1);
202
+ }
203
+
204
+ const fileContent = readFileSync(filepath, 'utf-8');
205
+ const fileName = filepath.split('/').pop() || 'file.txt';
206
+
207
+ const result = await apiCall('/send-with-file', {
208
+ channelId: channel,
209
+ fileName,
210
+ fileContent,
211
+ content: message,
212
+ });
213
+ console.log(`Sent file: ${result.messageId}`);
214
+ }
215
+
216
+ async function sendButtons() {
217
+ const channel = args[0];
218
+ if (!channel) {
219
+ console.error('Usage: tether buttons <channel> "prompt" --button label="..." id="..." [style="success"] [reply="..."] [webhook="..."]');
220
+ process.exit(1);
221
+ }
222
+
223
+ let promptText = '';
224
+ const buttons: any[] = [];
225
+ let i = 1;
226
+
227
+ while (i < args.length) {
228
+ const arg = args[i];
229
+ if (arg === '--button') {
230
+ // Collect all following key=value pairs until next flag or end
231
+ const button: any = {};
232
+ i++;
233
+ while (i < args.length && !args[i].startsWith('--')) {
234
+ const kvMatch = args[i].match(/^(\w+)=(.*)$/);
235
+ if (kvMatch) {
236
+ const [, key, value] = kvMatch;
237
+ if (key === 'style') {
238
+ button.style = BUTTON_STYLES[value.toLowerCase()] || 1;
239
+ } else {
240
+ button[key] = value;
241
+ }
242
+ }
243
+ i++;
244
+ }
245
+ if (button.label && button.id) {
246
+ // Convert to API format
247
+ const apiButton: any = {
248
+ label: button.label,
249
+ customId: button.id,
250
+ style: button.style || 1,
251
+ };
252
+ if (button.reply || button.webhook) {
253
+ apiButton.handler = {};
254
+ if (button.reply) {
255
+ apiButton.handler.type = 'inline';
256
+ apiButton.handler.content = button.reply;
257
+ apiButton.handler.ephemeral = true;
258
+ }
259
+ if (button.webhook) {
260
+ apiButton.handler.type = button.reply ? 'inline' : 'webhook';
261
+ apiButton.handler.webhookUrl = button.webhook;
262
+ }
263
+ }
264
+ buttons.push(apiButton);
265
+ }
266
+ continue; // Don't increment i again
267
+ } else if (!arg.startsWith('--')) {
268
+ promptText = arg;
269
+ }
270
+ i++;
271
+ }
272
+
273
+ if (buttons.length === 0) {
274
+ console.error('Error: At least one --button is required');
275
+ process.exit(1);
276
+ }
277
+
278
+ const result = await apiCall('/send-with-buttons', {
279
+ channelId: channel,
280
+ content: promptText,
281
+ buttons,
282
+ });
283
+ console.log(`Sent buttons: ${result.messageId}`);
284
+ }
285
+
286
+ async function askQuestion() {
287
+ const channel = args[0];
288
+ const questionText = args[1];
289
+
290
+ if (!channel || !questionText) {
291
+ console.error('Usage: tether ask <channelId> "question text" --option "Option A" --option "Option B" [--timeout 300]');
292
+ process.exit(1);
293
+ }
294
+
295
+ // Parse options and timeout
296
+ const options: string[] = [];
297
+ let timeout = 300; // Default 300 seconds (5 minutes)
298
+ let i = 2;
299
+
300
+ while (i < args.length) {
301
+ const arg = args[i];
302
+ if (arg === '--option' && args[i + 1]) {
303
+ options.push(args[i + 1]);
304
+ i += 2;
305
+ } else if (arg === '--timeout' && args[i + 1]) {
306
+ timeout = parseInt(args[i + 1], 10);
307
+ if (isNaN(timeout) || timeout <= 0) {
308
+ console.error('Error: --timeout must be a positive number');
309
+ process.exit(1);
310
+ }
311
+ i += 2;
312
+ } else {
313
+ i++;
314
+ }
315
+ }
316
+
317
+ if (options.length === 0) {
318
+ console.error('Error: At least one --option is required');
319
+ process.exit(1);
320
+ }
321
+
322
+ // Generate unique request ID
323
+ const requestId = randomUUID();
324
+ const API_PORT = process.env.TETHER_API_PORT ? parseInt(process.env.TETHER_API_PORT) : 2643;
325
+
326
+ // Build buttons array: one per option + "Type answer" button
327
+ const buttons = options.map((label, index) => ({
328
+ label,
329
+ customId: `ask_${requestId}_${index}`,
330
+ style: 'primary',
331
+ handler: {
332
+ type: 'webhook',
333
+ url: `http://localhost:${API_PORT}/question-response/${requestId}`,
334
+ data: {
335
+ option: label,
336
+ optionIndex: index,
337
+ },
338
+ },
339
+ }));
340
+
341
+ // Add "Type answer" button
342
+ buttons.push({
343
+ label: '✏️ Type answer',
344
+ customId: `ask_${requestId}_type`,
345
+ style: 'secondary',
346
+ handler: {
347
+ type: 'webhook',
348
+ url: `http://localhost:${API_PORT}/question-response/${requestId}`,
349
+ data: {
350
+ option: '__type__',
351
+ optionIndex: -1,
352
+ threadId: channel,
353
+ },
354
+ },
355
+ } as any);
356
+
357
+ // Send buttons message
358
+ try {
359
+ await apiCall('/send-with-buttons', {
360
+ channelId: channel,
361
+ content: questionText,
362
+ buttons,
363
+ });
364
+ } catch (error) {
365
+ console.error('Failed to send question message');
366
+ process.exit(1);
367
+ }
368
+
369
+ // Poll for response
370
+ const pollInterval = 2000; // 2 seconds
371
+ const maxAttempts = Math.ceil((timeout * 1000) / pollInterval);
372
+ let attempts = 0;
373
+
374
+ while (attempts < maxAttempts) {
375
+ try {
376
+ const response = await fetch(`http://localhost:${API_PORT}/question-response/${requestId}`);
377
+
378
+ if (response.status === 404) {
379
+ // Not yet registered or answered, keep polling
380
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
381
+ attempts++;
382
+ continue;
383
+ }
384
+
385
+ if (response.ok) {
386
+ const data = await response.json() as { answered: boolean; answer?: string; optionIndex?: number };
387
+
388
+ if (data.answered) {
389
+ if (data.answer === '__type__') {
390
+ // User clicked "Type answer" - keep polling for typed response
391
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
392
+ attempts++;
393
+ continue;
394
+ }
395
+
396
+ // Got a real answer
397
+ console.log(data.answer);
398
+ process.exit(0);
399
+ }
400
+ }
401
+ } catch (error) {
402
+ // Network error, keep trying
403
+ }
404
+
405
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
406
+ attempts++;
407
+ }
408
+
409
+ // Timeout
410
+ console.error('No response received');
411
+ process.exit(1);
412
+ }
413
+
414
+ async function startTyping() {
415
+ const channel = args[0];
416
+ if (!channel) {
417
+ console.error('Usage: tether typing <channel>');
418
+ process.exit(1);
419
+ }
420
+ await apiCall('/command', {
421
+ command: 'start-typing',
422
+ args: { channel },
423
+ });
424
+ console.log('Typing indicator sent');
425
+ }
426
+
427
+ async function editMessage() {
428
+ const channel = args[0];
429
+ const messageId = args[1];
430
+ const content = args[2];
431
+ if (!channel || !messageId || !content) {
432
+ console.error('Usage: tether edit <channel> <messageId> "new content"');
433
+ process.exit(1);
434
+ }
435
+ await apiCall('/command', {
436
+ command: 'edit-message',
437
+ args: { channel, message: messageId, content },
438
+ });
439
+ console.log(`Edited message: ${messageId}`);
440
+ }
441
+
442
+ async function deleteMessage() {
443
+ const channel = args[0];
444
+ const messageId = args[1];
445
+ if (!channel || !messageId) {
446
+ console.error('Usage: tether delete <channel> <messageId>');
447
+ process.exit(1);
448
+ }
449
+ await apiCall('/command', {
450
+ command: 'delete-message',
451
+ args: { channel, message: messageId },
452
+ });
453
+ console.log(`Deleted message: ${messageId}`);
454
+ }
455
+
456
+ async function renameThread() {
457
+ const threadId = args[0];
458
+ const name = args[1];
459
+ if (!threadId || !name) {
460
+ console.error('Usage: tether rename <threadId> "new name"');
461
+ process.exit(1);
462
+ }
463
+ await apiCall('/command', {
464
+ command: 'rename-thread',
465
+ args: { thread: threadId, name },
466
+ });
467
+ console.log(`Renamed thread: ${threadId}`);
468
+ }
469
+
470
+ async function replyToMessage() {
471
+ const channel = args[0];
472
+ const messageId = args[1];
473
+ const message = args[2];
474
+ if (!channel || !messageId || !message) {
475
+ console.error('Usage: tether reply <channel> <messageId> "message"');
476
+ process.exit(1);
477
+ }
478
+ const result = await apiCall('/command', {
479
+ command: 'reply-to-message',
480
+ args: { channel, message: messageId, content: message },
481
+ });
482
+ console.log(`Replied to message: ${result.messageId}`);
483
+ }
484
+
485
+ async function createThread() {
486
+ const channel = args[0];
487
+ const messageId = args[1];
488
+ const name = args[2];
489
+ if (!channel || !messageId || !name) {
490
+ console.error('Usage: tether thread <channel> <messageId> "thread name"');
491
+ process.exit(1);
492
+ }
493
+ const result = await apiCall('/command', {
494
+ command: 'create-thread',
495
+ args: { channel, message: messageId, name },
496
+ });
497
+ console.log(`Created thread: ${result.threadId}`);
498
+ }
499
+
500
+ async function addReaction() {
501
+ const channel = args[0];
502
+ const messageId = args[1];
503
+ const emoji = args[2];
504
+ if (!channel || !messageId || !emoji) {
505
+ console.error('Usage: tether react <channel> <messageId> "emoji"');
506
+ process.exit(1);
507
+ }
508
+ await apiCall('/command', {
509
+ command: 'add-reaction',
510
+ args: { channel, message: messageId, emoji },
511
+ });
512
+ console.log(`Added reaction: ${emoji}`);
513
+ }
514
+
515
+ async function sendDM() {
516
+ const userId = args[0];
517
+ if (!userId) {
518
+ console.error('Usage: tether dm <user-id> "message"');
519
+ console.error(' tether dm <user-id> --embed "description" [--title, --color, ...]');
520
+ console.error(' tether dm <user-id> --file <filepath> ["message"]');
521
+ process.exit(1);
522
+ }
523
+
524
+ const subArgs = args.slice(1);
525
+
526
+ // --file mode: tether dm <user-id> --file <path> ["message"]
527
+ if (subArgs[0] === '--file') {
528
+ const filepath = subArgs[1];
529
+ const message = subArgs[2] || '';
530
+
531
+ if (!filepath) {
532
+ console.error('Usage: tether dm <user-id> --file <filepath> ["message"]');
533
+ process.exit(1);
534
+ }
535
+
536
+ if (!existsSync(filepath)) {
537
+ console.error(`Error: File not found: ${filepath}`);
538
+ process.exit(1);
539
+ }
540
+
541
+ const fileContent = readFileSync(filepath, 'utf-8');
542
+ const fileName = filepath.split('/').pop() || 'file.txt';
543
+
544
+ const result = await apiCall('/send-dm-file', {
545
+ userId,
546
+ fileName,
547
+ fileContent,
548
+ content: message,
549
+ });
550
+ console.log(`DM file sent: ${result.messageId}`);
551
+ return;
552
+ }
553
+
554
+ // --embed mode: tether dm <user-id> --embed "description" [--title, --color, ...]
555
+ if (subArgs[0] === '--embed') {
556
+ const embed: any = {};
557
+ const fields: any[] = [];
558
+ let description = '';
559
+ let i = 1;
560
+
561
+ while (i < subArgs.length) {
562
+ const arg = subArgs[i];
563
+ if (arg === '--title' && subArgs[i + 1]) {
564
+ embed.title = subArgs[++i];
565
+ } else if (arg === '--color' && subArgs[i + 1]) {
566
+ const colorArg = subArgs[++i].toLowerCase();
567
+ embed.color = COLORS[colorArg] || parseInt(colorArg.replace('0x', ''), 16) || 0;
568
+ } else if (arg === '--footer' && subArgs[i + 1]) {
569
+ embed.footer = { text: subArgs[++i] };
570
+ } else if (arg === '--field' && subArgs[i + 1]) {
571
+ const fieldStr = subArgs[++i];
572
+ const parts = fieldStr.split(':');
573
+ if (parts.length >= 2) {
574
+ fields.push({
575
+ name: parts[0],
576
+ value: parts[1],
577
+ inline: parts[2]?.toLowerCase() === 'inline',
578
+ });
579
+ }
580
+ } else if (arg === '--timestamp') {
581
+ embed.timestamp = new Date().toISOString();
582
+ } else if (!arg.startsWith('--')) {
583
+ description = arg;
584
+ }
585
+ i++;
586
+ }
587
+
588
+ if (description) embed.description = description;
589
+ if (fields.length > 0) embed.fields = fields;
590
+
591
+ const result = await apiCall('/command', {
592
+ command: 'send-dm',
593
+ args: { userId, embeds: [embed] },
594
+ });
595
+ console.log(`DM embed sent: ${result.messageId}`);
596
+ return;
597
+ }
598
+
599
+ // Default: text message — tether dm <user-id> "message"
600
+ const message = subArgs[0];
601
+ if (!message) {
602
+ console.error('Usage: tether dm <user-id> "message"');
603
+ process.exit(1);
604
+ }
605
+
606
+ const result = await apiCall('/command', {
607
+ command: 'send-dm',
608
+ args: { userId, message },
609
+ });
610
+ console.log(`DM sent: ${result.messageId}`);
611
+ }
612
+
613
+ // State presets for thread status updates
614
+ const STATE_PRESETS: Record<string, string> = {
615
+ processing: '🤖 Processing...',
616
+ thinking: '🧠 Thinking...',
617
+ searching: '🔍 Searching...',
618
+ writing: '✍️ Writing...',
619
+ done: '✅ Done',
620
+ error: '❌ Something went wrong',
621
+ waiting: '⏳ Waiting for input...',
622
+ };
623
+
624
+ async function updateState() {
625
+ const channel = args[0];
626
+ const messageId = args[1];
627
+ const stateOrCustom = args[2];
628
+
629
+ if (!channel || !messageId || !stateOrCustom) {
630
+ console.error('Usage: tether state <channel> <messageId> <state>');
631
+ console.error('');
632
+ console.error('Preset states:');
633
+ console.error(' processing - 🤖 Processing...');
634
+ console.error(' thinking - 🧠 Thinking...');
635
+ console.error(' searching - 🔍 Searching...');
636
+ console.error(' writing - ✍️ Writing...');
637
+ console.error(' done - ✅ Done');
638
+ console.error(' error - ❌ Something went wrong');
639
+ console.error(' waiting - ⏳ Waiting for input...');
640
+ console.error('');
641
+ console.error('Or use custom text: tether state <channel> <messageId> "Custom status"');
642
+ process.exit(1);
643
+ }
644
+
645
+ const content = STATE_PRESETS[stateOrCustom.toLowerCase()] || stateOrCustom;
646
+
647
+ await apiCall('/command', {
648
+ command: 'edit-message',
649
+ args: { channel, message: messageId, content },
650
+ });
651
+ console.log(`Updated state: ${content}`);
652
+ }
653
+
654
+ // ============ Management Commands ============
655
+
656
+ async function setup() {
657
+ console.log('\n🔌 Tether Setup\n');
658
+
659
+ // Check for .env
660
+ const envPath = join(process.cwd(), '.env');
661
+ const envExamplePath = join(process.cwd(), '.env.example');
662
+
663
+ if (existsSync(envPath)) {
664
+ console.log('✓ .env file exists');
665
+ } else if (existsSync(envExamplePath)) {
666
+ console.log('Creating .env from .env.example...\n');
667
+
668
+ const token = await prompt('Distether Bot Token: ');
669
+ if (!token) {
670
+ console.log('Token required. Run setup again when ready.');
671
+ process.exit(1);
672
+ }
673
+
674
+ const tz = await prompt('Timezone (default: America/New_York): ') || 'America/New_York';
675
+
676
+ let envContent = readFileSync(envExamplePath, 'utf-8');
677
+ envContent = envContent.replace('your-bot-token-here', token);
678
+ envContent = envContent.replace('TZ=America/New_York', `TZ=${tz}`);
679
+
680
+ writeFileSync(envPath, envContent);
681
+ console.log('\n✓ .env file created');
682
+ }
683
+
684
+ // Check Redis
685
+ const redis = spawnSync(['redis-cli', 'ping'], { stdout: 'pipe', stderr: 'pipe' });
686
+ if (redis.exitCode === 0) {
687
+ console.log('✓ Redis is running');
688
+ } else {
689
+ console.log('⚠ Redis not running. Start it with: redis-server');
690
+ }
691
+
692
+ // Check Claude CLI
693
+ const claude = spawnSync(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
694
+ if (claude.exitCode === 0) {
695
+ console.log('✓ Claude CLI installed');
696
+ } else {
697
+ console.log('⚠ Claude CLI not found. Install from: https://claude.ai/code');
698
+ }
699
+
700
+ // Install Claude Code skill
701
+ const skillsDir = join(homedir(), '.claude', 'skills', 'cord');
702
+ const cordRoot = join(dirname(import.meta.dir));
703
+ const sourceSkillsDir = join(cordRoot, 'skills', 'cord');
704
+
705
+ if (existsSync(sourceSkillsDir)) {
706
+ console.log('\n📚 Claude Code Skill');
707
+ console.log(' Teaches your assistant how to send Distether messages, embeds,');
708
+ console.log(' files, and interactive buttons.');
709
+ const installSkill = await prompt('Install skill? (Y/n): ');
710
+ if (installSkill.toLowerCase() !== 'n') {
711
+ mkdirSync(skillsDir, { recursive: true });
712
+ cpSync(sourceSkillsDir, skillsDir, { recursive: true });
713
+ console.log(`✓ Skill installed to ${skillsDir}`);
714
+ }
715
+ }
716
+
717
+ console.log('\n✨ Setup complete! Run: tether start\n');
718
+ }
719
+
720
+ async function start() {
721
+ if (existsSync(PID_FILE)) {
722
+ console.log('Tether is already running. Run: tether stop');
723
+ process.exit(1);
724
+ }
725
+
726
+ console.log('Starting Tether...\n');
727
+
728
+ // Start bot
729
+ const bot = spawn(['bun', 'run', 'src/bot.ts'], {
730
+ stdout: 'inherit',
731
+ stderr: 'inherit',
732
+ cwd: process.cwd(),
733
+ });
734
+
735
+ // Start worker
736
+ const worker = spawn(['bun', 'run', 'src/worker.ts'], {
737
+ stdout: 'inherit',
738
+ stderr: 'inherit',
739
+ cwd: process.cwd(),
740
+ });
741
+
742
+ // Save PIDs
743
+ writeFileSync(PID_FILE, JSON.stringify({
744
+ bot: bot.pid,
745
+ worker: worker.pid,
746
+ startedAt: new Date().toISOString(),
747
+ }));
748
+
749
+ console.log(`Bot PID: ${bot.pid}`);
750
+ console.log(`Worker PID: ${worker.pid}`);
751
+ console.log('\nTether is running. Press Ctrl+C to stop.\n');
752
+
753
+ // Handle exit
754
+ process.on('SIGINT', () => {
755
+ console.log('\nStopping Tether...');
756
+ bot.kill();
757
+ worker.kill();
758
+ if (existsSync(PID_FILE)) {
759
+ const fs = require('fs');
760
+ fs.unlinkSync(PID_FILE);
761
+ }
762
+ process.exit(0);
763
+ });
764
+
765
+ // Wait for processes
766
+ await Promise.all([bot.exited, worker.exited]);
767
+ }
768
+
769
+ function stop() {
770
+ if (!existsSync(PID_FILE)) {
771
+ console.log('Tether is not running.');
772
+ return;
773
+ }
774
+
775
+ const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
776
+
777
+ try {
778
+ process.kill(pids.bot);
779
+ console.log(`Stopped bot (PID ${pids.bot})`);
780
+ } catch {}
781
+
782
+ try {
783
+ process.kill(pids.worker);
784
+ console.log(`Stopped worker (PID ${pids.worker})`);
785
+ } catch {}
786
+
787
+ const fs = require('fs');
788
+ fs.unlinkSync(PID_FILE);
789
+ console.log('Tether stopped.');
790
+ }
791
+
792
+ function status() {
793
+ if (!existsSync(PID_FILE)) {
794
+ console.log('Tether is not running.');
795
+ return;
796
+ }
797
+
798
+ const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
799
+
800
+ const botAlive = isProcessRunning(pids.bot);
801
+ const workerAlive = isProcessRunning(pids.worker);
802
+
803
+ console.log(`Bot: ${botAlive ? '✓ running' : '✗ stopped'} (PID ${pids.bot})`);
804
+ console.log(`Worker: ${workerAlive ? '✓ running' : '✗ stopped'} (PID ${pids.worker})`);
805
+ console.log(`Started: ${pids.startedAt}`);
806
+ }
807
+
808
+ function isProcessRunning(pid: number): boolean {
809
+ try {
810
+ process.kill(pid, 0);
811
+ return true;
812
+ } catch {
813
+ return false;
814
+ }
815
+ }
816
+
817
+ async function health() {
818
+ try {
819
+ const response = await fetch(`${API_BASE}/health`);
820
+ const data = await response.json() as { status: string; connected: boolean; user: string };
821
+
822
+ if (data.connected) {
823
+ console.log(`✓ Connected as ${data.user}`);
824
+ } else {
825
+ console.log('✗ Bot not connected to Discord');
826
+ }
827
+ } catch (error: any) {
828
+ if (error.code === 'ECONNREFUSED') {
829
+ console.log('✗ Cannot connect to Tether API. Is the bot running? (tether start)');
830
+ } else {
831
+ console.log(`✗ Error: ${error.message}`);
832
+ }
833
+ process.exit(1);
834
+ }
835
+ }
836
+
837
+ function showHelp() {
838
+ console.log(`
839
+ Tether - Distether to Claude Code bridge
840
+
841
+ Usage: tether <command> [options]
842
+
843
+ Management Commands:
844
+ start Start bot and worker
845
+ stop Stop all processes
846
+ status Show running status
847
+ health Check Distether connection
848
+ setup Interactive setup wizard
849
+ help Show this help
850
+
851
+ Distether Commands:
852
+ send <channel> "message"
853
+ Send a text message
854
+
855
+ embed <channel> "description" [options]
856
+ Send an embed with optional formatting
857
+ --title "..." Embed title
858
+ --url "..." Title link URL
859
+ --color <name|hex> red, green, blue, yellow, purple, orange, or 0xHEX
860
+ --author "..." Author name
861
+ --author-url "..." Author link
862
+ --author-icon "..." Author icon URL
863
+ --thumbnail "..." Small image (top right)
864
+ --image "..." Large image (bottom)
865
+ --footer "..." Footer text
866
+ --footer-icon "..." Footer icon URL
867
+ --timestamp Add current timestamp
868
+ --field "Name:Value" Add field (use :inline for inline)
869
+
870
+ file <channel> <filepath> ["message"]
871
+ Send a file attachment
872
+
873
+ buttons <channel> "prompt" --button label="..." id="..." [options]
874
+ Send interactive buttons
875
+ Button options:
876
+ label="..." Button text (required)
877
+ id="..." Custom ID (required)
878
+ style="..." primary, secondary, success, danger
879
+ reply="..." Ephemeral reply when clicked
880
+ webhook="..." URL to POST click data to
881
+
882
+ ask <channel> "question" --option "A" --option "B" [--timeout 300]
883
+ Ask a blocking question with button options (blocks until answered)
884
+ Prints selected answer to stdout, exits 0 on success, 1 on timeout
885
+ Automatically includes a "Type answer" button for free-form input
886
+
887
+ typing <channel>
888
+ Show typing indicator
889
+
890
+ edit <channel> <messageId> "content"
891
+ Edit an existing message
892
+
893
+ delete <channel> <messageId>
894
+ Delete a message
895
+
896
+ rename <threadId> "name"
897
+ Rename a thread
898
+
899
+ reply <channel> <messageId> "message"
900
+ Reply to a specific message
901
+
902
+ thread <channel> <messageId> "name"
903
+ Create a thread from a message
904
+
905
+ react <channel> <messageId> "emoji"
906
+ Add a reaction to a message
907
+
908
+ state <channel> <messageId> <state>
909
+ Update thread status with preset or custom text
910
+ Presets: processing, thinking, searching, writing, done, error, waiting
911
+
912
+ DM Commands (proactive outreach):
913
+ dm <user-id> "message"
914
+ Send a text DM to a user
915
+
916
+ dm <user-id> --embed "description" [options]
917
+ Send an embed DM (same options as embed command)
918
+
919
+ dm <user-id> --file <filepath> ["message"]
920
+ Send a file attachment via DM
921
+
922
+ Examples:
923
+ tether send 123456789 "Hello world!"
924
+ tether embed 123456789 "Status update" --title "Daily Report" --color green --field "Tasks:5 done:inline"
925
+ tether buttons 123456789 "Approve?" --button label="Yes" id="approve" style="success" reply="Approved!"
926
+ tether ask 123456789 "Deploy to prod?" --option "Yes" --option "No" --timeout 600
927
+ tether file 123456789 ./report.md "Here's the report"
928
+ tether state 123456789 1234567890 processing
929
+ tether state 123456789 1234567890 done
930
+ tether dm 987654321 "Hey, I need your approval on this PR"
931
+ tether dm 987654321 --embed "Build passed" --title "CI Update" --color green
932
+ tether dm 987654321 --file ./report.md "Here's the report"
933
+ `);
934
+ }
935
+
936
+ // ============ Main ============
937
+
938
+ switch (command) {
939
+ // Management
940
+ case 'start':
941
+ start();
942
+ break;
943
+ case 'stop':
944
+ stop();
945
+ break;
946
+ case 'status':
947
+ status();
948
+ break;
949
+ case 'setup':
950
+ setup();
951
+ break;
952
+ case 'health':
953
+ health();
954
+ break;
955
+ case 'help':
956
+ case '--help':
957
+ case '-h':
958
+ case undefined:
959
+ showHelp();
960
+ break;
961
+
962
+ // Distether commands
963
+ case 'send':
964
+ sendMessage();
965
+ break;
966
+ case 'embed':
967
+ sendEmbed();
968
+ break;
969
+ case 'file':
970
+ sendFile();
971
+ break;
972
+ case 'buttons':
973
+ sendButtons();
974
+ break;
975
+ case 'ask':
976
+ askQuestion();
977
+ break;
978
+ case 'typing':
979
+ startTyping();
980
+ break;
981
+ case 'edit':
982
+ editMessage();
983
+ break;
984
+ case 'delete':
985
+ deleteMessage();
986
+ break;
987
+ case 'rename':
988
+ renameThread();
989
+ break;
990
+ case 'reply':
991
+ replyToMessage();
992
+ break;
993
+ case 'thread':
994
+ createThread();
995
+ break;
996
+ case 'react':
997
+ addReaction();
998
+ break;
999
+ case 'state':
1000
+ updateState();
1001
+ break;
1002
+ case 'dm':
1003
+ sendDM();
1004
+ break;
1005
+
1006
+ default:
1007
+ console.log(`Unknown command: ${command}`);
1008
+ showHelp();
1009
+ process.exit(1);
1010
+ }