@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/v1/status — Tmux status (windows, hosts)
|
|
3
|
+
* GET /api/v1/status/tmux — Detailed tmux info
|
|
4
|
+
* GET /api/v1/status/pane/:window — Capture pane content
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { Router } = require('express');
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
// Overall status
|
|
11
|
+
router.get('/', (req, res) => {
|
|
12
|
+
const { tmux, store } = req;
|
|
13
|
+
const windows = tmux.listWindows();
|
|
14
|
+
const hosts = tmux.getAllHosts();
|
|
15
|
+
|
|
16
|
+
res.json({
|
|
17
|
+
tmux: {
|
|
18
|
+
session: tmux.sessionName,
|
|
19
|
+
alive: tmux.hasSession(),
|
|
20
|
+
windowCount: windows.length,
|
|
21
|
+
windows
|
|
22
|
+
},
|
|
23
|
+
hosts: hosts.map(h => ({
|
|
24
|
+
...h,
|
|
25
|
+
connected: tmux.testHost(h)
|
|
26
|
+
}))
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Detailed tmux windows
|
|
31
|
+
router.get('/tmux', (req, res) => {
|
|
32
|
+
const { tmux, store } = req;
|
|
33
|
+
const windows = tmux.listWindows();
|
|
34
|
+
|
|
35
|
+
// Enrich with DB data
|
|
36
|
+
const enriched = windows.map(w => {
|
|
37
|
+
const conv = store.findByWindow(w.name);
|
|
38
|
+
return {
|
|
39
|
+
...w,
|
|
40
|
+
conversationId: conv?.id || null,
|
|
41
|
+
engine: conv?.engine || null,
|
|
42
|
+
model: conv?.model || null,
|
|
43
|
+
host: conv?.host || 'local',
|
|
44
|
+
title: conv?.title || w.name
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
res.json({ windows: enriched });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Capture pane content
|
|
52
|
+
router.get('/pane/:window', (req, res) => {
|
|
53
|
+
const { tmux } = req;
|
|
54
|
+
const { host } = req.query;
|
|
55
|
+
const content = tmux.capturePane(req.params.window, host || null);
|
|
56
|
+
|
|
57
|
+
res.json({ window: req.params.window, content });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
module.exports = router;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NexusCrew Server — tmux-based AI cockpit
|
|
3
|
+
*
|
|
4
|
+
* Unified endpoint: POST /api/v1/send
|
|
5
|
+
* All engines routed through tmux windows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const cors = require('cors');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
|
|
15
|
+
const TmuxManager = require('../services/tmux-manager');
|
|
16
|
+
const EngineDiscovery = require('../services/engine-discovery');
|
|
17
|
+
const SessionStore = require('../services/session-store');
|
|
18
|
+
const LogWatcher = require('../services/log-watcher');
|
|
19
|
+
|
|
20
|
+
const sendRouter = require('./routes/send');
|
|
21
|
+
const sessionsRouter = require('./routes/sessions');
|
|
22
|
+
const modelsRouter = require('./routes/models');
|
|
23
|
+
const statusRouter = require('./routes/status');
|
|
24
|
+
const hostsRouter = require('./routes/hosts');
|
|
25
|
+
|
|
26
|
+
class NexusCrewServer {
|
|
27
|
+
constructor(config = {}) {
|
|
28
|
+
this.port = config.port || process.env.PORT || 41820;
|
|
29
|
+
this.configDir = config.configDir || path.join(process.env.HOME, '.nexuscrew');
|
|
30
|
+
this.config = this._loadConfig();
|
|
31
|
+
this.app = express();
|
|
32
|
+
this.server = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_loadConfig() {
|
|
36
|
+
const configPath = path.join(this.configDir, 'config.json');
|
|
37
|
+
if (fs.existsSync(configPath)) {
|
|
38
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
return { port: this.port, tmuxSession: 'nexuscrew', autoDiscovery: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_loadHosts() {
|
|
44
|
+
const hostsPath = path.join(this.configDir, 'hosts.json');
|
|
45
|
+
if (fs.existsSync(hostsPath)) {
|
|
46
|
+
return JSON.parse(fs.readFileSync(hostsPath, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
return [{ name: 'local', type: 'local', default: true }];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start() {
|
|
52
|
+
// Init services
|
|
53
|
+
const hosts = this._loadHosts();
|
|
54
|
+
const tmuxManager = new TmuxManager({
|
|
55
|
+
tmuxSession: this.config.tmuxSession || 'nexuscrew',
|
|
56
|
+
logDir: this.config.logDir || path.join(this.configDir, 'logs'),
|
|
57
|
+
hosts
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const engineDiscovery = new EngineDiscovery(this.config.providersPath);
|
|
61
|
+
const sessionStore = new SessionStore(path.join(this.configDir, 'nexuscrew.db'));
|
|
62
|
+
const logWatcher = new LogWatcher();
|
|
63
|
+
|
|
64
|
+
// Ensure tmux master session
|
|
65
|
+
tmuxManager.ensureSession();
|
|
66
|
+
|
|
67
|
+
// Sync DB with tmux reality
|
|
68
|
+
sessionStore.syncWithTmux(tmuxManager);
|
|
69
|
+
|
|
70
|
+
// Resume watching logs for active conversations
|
|
71
|
+
const active = sessionStore.listConversations({ status: 'active' });
|
|
72
|
+
for (const conv of active) {
|
|
73
|
+
if (conv.log_path && fs.existsSync(conv.log_path)) {
|
|
74
|
+
logWatcher.watch(conv.log_path, conv.engine, conv.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Middleware
|
|
79
|
+
this.app.use(cors());
|
|
80
|
+
this.app.use(express.json({ limit: '10mb' }));
|
|
81
|
+
|
|
82
|
+
// Health check
|
|
83
|
+
this.app.get('/health', (req, res) => {
|
|
84
|
+
res.json({
|
|
85
|
+
status: 'ok',
|
|
86
|
+
service: 'nexuscrew',
|
|
87
|
+
version: '0.1.0',
|
|
88
|
+
tmux: tmuxManager.hasSession(),
|
|
89
|
+
windows: tmuxManager.listWindows().length,
|
|
90
|
+
uptime: process.uptime()
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Inject services into request
|
|
95
|
+
this.app.use((req, res, next) => {
|
|
96
|
+
req.tmux = tmuxManager;
|
|
97
|
+
req.engines = engineDiscovery;
|
|
98
|
+
req.store = sessionStore;
|
|
99
|
+
req.logWatcher = logWatcher;
|
|
100
|
+
req.config = this.config;
|
|
101
|
+
next();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// API routes
|
|
105
|
+
this.app.use('/api/v1/send', sendRouter);
|
|
106
|
+
this.app.use('/api/v1/sessions', sessionsRouter);
|
|
107
|
+
this.app.use('/api/v1/models', modelsRouter);
|
|
108
|
+
this.app.use('/api/v1/status', statusRouter);
|
|
109
|
+
this.app.use('/api/v1/hosts', hostsRouter);
|
|
110
|
+
|
|
111
|
+
// Serve frontend
|
|
112
|
+
const frontendDist = path.join(__dirname, '../../frontend/dist');
|
|
113
|
+
if (fs.existsSync(frontendDist)) {
|
|
114
|
+
this.app.use(express.static(frontendDist));
|
|
115
|
+
this.app.get('*', (req, res) => {
|
|
116
|
+
if (!req.path.startsWith('/api/')) {
|
|
117
|
+
res.sendFile(path.join(frontendDist, 'index.html'));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Error handler
|
|
123
|
+
this.app.use((err, req, res, next) => {
|
|
124
|
+
console.error('[Error]', err.message);
|
|
125
|
+
res.status(500).json({ error: err.message });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Start HTTP server
|
|
129
|
+
this.server = http.createServer(this.app);
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
this.server.listen(this.port, () => {
|
|
133
|
+
console.log('');
|
|
134
|
+
console.log('╔══════════════════════════════════════════════╗');
|
|
135
|
+
console.log('║ NexusCrew — tmux AI cockpit ║');
|
|
136
|
+
console.log('╚══════════════════════════════════════════════╝');
|
|
137
|
+
console.log('');
|
|
138
|
+
console.log(` HTTP: http://localhost:${this.port}`);
|
|
139
|
+
console.log(` tmux: session "${tmuxManager.sessionName}" (${tmuxManager.listWindows().length} windows)`);
|
|
140
|
+
console.log(` Hosts: ${hosts.map(h => h.name).join(', ')}`);
|
|
141
|
+
console.log('');
|
|
142
|
+
|
|
143
|
+
const engines = engineDiscovery.discover();
|
|
144
|
+
for (const [name, info] of Object.entries(engines)) {
|
|
145
|
+
console.log(` ${info.available ? 'OK' : ' '} ${name}${info.aliases.length ? ` (+${info.aliases.length} aliases)` : ''}`);
|
|
146
|
+
}
|
|
147
|
+
console.log('');
|
|
148
|
+
|
|
149
|
+
resolve();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
stop() {
|
|
155
|
+
if (this.server) {
|
|
156
|
+
this.server.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Run if called directly
|
|
162
|
+
if (require.main === module) {
|
|
163
|
+
const server = new NexusCrewServer();
|
|
164
|
+
server.start().catch(err => {
|
|
165
|
+
console.error('Failed to start:', err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
process.on('SIGTERM', () => { server.stop(); process.exit(0); });
|
|
170
|
+
process.on('SIGINT', () => { console.log('\nShutting down...'); server.stop(); process.exit(0); });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = NexusCrewServer;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineDiscovery — Auto-detect AI CLIs and parse providers.zsh aliases
|
|
3
|
+
*
|
|
4
|
+
* Discovers:
|
|
5
|
+
* - Standard CLIs: claude, codex, gemini, qwen (via PATH + common paths)
|
|
6
|
+
* - Custom aliases/functions from providers.zsh (e.g. claude-glm-a, codex-alibaba-qwen35)
|
|
7
|
+
* - Each engine gets: path, type, available, aliases[], models[]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { execSync } = require('child_process');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
const CLI_SEARCH_PATHS = {
|
|
15
|
+
claude: [
|
|
16
|
+
'${HOME}/.local/bin/claude',
|
|
17
|
+
'${HOME}/.claude/local/claude',
|
|
18
|
+
'/usr/local/bin/claude',
|
|
19
|
+
'/usr/bin/claude',
|
|
20
|
+
'${PREFIX}/bin/claude'
|
|
21
|
+
],
|
|
22
|
+
codex: [
|
|
23
|
+
'${HOME}/.local/codex-lts/bin/codex',
|
|
24
|
+
'${HOME}/.local/codex-lts/node_modules/.bin/codex',
|
|
25
|
+
'${HOME}/.local/codex-lts/package/bin/android-arm64/codex',
|
|
26
|
+
'/usr/local/bin/codex'
|
|
27
|
+
],
|
|
28
|
+
gemini: [
|
|
29
|
+
'${HOME}/.local/bin/gemini',
|
|
30
|
+
'${HOME}/.gemini/bin/gemini',
|
|
31
|
+
'/usr/local/bin/gemini'
|
|
32
|
+
],
|
|
33
|
+
qwen: [
|
|
34
|
+
'${HOME}/.local/bin/qwen',
|
|
35
|
+
'${HOME}/.qwen/bin/qwen',
|
|
36
|
+
'/usr/local/bin/qwen'
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Engine types:
|
|
42
|
+
* - interactive: runs as REPL, needs PTY-like handling (send text + Enter)
|
|
43
|
+
* - exec: runs one-shot commands, returns structured output
|
|
44
|
+
*/
|
|
45
|
+
const ENGINE_TYPES = {
|
|
46
|
+
claude: 'interactive',
|
|
47
|
+
codex: 'exec',
|
|
48
|
+
gemini: 'interactive',
|
|
49
|
+
qwen: 'interactive'
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build command templates for each engine
|
|
54
|
+
*/
|
|
55
|
+
const ENGINE_COMMANDS = {
|
|
56
|
+
claude: {
|
|
57
|
+
new: (bin, opts) => `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`,
|
|
58
|
+
resume: (bin, opts) => `${bin} --output-format json -r ${opts.sessionId}${opts.model ? ` --model ${opts.model}` : ''}`,
|
|
59
|
+
interactive: (bin) => `${bin} --output-format json`
|
|
60
|
+
},
|
|
61
|
+
codex: {
|
|
62
|
+
new: (bin, opts) => `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}`,
|
|
63
|
+
resume: (bin, opts) => `${bin} exec --json resume ${opts.threadId}`,
|
|
64
|
+
interactive: (bin) => bin
|
|
65
|
+
},
|
|
66
|
+
gemini: {
|
|
67
|
+
new: (bin, opts) => `${bin}${opts.model ? ` -m ${opts.model}` : ''}`,
|
|
68
|
+
resume: (bin, opts) => `${bin} --resume ${opts.threadId}${opts.model ? ` -m ${opts.model}` : ''}`,
|
|
69
|
+
interactive: (bin) => bin
|
|
70
|
+
},
|
|
71
|
+
qwen: {
|
|
72
|
+
new: (bin, opts) => `${bin}${opts.model ? ` -m ${opts.model}` : ''}`,
|
|
73
|
+
resume: (bin, opts) => `${bin} --resume ${opts.threadId}${opts.model ? ` -m ${opts.model}` : ''}`,
|
|
74
|
+
interactive: (bin) => bin
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
class EngineDiscovery {
|
|
79
|
+
constructor(providersPath) {
|
|
80
|
+
this.providersPath = providersPath || '';
|
|
81
|
+
this._cache = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Discover all available engines, aliases, and models
|
|
86
|
+
* @returns {Object} engines map
|
|
87
|
+
*/
|
|
88
|
+
discover() {
|
|
89
|
+
if (this._cache) return this._cache;
|
|
90
|
+
|
|
91
|
+
const engines = {};
|
|
92
|
+
|
|
93
|
+
// 1. Discover standard CLIs
|
|
94
|
+
for (const [name, searchPaths] of Object.entries(CLI_SEARCH_PATHS)) {
|
|
95
|
+
engines[name] = this._discoverCli(name, searchPaths);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Parse providers.zsh for aliases/functions
|
|
99
|
+
const aliases = this._parseProviders();
|
|
100
|
+
for (const alias of aliases) {
|
|
101
|
+
const baseEngine = this._detectBaseEngine(alias.name);
|
|
102
|
+
if (baseEngine && engines[baseEngine]) {
|
|
103
|
+
engines[baseEngine].aliases.push({
|
|
104
|
+
name: alias.name,
|
|
105
|
+
command: alias.command,
|
|
106
|
+
description: this._describeAlias(alias.name),
|
|
107
|
+
modelHint: this._extractModelHint(alias.command)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Build model lists for each engine
|
|
113
|
+
for (const [name, engine] of Object.entries(engines)) {
|
|
114
|
+
engine.models = this._buildModels(name, engine);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this._cache = engines;
|
|
118
|
+
return engines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get flat list of all available models across all engines
|
|
123
|
+
* @returns {Array<{id, name, engine, type, alias?}>}
|
|
124
|
+
*/
|
|
125
|
+
getAllModels() {
|
|
126
|
+
const engines = this.discover();
|
|
127
|
+
const models = [];
|
|
128
|
+
for (const [engineName, engine] of Object.entries(engines)) {
|
|
129
|
+
for (const model of engine.models) {
|
|
130
|
+
models.push({ ...model, engine: engineName });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return models;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get engines formatted for the frontend model selector
|
|
138
|
+
*/
|
|
139
|
+
getForFrontend() {
|
|
140
|
+
const engines = this.discover();
|
|
141
|
+
const result = {};
|
|
142
|
+
for (const [name, engine] of Object.entries(engines)) {
|
|
143
|
+
result[name] = {
|
|
144
|
+
available: engine.available,
|
|
145
|
+
type: engine.type,
|
|
146
|
+
path: engine.path,
|
|
147
|
+
models: engine.models,
|
|
148
|
+
aliases: engine.aliases,
|
|
149
|
+
endpoint: '/api/v1/send' // Single unified endpoint
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Internal ---
|
|
156
|
+
|
|
157
|
+
_discoverCli(name, searchPaths) {
|
|
158
|
+
const resolved = searchPaths.map(p =>
|
|
159
|
+
p.replace(/\$\{HOME\}/g, process.env.HOME || '')
|
|
160
|
+
.replace(/\$\{PREFIX\}/g, process.env.PREFIX || '')
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
let foundPath = null;
|
|
164
|
+
for (const p of resolved) {
|
|
165
|
+
try {
|
|
166
|
+
if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
|
|
167
|
+
foundPath = p;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Fallback: which
|
|
174
|
+
if (!foundPath) {
|
|
175
|
+
try {
|
|
176
|
+
foundPath = execSync(`which ${name} 2>/dev/null`).toString().trim();
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
available: !!foundPath,
|
|
182
|
+
path: foundPath,
|
|
183
|
+
type: ENGINE_TYPES[name] || 'interactive',
|
|
184
|
+
aliases: [],
|
|
185
|
+
models: []
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_parseProviders() {
|
|
190
|
+
if (!fs.existsSync(this.providersPath)) return [];
|
|
191
|
+
|
|
192
|
+
const content = fs.readFileSync(this.providersPath, 'utf8');
|
|
193
|
+
const aliases = [];
|
|
194
|
+
|
|
195
|
+
// Match function definitions: name() { ... }
|
|
196
|
+
const funcRegex = /^(claude-[a-z0-9][a-z0-9-]*|codex-[a-z0-9][a-z0-9-]*|gemini-[a-z0-9][a-z0-9-]*|qwen-[a-z0-9][a-z0-9-]*)\(\)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gm;
|
|
197
|
+
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = funcRegex.exec(content)) !== null) {
|
|
200
|
+
aliases.push({
|
|
201
|
+
name: match[1],
|
|
202
|
+
command: match[2].trim()
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return aliases;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_detectBaseEngine(name) {
|
|
210
|
+
if (name.startsWith('claude')) return 'claude';
|
|
211
|
+
if (name.startsWith('codex')) return 'codex';
|
|
212
|
+
if (name.startsWith('gemini')) return 'gemini';
|
|
213
|
+
if (name.startsWith('qwen')) return 'qwen';
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_describeAlias(name) {
|
|
218
|
+
// Human-readable description from function name
|
|
219
|
+
const parts = name.split('-');
|
|
220
|
+
const engine = parts[0];
|
|
221
|
+
const rest = parts.slice(1);
|
|
222
|
+
|
|
223
|
+
const providerMap = {
|
|
224
|
+
'glm': 'Z.ai GLM',
|
|
225
|
+
'zai': 'Z.ai',
|
|
226
|
+
'alibaba': 'Alibaba',
|
|
227
|
+
'chutes': 'Chutes',
|
|
228
|
+
'minimax': 'MiniMax',
|
|
229
|
+
'minimax27': 'MiniMax M2.7'
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const modelMap = {
|
|
233
|
+
'a': '(Key A)',
|
|
234
|
+
'p': '(Key P)',
|
|
235
|
+
'qwen35': 'Qwen 3.5',
|
|
236
|
+
'qwen3max': 'Qwen 3 Max',
|
|
237
|
+
'glm5': 'GLM-5',
|
|
238
|
+
'glm5ist': 'GLM-5 Turbo',
|
|
239
|
+
'kimi': 'Kimi K2.5',
|
|
240
|
+
'minimax': 'MiniMax',
|
|
241
|
+
'minimax27': 'MiniMax M2.7',
|
|
242
|
+
'qwencodex': 'Qwen Coder',
|
|
243
|
+
'qwennext': 'Qwen Coder Next',
|
|
244
|
+
'qwen3next': 'Qwen 3 Next',
|
|
245
|
+
'glm5turbo': 'GLM-5 Turbo',
|
|
246
|
+
'deepseek': 'DeepSeek V3.2'
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const descriptions = [];
|
|
250
|
+
for (const part of rest) {
|
|
251
|
+
if (providerMap[part]) descriptions.push(providerMap[part]);
|
|
252
|
+
else if (modelMap[part]) descriptions.push(modelMap[part]);
|
|
253
|
+
else descriptions.push(part);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return `${engine} → ${descriptions.join(' / ')}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
_extractModelHint(command) {
|
|
260
|
+
// Try to extract model name from the command
|
|
261
|
+
const modelMatch = command.match(/"([^"]+)"/);
|
|
262
|
+
return modelMatch ? modelMatch[1] : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_buildModels(engineName, engine) {
|
|
266
|
+
const models = [];
|
|
267
|
+
|
|
268
|
+
if (!engine.available) return models;
|
|
269
|
+
|
|
270
|
+
// Native models per engine
|
|
271
|
+
const nativeModels = {
|
|
272
|
+
claude: [
|
|
273
|
+
{ id: 'sonnet', name: 'Claude Sonnet', lane: 'native' },
|
|
274
|
+
{ id: 'opus', name: 'Claude Opus', lane: 'native' },
|
|
275
|
+
{ id: 'haiku', name: 'Claude Haiku', lane: 'native' }
|
|
276
|
+
],
|
|
277
|
+
codex: [
|
|
278
|
+
{ id: 'gpt-5.4', name: 'GPT-5.4', lane: 'native' },
|
|
279
|
+
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', lane: 'native' },
|
|
280
|
+
{ id: 'codex-mini-latest', name: 'Codex Mini', lane: 'native' }
|
|
281
|
+
],
|
|
282
|
+
gemini: [
|
|
283
|
+
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', lane: 'native' },
|
|
284
|
+
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', lane: 'native' },
|
|
285
|
+
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', lane: 'native' }
|
|
286
|
+
],
|
|
287
|
+
qwen: [
|
|
288
|
+
{ id: 'qwen3-coder-plus', name: 'Qwen 3 Coder Plus', lane: 'native' },
|
|
289
|
+
{ id: 'qwen3.5-plus', name: 'Qwen 3.5 Plus', lane: 'native' },
|
|
290
|
+
{ id: 'qwen3-max', name: 'Qwen 3 Max', lane: 'native' }
|
|
291
|
+
]
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
models.push(...(nativeModels[engineName] || []));
|
|
295
|
+
|
|
296
|
+
// Alias-based custom models
|
|
297
|
+
for (const alias of engine.aliases) {
|
|
298
|
+
if (alias.modelHint) {
|
|
299
|
+
models.push({
|
|
300
|
+
id: `${engineName}-${alias.name}`,
|
|
301
|
+
name: alias.description || alias.name,
|
|
302
|
+
lane: 'custom',
|
|
303
|
+
alias: alias.name,
|
|
304
|
+
modelHint: alias.modelHint
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
models.push({
|
|
308
|
+
id: `${engineName}-${alias.name}`,
|
|
309
|
+
name: alias.description || alias.name,
|
|
310
|
+
lane: 'custom',
|
|
311
|
+
alias: alias.name
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return models;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = EngineDiscovery;
|