otherwise-cli 0.1.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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
package/src/index.js ADDED
@@ -0,0 +1,557 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import open from 'open';
4
+ import ora from 'ora';
5
+ import { startServer, enableSilentMode } from './server.js';
6
+ import { config } from './config.js';
7
+ import { logSink } from './logBridge.js';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('otherwise')
13
+ .description('Otherwise AI - Your personal AI that lives on your computer')
14
+ .version('0.1.0');
15
+
16
+ /**
17
+ * Set the terminal window title
18
+ * @param {string} title - The title to display
19
+ */
20
+ function setTerminalTitle(title) {
21
+ process.stdout.write(`\x1b]0;${title}\x07\x1b]2;${title}\x07`);
22
+ }
23
+
24
+ // Default command - start server AND Ink UI (CLI opens automatically)
25
+ program
26
+ .option('-p, -port <port>', 'Port to run on', '3000')
27
+ .option('-cli', 'Open web interface in browser')
28
+ .option('-server-only', 'Only start server (no CLI chat)')
29
+ .option('-v, --verbose', 'No Ink UI — raw backend logs in terminal, chat in browser')
30
+ .action(async (options) => {
31
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
32
+ const serverUrl = `http://localhost:${port}`;
33
+ const verbose = (options.verbose ?? options.Verbose ?? options.v) === true;
34
+ const cli = options.cli ?? options.Cli ?? false;
35
+ const serverOnly = options.serverOnly ?? options.ServerOnly ?? false;
36
+
37
+ // Set terminal window title
38
+ setTerminalTitle('Otherwise');
39
+
40
+ // --verbose: server only, backend logs in terminal, no Ink (fewer WS clients = no duplication)
41
+ if (verbose) {
42
+ try {
43
+ console.log(chalk.cyan.bold('\n Otherwise') + chalk.dim(' (verbose mode)\n'));
44
+ console.log(chalk.dim(' Web UI: ') + chalk.green.underline(serverUrl));
45
+ console.log(chalk.dim(' Ink UI disabled — all server logs printed below.'));
46
+ console.log(chalk.dim(' Press Ctrl+C to stop.\n'));
47
+ await startServer(port);
48
+ // Always open browser in verbose mode since there's no Ink terminal to chat in
49
+ await open(serverUrl);
50
+ } catch (err) {
51
+ console.error(chalk.red('Failed to start server:'), err.message);
52
+ process.exit(1);
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (serverOnly) {
58
+ // Server-only mode - show banner and keep running
59
+ console.log(chalk.cyan(`
60
+ ██████╗ ████████╗██╗ ██╗███████╗██████╗ ██╗ ██╗██╗███████╗███████╗
61
+ ██╔═══██╗╚══██╔══╝██║ ██║██╔════╝██╔══██╗██║ ██║██║██╔════╝██╔════╝
62
+ ██║ ██║ ██║ ███████║█████╗ ██████╔╝██║ █╗ ██║██║███████╗█████╗
63
+ ██║ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗██║███╗██║██║╚════██║██╔══╝
64
+ ╚██████╔╝ ██║ ██║ ██║███████╗██║ ██║╚███╔███╔╝██║███████║███████╗
65
+ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝╚══════╝╚══════╝
66
+ `));
67
+ console.log(chalk.dim(' Your AI that lives on your computer\n'));
68
+
69
+ const spinner = ora('Starting server...').start();
70
+
71
+ try {
72
+ await startServer(port);
73
+ spinner.succeed(`Server running at ${chalk.green(serverUrl)}`);
74
+
75
+ console.log();
76
+ console.log(chalk.cyan(' ┌────────────────────────────────────────┐'));
77
+ console.log(chalk.cyan(' │') + chalk.bold(' Web Interface: ') + chalk.green.underline(serverUrl) + chalk.cyan(' │'));
78
+ console.log(chalk.cyan(' └────────────────────────────────────────┘'));
79
+ console.log();
80
+
81
+ if (cli) {
82
+ console.log(chalk.dim(' Opening browser...'));
83
+ await open(serverUrl);
84
+ }
85
+
86
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
87
+ } catch (err) {
88
+ spinner.fail('Failed to start server');
89
+ console.error(chalk.red(err.message));
90
+ process.exit(1);
91
+ }
92
+
93
+ return;
94
+ }
95
+
96
+ // Standard mode - Ink UI (CLI opens automatically), no console logs in terminal (stream to web UI only)
97
+ process.env.SILENT_MODE = 'true';
98
+ enableSilentMode();
99
+
100
+ const originalConsoleLog = console.log;
101
+ const originalConsoleWarn = console.warn;
102
+ const originalConsoleInfo = console.info;
103
+ const originalConsoleDebug = console.debug;
104
+
105
+ const forward = (level, fn) => (...args) => {
106
+ try {
107
+ logSink.write(level, args);
108
+ } catch (e) {}
109
+ };
110
+ console.log = forward('info', originalConsoleLog);
111
+ console.warn = forward('warn', originalConsoleWarn);
112
+ console.info = forward('info', originalConsoleInfo);
113
+ console.debug = forward('debug', originalConsoleDebug);
114
+
115
+ try {
116
+ await startServer(port);
117
+
118
+ // Open browser to localhost so the app loads with local CLI connected and active
119
+ await open(serverUrl);
120
+
121
+ // Clear screen for clean Ink start
122
+ process.stdout.write('\x1b[2J\x1b[H');
123
+
124
+ // Spawn tsx process for Ink UI
125
+ const { spawn } = await import('child_process');
126
+ const { fileURLToPath } = await import('url');
127
+ const { dirname, join } = await import('path');
128
+
129
+ const __filename = fileURLToPath(import.meta.url);
130
+ const __dirname = dirname(__filename);
131
+ const inkEntryPath = join(__dirname, 'ui', 'ink-runner.js');
132
+
133
+ const child = spawn('npx', ['tsx', inkEntryPath, serverUrl, 'true'], {
134
+ stdio: 'inherit',
135
+ cwd: process.cwd(),
136
+ });
137
+
138
+ child.on('error', () => {
139
+ console.error = originalConsoleLog;
140
+ console.error(chalk.red('Failed to start UI'));
141
+ process.exit(1);
142
+ });
143
+
144
+ child.on('exit', (code) => {
145
+ process.exit(code || 0);
146
+ });
147
+ } catch (err) {
148
+ console.error = originalConsoleLog;
149
+ console.error(chalk.red('Failed to start server:'), err.message);
150
+ process.exit(1);
151
+ }
152
+ });
153
+
154
+ // Config command with subcommands
155
+ const configCmd = program
156
+ .command('config')
157
+ .description('Configure API keys and settings');
158
+
159
+ // Config show subcommand (default action)
160
+ configCmd
161
+ .command('show', { isDefault: true })
162
+ .description('Show current configuration')
163
+ .action(async () => {
164
+ const allConfig = config.store;
165
+ // Redact API keys
166
+ const display = { ...allConfig };
167
+ if (display.apiKeys) {
168
+ display.apiKeys = Object.fromEntries(
169
+ Object.entries(display.apiKeys).map(([k, v]) => [k, v ? '••••••••' : '(not set)'])
170
+ );
171
+ }
172
+ console.log(chalk.cyan('\nCurrent configuration:\n'));
173
+ console.log(JSON.stringify(display, null, 2));
174
+ console.log(chalk.dim(`\nConfig file: ${config.path}\n`));
175
+
176
+ console.log('To set API keys, use:');
177
+ console.log(chalk.green(' otherwise config set anthropic <key>'));
178
+ console.log(chalk.green(' otherwise config set openai <key>'));
179
+ console.log(chalk.green(' otherwise config set google <key>'));
180
+ console.log(chalk.green(' otherwise config set xai <key>'));
181
+ console.log(chalk.green(' otherwise config set openrouter <key>'));
182
+ console.log(chalk.green(' otherwise config set ollama <url>'));
183
+ console.log('');
184
+ });
185
+
186
+ // Config set subcommand
187
+ configCmd
188
+ .command('set <key> <value>')
189
+ .description('Set a configuration value')
190
+ .action((key, value) => {
191
+ const keyMap = {
192
+ 'anthropic': 'apiKeys.anthropic',
193
+ 'openai': 'apiKeys.openai',
194
+ 'google': 'apiKeys.google',
195
+ 'xai': 'apiKeys.xai',
196
+ 'openrouter': 'apiKeys.openrouter',
197
+ 'ollama': 'ollamaUrl',
198
+ 'model': 'model',
199
+ };
200
+
201
+ const configKey = keyMap[key] || key;
202
+ // Trim API keys to prevent whitespace issues causing auth failures
203
+ const finalValue = configKey.startsWith('apiKeys.') ? value.trim() : value;
204
+ config.set(configKey, finalValue);
205
+ console.log(chalk.green(`✓ Set ${key}`));
206
+ });
207
+
208
+ // Connect command - link this CLI to otherwise.ai (device code at otherwise.ai/connect only)
209
+ // Uses saved pairing token when available so you don't have to enter the code again.
210
+ program
211
+ .command('connect')
212
+ .description('Connect to otherwise.ai: get a code, enter it at otherwise.ai/connect')
213
+ .option('-p, -port <port>', 'Port to run on', '3000')
214
+ .option('-v, --verbose', 'No Ink UI — raw backend logs in terminal')
215
+ .action(async (options) => {
216
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
217
+ const serverUrl = `http://localhost:${port}`;
218
+ const verbose = (options.verbose ?? options.Verbose ?? options.v) === true;
219
+ setTerminalTitle('Otherwise (remote)');
220
+
221
+ const { getBackendBaseUrl } = await import('./remote/client.js');
222
+ const baseUrl = config.get('remote.backendUrl')
223
+ ? config.get('remote.backendUrl').replace(/^wss?:\/\//, 'https://').replace(/\/ws.*$/, '')
224
+ : getBackendBaseUrl();
225
+
226
+ const connectUrl = 'https://otherwise.ai/connect';
227
+ let pairingToken = config.get('remote.pairingToken');
228
+
229
+ // If we have a saved token, validate it first; if invalid/expired, clear and get a new code
230
+ if (pairingToken) {
231
+ let valid = false;
232
+ try {
233
+ const validatePath = '/api/pairing-token/validate';
234
+ let res = await fetch(`${baseUrl}${validatePath}?token=${encodeURIComponent(pairingToken)}`);
235
+ if (res.status === 404) {
236
+ const altPath = '/pairing-token/validate';
237
+ res = await fetch(`${baseUrl}${altPath}?token=${encodeURIComponent(pairingToken)}`);
238
+ }
239
+ valid = res.ok && (await res.json().catch(() => ({}))).valid === true;
240
+ } catch (_) {
241
+ valid = false;
242
+ }
243
+ if (!valid) {
244
+ config.set('remote.pairingToken', null);
245
+ pairingToken = null;
246
+ }
247
+ }
248
+
249
+ if (!pairingToken) {
250
+ console.log(chalk.cyan('\n Connect to otherwise.ai\n'));
251
+ console.log(chalk.dim(' Backend: ') + chalk.dim(baseUrl));
252
+ console.log(chalk.white(' Open: ') + chalk.underline(connectUrl) + chalk.white('\n'));
253
+
254
+ const spinner = ora('Getting device code...').start();
255
+ let codeDisplay;
256
+ let codeKey;
257
+ let deviceCodePath = '/api/device-code';
258
+ const maxAttempts = 5;
259
+ const retryDelays = [0, 3000, 6000, 10000, 15000];
260
+ let lastError;
261
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
262
+ if (attempt > 0) {
263
+ spinner.text = `Backend may be waking up, retrying (${attempt + 1}/${maxAttempts})...`;
264
+ await new Promise((r) => setTimeout(r, retryDelays[attempt]));
265
+ }
266
+ try {
267
+ const controller = new AbortController();
268
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
269
+ let url = `${baseUrl}${deviceCodePath}`;
270
+ let res = await fetch(url, {
271
+ method: 'POST',
272
+ signal: controller.signal,
273
+ headers: { 'Content-Type': 'application/json' },
274
+ });
275
+ if (res.status === 404 && deviceCodePath === '/api/device-code') {
276
+ deviceCodePath = '/device-code';
277
+ url = `${baseUrl}${deviceCodePath}`;
278
+ res = await fetch(url, {
279
+ method: 'POST',
280
+ signal: controller.signal,
281
+ headers: { 'Content-Type': 'application/json' },
282
+ });
283
+ }
284
+ clearTimeout(timeoutId);
285
+ const data = await res.json().catch(() => ({}));
286
+ if (res.ok && data.code) {
287
+ codeDisplay = data.code;
288
+ codeKey = codeDisplay.replace(/-/g, '');
289
+ spinner.succeed('Got device code');
290
+ break;
291
+ }
292
+ lastError = data.error || `Backend returned ${res.status}`;
293
+ } catch (err) {
294
+ lastError = err.message || String(err);
295
+ if (err.name === 'AbortError') lastError = 'Request timed out (backend may be starting)';
296
+ }
297
+ if (attempt === maxAttempts - 1) {
298
+ spinner.fail('Could not get device code');
299
+ console.error(chalk.red(lastError));
300
+ console.error(chalk.dim('Run otherwise connect again once your backend is available.'));
301
+ process.exit(1);
302
+ }
303
+ }
304
+
305
+ console.log(chalk.white(' Enter this code at ') + chalk.underline(connectUrl) + chalk.white(':\n'));
306
+ console.log(chalk.bold.cyan(` ${codeDisplay}\n`));
307
+ console.log(chalk.dim(' Waiting for you to authorize... (code expires in 15 min)\n'));
308
+
309
+ const statusPath = deviceCodePath + '/status';
310
+ const pollInterval = 2500;
311
+ pairingToken = await new Promise((resolve, reject) => {
312
+ const timer = setInterval(async () => {
313
+ try {
314
+ const statusRes = await fetch(`${baseUrl}${statusPath}?code=${encodeURIComponent(codeKey)}`);
315
+ const statusData = await statusRes.json().catch(() => ({}));
316
+ if (statusData.status === 'authorized' && statusData.token) {
317
+ clearInterval(timer);
318
+ resolve(statusData.token);
319
+ } else if (statusData.status === 'expired') {
320
+ clearInterval(timer);
321
+ reject(new Error('Code expired. Run otherwise connect again to get a new code.'));
322
+ }
323
+ } catch (e) {
324
+ // keep polling on network errors
325
+ }
326
+ }, pollInterval);
327
+ }).catch((err) => {
328
+ console.error(chalk.red(err.message));
329
+ process.exit(1);
330
+ });
331
+
332
+ config.set('remote.pairingToken', pairingToken);
333
+ config.set('remote.backendUrl', baseUrl);
334
+ } else {
335
+ console.log(chalk.cyan('\n Connect to otherwise.ai\n'));
336
+ console.log(chalk.dim(' Using saved connection (no code needed).\n'));
337
+ }
338
+
339
+ const spinner2 = ora('Starting server and connecting to backend...').start();
340
+ try {
341
+ await startServer(port, { remotePairingToken: pairingToken });
342
+ spinner2.succeed('Connected!');
343
+ console.log(chalk.green(` Server: ${serverUrl}`));
344
+ console.log(chalk.green(' Remote: otherwise.ai will route to this CLI.\n'));
345
+
346
+ if (verbose) {
347
+ console.log(chalk.dim(' Verbose mode — raw logs below. Chat in browser.'));
348
+ console.log(chalk.dim(' Press Ctrl+C to stop.\n'));
349
+ await open(serverUrl);
350
+ return;
351
+ }
352
+
353
+ console.log(chalk.dim(' Opening CLI — chat state will sync with otherwise.ai.'));
354
+ console.log(chalk.dim(' Press Ctrl+C to stop.\n'));
355
+ await new Promise((r) => setTimeout(r, 1500));
356
+ process.stdout.write('\x1b[2J\x1b[H');
357
+ const { spawn } = await import('child_process');
358
+ const { fileURLToPath } = await import('url');
359
+ const { dirname, join } = await import('path');
360
+ const __filename = fileURLToPath(import.meta.url);
361
+ const __dirname = dirname(__filename);
362
+ const inkEntryPath = join(__dirname, 'ui', 'ink-runner.js');
363
+ const child = spawn('npx', ['tsx', inkEntryPath, serverUrl, 'true', 'remote'], {
364
+ stdio: 'inherit',
365
+ cwd: process.cwd(),
366
+ });
367
+ await new Promise((resolve, reject) => {
368
+ child.on('error', reject);
369
+ child.on('exit', (code) => resolve(code));
370
+ });
371
+ } catch (err) {
372
+ spinner2.fail('Failed to start');
373
+ console.error(chalk.red(err.message));
374
+ process.exit(1);
375
+ }
376
+ });
377
+
378
+ // Deploy command
379
+ program
380
+ .command('deploy')
381
+ .description('Deploy to a public domain via Cloudflare Tunnel')
382
+ .option('-quick', 'Use quick tunnel (temporary URL)')
383
+ .option('-p, -port <port>', 'Port to tunnel', '3000')
384
+ .action(async (options) => {
385
+ const { isCloudflaredInstalled, startQuickTunnel, startNamedTunnel, getSetupInstructions } = await import('./tunnel/cloudflare.js');
386
+
387
+ console.log(chalk.cyan('\nDeploy to Public Domain\n'));
388
+
389
+ if (!isCloudflaredInstalled()) {
390
+ console.log(chalk.yellow('cloudflared is not installed.\n'));
391
+ console.log(getSetupInstructions());
392
+ return;
393
+ }
394
+
395
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
396
+
397
+ // Start the server first
398
+ const spinner = ora('Starting server...').start();
399
+ await startServer(port);
400
+ spinner.succeed(`Server running on port ${port}`);
401
+
402
+ if (options.quick ?? options.Quick) {
403
+ // Quick tunnel mode
404
+ const spinner2 = ora('Starting quick tunnel...').start();
405
+ try {
406
+ const url = await startQuickTunnel(port);
407
+ spinner2.succeed(`Tunnel active!`);
408
+ console.log(chalk.green(`\n Public URL: ${url}\n`));
409
+ console.log(chalk.dim(' This URL is temporary and will change on restart.'));
410
+ console.log(chalk.dim(' For a permanent domain, use: otherwise deploy (without -quick)\n'));
411
+ } catch (err) {
412
+ spinner2.fail('Failed to start tunnel');
413
+ console.error(chalk.red(err.message));
414
+ }
415
+ } else {
416
+ // Named tunnel mode
417
+ const tunnelName = config.get('tunnel.name');
418
+ const domain = config.get('tunnel.domain');
419
+
420
+ if (!tunnelName || !domain) {
421
+ console.log(chalk.yellow('Tunnel not configured.\n'));
422
+ console.log(getSetupInstructions());
423
+ return;
424
+ }
425
+
426
+ const spinner2 = ora(`Starting tunnel for ${domain}...`).start();
427
+ try {
428
+ const url = await startNamedTunnel(tunnelName, port, domain);
429
+ spinner2.succeed(`Tunnel active!`);
430
+ console.log(chalk.green(`\n Public URL: ${url}\n`));
431
+ } catch (err) {
432
+ spinner2.fail('Failed to start tunnel');
433
+ console.error(chalk.red(err.message));
434
+ }
435
+ }
436
+
437
+ console.log(chalk.dim('Press Ctrl+C to stop\n'));
438
+ });
439
+
440
+ // Login command - start server and open login page in browser
441
+ program
442
+ .command('login')
443
+ .description('Open the login page to sign in with your account (GitHub, Google, or email)')
444
+ .option('-p, -port <port>', 'Port to run on', '3000')
445
+ .action(async (options) => {
446
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
447
+ const serverUrl = `http://localhost:${port}`;
448
+ const loginUrl = `${serverUrl}/login`;
449
+
450
+ console.log(chalk.cyan('\n Sign in to Otherwise\n'));
451
+
452
+ const spinner = ora('Starting server...').start();
453
+ try {
454
+ await startServer(port);
455
+ spinner.succeed(`Server running at ${chalk.green(serverUrl)}`);
456
+ } catch (err) {
457
+ spinner.fail('Failed to start server');
458
+ console.error(chalk.red(err.message));
459
+ process.exit(1);
460
+ }
461
+
462
+ console.log();
463
+ console.log(chalk.cyan(' ┌────────────────────────────────────────┐'));
464
+ console.log(chalk.cyan(' │') + chalk.bold(' Login: ') + chalk.green.underline(loginUrl) + chalk.cyan(' │'));
465
+ console.log(chalk.cyan(' └────────────────────────────────────────┘'));
466
+ console.log();
467
+ console.log(chalk.dim(' Opening login page in your browser...'));
468
+ await open(loginUrl);
469
+ console.log(chalk.dim(' Sign in with GitHub, Google, or email to sync your chats.'));
470
+ console.log(chalk.dim(` If OAuth fails, add ${chalk.underline(serverUrl)} to Supabase Auth → URL Configuration → Redirect URLs.`));
471
+ console.log(chalk.dim(' Press Ctrl+C to stop the server.\n'));
472
+ });
473
+
474
+ // Chat command - connects to existing server
475
+ program
476
+ .command('chat')
477
+ .description('Connect to running server for CLI chat')
478
+ .option('-p, -port <port>', 'Server port to connect to', '3000')
479
+ .action(async (options) => {
480
+ setTerminalTitle('Otherwise');
481
+
482
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
483
+ const serverUrl = `http://localhost:${port}`;
484
+
485
+ // Check if server is running first
486
+ try {
487
+ const healthCheck = await fetch(`${serverUrl}/api/health`);
488
+ if (!healthCheck.ok) throw new Error('Server not healthy');
489
+ } catch (err) {
490
+ console.log(chalk.red('\n Server is not running.'));
491
+ console.log(chalk.yellow('\n TIP: Just run `otherwise` to start both server and CLI!\n'));
492
+ console.log(chalk.dim(' Or start server separately with: otherwise -server-only\n'));
493
+ process.exit(1);
494
+ }
495
+
496
+ // Spawn Ink UI connected to existing server
497
+ try {
498
+ const { spawn } = await import('child_process');
499
+ const { fileURLToPath } = await import('url');
500
+ const { dirname, join } = await import('path');
501
+
502
+ const __filename = fileURLToPath(import.meta.url);
503
+ const __dirname = dirname(__filename);
504
+ const inkEntryPath = join(__dirname, 'ui', 'ink-runner.js');
505
+
506
+ const child = spawn('npx', ['tsx', inkEntryPath, serverUrl, 'true'], {
507
+ stdio: 'inherit',
508
+ cwd: process.cwd(),
509
+ });
510
+
511
+ await new Promise((resolve, reject) => {
512
+ child.on('error', reject);
513
+ child.on('exit', resolve);
514
+ });
515
+ } catch (err) {
516
+ console.error(chalk.red('Failed to start UI:'), err.message);
517
+ process.exit(1);
518
+ }
519
+ });
520
+
521
+ // Voice command - start server and open voice mode in browser
522
+ program
523
+ .command('voice')
524
+ .description('Start voice mode - talk to your AI using speech')
525
+ .option('-p, -port <port>', 'Port to run on', '3000')
526
+ .action(async (options) => {
527
+ const port = parseInt(options.port ?? options.Port ?? '3000', 10);
528
+ const serverUrl = `http://localhost:${port}`;
529
+
530
+ console.log(chalk.cyan('\n Starting Voice Mode...\n'));
531
+
532
+ const spinner = ora('Starting server...').start();
533
+
534
+ try {
535
+ await startServer(port);
536
+ spinner.succeed(`Server running at ${chalk.green(serverUrl)}`);
537
+ } catch (err) {
538
+ spinner.fail('Failed to start server');
539
+ console.error(chalk.red(err.message));
540
+ process.exit(1);
541
+ }
542
+
543
+ const voiceUrl = `${serverUrl}?voice=true`;
544
+ console.log();
545
+ console.log(chalk.cyan(' ┌─────────────────────────────────────────────┐'));
546
+ console.log(chalk.cyan(' │') + chalk.bold(' Voice Mode: ') + chalk.green.underline(voiceUrl) + chalk.cyan(' │'));
547
+ console.log(chalk.cyan(' └─────────────────────────────────────────────┘'));
548
+ console.log();
549
+ console.log(chalk.dim(' Opening voice mode in your browser...'));
550
+ await open(voiceUrl);
551
+ console.log(chalk.dim(' Speak naturally — your voice will be transcribed and sent to your AI.'));
552
+ console.log(chalk.dim(' Press Ctrl+C to stop the server.\n'));
553
+ });
554
+
555
+ export function run() {
556
+ program.parse();
557
+ }
@@ -0,0 +1,113 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+
3
+ /**
4
+ * Get max output tokens for a Claude model
5
+ * @param {string} model - Model identifier
6
+ * @returns {number} - Max output tokens allowed
7
+ */
8
+ function getClaudeMaxOutputTokens(model) {
9
+ // Claude model max output token limits (as of 2025)
10
+ // See: https://docs.anthropic.com/en/docs/about-claude/models
11
+ if (model.includes('opus')) {
12
+ return 32768; // Claude 3 Opus: 32K output
13
+ }
14
+ if (model.includes('sonnet-4') || model.includes('claude-4')) {
15
+ return 64000; // Claude 4/Sonnet 4: 64K output
16
+ }
17
+ if (model.includes('sonnet')) {
18
+ return 8192; // Claude 3/3.5 Sonnet: 8K output
19
+ }
20
+ if (model.includes('haiku')) {
21
+ return 8192; // Claude 3 Haiku: 8K output
22
+ }
23
+ // Default conservative limit
24
+ return 8192;
25
+ }
26
+
27
+ /**
28
+ * Stream chat completion from Anthropic Claude
29
+ * @param {string} model - Model identifier (e.g., 'claude-sonnet-4-20250514')
30
+ * @param {Array} messages - Array of message objects with role and content
31
+ * @param {string} systemPrompt - System prompt
32
+ * @param {object} config - Configuration with API keys and settings
33
+ * @yields {object} - Chunks with type and content
34
+ */
35
+ export async function* streamClaude(model, messages, systemPrompt, config) {
36
+ const apiKey = config.apiKeys?.anthropic;
37
+ if (!apiKey) {
38
+ throw new Error('Anthropic API key not configured. Run: otherwise config set anthropic <key>');
39
+ }
40
+
41
+ const client = new Anthropic({ apiKey });
42
+
43
+ // Helper: parse data URL to base64 + media_type for Claude
44
+ const parseDataUrl = (dataUrl) => {
45
+ if (!dataUrl || !dataUrl.startsWith('data:')) return null;
46
+ const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
47
+ if (!match) return null;
48
+ return { mediaType: match[1], data: match[2] };
49
+ };
50
+
51
+ // Convert messages to Anthropic format (vision: user messages can have images)
52
+ const anthropicMessages = messages.map(m => {
53
+ const role = m.role === 'user' ? 'user' : 'assistant';
54
+ if (m.images && m.images.length > 0 && m.role === 'user') {
55
+ const content = [];
56
+ for (const imgDataUrl of m.images) {
57
+ const parsed = parseDataUrl(imgDataUrl);
58
+ if (parsed) {
59
+ content.push({
60
+ type: 'image',
61
+ source: {
62
+ type: 'base64',
63
+ media_type: parsed.mediaType,
64
+ data: parsed.data,
65
+ },
66
+ });
67
+ }
68
+ }
69
+ content.push({ type: 'text', text: m.content || '' });
70
+ return { role, content };
71
+ }
72
+ return { role, content: m.content };
73
+ });
74
+
75
+ // Cap max_tokens to the model's limit to prevent API errors
76
+ const modelMaxTokens = getClaudeMaxOutputTokens(model);
77
+ const requestedMaxTokens = config.maxTokens || 8192;
78
+ const effectiveMaxTokens = Math.min(requestedMaxTokens, modelMaxTokens);
79
+
80
+ const stream = await client.messages.create({
81
+ model,
82
+ system: systemPrompt,
83
+ messages: anthropicMessages,
84
+ max_tokens: effectiveMaxTokens,
85
+ temperature: Math.min(config.temperature || 0.7, 1), // Anthropic max is 1
86
+ stream: true,
87
+ });
88
+
89
+ let outputTokens = 0;
90
+ let inputTokens = 0;
91
+
92
+ for await (const event of stream) {
93
+ if (event.type === 'content_block_delta' && event.delta?.text) {
94
+ yield { type: 'text', content: event.delta.text };
95
+ } else if (event.type === 'message_delta' && event.usage) {
96
+ // Capture usage stats from message_delta event
97
+ outputTokens = event.usage.output_tokens || 0;
98
+ } else if (event.type === 'message_start' && event.message?.usage) {
99
+ // Capture input tokens from message_start event
100
+ inputTokens = event.message.usage.input_tokens || 0;
101
+ } else if (event.type === 'message_stop') {
102
+ // Stream ended - yield usage stats
103
+ yield {
104
+ type: 'usage',
105
+ inputTokens,
106
+ outputTokens,
107
+ totalTokens: inputTokens + outputTokens,
108
+ };
109
+ }
110
+ }
111
+ }
112
+
113
+ export default { streamClaude };