@mmmbuto/nexuscrew 0.2.0 → 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.
package/bin/nexuscrew.js CHANGED
@@ -2,353 +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.2.0');
241
+ .version('0.2.1');
20
242
 
21
- // --- init ---
22
243
  program.command('init')
23
- .description('First-time setup')
24
- .option('-s, --silent', 'Silent mode (no output)')
244
+ .description('Bootstrap config, directories, and host registry')
245
+ .option('-s, --silent', 'Silent mode')
25
246
  .action((opts) => {
26
- if (!fs.existsSync(CONFIG_DIR)) {
27
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
28
- if (!opts.silent) console.log('Created ~/.nexuscrew/');
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'}`);
29
256
  }
257
+ });
30
258
 
31
- // Default config
32
- if (!fs.existsSync(CONFIG_PATH)) {
33
- fs.writeFileSync(CONFIG_PATH, JSON.stringify({
34
- port: 41820,
35
- tmuxSession: TMUX_SESSION,
36
- logDir: path.join(CONFIG_DIR, 'logs'),
37
- autoDiscovery: true,
38
- providersPath: ''
39
- }, null, 2));
40
- if (!opts.silent) 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
+ }
41
282
  }
42
283
 
43
- // Default hosts
44
- if (!fs.existsSync(HOSTS_PATH)) {
45
- fs.writeFileSync(HOSTS_PATH, JSON.stringify([
46
- { name: 'local', type: 'local', default: true }
47
- ], null, 2));
48
- if (!opts.silent) 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);
49
301
  }
50
302
 
51
- // Log dir
52
- const logDir = path.join(CONFIG_DIR, 'logs');
53
- if (!fs.existsSync(logDir)) {
54
- fs.mkdirSync(logDir, { recursive: true });
55
- }
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);
306
+
307
+ if (configureBoot) {
308
+ configureTermuxBoot(current);
309
+ }
56
310
 
57
- // Check tmux
58
- try {
59
- execSync('which tmux', { stdio: 'pipe' });
60
- if (!opts.silent) console.log('tmux: OK');
61
- } catch {
62
- if (!opts.silent) console.error('tmux not found! Install: apt install tmux (or pkg install tmux)');
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
+ }
63
318
  }
64
319
 
65
- if (!opts.silent) 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.');
66
336
  });
67
337
 
68
- // --- start ---
69
338
  program.command('start')
70
- .description('Start the server')
71
- .option('-p, --port <port>', 'Port', '41820')
72
- .option('-d, --dev', 'Dev mode (no frontend)')
73
- .action((opts) => {
74
- // Ensure tmux master session exists
75
- try {
76
- execSync(`tmux has-session -t ${TMUX_SESSION} 2>/dev/null`);
77
- } catch {
78
- execSync(`tmux new-session -d -s ${TMUX_SESSION} -x 200 -y 50`);
79
- 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;
80
356
  }
81
357
 
82
- const env = { ...process.env, PORT: opts.port };
83
- if (opts.dev) env.NEXUSCREW_DEV = '1';
358
+ tmux.ensureSession('local');
84
359
 
85
- const args = [path.join(__dirname, '..', 'lib', 'server', 'server.js')];
86
- const child = require('child_process').spawn('node', args, {
87
- env,
88
- stdio: 'inherit',
89
- detached: false
90
- });
91
- 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
+ }
92
382
  });
93
383
 
94
- // --- stop ---
95
384
  program.command('stop')
96
385
  .description('Stop the server')
97
386
  .action(() => {
98
- try {
99
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
100
- if (pid) {
101
- execSync(`kill ${pid}`);
102
- console.log('Server stopped');
103
- } else {
104
- console.log('Server not running');
105
- }
106
- } catch {
107
- 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 {}
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;
108
405
  }
406
+
407
+ console.log('Server not running');
109
408
  });
110
409
 
111
- // --- status ---
112
410
  program.command('status')
113
- .description('Show server and tmux status')
114
- .action(() => {
115
- console.log('=== NexusCrew Status ===\n');
116
-
117
- // Server
118
- try {
119
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
120
- console.log(`Server: RUNNING (pid ${pid})`);
121
- } catch {
122
- console.log('Server: STOPPED');
123
- }
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
+ });
124
432
 
125
- // tmux
126
- try {
127
- const windows = execSync(`tmux list-windows -t ${TMUX_SESSION} -F '#{window_name} #{?window_active,*, }' 2>/dev/null`).toString().trim();
128
- console.log(`\ntmux session "${TMUX_SESSION}":`);
129
- windows.split('\n').forEach(w => console.log(` ${w}`));
130
- } catch {
131
- console.log(`\ntmux session "${TMUX_SESSION}": NOT FOUND`);
132
- }
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
+ });
133
438
 
134
- // Hosts
135
- if (fs.existsSync(HOSTS_PATH)) {
136
- const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
137
- console.log('\nHosts:');
138
- hosts.forEach(h => {
139
- if (h.type === 'local') {
140
- console.log(` ${h.name}: local`);
141
- } else {
142
- const status = testSSH(h) ? 'OK' : 'UNREACHABLE';
143
- console.log(` ${h.name}: ${h.user}@${h.host}${h.port ? ':' + h.port : ''} [${status}]`);
144
- }
145
- });
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
+ }
146
448
  }
147
449
  });
148
450
 
149
- // --- attach ---
150
451
  program.command('attach')
151
- .description('Attach to tmux session')
152
- .option('-w, --window <name>', 'Attach to specific window')
452
+ .description('Attach to local tmux session')
453
+ .option('-w, --window <name>', 'Window name')
153
454
  .action((opts) => {
154
- const target = opts.window ? `${TMUX_SESSION}:${opts.window}` : TMUX_SESSION;
155
- try {
156
- execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
157
- } catch {
158
- console.error(`Session/window not found: ${target}`);
159
- }
455
+ const tmuxSession = getConfig().tmuxSession || 'nexuscrew';
456
+ const target = opts.window ? `${tmuxSession}:${opts.window}` : tmuxSession;
457
+ execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
160
458
  });
161
459
 
162
- // --- sessions ---
163
460
  program.command('sessions')
164
- .description('List tmux windows')
461
+ .description('List local tmux windows')
165
462
  .action(() => {
166
- try {
167
- const out = execSync(
168
- `tmux list-windows -t ${TMUX_SESSION} -F '#{window_index}: #{window_name} [#{window_width}x#{window_height}] #{?window_active,(active),}' 2>/dev/null`
169
- ).toString().trim();
170
- console.log(out);
171
- } catch {
463
+ const tmux = new TmuxManager(getConfig());
464
+ const windows = tmux.listWindows('local');
465
+ if (windows.length === 0) {
172
466
  console.log('No tmux session found');
467
+ return;
173
468
  }
469
+ windows.forEach((window) => {
470
+ console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
471
+ });
174
472
  });
175
473
 
176
- // --- config ---
177
474
  program.command('config')
178
- .description('View/edit configuration')
179
- .option('-l, --list', 'Show current config')
180
- .option('-s, --set <key=value>', 'Set a config value')
181
- .action((opts) => {
182
- if (!fs.existsSync(CONFIG_PATH)) {
183
- 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));
184
482
  return;
185
483
  }
186
- const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
187
-
188
- if (opts.set) {
189
- const [key, ...rest] = opts.set.split('=');
190
- const value = rest.join('=');
191
- config[key] = isNaN(value) ? value : Number(value);
192
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
193
- console.log(`Set ${key} = ${value}`);
194
- } else {
195
- 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;
196
489
  }
490
+ console.log(JSON.stringify(getConfig(), null, 2));
197
491
  });
198
492
 
199
- // --- hosts ---
200
- const hostsCmd = program.command('hosts').description('Manage remote hosts');
493
+ const hostsCmd = program.command('hosts').description('Manage SSH hosts');
201
494
 
202
495
  hostsCmd.command('list')
203
- .description('List configured hosts')
204
496
  .action(() => {
205
- if (!fs.existsSync(HOSTS_PATH)) {
206
- console.log('No hosts configured. Run nexuscrew init.');
207
- return;
208
- }
209
- const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
210
- hosts.forEach((h, i) => {
211
- const status = h.type === 'local' ? 'local' : (testSSH(h) ? 'OK' : 'UNREACHABLE');
212
- 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}`}`);
213
499
  });
214
500
  });
215
501
 
216
502
  hostsCmd.command('add')
217
- .description('Add a remote host')
218
- .requiredOption('-n, --name <name>', 'Host name')
219
- .requiredOption('-h, --host <host>', 'Hostname or IP')
503
+ .requiredOption('-n, --name <name>')
504
+ .requiredOption('-h, --host <host>')
220
505
  .option('-u, --user <user>', 'SSH user', 'dag')
221
506
  .option('-p, --port <port>', 'SSH port', '22')
222
507
  .option('-k, --key <path>', 'SSH key path')
223
508
  .action((opts) => {
224
- const hosts = fs.existsSync(HOSTS_PATH)
225
- ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
226
- : [];
509
+ const hosts = readHosts().filter((host) => host.name !== opts.name);
227
510
  hosts.push({
228
511
  name: opts.name,
229
512
  type: 'ssh',
230
513
  host: opts.host,
231
514
  user: opts.user,
232
515
  port: opts.port,
233
- key: opts.key || undefined
516
+ key: opts.key || null,
517
+ default: false
234
518
  });
235
- fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
519
+ writeHosts(hosts);
236
520
  console.log(`Added host: ${opts.name}`);
237
521
  });
238
522
 
239
523
  hostsCmd.command('remove')
240
- .description('Remove a host')
241
- .requiredOption('-n, --name <name>', 'Host name')
524
+ .requiredOption('-n, --name <name>')
242
525
  .action((opts) => {
243
- let hosts = fs.existsSync(HOSTS_PATH)
244
- ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
245
- : [];
246
- hosts = hosts.filter(h => h.name !== opts.name);
247
- fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
526
+ writeHosts(readHosts().filter((host) => host.name !== opts.name));
248
527
  console.log(`Removed host: ${opts.name}`);
249
528
  });
250
529
 
251
- // --- engines ---
252
- program.command('engines')
253
- .description('Discover available AI CLIs')
254
- .action(() => {
255
- const engines = discoverEngines();
256
- console.log('\nDiscovered Engines:\n');
257
- for (const [name, info] of Object.entries(engines)) {
258
- console.log(` ${name}: ${info.available ? 'OK' : 'NOT FOUND'}`);
259
- if (info.path) console.log(` path: ${info.path}`);
260
- if (info.aliases.length) {
261
- console.log(` aliases: ${info.aliases.join(', ')}`);
262
- }
263
- }
264
- 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');
265
535
  });
266
536
 
267
- // --- helpers ---
268
- function testSSH(host) {
269
- try {
270
- const keyOpt = host.key ? `-i ${host.key}` : '';
271
- const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
272
- execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
273
- return true;
274
- } catch {
275
- return false;
276
- }
277
- }
537
+ const sttCmd = program.command('stt').description('Local speech-to-text tools');
278
538
 
279
- function discoverEngines() {
280
- const engines = {
281
- claude: { available: false, path: null, aliases: [], type: 'interactive' },
282
- codex: { available: false, path: null, aliases: [], type: 'exec' },
283
- gemini: { available: false, path: null, aliases: [], type: 'interactive' },
284
- qwen: { available: false, path: null, aliases: [], type: 'interactive' }
285
- };
286
-
287
- // Direct CLI discovery
288
- const cliPaths = {
289
- claude: [
290
- path.join(process.env.HOME, '.local/bin/claude'),
291
- path.join(process.env.HOME, '.claude/local/claude'),
292
- '/usr/local/bin/claude'
293
- ],
294
- codex: [
295
- path.join(process.env.HOME, '.local/codex-lts/bin/codex'),
296
- path.join(process.env.HOME, '.local/codex-lts/node_modules/.bin/codex')
297
- ],
298
- gemini: [
299
- path.join(process.env.HOME, '.local/bin/gemini'),
300
- path.join(process.env.HOME, '.gemini/bin/gemini')
301
- ],
302
- qwen: [
303
- path.join(process.env.HOME, '.local/bin/qwen'),
304
- path.join(process.env.HOME, '.qwen/bin/qwen')
305
- ]
306
- };
307
-
308
- for (const [engine, paths] of Object.entries(cliPaths)) {
309
- for (const p of paths) {
310
- try {
311
- if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
312
- engines[engine].available = true;
313
- engines[engine].path = p;
314
- break;
315
- }
316
- } catch {}
317
- }
318
- // Fallback: check PATH
319
- if (!engines[engine].available) {
320
- try {
321
- const which = execSync(`which ${engine} 2>/dev/null`).toString().trim();
322
- if (which) {
323
- engines[engine].available = true;
324
- engines[engine].path = which;
325
- }
326
- } catch {}
327
- }
328
- }
329
-
330
- // Parse providers file for aliases (if configured)
331
- const configPath = path.join(process.env.HOME || '', '.nexuscrew', 'config.json');
332
- let providersPath = '';
333
- try {
334
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
335
- providersPath = cfg.providersPath || '';
336
- } catch {}
337
- if (providersPath && fs.existsSync(providersPath)) {
338
- const content = fs.readFileSync(providersPath, 'utf8');
339
- const funcRegex = /^(codex-[a-z0-9-]+|claude-[a-z0-9-]+|gemini-[a-z0-9-]+|qwen-[a-z0-9-]+)\(\)/gm;
340
- let match;
341
- while ((match = funcRegex.exec(content)) !== null) {
342
- const alias = match[1];
343
- const baseEngine = alias.split('-')[0];
344
- const engine = baseEngine === 'codex' ? 'codex' : baseEngine === 'claude' ? 'claude' : baseEngine === 'gemini' ? 'gemini' : 'qwen';
345
- if (engines[engine]) {
346
- engines[engine].aliases.push(alias);
347
- }
348
- }
349
- }
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
+ });
350
546
 
351
- return engines;
352
- }
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
+ });
353
556
 
354
- program.parse();
557
+ program.parseAsync();