@mmmbuto/nexuscrew 0.2.0 → 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 (41) hide show
  1. package/README.md +124 -56
  2. package/bin/nexuscrew.js +79 -152
  3. package/frontend/dist/assets/{index-8pw4eMB-.js → index-BsldfYnr.js} +1704 -1716
  4. package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/server/routes/hosts.js +3 -2
  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 -218
  11. package/lib/server/routes/sessions.js +7 -39
  12. package/lib/server/routes/status.js +16 -18
  13. package/lib/server/routes/tmux.js +88 -0
  14. package/lib/server/server.js +45 -65
  15. package/lib/services/engine-discovery.js +192 -445
  16. package/lib/services/session-store.js +86 -60
  17. package/lib/services/tmux-manager.js +103 -154
  18. package/package.json +3 -7
  19. package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
  20. package/lib/config/manager.js +0 -362
  21. package/lib/config/models.js +0 -408
  22. package/lib/server/db/adapter.js +0 -274
  23. package/lib/server/db/drivers/sql-js.js +0 -75
  24. package/lib/server/db/migrate.js +0 -174
  25. package/lib/server/db/migrations/001_base_schema.sql +0 -70
  26. package/lib/server/middleware/auth.js +0 -134
  27. package/lib/server/middleware/rate-limit.js +0 -63
  28. package/lib/server/models/User.js +0 -128
  29. package/lib/server/routes/auth.js +0 -168
  30. package/lib/server/routes/keys.js +0 -15
  31. package/lib/server/routes/runtimes.js +0 -34
  32. package/lib/server/routes/speech.js +0 -75
  33. package/lib/server/routes/upload.js +0 -134
  34. package/lib/server/routes/wake-lock.js +0 -95
  35. package/lib/server/routes/workspaces.js +0 -101
  36. package/lib/server/services/context-bridge.js +0 -425
  37. package/lib/server/services/runtime-manager.js +0 -462
  38. package/lib/server/services/summary-generator.js +0 -309
  39. package/lib/server/services/workspace-manager.js +0 -79
  40. package/lib/utils/paths.js +0 -107
  41. package/lib/utils/termux.js +0 -145
package/README.md CHANGED
@@ -1,34 +1,71 @@
1
- # NexusCrew — tmux AI Cockpit
1
+ ## Overview
2
2
 
3
- **Mobile-first** web cockpit for AI CLI tools. Persistent sessions via tmux.
3
+ NexusCrew is a **tmux-based** AI cockpit that orchestrates Claude Code, Codex CLI, Gemini CLI, and Qwen Code through persistent tmux sessions.
4
+
5
+ This is a **study project** exploring an alternative approach to AI CLI orchestration, based on experience with [NexusCLI](https://www.npmjs.com/package/@mmmbuto/nexuscli). Instead of managing PTY processes in-memory, NexusCrew delegates session lifecycle to **tmux** so sessions survive server restarts and can be accessed directly from the terminal.
6
+
7
+ > **Status**: Beta — experimental, API may change
8
+
9
+ > **Disclaimer**: This project is experimental. The automation pipeline is fast enough that it may be generated, tested, and published before I finish thinking about it. Expect incomplete features, mistakes, and breaking changes.
10
+
11
+ ---
12
+
13
+ ## Why tmux?
14
+
15
+ | Problem | NexusCLI (PTY) | NexusCrew (tmux) |
16
+ |---------|---------------|-------------------|
17
+ | Server crash | All sessions die | Sessions survive |
18
+ | Direct access | Web UI only | Web UI + `tmux attach` |
19
+ | Remote machines | Not supported | Experimental host registry |
20
+ | Native deps | node-pty required | SQLite native addon |
21
+ | Session recovery | Manual | Automatic |
22
+
23
+ ---
4
24
 
5
25
  ## Features
6
26
 
7
- - **Persistent sessions** — tmux windows survive server restarts
8
- - **4 engines** — Claude Code, Codex CLI, Gemini CLI, Qwen Code
9
- - **Mobile-first** — Termux (Android) primary target, then Linux/Mac
10
- - **Zero native deps** — sql.js (WASM SQLite), no compilation needed
11
- - **Auth** — JWT with localhost auto-bypass
12
- - **Dark/Light theme** — CSS variables, instant toggle
13
- - **i18n** — 6 languages (en, it, es, ja, ru, zh)
14
- - **STT/TTS** — Speech-to-text (Web Speech API + OpenAI Whisper) + Read aloud
15
- - **File upload** — 50MB attachments
16
- - **Session dots** — Green (alive), Yellow (idle), Red (dead) tmux state
17
- - **Lane-aware models** — Native + Custom per engine
18
- - **Shell discovery** — Scans .zshrc, .bashrc, providers.zsh for aliases
19
- - **Remote hosts** — SSH to any machine
20
- - **SSE streaming** — Real-time AI output
21
- - **6 languages UI** — Auto-detects browser language
27
+ - **Persistent sessions** — standalone tmux sessions survive server restarts
28
+ - **Multi-engine** — Claude Code, Codex CLI, Gemini CLI, Qwen Code
29
+ - **Auto-discovery** — detects installed CLIs plus live shell aliases/functions and generic shell config fallbacks
30
+ - **Explicit tmux creation** — create a new tmux session only from a detected runnable launcher
31
+ - **Unified web UI** — attach chat to real active tmux sessions
32
+ - **SSE streaming** — real-time output from tmux sessions
33
+ - **Cross-platform** — Linux, macOS, Termux (Android)
34
+
35
+ ---
36
+
37
+ ## Engine Discovery
38
+
39
+ NexusCrew automatically detects:
40
+
41
+ 1. **PATH binaries** — `claude`, `codex`, `gemini`, `qwen` when present in the runtime PATH
42
+ 2. **Live shell wrappers** — aliases/functions available in the current login shell
43
+ 3. **Shell-file fallbacks** — wrappers detected from `.zshrc`, `.bashrc`, `.profile`, and optional extra shell sources
44
+
45
+ Launchers detected only from shell files are shown as detected metadata but are not treated as runnable until they are verified in the active shell context.
46
+
47
+ ---
22
48
 
23
49
  ## Install
24
50
 
25
51
  ```bash
26
52
  npm install -g @mmmbuto/nexuscrew
53
+ ```
54
+
55
+ Then initialize:
56
+
57
+ ```bash
27
58
  nexuscrew init
59
+ ```
60
+
61
+ And start:
62
+
63
+ ```bash
28
64
  nexuscrew start
29
- # Open http://localhost:41820
30
65
  ```
31
66
 
67
+ Open `http://localhost:41820` in your browser.
68
+
32
69
  ### Termux (Android)
33
70
 
34
71
  ```bash
@@ -38,72 +75,103 @@ nexuscrew init
38
75
  nexuscrew start
39
76
  ```
40
77
 
41
- ## Quick Start
78
+ ---
79
+
80
+ ## Hosts Registry
81
+
82
+ You can store SSH host definitions for later work:
42
83
 
43
84
  ```bash
44
- nexuscrew init # First-time setup
45
- nexuscrew start # Start server + tmux
46
- nexuscrew status # Check server + sessions
47
- nexuscrew attach # Attach to tmux directly
48
- nexuscrew engines # Discover AI CLIs
85
+ nexuscrew hosts add --name vps1 --host example.com --user user
86
+ nexuscrew hosts add --name vps2 --host 192.168.1.10 --user admin --port 2222
49
87
  ```
50
88
 
51
- ## Architecture
89
+ Host storage and connectivity tests are available, but the current UI and default session flow are local tmux-first. Treat remote execution as experimental until it is revalidated end-to-end.
52
90
 
53
- ```
54
- Browser/WebView ←SSE/REST→ Express :41820 ←tmux→ CLI AI processes
55
- |
56
- sql.js DB (WASM)
57
- JWT Auth
58
- ```
91
+ ---
92
+
93
+ ## Commands
59
94
 
60
- Each AI session = one tmux window. Output captured via `tee`, streamed to UI via SSE.
95
+ | Command | Description |
96
+ |---------|-------------|
97
+ | `nexuscrew init` | First-time setup (config, hosts, tmux check) |
98
+ | `nexuscrew start` | Start server |
99
+ | `nexuscrew stop` | Stop server |
100
+ | `nexuscrew status` | Server + tmux + hosts status |
101
+ | `nexuscrew engines` | Discover available AI CLIs and aliases |
102
+ | `nexuscrew sessions` | List tmux sessions |
103
+ | `nexuscrew attach` | Attach to tmux session directly |
104
+ | `nexuscrew config` | View/edit configuration |
105
+ | `nexuscrew hosts list` | List configured hosts |
106
+ | `nexuscrew hosts add` | Add remote SSH host |
107
+ | `nexuscrew hosts remove` | Remove a host |
108
+
109
+ ---
61
110
 
62
111
  ## Configuration
63
112
 
64
- `~/.nexuscrew/config.json`:
113
+ Config stored in `~/.nexuscrew/config.json`:
114
+
65
115
  ```json
66
116
  {
67
117
  "port": 41820,
68
- "tmuxSession": "nexuscrew",
69
- "autoDiscovery": true
118
+ "logDir": "~/.nexuscrew/logs",
119
+ "autoDiscovery": true,
120
+ "preferredShell": "",
121
+ "extraShellSources": []
70
122
  }
71
123
  ```
72
124
 
73
- ## Commands
125
+ Hosts stored in `~/.nexuscrew/hosts.json`:
74
126
 
75
- | Command | Description |
76
- |---------|-------------|
77
- | `nexuscrew init` | First-time setup |
78
- | `nexuscrew start` | Start server + tmux |
79
- | `nexuscrew stop` | Stop server |
80
- | `nexuscrew status` | Server + tmux + hosts |
81
- | `nexuscrew engines` | Discover CLIs + aliases |
82
- | `nexuscrew sessions` | List tmux windows |
83
- | `nexuscrew attach` | Attach to tmux |
84
- | `nexuscrew config` | View/edit config |
85
- | `nexuscrew hosts add` | Add SSH host |
127
+ ```json
128
+ [
129
+ { "name": "local", "type": "local", "default": true },
130
+ { "name": "vps1", "type": "ssh", "host": "example.com", "user": "user", "port": "22" }
131
+ ]
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Network
137
+
138
+ | Protocol | Default Port | Use Case |
139
+ |----------|--------------|----------|
140
+ | HTTP | `41820` | Local access |
141
+
142
+ ---
86
143
 
87
- ## API
144
+ ## Architecture
145
+
146
+ ```
147
+ Browser ←SSE→ Express Server ←tmux→ active tmux sessions
148
+ ├─ session: claude-work
149
+ ├─ session: codex-debug
150
+ └─ session: qwen-sandbox
151
+ ```
88
152
 
89
- Public: `/health`, `/api/v1/auth/login`, `/api/v1/models`, `/api/v1/runtimes`
90
- Protected (JWT/localhost): `/api/v1/send`, `/api/v1/sessions`, `/api/v1/upload`, `/api/v1/workspaces`
153
+ Each AI session = one standalone tmux session. Output is piped to log files, parsed by the watcher, and streamed to the UI via SSE.
91
154
 
92
- Default login: `admin` / `admin` (change after first login)
155
+ ---
93
156
 
94
157
  ## Development
95
158
 
96
159
  ```bash
97
- git clone https://github.com/DioNanos/nexuscrew
98
- cd nexuscrew && npm install
160
+ git clone https://github.com/dionanos/nexuscrew
161
+ cd nexuscrew
162
+ npm install
99
163
  cd frontend && npm install && npm run build && cd ..
100
164
  npm run dev
101
165
  ```
102
166
 
103
- ## Related
167
+ ---
168
+
169
+ ## Relation to NexusCLI
170
+
171
+ NexusCrew is a **study branch** inspired by [NexusCLI](https://www.npmjs.com/package/@mmmbuto/nexuscli). It shares the same web UI direction but replaces PTY-based process management with tmux-based persistent sessions. The goal is to explore whether tmux provides a simpler, more robust foundation for local AI CLI orchestration and crash recovery.
104
172
 
105
- - [NexusCLI](https://www.npmjs.com/package/@mmmbuto/nexuscli) — PTY-based variant
173
+ ---
106
174
 
107
175
  ## License
108
176
 
109
- MIT
177
+ MIT License. See [LICENSE](LICENSE).
package/bin/nexuscrew.js CHANGED
@@ -4,86 +4,83 @@
4
4
  const { Command } = require('commander');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
- const { execSync } = require('child_process');
7
+ const os = require('os');
8
+ const { execSync, spawn } = require('child_process');
8
9
 
9
- const CONFIG_DIR = path.join(process.env.HOME || '', '.nexuscrew');
10
- const DB_PATH = path.join(CONFIG_DIR, 'nexuscrew.db');
10
+ const EngineDiscovery = require('../lib/services/engine-discovery');
11
+ const packageJson = require('../package.json');
12
+
13
+ const CONFIG_DIR = path.join(os.homedir(), '.nexuscrew');
11
14
  const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
12
15
  const HOSTS_PATH = path.join(CONFIG_DIR, 'hosts.json');
13
- const TMUX_SESSION = 'nexuscrew';
16
+
17
+ const DEFAULT_PORT = '41820';
14
18
 
15
19
  const program = new Command();
16
20
  program
17
21
  .name('nexuscrew')
18
22
  .description('tmux-based AI cockpit')
19
- .version('0.2.0');
23
+ .version(packageJson.version);
20
24
 
21
- // --- init ---
22
25
  program.command('init')
23
26
  .description('First-time setup')
24
- .option('-s, --silent', 'Silent mode (no output)')
27
+ .option('--silent', 'Suppress setup output')
25
28
  .action((opts) => {
29
+ const log = (...args) => {
30
+ if (!opts.silent) console.log(...args);
31
+ };
32
+ const error = (...args) => {
33
+ if (!opts.silent) console.error(...args);
34
+ };
35
+
26
36
  if (!fs.existsSync(CONFIG_DIR)) {
27
37
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
28
- if (!opts.silent) console.log('Created ~/.nexuscrew/');
38
+ log('Created ~/.nexuscrew/');
29
39
  }
30
40
 
31
- // Default config
32
41
  if (!fs.existsSync(CONFIG_PATH)) {
33
42
  fs.writeFileSync(CONFIG_PATH, JSON.stringify({
34
- port: 41820,
35
- tmuxSession: TMUX_SESSION,
43
+ port: Number(DEFAULT_PORT),
36
44
  logDir: path.join(CONFIG_DIR, 'logs'),
37
45
  autoDiscovery: true,
38
- providersPath: ''
46
+ preferredShell: '',
47
+ extraShellSources: []
39
48
  }, null, 2));
40
- if (!opts.silent) console.log('Created config.json');
49
+ log('Created config.json');
41
50
  }
42
51
 
43
- // Default hosts
44
52
  if (!fs.existsSync(HOSTS_PATH)) {
45
53
  fs.writeFileSync(HOSTS_PATH, JSON.stringify([
46
54
  { name: 'local', type: 'local', default: true }
47
55
  ], null, 2));
48
- if (!opts.silent) console.log('Created hosts.json');
56
+ log('Created hosts.json');
49
57
  }
50
58
 
51
- // Log dir
52
59
  const logDir = path.join(CONFIG_DIR, 'logs');
53
60
  if (!fs.existsSync(logDir)) {
54
61
  fs.mkdirSync(logDir, { recursive: true });
55
62
  }
56
63
 
57
- // Check tmux
58
64
  try {
59
- execSync('which tmux', { stdio: 'pipe' });
60
- if (!opts.silent) console.log('tmux: OK');
65
+ execSync('command -v tmux >/dev/null 2>&1', { stdio: 'pipe' });
66
+ log('tmux: OK');
61
67
  } catch {
62
- if (!opts.silent) console.error('tmux not found! Install: apt install tmux (or pkg install tmux)');
68
+ error('tmux not found! Install: apt install tmux (or pkg install tmux)');
63
69
  }
64
70
 
65
- if (!opts.silent) console.log('\nDone! Run: nexuscrew start');
71
+ log('\nDone! Run: nexuscrew start');
66
72
  });
67
73
 
68
- // --- start ---
69
74
  program.command('start')
70
75
  .description('Start the server')
71
- .option('-p, --port <port>', 'Port', '41820')
76
+ .option('-p, --port <port>', 'Port', DEFAULT_PORT)
72
77
  .option('-d, --dev', 'Dev mode (no frontend)')
73
78
  .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}`);
80
- }
81
-
82
79
  const env = { ...process.env, PORT: opts.port };
83
80
  if (opts.dev) env.NEXUSCREW_DEV = '1';
84
81
 
85
82
  const args = [path.join(__dirname, '..', 'lib', 'server', 'server.js')];
86
- const child = require('child_process').spawn('node', args, {
83
+ const child = spawn('node', args, {
87
84
  env,
88
85
  stdio: 'inherit',
89
86
  detached: false
@@ -91,12 +88,11 @@ program.command('start')
91
88
  child.on('exit', (code) => process.exit(code || 0));
92
89
  });
93
90
 
94
- // --- stop ---
95
91
  program.command('stop')
96
92
  .description('Stop the server')
97
93
  .action(() => {
98
94
  try {
99
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
95
+ const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
100
96
  if (pid) {
101
97
  execSync(`kill ${pid}`);
102
98
  console.log('Server stopped');
@@ -108,72 +104,64 @@ program.command('stop')
108
104
  }
109
105
  });
110
106
 
111
- // --- status ---
112
107
  program.command('status')
113
108
  .description('Show server and tmux status')
114
109
  .action(() => {
115
110
  console.log('=== NexusCrew Status ===\n');
116
111
 
117
- // Server
118
112
  try {
119
- const pid = execSync(`lsof -ti :41820 2>/dev/null`).toString().trim();
113
+ const pid = execSync(`lsof -ti :${DEFAULT_PORT} 2>/dev/null`).toString().trim();
120
114
  console.log(`Server: RUNNING (pid ${pid})`);
121
115
  } catch {
122
116
  console.log('Server: STOPPED');
123
117
  }
124
118
 
125
- // tmux
126
119
  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}`));
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}`));
130
123
  } catch {
131
- console.log(`\ntmux session "${TMUX_SESSION}": NOT FOUND`);
124
+ console.log('\nActive tmux sessions: none');
132
125
  }
133
126
 
134
- // Hosts
135
127
  if (fs.existsSync(HOSTS_PATH)) {
136
128
  const hosts = JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'));
137
129
  console.log('\nHosts:');
138
- hosts.forEach(h => {
139
- if (h.type === 'local') {
140
- console.log(` ${h.name}: local`);
130
+ hosts.forEach((host) => {
131
+ if (host.type === 'local') {
132
+ console.log(` ${host.name}: local`);
141
133
  } else {
142
- const status = testSSH(h) ? 'OK' : 'UNREACHABLE';
143
- console.log(` ${h.name}: ${h.user}@${h.host}${h.port ? ':' + h.port : ''} [${status}]`);
134
+ const status = testSSH(host) ? 'OK' : 'UNREACHABLE';
135
+ console.log(` ${host.name}: ${host.user}@${host.host}${host.port ? ':' + host.port : ''} [${status}]`);
144
136
  }
145
137
  });
146
138
  }
147
139
  });
148
140
 
149
- // --- attach ---
150
141
  program.command('attach')
151
- .description('Attach to tmux session')
152
- .option('-w, --window <name>', 'Attach to specific window')
142
+ .description('Attach to a tmux session')
143
+ .requiredOption('-s, --session <name>', 'Session name')
153
144
  .action((opts) => {
154
- const target = opts.window ? `${TMUX_SESSION}:${opts.window}` : TMUX_SESSION;
155
145
  try {
156
- execSync(`tmux attach -t ${target}`, { stdio: 'inherit' });
146
+ execSync(`tmux attach -t ${shellQuote(opts.session)}`, { stdio: 'inherit' });
157
147
  } catch {
158
- console.error(`Session/window not found: ${target}`);
148
+ console.error(`Session not found: ${opts.session}`);
159
149
  }
160
150
  });
161
151
 
162
- // --- sessions ---
163
152
  program.command('sessions')
164
- .description('List tmux windows')
153
+ .description('List active tmux sessions')
165
154
  .action(() => {
166
155
  try {
167
156
  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`
157
+ "tmux list-sessions -F '#{session_name} [#{session_windows} windows] #{?session_attached,(attached),}' 2>/dev/null"
169
158
  ).toString().trim();
170
- console.log(out);
159
+ console.log(out || 'No tmux sessions found');
171
160
  } catch {
172
- console.log('No tmux session found');
161
+ console.log('No tmux sessions found');
173
162
  }
174
163
  });
175
164
 
176
- // --- config ---
177
165
  program.command('config')
178
166
  .description('View/edit configuration')
179
167
  .option('-l, --list', 'Show current config')
@@ -188,7 +176,11 @@ program.command('config')
188
176
  if (opts.set) {
189
177
  const [key, ...rest] = opts.set.split('=');
190
178
  const value = rest.join('=');
191
- config[key] = isNaN(value) ? value : Number(value);
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;
192
184
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
193
185
  console.log(`Set ${key} = ${value}`);
194
186
  } else {
@@ -196,7 +188,6 @@ program.command('config')
196
188
  }
197
189
  });
198
190
 
199
- // --- hosts ---
200
191
  const hostsCmd = program.command('hosts').description('Manage remote hosts');
201
192
 
202
193
  hostsCmd.command('list')
@@ -207,9 +198,9 @@ hostsCmd.command('list')
207
198
  return;
208
199
  }
209
200
  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*' : ''}`);
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*' : ''}`);
213
204
  });
214
205
  });
215
206
 
@@ -217,7 +208,7 @@ hostsCmd.command('add')
217
208
  .description('Add a remote host')
218
209
  .requiredOption('-n, --name <name>', 'Host name')
219
210
  .requiredOption('-h, --host <host>', 'Hostname or IP')
220
- .option('-u, --user <user>', 'SSH user', 'dag')
211
+ .option('-u, --user <user>', 'SSH user', os.userInfo().username)
221
212
  .option('-p, --port <port>', 'SSH port', '22')
222
213
  .option('-k, --key <path>', 'SSH key path')
223
214
  .action((opts) => {
@@ -243,31 +234,38 @@ hostsCmd.command('remove')
243
234
  let hosts = fs.existsSync(HOSTS_PATH)
244
235
  ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
245
236
  : [];
246
- hosts = hosts.filter(h => h.name !== opts.name);
237
+ hosts = hosts.filter((host) => host.name !== opts.name);
247
238
  fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
248
239
  console.log(`Removed host: ${opts.name}`);
249
240
  });
250
241
 
251
- // --- engines ---
252
242
  program.command('engines')
253
- .description('Discover available AI CLIs')
243
+ .description('Discover available launchers')
254
244
  .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
- }
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}`);
263
251
  }
264
252
  console.log('');
265
253
  });
266
254
 
267
- // --- helpers ---
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
+ }
265
+
268
266
  function testSSH(host) {
269
267
  try {
270
- const keyOpt = host.key ? `-i ${host.key}` : '';
268
+ const keyOpt = host.key ? `-i ${shellQuote(host.key)}` : '';
271
269
  const portOpt = host.port && host.port !== '22' ? `-p ${host.port}` : '';
272
270
  execSync(`ssh -o ConnectTimeout=3 -o BatchMode=yes ${keyOpt} ${portOpt} ${host.user}@${host.host} echo ok`, { stdio: 'pipe' });
273
271
  return true;
@@ -276,79 +274,8 @@ function testSSH(host) {
276
274
  }
277
275
  }
278
276
 
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
- }
350
-
351
- return engines;
277
+ function shellQuote(value) {
278
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
352
279
  }
353
280
 
354
281
  program.parse();