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.
- package/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- 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 };
|