@thesammykins/tether 1.0.1 → 1.3.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
@@ -8,6 +8,7 @@
8
8
  * tether status - Show running status
9
9
  * tether health - Check Distether connection
10
10
  * tether setup - Interactive setup wizard
11
+ * tether config - Manage configuration and encrypted secrets
11
12
  *
12
13
  * Distether Commands:
13
14
  * tether send <channel> "message"
@@ -31,11 +32,17 @@
31
32
  */
32
33
 
33
34
  import { spawn, spawnSync } from 'bun';
34
- import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync } from 'fs';
35
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, symlinkSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
35
36
  import { join, dirname } from 'path';
36
37
  import * as readline from 'readline';
37
38
  import { homedir } from 'os';
38
39
  import { randomUUID } from 'crypto';
40
+ import {
41
+ ensureConfigDir, readPreferences, writePreference, readSecrets, writeSecret,
42
+ deleteKey as deleteConfigKey, resolve as resolveConfig, resolveAll,
43
+ isKnownKey, isSecret, getKeyMeta, getKnownKeys, hasSecrets, hasConfig,
44
+ importDotEnv, CONFIG_PATHS,
45
+ } from '../src/config';
39
46
 
40
47
  const PID_FILE = join(process.cwd(), '.tether.pid');
41
48
  const API_BASE = process.env.TETHER_API_URL || 'http://localhost:2643';
@@ -80,9 +87,16 @@ async function prompt(question: string): Promise<string> {
80
87
 
81
88
  async function apiCall(endpoint: string, body: any): Promise<any> {
82
89
  try {
90
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
91
+
92
+ // Add Authorization header if API_TOKEN is set
93
+ if (process.env.API_TOKEN) {
94
+ headers['Authorization'] = `Bearer ${process.env.API_TOKEN}`;
95
+ }
96
+
83
97
  const response = await fetch(`${API_BASE}${endpoint}`, {
84
98
  method: 'POST',
85
- headers: { 'Content-Type': 'application/json' },
99
+ headers,
86
100
  body: JSON.stringify(body),
87
101
  });
88
102
  const data = await response.json();
@@ -689,28 +703,106 @@ async function setup() {
689
703
  console.log('⚠ Redis not running. Start it with: redis-server');
690
704
  }
691
705
 
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');
706
+ // Check Agent CLI based on TETHER_AGENT env var
707
+ const agentType = process.env.TETHER_AGENT || 'claude';
708
+ let agentBinary: string;
709
+ let agentInstallUrl: string;
710
+
711
+ switch (agentType) {
712
+ case 'claude-code':
713
+ case 'claude':
714
+ agentBinary = 'claude';
715
+ agentInstallUrl = 'https://claude.ai/code';
716
+ break;
717
+ case 'opencode':
718
+ agentBinary = 'opencode';
719
+ agentInstallUrl = 'https://github.com/getcursor/opencode';
720
+ break;
721
+ case 'codex-cli':
722
+ case 'codex':
723
+ agentBinary = 'codex';
724
+ agentInstallUrl = 'https://github.com/getcursor/codex';
725
+ break;
726
+ default:
727
+ agentBinary = agentType;
728
+ agentInstallUrl = '(unknown agent type)';
729
+ }
730
+
731
+ const agentCheck = spawnSync([agentBinary, '--version'], { stdout: 'pipe', stderr: 'pipe' });
732
+ if (agentCheck.exitCode === 0) {
733
+ console.log(`✓ ${agentBinary} CLI installed`);
696
734
  } else {
697
- console.log('⚠ Claude CLI not found. Install from: https://claude.ai/code');
735
+ console.log(`⚠ ${agentBinary} CLI not found. Install from: ${agentInstallUrl}`);
698
736
  }
699
737
 
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');
738
+ // Install Tether skill for agent (legacy Claude Code location)
739
+ const legacySkillsDir = join(homedir(), '.claude', 'skills', 'tether');
740
+ const tetherRoot = dirname(import.meta.dir);
741
+ const sourceSkillsDir = join(tetherRoot, 'skills', 'tether');
704
742
 
705
743
  if (existsSync(sourceSkillsDir)) {
706
- console.log('\n📚 Claude Code Skill');
707
- console.log(' Teaches your assistant how to send Distether messages, embeds,');
744
+ console.log('\n📚 Tether Skill (Legacy Claude Code location)');
745
+ console.log(' Teaches your assistant how to send Discord messages, embeds,');
708
746
  console.log(' files, and interactive buttons.');
709
- const installSkill = await prompt('Install skill? (Y/n): ');
747
+ const installSkill = await prompt('Install skill to ~/.claude/skills/tether? (Y/n): ');
710
748
  if (installSkill.toLowerCase() !== 'n') {
711
- mkdirSync(skillsDir, { recursive: true });
712
- cpSync(sourceSkillsDir, skillsDir, { recursive: true });
713
- console.log(`✓ Skill installed to ${skillsDir}`);
749
+ mkdirSync(legacySkillsDir, { recursive: true });
750
+ cpSync(sourceSkillsDir, legacySkillsDir, { recursive: true });
751
+ console.log(`✓ Skill installed to ${legacySkillsDir}`);
752
+ }
753
+ }
754
+
755
+ // Symlink skill to standard location (~/.agents/skills/tether)
756
+ const standardSkillsDir = join(homedir(), '.agents', 'skills', 'tether');
757
+
758
+ if (existsSync(sourceSkillsDir)) {
759
+ console.log('\n📚 Tether Skill (Standard location for all agents)');
760
+ console.log(' Symlinking to ~/.agents/skills/tether for OpenCode, Codex, etc.');
761
+
762
+ // Create ~/.agents/skills/ if it doesn't exist
763
+ mkdirSync(join(homedir(), '.agents', 'skills'), { recursive: true });
764
+
765
+ // Check if symlink already exists
766
+ let shouldCreateSymlink = true;
767
+ if (existsSync(standardSkillsDir)) {
768
+ try {
769
+ const stats = lstatSync(standardSkillsDir);
770
+ if (stats.isSymbolicLink()) {
771
+ const target = readlinkSync(standardSkillsDir);
772
+ if (target === sourceSkillsDir) {
773
+ console.log(`✓ Symlink already exists and points to correct location`);
774
+ shouldCreateSymlink = false;
775
+ } else {
776
+ console.log(`⚠ Symlink exists but points to: ${target}`);
777
+ const overwrite = await prompt('Overwrite? (Y/n): ');
778
+ if (overwrite.toLowerCase() !== 'n') {
779
+ unlinkSync(standardSkillsDir);
780
+ } else {
781
+ shouldCreateSymlink = false;
782
+ }
783
+ }
784
+ } else {
785
+ console.log(`⚠ ${standardSkillsDir} exists but is not a symlink`);
786
+ const overwrite = await prompt('Remove and create symlink? (Y/n): ');
787
+ if (overwrite.toLowerCase() !== 'n') {
788
+ unlinkSync(standardSkillsDir);
789
+ } else {
790
+ shouldCreateSymlink = false;
791
+ }
792
+ }
793
+ } catch (err) {
794
+ console.log(`⚠ Error checking existing path: ${err}`);
795
+ shouldCreateSymlink = false;
796
+ }
797
+ }
798
+
799
+ if (shouldCreateSymlink) {
800
+ try {
801
+ symlinkSync(sourceSkillsDir, standardSkillsDir, 'dir');
802
+ console.log(`✓ Symlinked ${standardSkillsDir} → ${sourceSkillsDir}`);
803
+ } catch (err) {
804
+ console.log(`⚠ Failed to create symlink: ${err}`);
805
+ }
714
806
  }
715
807
  }
716
808
 
@@ -725,18 +817,46 @@ async function start() {
725
817
 
726
818
  console.log('Starting Tether...\n');
727
819
 
820
+ // Resolve script paths relative to the package root, not process.cwd().
821
+ // When installed globally or via npx, cwd is the user's project dir where
822
+ // src/bot.ts doesn't exist. import.meta.dir is bin/, so one level up is root.
823
+ const packageRoot = dirname(import.meta.dir);
824
+ const botScript = join(packageRoot, 'src', 'bot.ts');
825
+ const workerScript = join(packageRoot, 'src', 'worker.ts');
826
+
827
+ // Load config store values into child process environment.
828
+ // Secrets are encrypted on disk — decrypt them so bot/worker can read
829
+ // DISCORD_BOT_TOKEN etc. from process.env as they expect.
830
+ const childEnv: Record<string, string | undefined> = { ...process.env };
831
+ const prefs = readPreferences();
832
+ for (const [key, value] of Object.entries(prefs)) {
833
+ if (!childEnv[key]) childEnv[key] = value;
834
+ }
835
+ if (hasSecrets()) {
836
+ const pw = await promptPassword('Encryption password: ');
837
+ try {
838
+ const secrets = readSecrets(pw);
839
+ for (const [key, value] of Object.entries(secrets)) {
840
+ if (!childEnv[key]) childEnv[key] = value;
841
+ }
842
+ } catch {
843
+ console.error('Wrong password or corrupted secrets file.');
844
+ process.exit(1);
845
+ }
846
+ }
847
+
728
848
  // Start bot
729
- const bot = spawn(['bun', 'run', 'src/bot.ts'], {
849
+ const bot = spawn(['bun', 'run', botScript], {
730
850
  stdout: 'inherit',
731
851
  stderr: 'inherit',
732
- cwd: process.cwd(),
852
+ env: childEnv,
733
853
  });
734
854
 
735
855
  // Start worker
736
- const worker = spawn(['bun', 'run', 'src/worker.ts'], {
856
+ const worker = spawn(['bun', 'run', workerScript], {
737
857
  stdout: 'inherit',
738
858
  stderr: 'inherit',
739
- cwd: process.cwd(),
859
+ env: childEnv,
740
860
  });
741
861
 
742
862
  // Save PIDs
@@ -834,6 +954,236 @@ async function health() {
834
954
  }
835
955
  }
836
956
 
957
+ // ============ Config ============
958
+
959
+ async function promptPassword(label = 'Password: '): Promise<string> {
960
+ // Use raw mode to hide password input
961
+ if (process.stdin.isTTY) {
962
+ process.stdout.write(label);
963
+ return new Promise((resolve) => {
964
+ let pw = '';
965
+ process.stdin.setRawMode(true);
966
+ process.stdin.resume();
967
+ process.stdin.setEncoding('utf-8');
968
+ process.stdin.on('data', (ch: string) => {
969
+ if (ch === '\r' || ch === '\n') {
970
+ process.stdin.setRawMode(false);
971
+ process.stdin.pause();
972
+ process.stdout.write('\n');
973
+ resolve(pw);
974
+ } else if (ch === '\x03') {
975
+ process.exit(130);
976
+ } else if (ch === '\x7f' || ch === '\b') {
977
+ pw = pw.slice(0, -1);
978
+ } else {
979
+ pw += ch;
980
+ }
981
+ });
982
+ });
983
+ }
984
+ return prompt(label);
985
+ }
986
+
987
+ async function configCommand() {
988
+ const subcommand = args[0];
989
+
990
+ switch (subcommand) {
991
+ case 'set': {
992
+ const key = args[1];
993
+ if (!key) {
994
+ console.error('Usage: tether config set <key> [value]');
995
+ process.exit(1);
996
+ }
997
+ if (!isKnownKey(key)) {
998
+ console.error(`Unknown config key: ${key}`);
999
+ console.error(`Run "tether config list" to see all keys`);
1000
+ process.exit(1);
1001
+ }
1002
+
1003
+ let value = args[2];
1004
+ if (isSecret(key)) {
1005
+ if (!value) {
1006
+ value = await promptPassword(`${key}: `);
1007
+ }
1008
+ const pw = await promptPassword('Encryption password: ');
1009
+ if (!pw) {
1010
+ console.error('Password cannot be empty');
1011
+ process.exit(1);
1012
+ }
1013
+ writeSecret(key, value, pw);
1014
+ console.log(`✔ Secret "${key}" saved (encrypted)`);
1015
+ } else {
1016
+ if (value === undefined) {
1017
+ console.error(`Usage: tether config set ${key} <value>`);
1018
+ process.exit(1);
1019
+ }
1020
+ writePreference(key, value);
1021
+ console.log(`✔ "${key}" = "${value}"`);
1022
+ }
1023
+ break;
1024
+ }
1025
+
1026
+ case 'get': {
1027
+ const key = args[1];
1028
+ if (!key) {
1029
+ console.error('Usage: tether config get <key>');
1030
+ process.exit(1);
1031
+ }
1032
+ if (!isKnownKey(key)) {
1033
+ console.error(`Unknown config key: ${key}`);
1034
+ process.exit(1);
1035
+ }
1036
+
1037
+ let password: string | undefined;
1038
+ if (isSecret(key) && hasSecrets()) {
1039
+ password = await promptPassword('Encryption password: ');
1040
+ }
1041
+
1042
+ const value = resolveConfig(key, password);
1043
+ const meta = getKeyMeta(key);
1044
+
1045
+ // Show source
1046
+ const envValue = process.env[key];
1047
+ let source = 'default';
1048
+ if (envValue !== undefined && envValue !== '') {
1049
+ source = 'env';
1050
+ } else if (isSecret(key) && password) {
1051
+ try {
1052
+ const secrets = readSecrets(password);
1053
+ if (key in secrets) source = 'secrets.enc';
1054
+ } catch { /* wrong password */ }
1055
+ } else {
1056
+ const prefs = readPreferences();
1057
+ if (key in prefs) source = 'config.toml';
1058
+ }
1059
+
1060
+ console.log(`${key} = ${value || '(empty)'}`);
1061
+ console.log(` source: ${source} section: [${meta?.section}]`);
1062
+ if (meta?.description) console.log(` ${meta.description}`);
1063
+ break;
1064
+ }
1065
+
1066
+ case 'list': {
1067
+ const keys = getKnownKeys();
1068
+ const prefs = readPreferences();
1069
+
1070
+ console.log('\nTether Configuration\n');
1071
+ let currentSection = '';
1072
+
1073
+ for (const key of keys) {
1074
+ const meta = getKeyMeta(key)!;
1075
+ if (meta.section !== currentSection) {
1076
+ currentSection = meta.section;
1077
+ console.log(`[${currentSection}]`);
1078
+ }
1079
+
1080
+ // Determine value & source
1081
+ const envValue = process.env[key];
1082
+ let value: string;
1083
+ let source: string;
1084
+
1085
+ if (envValue !== undefined && envValue !== '') {
1086
+ value = isSecret(key) ? '***' : envValue;
1087
+ source = 'env';
1088
+ } else if (isSecret(key)) {
1089
+ value = hasSecrets() ? '(encrypted)' : '(not set)';
1090
+ source = hasSecrets() ? 'secrets.enc' : 'default';
1091
+ } else if (key in prefs) {
1092
+ value = prefs[key]!;
1093
+ source = 'config.toml';
1094
+ } else {
1095
+ value = meta.default || '(not set)';
1096
+ source = 'default';
1097
+ }
1098
+
1099
+ const pad = ' '.repeat(Math.max(1, 28 - key.length));
1100
+ console.log(` ${key}${pad}${value} (${source})`);
1101
+ }
1102
+ console.log('');
1103
+ break;
1104
+ }
1105
+
1106
+ case 'delete':
1107
+ case 'unset': {
1108
+ const key = args[1];
1109
+ if (!key) {
1110
+ console.error('Usage: tether config delete <key>');
1111
+ process.exit(1);
1112
+ }
1113
+ if (!isKnownKey(key)) {
1114
+ console.error(`Unknown config key: ${key}`);
1115
+ process.exit(1);
1116
+ }
1117
+
1118
+ let password: string | undefined;
1119
+ if (isSecret(key)) {
1120
+ password = await promptPassword('Encryption password: ');
1121
+ }
1122
+
1123
+ const deleted = deleteConfigKey(key, password);
1124
+ if (deleted) {
1125
+ console.log(`✔ "${key}" deleted`);
1126
+ } else {
1127
+ console.log(`"${key}" was not set`);
1128
+ }
1129
+ break;
1130
+ }
1131
+
1132
+ case 'import': {
1133
+ const envPath = args[1] || join(process.cwd(), '.env');
1134
+ if (!existsSync(envPath)) {
1135
+ console.error(`File not found: ${envPath}`);
1136
+ process.exit(1);
1137
+ }
1138
+
1139
+ const pw = await promptPassword('Encryption password (for secrets): ');
1140
+ if (!pw) {
1141
+ console.error('Password cannot be empty');
1142
+ process.exit(1);
1143
+ }
1144
+
1145
+ const result = importDotEnv(envPath, pw);
1146
+ console.log(`\n✔ Imported ${result.imported.length} keys:`);
1147
+ for (const k of result.imported) {
1148
+ console.log(` ${k}${isSecret(k) ? ' (encrypted)' : ''}`);
1149
+ }
1150
+ if (result.skipped.length > 0) {
1151
+ console.log(`\n⚠ Skipped ${result.skipped.length} keys:`);
1152
+ for (const k of result.skipped) {
1153
+ console.log(` ${k}${isKnownKey(k) ? ' (empty/placeholder)' : ' (unknown)'}`);
1154
+ }
1155
+ }
1156
+ console.log('');
1157
+ break;
1158
+ }
1159
+
1160
+ case 'path': {
1161
+ console.log(`Config dir: ${CONFIG_PATHS.CONFIG_DIR}`);
1162
+ console.log(`Preferences: ${CONFIG_PATHS.CONFIG_PATH} ${hasConfig() ? '✔' : '(not created)'}`);
1163
+ console.log(`Secrets: ${CONFIG_PATHS.SECRETS_PATH} ${hasSecrets() ? '✔' : '(not created)'}`);
1164
+ break;
1165
+ }
1166
+
1167
+ default:
1168
+ console.log(`
1169
+ Usage: tether config <subcommand>
1170
+
1171
+ Subcommands:
1172
+ set <key> [value] Set a config value (prompts for secrets)
1173
+ get <key> Get a resolved config value
1174
+ list List all config values with sources
1175
+ delete <key> Delete a config value
1176
+ import [path] Import from .env file (default: ./.env)
1177
+ path Show config file locations
1178
+ `);
1179
+ if (subcommand) {
1180
+ console.error(`Unknown config subcommand: ${subcommand}`);
1181
+ process.exit(1);
1182
+ }
1183
+ break;
1184
+ }
1185
+ }
1186
+
837
1187
  function showHelp() {
838
1188
  console.log(`
839
1189
  Tether - Distether to Claude Code bridge
@@ -846,6 +1196,7 @@ Management Commands:
846
1196
  status Show running status
847
1197
  health Check Distether connection
848
1198
  setup Interactive setup wizard
1199
+ config Manage configuration and encrypted secrets
849
1200
  help Show this help
850
1201
 
851
1202
  Distether Commands:
@@ -916,20 +1267,32 @@ DM Commands (proactive outreach):
916
1267
  dm <user-id> --embed "description" [options]
917
1268
  Send an embed DM (same options as embed command)
918
1269
 
919
- dm <user-id> --file <filepath> ["message"]
920
- Send a file attachment via DM
1270
+ dm <user-id> --file <filepath> ["message"]
1271
+ Send a file attachment via DM
1272
+
1273
+ Config Commands:
1274
+ config set <key> [value] Set a config value (prompts for secrets)
1275
+ config get <key> Get a resolved config value with source
1276
+ config list List all config values with sources
1277
+ config delete <key> Delete a config value
1278
+ config import [path] Import from .env file (default: ./.env)
1279
+ config path Show config file locations
921
1280
 
922
1281
  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
1282
+ tether send 123456789 "Hello world!"
1283
+ tether embed 123456789 "Status update" --title "Daily Report" --color green --field "Tasks:5 done:inline"
1284
+ tether buttons 123456789 "Approve?" --button label="Yes" id="approve" style="success" reply="Approved!"
1285
+ tether ask 123456789 "Deploy to prod?" --option "Yes" --option "No" --timeout 600
1286
+ tether file 123456789 ./report.md "Here's the report"
1287
+ tether state 123456789 1234567890 processing
929
1288
  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"
1289
+ tether dm 987654321 "Hey, I need your approval on this PR"
1290
+ tether dm 987654321 --embed "Build passed" --title "CI Update" --color green
1291
+ tether dm 987654321 --file ./report.md "Here's the report"
1292
+ tether config set AGENT_TYPE opencode
1293
+ tether config set DISCORD_BOT_TOKEN
1294
+ tether config import .env
1295
+ tether config list
933
1296
  `);
934
1297
  }
935
1298
 
@@ -1002,6 +1365,9 @@ switch (command) {
1002
1365
  case 'dm':
1003
1366
  sendDM();
1004
1367
  break;
1368
+ case 'config':
1369
+ configCommand();
1370
+ break;
1005
1371
 
1006
1372
  default:
1007
1373
  console.log(`Unknown command: ${command}`);