@mmmbuto/nexuscrew 0.2.1 → 0.2.2

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 (52) hide show
  1. package/README.md +122 -67
  2. package/bin/nexuscrew.js +194 -470
  3. package/frontend/dist/assets/{index-BG1N7bSL.js → index-BsldfYnr.js} +1704 -1711
  4. package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
  5. package/frontend/dist/index.html +3 -11
  6. package/lib/server/routes/hosts.js +22 -12
  7. package/lib/server/routes/launchers.js +12 -0
  8. package/lib/server/routes/models.js +16 -63
  9. package/lib/server/routes/runtime.js +10 -0
  10. package/lib/server/routes/send.js +97 -258
  11. package/lib/server/routes/sessions.js +13 -42
  12. package/lib/server/routes/status.js +18 -22
  13. package/lib/server/routes/tmux.js +88 -0
  14. package/lib/server/server.js +60 -81
  15. package/lib/services/engine-discovery.js +192 -445
  16. package/lib/services/log-watcher.js +22 -40
  17. package/lib/services/session-store.js +88 -62
  18. package/lib/services/tmux-manager.js +111 -173
  19. package/package.json +5 -9
  20. package/frontend/dist/apple-touch-icon.png +0 -0
  21. package/frontend/dist/assets/index-CiAtinNP.css +0 -1
  22. package/frontend/dist/favicon.svg +0 -20
  23. package/frontend/dist/icon-192.png +0 -0
  24. package/frontend/dist/icon-512.png +0 -0
  25. package/frontend/dist/site.webmanifest +0 -29
  26. package/lib/config/hosts.js +0 -67
  27. package/lib/config/manager.js +0 -379
  28. package/lib/config/models.js +0 -408
  29. package/lib/server/db/adapter.js +0 -274
  30. package/lib/server/db/drivers/sql-js.js +0 -75
  31. package/lib/server/db/migrate.js +0 -174
  32. package/lib/server/db/migrations/001_base_schema.sql +0 -70
  33. package/lib/server/middleware/auth.js +0 -134
  34. package/lib/server/middleware/rate-limit.js +0 -63
  35. package/lib/server/models/User.js +0 -128
  36. package/lib/server/routes/auth.js +0 -168
  37. package/lib/server/routes/keys.js +0 -28
  38. package/lib/server/routes/runtimes.js +0 -34
  39. package/lib/server/routes/speech.js +0 -46
  40. package/lib/server/routes/upload.js +0 -135
  41. package/lib/server/routes/wake-lock.js +0 -95
  42. package/lib/server/routes/workspaces.js +0 -101
  43. package/lib/server/services/attachment-manager.js +0 -57
  44. package/lib/server/services/context-bridge.js +0 -425
  45. package/lib/server/services/runtime-manager.js +0 -462
  46. package/lib/server/services/speech-manager.js +0 -76
  47. package/lib/server/services/summary-generator.js +0 -309
  48. package/lib/server/services/workspace-manager.js +0 -79
  49. package/lib/services/remote-pane-watcher.js +0 -155
  50. package/lib/setup/postinstall.js +0 -38
  51. package/lib/utils/paths.js +0 -124
  52. package/lib/utils/termux.js +0 -182
package/bin/nexuscrew.js CHANGED
@@ -2,556 +2,280 @@
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');
8
5
  const path = require('path');
9
6
  const fs = require('fs');
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');
35
-
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
- }
7
+ const os = require('os');
8
+ const { execSync, spawn } = require('child_process');
140
9
 
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
- }
10
+ const EngineDiscovery = require('../lib/services/engine-discovery');
11
+ const packageJson = require('../package.json');
219
12
 
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
- });
13
+ const CONFIG_DIR = path.join(os.homedir(), '.nexuscrew');
14
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
15
+ const HOSTS_PATH = path.join(CONFIG_DIR, 'hosts.json');
232
16
 
233
- child.unref();
234
- writePidFile(child.pid);
235
- return child.pid;
236
- }
17
+ const DEFAULT_PORT = '41820';
237
18
 
19
+ const program = new Command();
238
20
  program
239
21
  .name('nexuscrew')
240
22
  .description('tmux-based AI cockpit')
241
- .version('0.2.1');
23
+ .version(packageJson.version);
242
24
 
243
25
  program.command('init')
244
- .description('Bootstrap config, directories, and host registry')
245
- .option('-s, --silent', 'Silent mode')
26
+ .description('First-time setup')
27
+ .option('--silent', 'Suppress setup output')
246
28
  .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'}`);
256
- }
257
- });
29
+ const log = (...args) => {
30
+ if (!opts.silent) console.log(...args);
31
+ };
32
+ const error = (...args) => {
33
+ if (!opts.silent) console.error(...args);
34
+ };
258
35
 
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
- }
36
+ if (!fs.existsSync(CONFIG_DIR)) {
37
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
38
+ log('Created ~/.nexuscrew/');
282
39
  }
283
40
 
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);
41
+ if (!fs.existsSync(CONFIG_PATH)) {
42
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify({
43
+ port: Number(DEFAULT_PORT),
44
+ logDir: path.join(CONFIG_DIR, 'logs'),
45
+ autoDiscovery: true,
46
+ preferredShell: '',
47
+ extraShellSources: []
48
+ }, null, 2));
49
+ log('Created config.json');
301
50
  }
302
51
 
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
- }
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
- }
52
+ if (!fs.existsSync(HOSTS_PATH)) {
53
+ fs.writeFileSync(HOSTS_PATH, JSON.stringify([
54
+ { name: 'local', type: 'local', default: true }
55
+ ], null, 2));
56
+ log('Created hosts.json');
318
57
  }
319
58
 
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')}`);
59
+ const logDir = path.join(CONFIG_DIR, 'logs');
60
+ if (!fs.existsSync(logDir)) {
61
+ fs.mkdirSync(logDir, { recursive: true });
334
62
  }
335
- console.log('Run "nexuscrew stt doctor" to finish local speech setup.');
336
- });
337
63
 
338
- program.command('start')
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;
356
- }
357
-
358
- tmux.ensureSession('local');
359
-
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;
64
+ try {
65
+ execSync('command -v tmux >/dev/null 2>&1', { stdio: 'pipe' });
66
+ log('tmux: OK');
67
+ } catch {
68
+ error('tmux not found! Install: apt install tmux (or pkg install tmux)');
372
69
  }
373
70
 
374
- const pid = getExistingServerPid(port);
375
- console.log(`Server started: ${getServerUrl(port)}${pid ? ` (PID ${pid})` : ''}`);
376
- console.log(`Log: ${PATHS.SERVER_LOG}`);
71
+ log('\nDone! Run: nexuscrew start');
72
+ });
377
73
 
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
- }
74
+ program.command('start')
75
+ .description('Start the server')
76
+ .option('-p, --port <port>', 'Port', DEFAULT_PORT)
77
+ .option('-d, --dev', 'Dev mode (no frontend)')
78
+ .action((opts) => {
79
+ const env = { ...process.env, PORT: opts.port };
80
+ if (opts.dev) env.NEXUSCREW_DEV = '1';
81
+
82
+ const args = [path.join(__dirname, '..', 'lib', 'server', 'server.js')];
83
+ const child = spawn('node', args, {
84
+ env,
85
+ stdio: 'inherit',
86
+ detached: false
87
+ });
88
+ child.on('exit', (code) => process.exit(code || 0));
382
89
  });
383
90
 
384
91
  program.command('stop')
385
92
  .description('Stop the server')
386
93
  .action(() => {
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;
94
+ try {
95
+ const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
96
+ if (pid) {
97
+ execSync(`kill ${pid}`);
98
+ console.log('Server stopped');
99
+ } else {
100
+ console.log('Server not running');
101
+ }
102
+ } catch {
103
+ console.log('Server not running');
405
104
  }
406
-
407
- console.log('Server not running');
408
105
  });
409
106
 
410
107
  program.command('status')
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
- });
108
+ .description('Show server and tmux status')
109
+ .action(() => {
110
+ console.log('=== NexusCrew Status ===\n');
432
111
 
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
- });
112
+ try {
113
+ const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
114
+ console.log(`Server: RUNNING (pid ${pid})`);
115
+ } catch {
116
+ console.log('Server: STOPPED');
117
+ }
438
118
 
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
- }
119
+ try {
120
+ const sessions = execSync("tmux list-sessions -F '#{session_name} #{?session_attached,*, }' 2>/dev/null").toString().trim();
121
+ console.log('\nActive tmux sessions:');
122
+ sessions.split('\n').filter(Boolean).forEach((session) => console.log(` ${session}`));
123
+ } catch {
124
+ console.log('\nActive tmux sessions: none');
125
+ }
126
+
127
+ if (fs.existsSync(HOSTS_PATH)) {
128
+ const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
129
+ console.log('\nHosts:');
130
+ hosts.forEach((host) => {
131
+ if (host.type === 'local') {
132
+ console.log(` ${host.name}: local`);
133
+ } else {
134
+ const status = testSSH(host) ? 'OK' : 'UNREACHABLE';
135
+ console.log(` ${host.name}: ${host.user}@${host.host}${host.port ? ':' + host.port : ''} [${status}]`);
136
+ }
137
+ });
448
138
  }
449
139
  });
450
140
 
451
141
  program.command('attach')
452
- .description('Attach to local tmux session')
453
- .option('-w, --window <name>', 'Window name')
142
+ .description('Attach to a tmux session')
143
+ .requiredOption('-s, --session <name>', 'Session name')
454
144
  .action((opts) => {
455
- const tmuxSession = getConfig().tmuxSession || 'nexuscrew';
456
- const target = opts.window ? `${tmuxSession}:${opts.window}` : tmuxSession;
457
- execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
145
+ try {
146
+ execSync(`tmux attach -t ${shellQuote(opts.session)}`, { stdio: 'inherit' });
147
+ } catch {
148
+ console.error(`Session not found: ${opts.session}`);
149
+ }
458
150
  });
459
151
 
460
152
  program.command('sessions')
461
- .description('List local tmux windows')
153
+ .description('List active tmux sessions')
462
154
  .action(() => {
463
- const tmux = new TmuxManager(getConfig());
464
- const windows = tmux.listWindows('local');
465
- if (windows.length === 0) {
466
- console.log('No tmux session found');
467
- return;
155
+ try {
156
+ const out = execSync(
157
+ "tmux list-sessions -F '#{session_name} [#{session_windows} windows] #{?session_attached,(attached),}' 2>/dev/null"
158
+ ).toString().trim();
159
+ console.log(out || 'No tmux sessions found');
160
+ } catch {
161
+ console.log('No tmux sessions found');
468
162
  }
469
- windows.forEach((window) => {
470
- console.log(`${window.index}: ${window.name} [${window.size}] ${window.active ? '(active)' : ''}`);
471
- });
472
163
  });
473
164
 
474
165
  program.command('config')
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));
166
+ .description('View/edit configuration')
167
+ .option('-l, --list', 'Show current config')
168
+ .option('-s, --set <key=value>', 'Set a config value')
169
+ .action((opts) => {
170
+ if (!fs.existsSync(CONFIG_PATH)) {
171
+ console.error('Run nexuscrew init first');
482
172
  return;
483
173
  }
484
- if (action === 'set' && payload) {
485
- const [key, ...rest] = payload.split('=');
486
- setConfigValue(key, rest.join('='));
487
- console.log(`Updated ${key}`);
488
- return;
174
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
175
+
176
+ if (opts.set) {
177
+ const [key, ...rest] = opts.set.split('=');
178
+ const value = rest.join('=');
179
+ let parsedValue = value;
180
+ if (value === 'true') parsedValue = true;
181
+ if (value === 'false') parsedValue = false;
182
+ if (!Number.isNaN(Number(value)) && value.trim() !== '') parsedValue = Number(value);
183
+ config[key] = parsedValue;
184
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
185
+ console.log(`Set ${key} = ${value}`);
186
+ } else {
187
+ console.log(JSON.stringify(config, null, 2));
489
188
  }
490
- console.log(JSON.stringify(getConfig(), null, 2));
491
189
  });
492
190
 
493
- const hostsCmd = program.command('hosts').description('Manage SSH hosts');
191
+ const hostsCmd = program.command('hosts').description('Manage remote hosts');
494
192
 
495
193
  hostsCmd.command('list')
194
+ .description('List configured hosts')
496
195
  .action(() => {
497
- readHosts().forEach((host) => {
498
- console.log(`${host.name} ${host.type === 'local' ? '(local)' : `${host.user}@${host.host}:${host.port}`}`);
196
+ if (!fs.existsSync(HOSTS_PATH)) {
197
+ console.log('No hosts configured. Run nexuscrew init.');
198
+ return;
199
+ }
200
+ const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
201
+ hosts.forEach((host, i) => {
202
+ const status = host.type === 'local' ? 'local' : (testSSH(host) ? 'OK' : 'UNREACHABLE');
203
+ console.log(`[${i}] ${host.name} (${host.type}) ${host.host || ''} [${status}]${host.default ? ' *default*' : ''}`);
499
204
  });
500
205
  });
501
206
 
502
207
  hostsCmd.command('add')
503
- .requiredOption('-n, --name <name>')
504
- .requiredOption('-h, --host <host>')
505
- .option('-u, --user <user>', 'SSH user', 'dag')
208
+ .description('Add a remote host')
209
+ .requiredOption('-n, --name <name>', 'Host name')
210
+ .requiredOption('-h, --host <host>', 'Hostname or IP')
211
+ .option('-u, --user <user>', 'SSH user', os.userInfo().username)
506
212
  .option('-p, --port <port>', 'SSH port', '22')
507
213
  .option('-k, --key <path>', 'SSH key path')
508
214
  .action((opts) => {
509
- const hosts = readHosts().filter((host) => host.name !== opts.name);
215
+ const hosts = fs.existsSync(HOSTS_PATH)
216
+ ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
217
+ : [];
510
218
  hosts.push({
511
219
  name: opts.name,
512
220
  type: 'ssh',
513
221
  host: opts.host,
514
222
  user: opts.user,
515
223
  port: opts.port,
516
- key: opts.key || null,
517
- default: false
224
+ key: opts.key || undefined
518
225
  });
519
- writeHosts(hosts);
226
+ fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
520
227
  console.log(`Added host: ${opts.name}`);
521
228
  });
522
229
 
523
230
  hostsCmd.command('remove')
524
- .requiredOption('-n, --name <name>')
231
+ .description('Remove a host')
232
+ .requiredOption('-n, --name <name>', 'Host name')
525
233
  .action((opts) => {
526
- writeHosts(readHosts().filter((host) => host.name !== opts.name));
234
+ let hosts = fs.existsSync(HOSTS_PATH)
235
+ ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
236
+ : [];
237
+ hosts = hosts.filter((host) => host.name !== opts.name);
238
+ fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
527
239
  console.log(`Removed host: ${opts.name}`);
528
240
  });
529
241
 
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');
242
+ program.command('engines')
243
+ .description('Discover available launchers')
244
+ .action(() => {
245
+ const launchers = new EngineDiscovery(readConfig()).getLaunchers();
246
+ console.log('\nDiscovered Launchers:\n');
247
+ for (const launcher of launchers) {
248
+ console.log(` ${launcher.label}: ${launcher.kind} [${launcher.family}]${launcher.runnable ? '' : ' (detected only)'}`);
249
+ console.log(` command: ${launcher.commandPreview}`);
250
+ console.log(` source: ${launcher.source}`);
251
+ }
252
+ console.log('');
535
253
  });
536
254
 
537
- const sttCmd = program.command('stt').description('Local speech-to-text tools');
255
+ function readConfig() {
256
+ if (!fs.existsSync(CONFIG_PATH)) {
257
+ return { preferredShell: '', extraShellSources: [] };
258
+ }
259
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
260
+ if (config.providersPath && !config.extraShellSources) {
261
+ config.extraShellSources = [config.providersPath].filter(Boolean);
262
+ }
263
+ return config;
264
+ }
538
265
 
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
- });
266
+ function testSSH(host) {
267
+ try {
268
+ const keyOpt = host.key ? `-i ${shellQuote(host.key)}` : '';
269
+ const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
270
+ execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
546
276
 
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
- });
277
+ function shellQuote(value) {
278
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
279
+ }
556
280
 
557
- program.parseAsync();
281
+ program.parse();