@mmmbuto/nexuscrew 0.1.0-beta.1 → 0.2.1

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 (49) hide show
  1. package/README.md +67 -120
  2. package/bin/nexuscrew.js +468 -264
  3. package/frontend/dist/apple-touch-icon.png +0 -0
  4. package/frontend/dist/assets/{index-C7bAndew.js → index-BG1N7bSL.js} +1710 -1702
  5. package/frontend/dist/assets/index-CiAtinNP.css +1 -0
  6. package/frontend/dist/favicon.svg +20 -0
  7. package/frontend/dist/icon-192.png +0 -0
  8. package/frontend/dist/icon-512.png +0 -0
  9. package/frontend/dist/index.html +11 -3
  10. package/frontend/dist/site.webmanifest +29 -0
  11. package/lib/config/hosts.js +67 -0
  12. package/lib/config/manager.js +379 -0
  13. package/lib/config/models.js +408 -0
  14. package/lib/server/db/adapter.js +274 -0
  15. package/lib/server/db/drivers/sql-js.js +75 -0
  16. package/lib/server/db/migrate.js +174 -0
  17. package/lib/server/db/migrations/001_base_schema.sql +70 -0
  18. package/lib/server/middleware/auth.js +134 -0
  19. package/lib/server/middleware/rate-limit.js +63 -0
  20. package/lib/server/models/User.js +128 -0
  21. package/lib/server/routes/auth.js +168 -0
  22. package/lib/server/routes/hosts.js +11 -20
  23. package/lib/server/routes/keys.js +28 -0
  24. package/lib/server/routes/models.js +59 -4
  25. package/lib/server/routes/runtimes.js +34 -0
  26. package/lib/server/routes/send.js +76 -12
  27. package/lib/server/routes/sessions.js +39 -10
  28. package/lib/server/routes/speech.js +46 -0
  29. package/lib/server/routes/status.js +8 -6
  30. package/lib/server/routes/upload.js +135 -0
  31. package/lib/server/routes/wake-lock.js +95 -0
  32. package/lib/server/routes/workspaces.js +101 -0
  33. package/lib/server/server.js +66 -33
  34. package/lib/server/services/attachment-manager.js +57 -0
  35. package/lib/server/services/context-bridge.js +425 -0
  36. package/lib/server/services/runtime-manager.js +462 -0
  37. package/lib/server/services/speech-manager.js +76 -0
  38. package/lib/server/services/summary-generator.js +309 -0
  39. package/lib/server/services/workspace-manager.js +79 -0
  40. package/lib/services/engine-discovery.js +198 -13
  41. package/lib/services/log-watcher.js +40 -22
  42. package/lib/services/remote-pane-watcher.js +155 -0
  43. package/lib/services/session-store.js +60 -64
  44. package/lib/services/tmux-manager.js +127 -116
  45. package/lib/setup/postinstall.js +38 -0
  46. package/lib/utils/paths.js +124 -0
  47. package/lib/utils/termux.js +182 -0
  48. package/package.json +8 -4
  49. package/frontend/dist/assets/index-OENqI1_9.css +0 -1
package/bin/nexuscrew.js CHANGED
@@ -2,352 +2,556 @@
2
2
  'use strict';
3
3
 
4
4
  const { Command } = require('commander');
5
+ const readline = require('readline/promises');
6
+ const { stdin, stdout } = require('process');
7
+ const { spawn, execSync } = require('child_process');
5
8
  const path = require('path');
6
9
  const fs = require('fs');
7
- const { execSync } = require('child_process');
8
-
9
- const CONFIG_DIR = path.join(process.env.HOME || '', '.nexuscrew');
10
- const DB_PATH = path.join(CONFIG_DIR, 'nexuscrew.db');
11
- const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
12
- const HOSTS_PATH = path.join(CONFIG_DIR, 'hosts.json');
13
- const TMUX_SESSION = 'nexuscrew';
10
+ const http = require('http');
11
+
12
+ const {
13
+ getConfig,
14
+ initializeConfig,
15
+ getConfigValue,
16
+ setConfigValue,
17
+ isInitialized,
18
+ saveConfig
19
+ } = require('../lib/config/manager');
20
+ const { readHosts, writeHosts, ensureHostsFile } = require('../lib/config/hosts');
21
+ const { PATHS, ensureDirectories } = require('../lib/utils/paths');
22
+ const {
23
+ isTermux,
24
+ installPackage,
25
+ isPackageInstalled,
26
+ isTermuxBootAvailable,
27
+ isTermuxServicesAvailable,
28
+ getServiceDir,
29
+ getStartServicesScript,
30
+ openUrl
31
+ } = require('../lib/utils/termux');
32
+ const TmuxManager = require('../lib/services/tmux-manager');
33
+ const SpeechManager = require('../lib/server/services/speech-manager');
34
+ const EngineDiscovery = require('../lib/services/engine-discovery');
14
35
 
15
36
  const program = new Command();
37
+
38
+ function ensureBaseState() {
39
+ ensureDirectories();
40
+ ensureHostsFile();
41
+ if (!isInitialized()) {
42
+ initializeConfig({ workspaces: [path.join(process.env.HOME || '', 'Dev')] });
43
+ }
44
+ }
45
+
46
+ function commandExists(command) {
47
+ try {
48
+ execSync(`which ${command}`, { stdio: 'ignore' });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function prompt(question, fallback = '') {
56
+ const rl = readline.createInterface({ input: stdin, output: stdout });
57
+ try {
58
+ const answer = await rl.question(`${question}${fallback ? ` [${fallback}]` : ''}: `);
59
+ return answer.trim() || fallback;
60
+ } finally {
61
+ rl.close();
62
+ }
63
+ }
64
+
65
+ async function promptBoolean(question, defaultValue = false) {
66
+ const answer = await prompt(question, defaultValue ? 'Y' : 'N');
67
+ return answer.toLowerCase() === 'y';
68
+ }
69
+
70
+ function getProjectRoot() {
71
+ return path.join(__dirname, '..');
72
+ }
73
+
74
+ function getServerEntry() {
75
+ return path.join(getProjectRoot(), 'lib', 'server', 'server.js');
76
+ }
77
+
78
+ function getServerUrl(port) {
79
+ return `http://localhost:${port}`;
80
+ }
81
+
82
+ function ensureParentDir(filePath) {
83
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
84
+ }
85
+
86
+ function readPidFile() {
87
+ if (!fs.existsSync(PATHS.PID_FILE)) {
88
+ return null;
89
+ }
90
+
91
+ const pid = Number(fs.readFileSync(PATHS.PID_FILE, 'utf8').trim());
92
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
93
+ }
94
+
95
+ function isPidRunning(pid) {
96
+ if (!pid) return false;
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function writePidFile(pid) {
106
+ ensureParentDir(PATHS.PID_FILE);
107
+ fs.writeFileSync(PATHS.PID_FILE, `${pid}\n`, 'utf8');
108
+ }
109
+
110
+ function removePidFile() {
111
+ if (fs.existsSync(PATHS.PID_FILE)) {
112
+ fs.unlinkSync(PATHS.PID_FILE);
113
+ }
114
+ }
115
+
116
+ function getListeningPid(port) {
117
+ try {
118
+ const pid = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf8' }).trim();
119
+ return pid ? Number(pid) : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function getExistingServerPid(port) {
126
+ const pidFromFile = readPidFile();
127
+ if (isPidRunning(pidFromFile)) {
128
+ return pidFromFile;
129
+ }
130
+
131
+ if (pidFromFile) {
132
+ removePidFile();
133
+ }
134
+
135
+ const pidFromPort = getListeningPid(port);
136
+ if (isPidRunning(pidFromPort)) {
137
+ writePidFile(pidFromPort);
138
+ return pidFromPort;
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ function isTermuxServiceConfigured(config) {
145
+ const serviceName = config.termux?.service_name || 'nexuscrew';
146
+ return fs.existsSync(getServiceDir(serviceName));
147
+ }
148
+
149
+ function getTermuxServiceStatus(config) {
150
+ const serviceName = config.termux?.service_name || 'nexuscrew';
151
+ if (!isTermux() || !isPackageInstalled('sv') || !fs.existsSync(getServiceDir(serviceName))) {
152
+ return null;
153
+ }
154
+
155
+ try {
156
+ return execSync(`sv status ${serviceName}`, { encoding: 'utf8' }).trim();
157
+ } catch (error) {
158
+ return error.stdout?.toString?.().trim() || 'unknown';
159
+ }
160
+ }
161
+
162
+ function configureTermuxBoot(config) {
163
+ ensureParentDir(PATHS.BOOT_SCRIPT);
164
+ const startServicesScript = getStartServicesScript();
165
+ const script = [
166
+ '#!/data/data/com.termux/files/usr/bin/sh',
167
+ 'termux-wake-lock >/dev/null 2>&1 || true',
168
+ `[ -f "${startServicesScript}" ] && . "${startServicesScript}"`
169
+ ].join('\n') + '\n';
170
+
171
+ fs.writeFileSync(PATHS.BOOT_SCRIPT, script, 'utf8');
172
+ fs.chmodSync(PATHS.BOOT_SCRIPT, 0o755);
173
+ config.termux.boot_start = true;
174
+ }
175
+
176
+ function configureTermuxService(config, port) {
177
+ const serviceName = config.termux?.service_name || 'nexuscrew';
178
+ const serviceDir = getServiceDir(serviceName);
179
+ const runFile = path.join(serviceDir, 'run');
180
+ const script = [
181
+ '#!/data/data/com.termux/files/usr/bin/sh',
182
+ `cd "${getProjectRoot()}" || exit 1`,
183
+ 'mkdir -p "$HOME/.nexuscrew/logs"',
184
+ `export PORT="${port}"`,
185
+ 'exec node lib/server/server.js >>"$HOME/.nexuscrew/logs/server.log" 2>&1'
186
+ ].join('\n') + '\n';
187
+
188
+ fs.mkdirSync(serviceDir, { recursive: true });
189
+ fs.writeFileSync(runFile, script, 'utf8');
190
+ fs.chmodSync(runFile, 0o755);
191
+ execSync(`sv-enable ${serviceName}`, { stdio: 'ignore' });
192
+ config.termux.service_enabled = true;
193
+ }
194
+
195
+ function waitForHealth(port, timeoutMs = 8000) {
196
+ const deadline = Date.now() + timeoutMs;
197
+
198
+ return new Promise((resolve) => {
199
+ const probe = () => {
200
+ const req = http.get(`${getServerUrl(port)}/health`, (res) => {
201
+ res.resume();
202
+ resolve(res.statusCode === 200);
203
+ });
204
+
205
+ req.on('error', () => {
206
+ if (Date.now() >= deadline) {
207
+ resolve(false);
208
+ return;
209
+ }
210
+ setTimeout(probe, 250);
211
+ });
212
+
213
+ req.setTimeout(1000, () => req.destroy());
214
+ };
215
+
216
+ probe();
217
+ });
218
+ }
219
+
220
+ function startDetachedServer(port) {
221
+ ensureParentDir(PATHS.SERVER_LOG);
222
+ const logFd = fs.openSync(PATHS.SERVER_LOG, 'a');
223
+ const child = spawn(process.execPath, [getServerEntry()], {
224
+ cwd: getProjectRoot(),
225
+ detached: true,
226
+ env: {
227
+ ...process.env,
228
+ PORT: String(port)
229
+ },
230
+ stdio: ['ignore', logFd, logFd]
231
+ });
232
+
233
+ child.unref();
234
+ writePidFile(child.pid);
235
+ return child.pid;
236
+ }
237
+
16
238
  program
17
239
  .name('nexuscrew')
18
240
  .description('tmux-based AI cockpit')
19
- .version('0.1.0');
241
+ .version('0.2.1');
20
242
 
21
- // --- init ---
22
243
  program.command('init')
23
- .description('First-time setup')
24
- .action(() => {
25
- if (!fs.existsSync(CONFIG_DIR)) {
26
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
27
- console.log('Created ~/.nexuscrew/');
244
+ .description('Bootstrap config, directories, and host registry')
245
+ .option('-s, --silent', 'Silent mode')
246
+ .action((opts) => {
247
+ ensureBaseState();
248
+ if (!opts.silent) {
249
+ console.log(`Config: ${PATHS.CONFIG_FILE}`);
250
+ console.log(`Hosts: ${PATHS.HOSTS_FILE}`);
251
+ console.log(`Logs: ${PATHS.LOGS_DIR}`);
252
+ console.log(`Attachments: ${PATHS.ATTACHMENTS_DIR}`);
253
+ console.log(`tmux: ${commandExists('tmux') ? 'OK' : 'missing'}`);
254
+ console.log(`ssh: ${commandExists('ssh') ? 'OK' : 'missing'}`);
255
+ console.log(`scp: ${commandExists('scp') ? 'OK' : 'missing'}`);
28
256
  }
257
+ });
29
258
 
30
- // Default config
31
- if (!fs.existsSync(CONFIG_PATH)) {
32
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({
33
- port: 41820,
34
- tmuxSession: TMUX_SESSION,
35
- logDir: path.join(CONFIG_DIR, 'logs'),
36
- autoDiscovery: true,
37
- providersPath: ''
38
- }, null, 2));
39
- console.log('Created config.json');
259
+ program.command('setup')
260
+ .description('Interactive setup wizard')
261
+ .action(async () => {
262
+ ensureBaseState();
263
+
264
+ const current = getConfig();
265
+ const defaultWorkspace = current.workspaces?.default || path.join(process.env.HOME || '', 'Dev');
266
+ const port = Number(await prompt('HTTP port', String(current.server?.port || 41820)));
267
+ const tmuxSession = await prompt('tmux session name', current.tmuxSession || 'nexuscrew');
268
+ const workspace = await prompt('Default workspace', defaultWorkspace);
269
+ const addHost = await promptBoolean('Add SSH host now? (y/N)', false);
270
+
271
+ current.server.port = port;
272
+ current.tmuxSession = tmuxSession;
273
+ current.workspaces.default = workspace;
274
+ current.workspaces.paths = Array.from(new Set([workspace, ...(current.workspaces.paths || [])]));
275
+
276
+ if (isTermux()) {
277
+ for (const pkg of ['tmux', 'openssh', 'termux-services']) {
278
+ if (!isPackageInstalled(pkg)) {
279
+ installPackage(pkg);
280
+ }
281
+ }
40
282
  }
41
283
 
42
- // Default hosts
43
- if (!fs.existsSync(HOSTS_PATH)) {
44
- fs.writeFileSync(HOSTS_PATH, JSON.stringify([
45
- { name: 'local', type: 'local', default: true }
46
- ], null, 2));
47
- console.log('Created hosts.json');
284
+ if (addHost) {
285
+ const name = await prompt('Host alias', 'server');
286
+ const host = await prompt('SSH host');
287
+ const user = await prompt('SSH user', 'dag');
288
+ const sshPort = await prompt('SSH port', '22');
289
+ const key = await prompt('SSH key path (optional)', '');
290
+ const hosts = readHosts().filter((entry) => entry.name !== name);
291
+ hosts.push({
292
+ name,
293
+ type: 'ssh',
294
+ host,
295
+ user,
296
+ port: sshPort,
297
+ key: key || null,
298
+ default: false
299
+ });
300
+ writeHosts(hosts);
48
301
  }
49
302
 
50
- // Log dir
51
- const logDir = path.join(CONFIG_DIR, 'logs');
52
- if (!fs.existsSync(logDir)) {
53
- fs.mkdirSync(logDir, { recursive: true });
54
- }
303
+ if (isTermux()) {
304
+ const configureBoot = await promptBoolean('Configure Termux boot? (y/N)', false);
305
+ const configureService = await promptBoolean('Enable native Termux runit service watchdog? (y/N)', false);
55
306
 
56
- // Check tmux
57
- try {
58
- execSync('which tmux', { stdio: 'pipe' });
59
- console.log('tmux: OK');
60
- } catch {
61
- console.error('tmux not found! Install: apt install tmux (or pkg install tmux)');
307
+ if (configureBoot) {
308
+ configureTermuxBoot(current);
309
+ }
310
+
311
+ if (configureService) {
312
+ if (!isTermuxServicesAvailable()) {
313
+ console.log('termux-services is not ready yet. Restart the shell after installation and rerun setup if needed.');
314
+ } else {
315
+ configureTermuxService(current, port);
316
+ }
317
+ }
62
318
  }
63
319
 
64
- console.log('\nDone! Run: nexuscrew start');
320
+ saveConfig(current);
321
+
322
+ const speech = new SpeechManager();
323
+ const sttStatus = speech.getStatus();
324
+ console.log(`STT provider: ${sttStatus.provider}`);
325
+ console.log(`Whisper configured: ${sttStatus.whisper.configured ? 'yes' : 'no'}`);
326
+ if (isTermux()) {
327
+ if (current.termux?.boot_start && !isTermuxBootAvailable()) {
328
+ console.log('Boot script created. Install/open the Termux:Boot app once to activate it on boot.');
329
+ }
330
+ console.log(`Termux boot: ${current.termux?.boot_start ? 'enabled' : 'disabled'}`);
331
+ console.log(`Termux service: ${current.termux?.service_enabled ? 'enabled' : 'disabled'}`);
332
+ console.log(`Boot script: ${PATHS.BOOT_SCRIPT}`);
333
+ console.log(`Service dir: ${getServiceDir(current.termux?.service_name || 'nexuscrew')}`);
334
+ }
335
+ console.log('Run "nexuscrew stt doctor" to finish local speech setup.');
65
336
  });
66
337
 
67
- // --- start ---
68
338
  program.command('start')
69
- .description('Start the server')
70
- .option('-p, --port <port>', 'Port', '41820')
71
- .option('-d, --dev', 'Dev mode (no frontend)')
72
- .action((opts) => {
73
- // Ensure tmux master session exists
74
- try {
75
- execSync(`tmux has-session -t ${TMUX_SESSION} 2>/dev/null`);
76
- } catch {
77
- execSync(`tmux new-session -d -s ${TMUX_SESSION} -x 200 -y 50`);
78
- console.log(`Created tmux session: ${TMUX_SESSION}`);
339
+ .description('Start the server in background')
340
+ .option('-p, --port <port>', 'Port override')
341
+ .action(async (opts) => {
342
+ ensureBaseState();
343
+ const config = getConfig();
344
+ const tmux = new TmuxManager(config);
345
+ const port = Number(opts.port || config.server?.port || 41820);
346
+ const serviceConfigured = isTermux() && isTermuxServicesAvailable() && isTermuxServiceConfigured(config);
347
+ const existingPid = getExistingServerPid(port);
348
+
349
+ if (existingPid) {
350
+ console.log(`Server already running on ${getServerUrl(port)} (PID ${existingPid})`);
351
+ const shouldOpen = await promptBoolean('Open browser? (Y/n)', true);
352
+ if (shouldOpen && !openUrl(getServerUrl(port))) {
353
+ console.log(`Open this URL manually: ${getServerUrl(port)}`);
354
+ }
355
+ return;
79
356
  }
80
357
 
81
- const env = { ...process.env, PORT: opts.port };
82
- if (opts.dev) env.NEXUSCREW_DEV = '1';
358
+ tmux.ensureSession('local');
83
359
 
84
- const args = [path.join(__dirname, '..', 'lib', 'server', 'server.js')];
85
- const child = require('child_process').spawn('node', args, {
86
- env,
87
- stdio: 'inherit',
88
- detached: false
89
- });
90
- child.on('exit', (code) => process.exit(code || 0));
360
+ if (serviceConfigured) {
361
+ execSync(`sv up ${config.termux?.service_name || 'nexuscrew'}`, { stdio: 'ignore' });
362
+ } else {
363
+ startDetachedServer(port);
364
+ }
365
+
366
+ const healthy = await waitForHealth(port);
367
+ if (!healthy) {
368
+ console.error('Server did not become healthy in time. Check the log file for details.');
369
+ console.error(`Log: ${PATHS.SERVER_LOG}`);
370
+ process.exitCode = 1;
371
+ return;
372
+ }
373
+
374
+ const pid = getExistingServerPid(port);
375
+ console.log(`Server started: ${getServerUrl(port)}${pid ? ` (PID ${pid})` : ''}`);
376
+ console.log(`Log: ${PATHS.SERVER_LOG}`);
377
+
378
+ const shouldOpen = await promptBoolean('Open browser? (Y/n)', true);
379
+ if (shouldOpen && !openUrl(getServerUrl(port))) {
380
+ console.log(`Open this URL manually: ${getServerUrl(port)}`);
381
+ }
91
382
  });
92
383
 
93
- // --- stop ---
94
384
  program.command('stop')
95
385
  .description('Stop the server')
96
386
  .action(() => {
97
- try {
98
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
99
- if (pid) {
100
- execSync(`kill ${pid}`);
101
- console.log('Server stopped');
102
- } else {
103
- console.log('Server not running');
104
- }
105
- } catch {
106
- console.log('Server not running');
387
+ const config = getConfig();
388
+ const port = config.server?.port || 41820;
389
+ const serviceName = config.termux?.service_name || 'nexuscrew';
390
+
391
+ if (isTermux() && isTermuxServicesAvailable() && isTermuxServiceConfigured(config)) {
392
+ try {
393
+ execSync(`sv down ${serviceName}`, { stdio: 'ignore' });
394
+ } catch {}
107
395
  }
396
+
397
+ const pid = getExistingServerPid(port);
398
+ if (pid) {
399
+ try {
400
+ process.kill(pid, 'SIGTERM');
401
+ } catch {}
402
+ removePidFile();
403
+ console.log('Server stopped');
404
+ return;
405
+ }
406
+
407
+ console.log('Server not running');
108
408
  });
109
409
 
110
- // --- status ---
111
410
  program.command('status')
112
- .description('Show server and tmux status')
113
- .action(() => {
114
- console.log('=== NexusCrew Status ===\n');
115
-
116
- // Server
117
- try {
118
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
119
- console.log(`Server: RUNNING (pid ${pid})`);
120
- } catch {
121
- console.log('Server: STOPPED');
122
- }
411
+ .description('Show server, tmux, host, and speech status')
412
+ .action(async () => {
413
+ ensureBaseState();
414
+ const config = getConfig();
415
+ const tmux = new TmuxManager(config);
416
+ const speech = new SpeechManager();
417
+ const port = config.server?.port || 41820;
418
+ const pid = getExistingServerPid(port);
419
+ const healthy = pid ? await waitForHealth(port, 1500) : false;
420
+
421
+ console.log('=== NexusCrew Status ===');
422
+ console.log(`Server: ${pid ? `RUNNING (${pid})` : 'STOPPED'}`);
423
+ console.log(`Health: ${healthy ? 'OK' : 'DOWN'}`);
424
+ console.log(`HTTP: ${getServerUrl(port)}`);
425
+ console.log(`PID file: ${fs.existsSync(PATHS.PID_FILE) ? PATHS.PID_FILE : 'missing'}`);
426
+ console.log(`Log file: ${PATHS.SERVER_LOG}`);
427
+
428
+ console.log(`tmux session: ${config.tmuxSession}`);
429
+ tmux.listWindows('local').forEach((window) => {
430
+ console.log(` ${window.index}: ${window.name} ${window.active ? '(active)' : ''}`);
431
+ });
123
432
 
124
- // tmux
125
- try {
126
- const windows = execSync(`tmux list-windows -t ${TMUX_SESSION} -F '#{window_name} #{?window_active,*, }' 2>/dev/null`).toString().trim();
127
- console.log(`\ntmux session "${TMUX_SESSION}":`);
128
- windows.split('\n').forEach(w => console.log(` ${w}`));
129
- } catch {
130
- console.log(`\ntmux session "${TMUX_SESSION}": NOT FOUND`);
131
- }
433
+ console.log('Hosts:');
434
+ readHosts().forEach((host) => {
435
+ const status = host.type === 'local' ? 'local' : (tmux.testHost(host) ? 'OK' : 'UNREACHABLE');
436
+ console.log(` ${host.name}: ${status}`);
437
+ });
132
438
 
133
- // Hosts
134
- if (fs.existsSync(HOSTS_PATH)) {
135
- const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
136
- console.log('\nHosts:');
137
- hosts.forEach(h => {
138
- if (h.type === 'local') {
139
- console.log(` ${h.name}: local`);
140
- } else {
141
- const status = testSSH(h) ? 'OK' : 'UNREACHABLE';
142
- console.log(` ${h.name}: ${h.user}@${h.host}${h.port ? ':' + h.port : ''} [${status}]`);
143
- }
144
- });
439
+ const speechStatus = speech.getStatus();
440
+ console.log(`STT: ${speechStatus.provider} (${speechStatus.whisper.available ? 'bin OK' : 'bin missing'})`);
441
+ console.log(`TTS: ${speechStatus.tts.provider}`);
442
+ if (isTermux()) {
443
+ console.log(`Termux boot: ${config.termux?.boot_start ? 'enabled' : 'disabled'}`);
444
+ const serviceStatus = getTermuxServiceStatus(config);
445
+ if (serviceStatus) {
446
+ console.log(`Termux service: ${serviceStatus}`);
447
+ }
145
448
  }
146
449
  });
147
450
 
148
- // --- attach ---
149
451
  program.command('attach')
150
- .description('Attach to tmux session')
151
- .option('-w, --window <name>', 'Attach to specific window')
452
+ .description('Attach to local tmux session')
453
+ .option('-w, --window <name>', 'Window name')
152
454
  .action((opts) => {
153
- const target = opts.window ? `${TMUX_SESSION}:${opts.window}` : TMUX_SESSION;
154
- try {
155
- execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
156
- } catch {
157
- console.error(`Session/window not found: ${target}`);
158
- }
455
+ const tmuxSession = getConfig().tmuxSession || 'nexuscrew';
456
+ const target = opts.window ? `${tmuxSession}:${opts.window}` : tmuxSession;
457
+ execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
159
458
  });
160
459
 
161
- // --- sessions ---
162
460
  program.command('sessions')
163
- .description('List tmux windows')
461
+ .description('List local tmux windows')
164
462
  .action(() => {
165
- try {
166
- const out = execSync(
167
- `tmux list-windows -t ${TMUX_SESSION} -F '#{window_index}: #{window_name} [#{window_width}x#{window_height}] #{?window_active,(active),}' 2>/dev/null`
168
- ).toString().trim();
169
- console.log(out);
170
- } catch {
463
+ const tmux = new TmuxManager(getConfig());
464
+ const windows = tmux.listWindows('local');
465
+ if (windows.length === 0) {
171
466
  console.log('No tmux session found');
467
+ return;
172
468
  }
469
+ windows.forEach((window) => {
470
+ console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
471
+ });
173
472
  });
174
473
 
175
- // --- config ---
176
474
  program.command('config')
177
- .description('View/edit configuration')
178
- .option('-l, --list', 'Show current config')
179
- .option('-s, --set <key=value>', 'Set a config value')
180
- .action((opts) => {
181
- if (!fs.existsSync(CONFIG_PATH)) {
182
- console.error('Run nexuscrew init first');
475
+ .description('Read or update configuration')
476
+ .argument('[action]', 'get | set | list', 'list')
477
+ .argument('[payload]')
478
+ .action((action, payload) => {
479
+ ensureBaseState();
480
+ if (action === 'get' && payload) {
481
+ console.log(JSON.stringify(getConfigValue(payload), null, 2));
183
482
  return;
184
483
  }
185
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
186
-
187
- if (opts.set) {
188
- const [key, ...rest] = opts.set.split('=');
189
- const value = rest.join('=');
190
- config[key] = isNaN(value) ? value : Number(value);
191
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
192
- console.log(`Set ${key} = ${value}`);
193
- } else {
194
- console.log(JSON.stringify(config, null, 2));
484
+ if (action === 'set' && payload) {
485
+ const [key, ...rest] = payload.split('=');
486
+ setConfigValue(key, rest.join('='));
487
+ console.log(`Updated ${key}`);
488
+ return;
195
489
  }
490
+ console.log(JSON.stringify(getConfig(), null, 2));
196
491
  });
197
492
 
198
- // --- hosts ---
199
- const hostsCmd = program.command('hosts').description('Manage remote hosts');
493
+ const hostsCmd = program.command('hosts').description('Manage SSH hosts');
200
494
 
201
495
  hostsCmd.command('list')
202
- .description('List configured hosts')
203
496
  .action(() => {
204
- if (!fs.existsSync(HOSTS_PATH)) {
205
- console.log('No hosts configured. Run nexuscrew init.');
206
- return;
207
- }
208
- const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
209
- hosts.forEach((h, i) => {
210
- const status = h.type === 'local' ? 'local' : (testSSH(h) ? 'OK' : 'UNREACHABLE');
211
- console.log(`[${i}] ${h.name} (${h.type}) ${h.host || ''} [${status}]${h.default ? ' *default*' : ''}`);
497
+ readHosts().forEach((host) => {
498
+ console.log(`${host.name} ${host.type === 'local' ? '(local)' : `${host.user}@${host.host}:${host.port}`}`);
212
499
  });
213
500
  });
214
501
 
215
502
  hostsCmd.command('add')
216
- .description('Add a remote host')
217
- .requiredOption('-n, --name <name>', 'Host name')
218
- .requiredOption('-h, --host <host>', 'Hostname or IP')
503
+ .requiredOption('-n, --name <name>')
504
+ .requiredOption('-h, --host <host>')
219
505
  .option('-u, --user <user>', 'SSH user', 'dag')
220
506
  .option('-p, --port <port>', 'SSH port', '22')
221
507
  .option('-k, --key <path>', 'SSH key path')
222
508
  .action((opts) => {
223
- const hosts = fs.existsSync(HOSTS_PATH)
224
- ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
225
- : [];
509
+ const hosts = readHosts().filter((host) => host.name !== opts.name);
226
510
  hosts.push({
227
511
  name: opts.name,
228
512
  type: 'ssh',
229
513
  host: opts.host,
230
514
  user: opts.user,
231
515
  port: opts.port,
232
- key: opts.key || undefined
516
+ key: opts.key || null,
517
+ default: false
233
518
  });
234
- fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
519
+ writeHosts(hosts);
235
520
  console.log(`Added host: ${opts.name}`);
236
521
  });
237
522
 
238
523
  hostsCmd.command('remove')
239
- .description('Remove a host')
240
- .requiredOption('-n, --name <name>', 'Host name')
524
+ .requiredOption('-n, --name <name>')
241
525
  .action((opts) => {
242
- let hosts = fs.existsSync(HOSTS_PATH)
243
- ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
244
- : [];
245
- hosts = hosts.filter(h => h.name !== opts.name);
246
- fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
526
+ writeHosts(readHosts().filter((host) => host.name !== opts.name));
247
527
  console.log(`Removed host: ${opts.name}`);
248
528
  });
249
529
 
250
- // --- engines ---
251
- program.command('engines')
252
- .description('Discover available AI CLIs')
253
- .action(() => {
254
- const engines = discoverEngines();
255
- console.log('\nDiscovered Engines:\n');
256
- for (const [name, info] of Object.entries(engines)) {
257
- console.log(` ${name}: ${info.available ? 'OK' : 'NOT FOUND'}`);
258
- if (info.path) console.log(` path: ${info.path}`);
259
- if (info.aliases.length) {
260
- console.log(` aliases: ${info.aliases.join(', ')}`);
261
- }
262
- }
263
- console.log('');
530
+ hostsCmd.command('test')
531
+ .argument('[name]', 'Host name', 'local')
532
+ .action((name) => {
533
+ const tmux = new TmuxManager(getConfig());
534
+ console.log(tmux.testHost(name) ? 'OK' : 'UNREACHABLE');
264
535
  });
265
536
 
266
- // --- helpers ---
267
- function testSSH(host) {
268
- try {
269
- const keyOpt = host.key ? `-i ${host.key}` : '';
270
- const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
271
- execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
272
- return true;
273
- } catch {
274
- return false;
275
- }
276
- }
537
+ const sttCmd = program.command('stt').description('Local speech-to-text tools');
277
538
 
278
- function discoverEngines() {
279
- const engines = {
280
- claude: { available: false, path: null, aliases: [], type: 'interactive' },
281
- codex: { available: false, path: null, aliases: [], type: 'exec' },
282
- gemini: { available: false, path: null, aliases: [], type: 'interactive' },
283
- qwen: { available: false, path: null, aliases: [], type: 'interactive' }
284
- };
285
-
286
- // Direct CLI discovery
287
- const cliPaths = {
288
- claude: [
289
- path.join(process.env.HOME, '.local/bin/claude'),
290
- path.join(process.env.HOME, '.claude/local/claude'),
291
- '/usr/local/bin/claude'
292
- ],
293
- codex: [
294
- path.join(process.env.HOME, '.local/codex-lts/bin/codex'),
295
- path.join(process.env.HOME, '.local/codex-lts/node_modules/.bin/codex')
296
- ],
297
- gemini: [
298
- path.join(process.env.HOME, '.local/bin/gemini'),
299
- path.join(process.env.HOME, '.gemini/bin/gemini')
300
- ],
301
- qwen: [
302
- path.join(process.env.HOME, '.local/bin/qwen'),
303
- path.join(process.env.HOME, '.qwen/bin/qwen')
304
- ]
305
- };
306
-
307
- for (const [engine, paths] of Object.entries(cliPaths)) {
308
- for (const p of paths) {
309
- try {
310
- if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
311
- engines[engine].available = true;
312
- engines[engine].path = p;
313
- break;
314
- }
315
- } catch {}
316
- }
317
- // Fallback: check PATH
318
- if (!engines[engine].available) {
319
- try {
320
- const which = execSync(`which ${engine} 2>/dev/null`).toString().trim();
321
- if (which) {
322
- engines[engine].available = true;
323
- engines[engine].path = which;
324
- }
325
- } catch {}
326
- }
327
- }
328
-
329
- // Parse providers file for aliases (if configured)
330
- const configPath = path.join(process.env.HOME || '', '.nexuscrew', 'config.json');
331
- let providersPath = '';
332
- try {
333
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
334
- providersPath = cfg.providersPath || '';
335
- } catch {}
336
- if (providersPath && fs.existsSync(providersPath)) {
337
- const content = fs.readFileSync(providersPath, 'utf8');
338
- const funcRegex = /^(codex-[a-z0-9-]+|claude-[a-z0-9-]+|gemini-[a-z0-9-]+|qwen-[a-z0-9-]+)\(\)/gm;
339
- let match;
340
- while ((match = funcRegex.exec(content)) !== null) {
341
- const alias = match[1];
342
- const baseEngine = alias.split('-')[0];
343
- const engine = baseEngine === 'codex' ? 'codex' : baseEngine === 'claude' ? 'claude' : baseEngine === 'gemini' ? 'gemini' : 'qwen';
344
- if (engines[engine]) {
345
- engines[engine].aliases.push(alias);
346
- }
347
- }
348
- }
539
+ sttCmd.command('doctor')
540
+ .description('Inspect local whisper.cpp setup')
541
+ .action(() => {
542
+ const speech = new SpeechManager();
543
+ const status = speech.getStatus();
544
+ console.log(JSON.stringify(status, null, 2));
545
+ });
349
546
 
350
- return engines;
351
- }
547
+ program.command('engines')
548
+ .description('List discovered CLI engines')
549
+ .action(() => {
550
+ const discovery = new EngineDiscovery(getConfig().providersPath);
551
+ const engines = discovery.discover();
552
+ Object.entries(engines).forEach(([name, info]) => {
553
+ console.log(`${name}: ${info.available ? 'OK' : 'NOT FOUND'}${info.path ? ` -> ${info.path}` : ''}`);
554
+ });
555
+ });
352
556
 
353
- program.parse();
557
+ program.parseAsync();