@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
@@ -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;