@thesammykins/tether 1.6.0 → 1.7.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 CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  deleteKey as deleteConfigKey, resolve as resolveConfig, resolveAll,
43
43
  isKnownKey, isSecret, getKeyMeta, getKnownKeys, hasSecrets, hasConfig,
44
44
  importDotEnv, CONFIG_PATHS,
45
- } from '../src/config';
45
+ } from '../src/config.js';
46
46
 
47
47
  const PID_FILE = join(process.cwd(), '.tether.pid');
48
48
  const API_BASE = process.env.TETHER_API_URL || 'http://localhost:2643';
@@ -674,6 +674,164 @@ async function updateState() {
674
674
 
675
675
  // ============ Management Commands ============
676
676
 
677
+ async function projectCommand() {
678
+ const subcommand = args[0];
679
+
680
+ switch (subcommand) {
681
+ case 'add': {
682
+ const name = args[1];
683
+ const rawPath = args[2];
684
+ if (!name || !rawPath) {
685
+ console.error('Usage: tether project add <name> <path>');
686
+ process.exit(1);
687
+ }
688
+
689
+ const { resolve: resolvePath } = await import('path');
690
+ const resolvedPath = resolvePath(rawPath);
691
+
692
+ if (!existsSync(resolvedPath)) {
693
+ console.error(`Error: Path does not exist: ${resolvedPath}`);
694
+ process.exit(1);
695
+ }
696
+
697
+ try {
698
+ const response = await fetch(`${API_BASE}/projects`, {
699
+ method: 'POST',
700
+ headers: buildApiHeaders(),
701
+ body: JSON.stringify({ name, path: resolvedPath }),
702
+ });
703
+ const data = await response.json() as Record<string, unknown>;
704
+ if (!response.ok || data.error) {
705
+ console.error('Error:', data.error || 'Request failed');
706
+ process.exit(1);
707
+ }
708
+ console.log(`Project "${name}" added: ${resolvedPath}`);
709
+ } catch (error: unknown) {
710
+ const err = error as { code?: string; message?: string };
711
+ if (err.code === 'ECONNREFUSED') {
712
+ console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
713
+ } else {
714
+ console.error('Error:', err.message);
715
+ }
716
+ process.exit(1);
717
+ }
718
+ break;
719
+ }
720
+
721
+ case 'list': {
722
+ try {
723
+ const response = await fetch(`${API_BASE}/projects`, {
724
+ headers: buildApiHeaders(),
725
+ });
726
+ const projects = await response.json() as Array<{
727
+ name: string;
728
+ path: string;
729
+ is_default: number;
730
+ }>;
731
+ if (!response.ok) {
732
+ console.error('Error: Failed to list projects');
733
+ process.exit(1);
734
+ }
735
+ if (projects.length === 0) {
736
+ console.log('No projects registered. Add one with: tether project add <name> <path>');
737
+ return;
738
+ }
739
+ console.log('\nProjects:\n');
740
+ for (const p of projects) {
741
+ const marker = p.is_default ? ' (default)' : '';
742
+ console.log(` ${p.name}${marker}`);
743
+ console.log(` ${p.path}\n`);
744
+ }
745
+ } catch (error: unknown) {
746
+ const err = error as { code?: string; message?: string };
747
+ if (err.code === 'ECONNREFUSED') {
748
+ console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
749
+ } else {
750
+ console.error('Error:', err.message);
751
+ }
752
+ process.exit(1);
753
+ }
754
+ break;
755
+ }
756
+
757
+ case 'remove': {
758
+ const name = args[1];
759
+ if (!name) {
760
+ console.error('Usage: tether project remove <name>');
761
+ process.exit(1);
762
+ }
763
+
764
+ try {
765
+ const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}`, {
766
+ method: 'DELETE',
767
+ headers: buildApiHeaders(),
768
+ });
769
+ const data = await response.json() as Record<string, unknown>;
770
+ if (!response.ok || data.error) {
771
+ console.error('Error:', data.error || 'Request failed');
772
+ process.exit(1);
773
+ }
774
+ console.log(`Project "${name}" removed.`);
775
+ } catch (error: unknown) {
776
+ const err = error as { code?: string; message?: string };
777
+ if (err.code === 'ECONNREFUSED') {
778
+ console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
779
+ } else {
780
+ console.error('Error:', err.message);
781
+ }
782
+ process.exit(1);
783
+ }
784
+ break;
785
+ }
786
+
787
+ case 'set-default': {
788
+ const name = args[1];
789
+ if (!name) {
790
+ console.error('Usage: tether project set-default <name>');
791
+ process.exit(1);
792
+ }
793
+
794
+ try {
795
+ const response = await fetch(`${API_BASE}/projects/${encodeURIComponent(name)}/default`, {
796
+ method: 'POST',
797
+ headers: buildApiHeaders(),
798
+ });
799
+ const data = await response.json() as Record<string, unknown>;
800
+ if (!response.ok || data.error) {
801
+ console.error('Error:', data.error || 'Request failed');
802
+ process.exit(1);
803
+ }
804
+ console.log(`Project "${name}" set as default.`);
805
+ } catch (error: unknown) {
806
+ const err = error as { code?: string; message?: string };
807
+ if (err.code === 'ECONNREFUSED') {
808
+ console.error('Error: Cannot connect to Tether API. Is the bot running? (tether start)');
809
+ } else {
810
+ console.error('Error:', err.message);
811
+ }
812
+ process.exit(1);
813
+ }
814
+ break;
815
+ }
816
+
817
+ default:
818
+ console.log(`
819
+ Usage: tether project <subcommand>
820
+
821
+ Subcommands:
822
+ add <name> <path> Register a project (validates path exists)
823
+ list List all registered projects
824
+ remove <name> Remove a project
825
+ set-default <name> Set a project as the default
826
+ `);
827
+ if (subcommand) {
828
+ console.error(`Unknown project subcommand: ${subcommand}`);
829
+ process.exit(1);
830
+ }
831
+ break;
832
+ }
833
+ }
834
+
677
835
  async function setup() {
678
836
  console.log('\n🔌 Tether Setup\n');
679
837
 
@@ -1268,6 +1426,7 @@ Management Commands:
1268
1426
  health Check Distether connection
1269
1427
  setup Interactive setup wizard
1270
1428
  config Manage configuration and encrypted secrets
1429
+ project Manage named projects
1271
1430
  help Show this help
1272
1431
 
1273
1432
  Distether Commands:
@@ -1349,6 +1508,12 @@ Config Commands:
1349
1508
  config import [path] Import from .env file (default: ./.env)
1350
1509
  config path Show config file locations
1351
1510
 
1511
+ Project Commands:
1512
+ project add <name> <path> Register a named project directory
1513
+ project list List all registered projects
1514
+ project remove <name> Remove a project
1515
+ project set-default <name> Set a project as the default
1516
+
1352
1517
  Examples:
1353
1518
  tether send 123456789 "Hello world!"
1354
1519
  tether embed 123456789 "Status update" --title "Daily Report" --color green --field "Tasks:5 done:inline"
@@ -1439,6 +1604,9 @@ switch (command) {
1439
1604
  case 'config':
1440
1605
  configCommand();
1441
1606
  break;
1607
+ case 'project':
1608
+ projectCommand();
1609
+ break;
1442
1610
 
1443
1611
  default:
1444
1612
  console.log(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thesammykins/tether",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Discord bot that bridges messages to AI agent sessions (Claude, OpenCode, Codex)",
5
5
  "license": "MIT",
6
6
  "author": "thesammykins",
@@ -10,9 +10,12 @@ import { debugLog, debugBlock } from '../debug.js';
10
10
  * Wraps the Codex CLI with session handling and JSON output parsing.
11
11
  *
12
12
  * Key commands:
13
- * - `codex exec "<prompt>"`: Execute a new prompt
14
- * - `codex exec resume <sessionId> "<prompt>"`: Resume existing session
13
+ * - `codex exec`: Execute a new prompt (reads from stdin)
14
+ * - `codex exec resume <sessionId>`: Resume existing session
15
15
  * - `--json`: Structured output
16
+ *
17
+ * The prompt is piped via stdin to handle multi-line content with
18
+ * XML tags and special characters safely.
16
19
  */
17
20
 
18
21
  // Cache resolved binary path
@@ -106,17 +109,15 @@ export class CodexAdapter implements AgentAdapter {
106
109
 
107
110
  // Session handling
108
111
  if (resume) {
109
- // Resume existing session
110
112
  args.push('resume', sessionId);
111
113
  }
112
114
 
113
115
  // JSON output format
114
116
  args.push('--json');
115
117
 
116
- // The prompt (always last)
117
- args.push(prompt);
118
+ // Prompt is piped via stdin to avoid shell escaping issues with
119
+ // multi-line prompts containing XML tags, angle brackets, etc.
118
120
 
119
- // Spawn the process
120
121
  const cwd = workingDir || process.cwd();
121
122
 
122
123
  debugBlock('codex', 'Spawn', {
@@ -124,6 +125,7 @@ export class CodexAdapter implements AgentAdapter {
124
125
  args: args.join(' '),
125
126
  cwd,
126
127
  resume: String(resume),
128
+ promptLength: String(prompt.length),
127
129
  });
128
130
 
129
131
  let proc: ReturnType<typeof Bun.spawn>;
@@ -131,6 +133,7 @@ export class CodexAdapter implements AgentAdapter {
131
133
  proc = Bun.spawn(args, {
132
134
  cwd,
133
135
  env: process.env,
136
+ stdin: 'pipe',
134
137
  stdout: 'pipe',
135
138
  stderr: 'pipe',
136
139
  });
@@ -146,6 +149,19 @@ export class CodexAdapter implements AgentAdapter {
146
149
  });
147
150
  }
148
151
 
152
+ // Write prompt to stdin then close the stream.
153
+ // Bun.spawn with stdin:'pipe' returns a FileSink.
154
+ const stdin = proc.stdin;
155
+ if (!stdin || typeof stdin === 'number') {
156
+ throw new Error('Failed to get writable stdin from Codex process');
157
+ }
158
+ try {
159
+ stdin.write(prompt);
160
+ stdin.end();
161
+ } catch (error) {
162
+ throw new Error(`Failed to write prompt to Codex stdin: ${error}`);
163
+ }
164
+
149
165
  // Collect output and handle async spawn failures
150
166
  let stdout: string;
151
167
  let stderr: string;
@@ -10,10 +10,12 @@ import { debugLog, debugBlock } from '../debug.js';
10
10
  * Wraps the OpenCode CLI with session handling and JSON output parsing.
11
11
  *
12
12
  * Key flags:
13
- * - `run "<prompt>"`: Execute a prompt
13
+ * - `run`: Execute a prompt (reads from stdin or positional args)
14
14
  * - `--session <id>` or `--continue`: Resume existing session
15
- * - `--format json`: Structured output
16
- * - `--cwd <path>`: Set working directory
15
+ * - `--format json`: NDJSON event stream output
16
+ *
17
+ * The prompt is piped via stdin to handle multi-line content with
18
+ * XML tags and special characters safely.
17
19
  */
18
20
 
19
21
  // Cache resolved binary path
@@ -26,6 +28,45 @@ export function _resetBinaryCache(): void {
26
28
  cachedBinarySource = 'unknown';
27
29
  }
28
30
 
31
+ /**
32
+ * Parse NDJSON event stream from `opencode run --format json`.
33
+ *
34
+ * Each line is a JSON object like:
35
+ * {"type":"text","sessionID":"ses_xxx","part":{"text":"Hello",...}}
36
+ * {"type":"step_start","sessionID":"ses_xxx","part":{...}}
37
+ * {"type":"step_finish","sessionID":"ses_xxx","part":{...}}
38
+ *
39
+ * We concatenate all "text" event parts and extract the sessionID.
40
+ */
41
+ function parseNdjsonEvents(raw: string): { text: string; eventSessionId: string | null } {
42
+ const lines = raw.trim().split('\n').filter(Boolean);
43
+ const textParts: string[] = [];
44
+ let eventSessionId: string | null = null;
45
+
46
+ for (const line of lines) {
47
+ try {
48
+ const event = JSON.parse(line) as Record<string, unknown>;
49
+
50
+ // Extract sessionID from any event
51
+ if (!eventSessionId && typeof event.sessionID === 'string') {
52
+ eventSessionId = event.sessionID;
53
+ }
54
+
55
+ // Collect text content
56
+ if (event.type === 'text') {
57
+ const part = event.part as Record<string, unknown> | undefined;
58
+ if (part && typeof part.text === 'string') {
59
+ textParts.push(part.text);
60
+ }
61
+ }
62
+ } catch {
63
+ // Skip non-JSON lines (shouldn't happen with --format json)
64
+ }
65
+ }
66
+
67
+ return { text: textParts.join(''), eventSessionId };
68
+ }
69
+
29
70
  export class OpenCodeAdapter implements AgentAdapter {
30
71
  readonly name = 'opencode';
31
72
 
@@ -105,24 +146,20 @@ export class OpenCodeAdapter implements AgentAdapter {
105
146
  const binaryPath = await this.getBinaryPath();
106
147
  const args = [binaryPath, 'run'];
107
148
 
108
- // Format as JSON for structured output
149
+ // Format as JSON for structured output (NDJSON event stream)
109
150
  args.push('--format', 'json');
110
151
 
111
152
  // Session handling
112
153
  if (resume) {
113
- // Resume existing session
114
154
  args.push('--session', sessionId);
115
155
  }
116
156
 
117
- // Working directory
118
- if (workingDir) {
119
- args.push('--cwd', workingDir);
120
- }
157
+ // Note: opencode has no --cwd flag; working directory is set via
158
+ // Bun.spawn's cwd option below.
121
159
 
122
- // The prompt (always last)
123
- args.push(prompt);
160
+ // Prompt is piped via stdin to avoid shell escaping issues with
161
+ // multi-line prompts containing XML tags, angle brackets, etc.
124
162
 
125
- // Spawn the process
126
163
  const cwd = workingDir || process.cwd();
127
164
 
128
165
  debugBlock('opencode', 'Spawn', {
@@ -130,6 +167,7 @@ export class OpenCodeAdapter implements AgentAdapter {
130
167
  args: args.join(' '),
131
168
  cwd,
132
169
  resume: String(resume),
170
+ promptLength: String(prompt.length),
133
171
  });
134
172
 
135
173
  let proc: ReturnType<typeof Bun.spawn>;
@@ -137,6 +175,7 @@ export class OpenCodeAdapter implements AgentAdapter {
137
175
  proc = Bun.spawn(args, {
138
176
  cwd,
139
177
  env: process.env,
178
+ stdin: 'pipe',
140
179
  stdout: 'pipe',
141
180
  stderr: 'pipe',
142
181
  });
@@ -152,6 +191,19 @@ export class OpenCodeAdapter implements AgentAdapter {
152
191
  });
153
192
  }
154
193
 
194
+ // Write prompt to stdin then close the stream.
195
+ // Bun.spawn with stdin:'pipe' returns a FileSink.
196
+ const stdin = proc.stdin;
197
+ if (!stdin || typeof stdin === 'number') {
198
+ throw new Error('Failed to get writable stdin from OpenCode process');
199
+ }
200
+ try {
201
+ stdin.write(prompt);
202
+ stdin.end();
203
+ } catch (error) {
204
+ throw new Error(`Failed to write prompt to OpenCode stdin: ${error}`);
205
+ }
206
+
155
207
  // Collect output and handle async spawn failures
156
208
  let stdout: string;
157
209
  let stderr: string;
@@ -178,24 +230,13 @@ export class OpenCodeAdapter implements AgentAdapter {
178
230
  throw new Error(`OpenCode CLI failed (exit ${exitCode}): ${stderr || 'Unknown error'}`);
179
231
  }
180
232
 
181
- // Parse JSON output
182
- let output = stdout.trim();
183
- let finalSessionId = sessionId;
184
-
185
- try {
186
- const parsed = JSON.parse(stdout);
187
- output = parsed.output || parsed.response || parsed.result || stdout.trim();
188
-
189
- // Extract session ID from output if not resuming
190
- if (!resume && parsed.sessionId) {
191
- finalSessionId = parsed.sessionId;
192
- } else if (!resume && parsed.session_id) {
193
- finalSessionId = parsed.session_id;
194
- }
195
- } catch {
196
- // Not JSON, use raw output
197
- output = stdout.trim();
198
- }
233
+ // Parse NDJSON event stream from --format json output.
234
+ // Each line is a JSON object with a "type" field. We extract:
235
+ // - sessionID from any event (they all carry it)
236
+ // - text content from "text" type events
237
+ const { text, eventSessionId } = parseNdjsonEvents(stdout);
238
+ const output = text || stdout.trim();
239
+ const finalSessionId = eventSessionId || sessionId;
199
240
 
200
241
  return {
201
242
  output,
package/src/api.ts CHANGED
@@ -7,6 +7,12 @@
7
7
 
8
8
  import { Client, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
9
9
  import { timingSafeEqual, createHmac } from 'crypto';
10
+ import { existsSync } from 'fs';
11
+ import { resolve } from 'path';
12
+ import {
13
+ listProjects, createProject, deleteProject, setProjectDefault,
14
+ getProject,
15
+ } from './db.js';
10
16
 
11
17
  const log = (msg: string) => process.stdout.write(`[api] ${msg}\n`);
12
18
 
@@ -559,6 +565,110 @@ export function startApiServer(client: Client, port: number = 2643) {
559
565
  }), { headers });
560
566
  }
561
567
 
568
+ // --- Project management endpoints ---
569
+
570
+ // GET /projects — list all projects
571
+ if (url.pathname === '/projects' && req.method === 'GET') {
572
+ const projects = listProjects();
573
+ return new Response(JSON.stringify(projects), { headers });
574
+ }
575
+
576
+ // POST /projects — create a project
577
+ if (url.pathname === '/projects' && req.method === 'POST') {
578
+ try {
579
+ const body = await req.json() as {
580
+ name?: string;
581
+ path?: string;
582
+ isDefault?: boolean;
583
+ };
584
+
585
+ if (!body.name || !body.path) {
586
+ return new Response(JSON.stringify({ error: 'name and path are required' }), {
587
+ status: 400,
588
+ headers,
589
+ });
590
+ }
591
+
592
+ // Validate path exists on disk
593
+ const resolvedPath = resolve(body.path);
594
+ if (!existsSync(resolvedPath)) {
595
+ return new Response(JSON.stringify({ error: `Path does not exist: ${resolvedPath}` }), {
596
+ status: 400,
597
+ headers,
598
+ });
599
+ }
600
+
601
+ createProject(body.name, resolvedPath, body.isDefault);
602
+ const project = getProject(body.name);
603
+ log(`Project created: ${body.name} → ${resolvedPath}`);
604
+ return new Response(JSON.stringify({ success: true, project }), {
605
+ status: 201,
606
+ headers,
607
+ });
608
+ } catch (error) {
609
+ const message = error instanceof Error ? error.message : String(error);
610
+ // Surface unique constraint violations as 409 Conflict
611
+ if (message.includes('UNIQUE constraint')) {
612
+ return new Response(JSON.stringify({ error: `Project already exists` }), {
613
+ status: 409,
614
+ headers,
615
+ });
616
+ }
617
+ log(`Create project error: ${error instanceof Error ? error.stack : message}`);
618
+ return new Response(JSON.stringify({ error: 'Internal server error' }), {
619
+ status: 500,
620
+ headers,
621
+ });
622
+ }
623
+ }
624
+
625
+ // DELETE /projects/:name — delete a project
626
+ if (url.pathname.startsWith('/projects/') && req.method === 'DELETE') {
627
+ const name = decodeURIComponent(url.pathname.slice('/projects/'.length).split('/')[0] || '');
628
+ if (!name) {
629
+ return new Response(JSON.stringify({ error: 'Project name is required' }), {
630
+ status: 400,
631
+ headers,
632
+ });
633
+ }
634
+
635
+ const existing = getProject(name);
636
+ if (!existing) {
637
+ return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
638
+ status: 404,
639
+ headers,
640
+ });
641
+ }
642
+
643
+ deleteProject(name);
644
+ log(`Project deleted: ${name}`);
645
+ return new Response(JSON.stringify({ success: true }), { headers });
646
+ }
647
+
648
+ // POST /projects/:name/default — set project as default
649
+ if (url.pathname.match(/^\/projects\/[^/]+\/default$/) && req.method === 'POST') {
650
+ const parts = url.pathname.split('/');
651
+ const name = decodeURIComponent(parts[2] || '');
652
+ if (!name) {
653
+ return new Response(JSON.stringify({ error: 'Project name is required' }), {
654
+ status: 400,
655
+ headers,
656
+ });
657
+ }
658
+
659
+ const existing = getProject(name);
660
+ if (!existing) {
661
+ return new Response(JSON.stringify({ error: `Project "${name}" not found` }), {
662
+ status: 404,
663
+ headers,
664
+ });
665
+ }
666
+
667
+ setProjectDefault(name);
668
+ log(`Project set as default: ${name}`);
669
+ return new Response(JSON.stringify({ success: true }), { headers });
670
+ }
671
+
562
672
  // 404 for unknown routes
563
673
  return new Response(JSON.stringify({ error: 'Not found' }), {
564
674
  status: 404,