@thesammykins/tether 1.6.1 → 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.1",
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",
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,