@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.
- package/README.md +124 -56
- package/bin/nexuscrew.js +79 -152
- package/frontend/dist/assets/{index-8pw4eMB-.js → index-BsldfYnr.js} +1704 -1716
- package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/lib/server/routes/hosts.js +3 -2
- package/lib/server/routes/launchers.js +12 -0
- package/lib/server/routes/models.js +16 -63
- package/lib/server/routes/runtime.js +10 -0
- package/lib/server/routes/send.js +97 -218
- package/lib/server/routes/sessions.js +7 -39
- package/lib/server/routes/status.js +16 -18
- package/lib/server/routes/tmux.js +88 -0
- package/lib/server/server.js +45 -65
- package/lib/services/engine-discovery.js +192 -445
- package/lib/services/session-store.js +86 -60
- package/lib/services/tmux-manager.js +103 -154
- package/package.json +3 -7
- package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
- package/lib/config/manager.js +0 -362
- package/lib/config/models.js +0 -408
- package/lib/server/db/adapter.js +0 -274
- package/lib/server/db/drivers/sql-js.js +0 -75
- package/lib/server/db/migrate.js +0 -174
- package/lib/server/db/migrations/001_base_schema.sql +0 -70
- package/lib/server/middleware/auth.js +0 -134
- package/lib/server/middleware/rate-limit.js +0 -63
- package/lib/server/models/User.js +0 -128
- package/lib/server/routes/auth.js +0 -168
- package/lib/server/routes/keys.js +0 -15
- package/lib/server/routes/runtimes.js +0 -34
- package/lib/server/routes/speech.js +0 -75
- package/lib/server/routes/upload.js +0 -134
- package/lib/server/routes/wake-lock.js +0 -95
- package/lib/server/routes/workspaces.js +0 -101
- package/lib/server/services/context-bridge.js +0 -425
- package/lib/server/services/runtime-manager.js +0 -462
- package/lib/server/services/summary-generator.js +0 -309
- package/lib/server/services/workspace-manager.js +0 -79
- package/lib/utils/paths.js +0 -107
- package/lib/utils/termux.js +0 -145
package/README.md
CHANGED
|
@@ -1,34 +1,71 @@
|
|
|
1
|
-
|
|
1
|
+
## Overview
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Hosts Registry
|
|
81
|
+
|
|
82
|
+
You can store SSH host definitions for later work:
|
|
42
83
|
|
|
43
84
|
```bash
|
|
44
|
-
nexuscrew
|
|
45
|
-
nexuscrew
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
sql.js DB (WASM)
|
|
57
|
-
JWT Auth
|
|
58
|
-
```
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Commands
|
|
59
94
|
|
|
60
|
-
|
|
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
|
-
"
|
|
69
|
-
"autoDiscovery": true
|
|
118
|
+
"logDir": "~/.nexuscrew/logs",
|
|
119
|
+
"autoDiscovery": true,
|
|
120
|
+
"preferredShell": "",
|
|
121
|
+
"extraShellSources": []
|
|
70
122
|
}
|
|
71
123
|
```
|
|
72
124
|
|
|
73
|
-
|
|
125
|
+
Hosts stored in `~/.nexuscrew/hosts.json`:
|
|
74
126
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
155
|
+
---
|
|
93
156
|
|
|
94
157
|
## Development
|
|
95
158
|
|
|
96
159
|
```bash
|
|
97
|
-
git clone https://github.com/
|
|
98
|
-
cd nexuscrew
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const { execSync, spawn } = require('child_process');
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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
|
-
|
|
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(
|
|
23
|
+
.version(packageJson.version);
|
|
20
24
|
|
|
21
|
-
// --- init ---
|
|
22
25
|
program.command('init')
|
|
23
26
|
.description('First-time setup')
|
|
24
|
-
.option('
|
|
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
|
-
|
|
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:
|
|
35
|
-
tmuxSession: TMUX_SESSION,
|
|
43
|
+
port: Number(DEFAULT_PORT),
|
|
36
44
|
logDir: path.join(CONFIG_DIR, 'logs'),
|
|
37
45
|
autoDiscovery: true,
|
|
38
|
-
|
|
46
|
+
preferredShell: '',
|
|
47
|
+
extraShellSources: []
|
|
39
48
|
}, null, 2));
|
|
40
|
-
|
|
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
|
-
|
|
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('
|
|
60
|
-
|
|
65
|
+
execSync('command -v tmux >/dev/null 2>&1', { stdio: 'pipe' });
|
|
66
|
+
log('tmux: OK');
|
|
61
67
|
} catch {
|
|
62
|
-
|
|
68
|
+
error('tmux not found! Install: apt install tmux (or pkg install tmux)');
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
|
|
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',
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
128
|
-
console.log(
|
|
129
|
-
|
|
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(
|
|
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(
|
|
139
|
-
if (
|
|
140
|
-
console.log(` ${
|
|
130
|
+
hosts.forEach((host) => {
|
|
131
|
+
if (host.type === 'local') {
|
|
132
|
+
console.log(` ${host.name}: local`);
|
|
141
133
|
} else {
|
|
142
|
-
const status = testSSH(
|
|
143
|
-
console.log(` ${
|
|
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
|
-
.
|
|
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 ${
|
|
146
|
+
execSync(`tmux attach -t ${shellQuote(opts.session)}`, { stdio: 'inherit' });
|
|
157
147
|
} catch {
|
|
158
|
-
console.error(`Session
|
|
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
|
|
153
|
+
.description('List active tmux sessions')
|
|
165
154
|
.action(() => {
|
|
166
155
|
try {
|
|
167
156
|
const out = execSync(
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
211
|
-
const status =
|
|
212
|
-
console.log(`[${i}] ${
|
|
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',
|
|
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(
|
|
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
|
|
243
|
+
.description('Discover available launchers')
|
|
254
244
|
.action(() => {
|
|
255
|
-
const
|
|
256
|
-
console.log('\nDiscovered
|
|
257
|
-
for (const
|
|
258
|
-
console.log(` ${
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
280
|
-
|
|
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();
|