@mmmbuto/nexuscrew 0.1.0-beta.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 (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -0
  3. package/bin/nexuscrew.js +353 -0
  4. package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/frontend/dist/assets/index-C7bAndew.js +8613 -0
  64. package/frontend/dist/assets/index-OENqI1_9.css +1 -0
  65. package/frontend/dist/index.html +13 -0
  66. package/lib/server/routes/hosts.js +78 -0
  67. package/lib/server/routes/models.js +14 -0
  68. package/lib/server/routes/send.js +277 -0
  69. package/lib/server/routes/sessions.js +104 -0
  70. package/lib/server/routes/status.js +60 -0
  71. package/lib/server/server.js +173 -0
  72. package/lib/services/engine-discovery.js +320 -0
  73. package/lib/services/log-watcher.js +279 -0
  74. package/lib/services/session-store.js +176 -0
  75. package/lib/services/tmux-manager.js +256 -0
  76. package/package.json +55 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Davide A. Guglielmi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ ## Overview
2
+
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** — sessions survive server restarts, can be accessed directly from the terminal, and extend to remote hosts via SSH.
6
+
7
+ > **Status**: Beta — experimental, API may change
8
+
9
+ ---
10
+
11
+ ## Why tmux?
12
+
13
+ | Problem | NexusCLI (PTY) | NexusCrew (tmux) |
14
+ |---------|---------------|-------------------|
15
+ | Server crash | All sessions die | Sessions survive |
16
+ | Direct access | Web UI only | Web UI + `tmux attach` |
17
+ | Remote machines | Not supported | SSH to any host |
18
+ | Native deps | node-pty required | Zero native deps |
19
+ | Session recovery | Manual | Automatic |
20
+
21
+ ---
22
+
23
+ ## Features
24
+
25
+ - **Persistent sessions** — tmux windows survive server restarts
26
+ - **Multi-engine** — Claude Code, Codex CLI, Gemini CLI, Qwen Code
27
+ - **Auto-discovery** — detects installed CLIs + parses shell aliases/functions from `providers.zsh`
28
+ - **Remote hosts** — manage tmux sessions on any machine via SSH
29
+ - **Unified web UI** — same interface across all engines and hosts
30
+ - **SSE streaming** — real-time output from tmux sessions
31
+ - **Zero native deps** — no node-pty, no compilation required
32
+ - **Cross-platform** — Linux, macOS, Termux (Android)
33
+
34
+ ---
35
+
36
+ ## Engine Discovery
37
+
38
+ NexusCrew automatically detects:
39
+
40
+ 1. **Standard CLIs** — `claude`, `codex`, `gemini`, `qwen` via PATH and common install paths
41
+ 2. **Shell aliases** — parses `~/Dev/providers.zsh` (or configured path) for wrapper functions like:
42
+ - `claude-glm-a`, `codex-alibaba-qwen35`, `claude-minimax27`, etc.
43
+ - Each alias becomes available as a custom lane in the model selector
44
+
45
+ ---
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ npm install -g @mmmbuto/nexuscrew
51
+ ```
52
+
53
+ Then initialize:
54
+
55
+ ```bash
56
+ nexuscrew init
57
+ ```
58
+
59
+ And start:
60
+
61
+ ```bash
62
+ nexuscrew start
63
+ ```
64
+
65
+ Open `http://localhost:41820` in your browser.
66
+
67
+ ### Termux (Android)
68
+
69
+ ```bash
70
+ pkg install tmux nodejs
71
+ npm install -g @mmmbuto/nexuscrew
72
+ nexuscrew init
73
+ nexuscrew start
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Remote Hosts
79
+
80
+ Add SSH hosts to manage tmux sessions on remote machines:
81
+
82
+ ```bash
83
+ nexuscrew hosts add --name vps1 --host example.com --user user
84
+ nexuscrew hosts add --name vps2 --host 192.168.1.10 --user admin --port 2222
85
+ ```
86
+
87
+ Hosts appear in the UI's model selector. Sessions created on a remote host open a tmux window there via SSH.
88
+
89
+ ---
90
+
91
+ ## Commands
92
+
93
+ | Command | Description |
94
+ |---------|-------------|
95
+ | `nexuscrew init` | First-time setup (config, hosts, tmux check) |
96
+ | `nexuscrew start` | Start server + tmux session |
97
+ | `nexuscrew stop` | Stop server |
98
+ | `nexuscrew status` | Server + tmux + hosts status |
99
+ | `nexuscrew engines` | Discover available AI CLIs and aliases |
100
+ | `nexuscrew sessions` | List tmux windows |
101
+ | `nexuscrew attach` | Attach to tmux session directly |
102
+ | `nexuscrew config` | View/edit configuration |
103
+ | `nexuscrew hosts list` | List configured hosts |
104
+ | `nexuscrew hosts add` | Add remote SSH host |
105
+ | `nexuscrew hosts remove` | Remove a host |
106
+
107
+ ---
108
+
109
+ ## Configuration
110
+
111
+ Config stored in `~/.nexuscrew/config.json`:
112
+
113
+ ```json
114
+ {
115
+ "port": 41820,
116
+ "tmuxSession": "nexuscrew",
117
+ "logDir": "~/.nexuscrew/logs",
118
+ "autoDiscovery": true,
119
+ "providersPath": "~/Dev/providers.zsh"
120
+ }
121
+ ```
122
+
123
+ Hosts stored in `~/.nexuscrew/hosts.json`:
124
+
125
+ ```json
126
+ [
127
+ { "name": "local", "type": "local", "default": true },
128
+ { "name": "vps1", "type": "ssh", "host": "example.com", "user": "user", "port": "22" }
129
+ ]
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Network
135
+
136
+ | Protocol | Default Port | Use Case |
137
+ |----------|--------------|----------|
138
+ | HTTP | `41820` | Local access |
139
+
140
+ ---
141
+
142
+ ## Architecture
143
+
144
+ ```
145
+ Browser ←SSE→ Express Server ←tmux→ tmux session "nexuscrew"
146
+ ├─ window: nc-abc12345 (claude)
147
+ ├─ window: nc-def67890 (codex)
148
+ └─ window: nc-ghi11111 (gemini)
149
+ ```
150
+
151
+ Each AI session = one tmux window. Output captured via `tee` to log files, parsed by engine-specific parsers, and streamed to the UI via SSE.
152
+
153
+ ---
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ git clone https://github.com/dionanos/nexuscrew
159
+ cd nexuscrew
160
+ npm install
161
+ cd frontend && npm install && npm run build && cd ..
162
+ npm run dev
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Relation to NexusCLI
168
+
169
+ NexusCrew is a **study branch** inspired by [NexusCLI](https://www.npmjs.com/package/@mmmbuto/nexuscli). It shares the same web UI but replaces the PTY-based process management with tmux-based persistent sessions. The goal is to explore whether tmux provides a simpler, more robust foundation for AI CLI orchestration — especially for remote hosts and crash recovery.
170
+
171
+ ---
172
+
173
+ ## License
174
+
175
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { Command } = require('commander');
5
+ const path = require('path');
6
+ 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';
14
+
15
+ const program = new Command();
16
+ program
17
+ .name('nexuscrew')
18
+ .description('tmux-based AI cockpit')
19
+ .version('0.1.0');
20
+
21
+ // --- init ---
22
+ 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/');
28
+ }
29
+
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');
40
+ }
41
+
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');
48
+ }
49
+
50
+ // Log dir
51
+ const logDir = path.join(CONFIG_DIR, 'logs');
52
+ if (!fs.existsSync(logDir)) {
53
+ fs.mkdirSync(logDir, { recursive: true });
54
+ }
55
+
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)');
62
+ }
63
+
64
+ console.log('\nDone! Run: nexuscrew start');
65
+ });
66
+
67
+ // --- start ---
68
+ 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}`);
79
+ }
80
+
81
+ const env = { ...process.env, PORT: opts.port };
82
+ if (opts.dev) env.NEXUSCREW_DEV = '1';
83
+
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));
91
+ });
92
+
93
+ // --- stop ---
94
+ program.command('stop')
95
+ .description('Stop the server')
96
+ .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');
107
+ }
108
+ });
109
+
110
+ // --- status ---
111
+ 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
+ }
123
+
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
+ }
132
+
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
+ });
145
+ }
146
+ });
147
+
148
+ // --- attach ---
149
+ program.command('attach')
150
+ .description('Attach to tmux session')
151
+ .option('-w, --window <name>', 'Attach to specific window')
152
+ .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
+ }
159
+ });
160
+
161
+ // --- sessions ---
162
+ program.command('sessions')
163
+ .description('List tmux windows')
164
+ .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 {
171
+ console.log('No tmux session found');
172
+ }
173
+ });
174
+
175
+ // --- config ---
176
+ 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');
183
+ return;
184
+ }
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));
195
+ }
196
+ });
197
+
198
+ // --- hosts ---
199
+ const hostsCmd = program.command('hosts').description('Manage remote hosts');
200
+
201
+ hostsCmd.command('list')
202
+ .description('List configured hosts')
203
+ .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*' : ''}`);
212
+ });
213
+ });
214
+
215
+ hostsCmd.command('add')
216
+ .description('Add a remote host')
217
+ .requiredOption('-n, --name <name>', 'Host name')
218
+ .requiredOption('-h, --host <host>', 'Hostname or IP')
219
+ .option('-u, --user <user>', 'SSH user', 'dag')
220
+ .option('-p, --port <port>', 'SSH port', '22')
221
+ .option('-k, --key <path>', 'SSH key path')
222
+ .action((opts) => {
223
+ const hosts = fs.existsSync(HOSTS_PATH)
224
+ ? JSON.parse(fs.readFileSync(HOSTS_PATH, 'utf8'))
225
+ : [];
226
+ hosts.push({
227
+ name: opts.name,
228
+ type: 'ssh',
229
+ host: opts.host,
230
+ user: opts.user,
231
+ port: opts.port,
232
+ key: opts.key || undefined
233
+ });
234
+ fs.writeFileSync(HOSTS_PATH, JSON.stringify(hosts, null, 2));
235
+ console.log(`Added host: ${opts.name}`);
236
+ });
237
+
238
+ hostsCmd.command('remove')
239
+ .description('Remove a host')
240
+ .requiredOption('-n, --name <name>', 'Host name')
241
+ .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));
247
+ console.log(`Removed host: ${opts.name}`);
248
+ });
249
+
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('');
264
+ });
265
+
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
+ }
277
+
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
+ }
349
+
350
+ return engines;
351
+ }
352
+
353
+ program.parse();