@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.
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/bin/nexuscrew.js +353 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/frontend/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/frontend/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/frontend/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/frontend/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/frontend/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/frontend/dist/assets/index-C7bAndew.js +8613 -0
- package/frontend/dist/assets/index-OENqI1_9.css +1 -0
- package/frontend/dist/index.html +13 -0
- package/lib/server/routes/hosts.js +78 -0
- package/lib/server/routes/models.js +14 -0
- package/lib/server/routes/send.js +277 -0
- package/lib/server/routes/sessions.js +104 -0
- package/lib/server/routes/status.js +60 -0
- package/lib/server/server.js +173 -0
- package/lib/services/engine-discovery.js +320 -0
- package/lib/services/log-watcher.js +279 -0
- package/lib/services/session-store.js +176 -0
- package/lib/services/tmux-manager.js +256 -0
- 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).
|
package/bin/nexuscrew.js
ADDED
|
@@ -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();
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|