@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.
Files changed (41) hide show
  1. package/README.md +124 -56
  2. package/bin/nexuscrew.js +79 -152
  3. package/frontend/dist/assets/{index-8pw4eMB-.js → index-BsldfYnr.js} +1704 -1716
  4. package/frontend/dist/assets/index-DcQGg3dp.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/lib/server/routes/hosts.js +3 -2
  7. package/lib/server/routes/launchers.js +12 -0
  8. package/lib/server/routes/models.js +16 -63
  9. package/lib/server/routes/runtime.js +10 -0
  10. package/lib/server/routes/send.js +97 -218
  11. package/lib/server/routes/sessions.js +7 -39
  12. package/lib/server/routes/status.js +16 -18
  13. package/lib/server/routes/tmux.js +88 -0
  14. package/lib/server/server.js +45 -65
  15. package/lib/services/engine-discovery.js +192 -445
  16. package/lib/services/session-store.js +86 -60
  17. package/lib/services/tmux-manager.js +103 -154
  18. package/package.json +3 -7
  19. package/frontend/dist/assets/index-BF0tdvNT.css +0 -1
  20. package/lib/config/manager.js +0 -362
  21. package/lib/config/models.js +0 -408
  22. package/lib/server/db/adapter.js +0 -274
  23. package/lib/server/db/drivers/sql-js.js +0 -75
  24. package/lib/server/db/migrate.js +0 -174
  25. package/lib/server/db/migrations/001_base_schema.sql +0 -70
  26. package/lib/server/middleware/auth.js +0 -134
  27. package/lib/server/middleware/rate-limit.js +0 -63
  28. package/lib/server/models/User.js +0 -128
  29. package/lib/server/routes/auth.js +0 -168
  30. package/lib/server/routes/keys.js +0 -15
  31. package/lib/server/routes/runtimes.js +0 -34
  32. package/lib/server/routes/speech.js +0 -75
  33. package/lib/server/routes/upload.js +0 -134
  34. package/lib/server/routes/wake-lock.js +0 -95
  35. package/lib/server/routes/workspaces.js +0 -101
  36. package/lib/server/services/context-bridge.js +0 -425
  37. package/lib/server/services/runtime-manager.js +0 -462
  38. package/lib/server/services/summary-generator.js +0 -309
  39. package/lib/server/services/workspace-manager.js +0 -79
  40. package/lib/utils/paths.js +0 -107
  41. package/lib/utils/termux.js +0 -145
@@ -1,504 +1,251 @@
1
1
  /**
2
- * EngineDiscoveryAuto-detect AI CLIs and parse providers.zsh aliases
2
+ * LauncherDiscoveryenvironment-agnostic launcher detection
3
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[]
4
+ * Discovers launchers from:
5
+ * - the current runtime shell via live introspection
6
+ * - generic shell rc/profile files as fallback
7
+ * - PATH binaries
8
+ *
9
+ * No user-specific config is assumed and no model catalog is hardcoded.
8
10
  */
9
11
 
10
- const { execSync } = require('child_process');
12
+ const { execFileSync, execSync } = require('child_process');
11
13
  const path = require('path');
12
14
  const fs = require('fs');
15
+ const os = require('os');
13
16
 
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
- const SHELL_SOURCES = [
41
- '${HOME}/.zshrc',
42
- '${HOME}/.bashrc',
43
- '${HOME}/.bash_profile',
44
- '${HOME}/.profile',
45
- '${HOME}/.zprofile',
46
- '${HOME}/.config/ai-shell/providers.zsh',
47
- '${HOME}/.config/zsh/chutes-wrapper.zsh',
48
- '${HOME}/.nexuscrew/providers.zsh'
49
- ];
50
-
51
- const ENGINE_PREFIXES = ['claude', 'codex', 'gemini', 'qwen'];
52
-
53
- /**
54
- * Engine types:
55
- * - interactive: runs as REPL, needs PTY-like handling (send text + Enter)
56
- * - exec: runs one-shot commands, returns structured output
57
- */
58
- const ENGINE_TYPES = {
59
- claude: 'interactive',
60
- codex: 'exec',
61
- gemini: 'interactive',
62
- qwen: 'interactive'
63
- };
64
-
65
- /**
66
- * Build command templates for each engine
67
- */
68
- const ENGINE_COMMANDS = {
69
- claude: {
70
- new: (bin, opts) => `${bin} --output-format json${opts.model ? ` --model ${opts.model}` : ''}`,
71
- resume: (bin, opts) => `${bin} --output-format json -r ${opts.sessionId}${opts.model ? ` --model ${opts.model}` : ''}`,
72
- interactive: (bin) => `${bin} --output-format json`
73
- },
74
- codex: {
75
- new: (bin, opts) => `${bin} exec --json${opts.model ? ` -m ${opts.model}` : ''}${opts.reasoningEffort ? ` --reasoning ${opts.reasoningEffort}` : ''}`,
76
- resume: (bin, opts) => `${bin} exec --json resume ${opts.threadId}`,
77
- interactive: (bin) => bin
78
- },
79
- gemini: {
80
- new: (bin, opts) => `${bin}${opts.model ? ` -m ${opts.model}` : ''}`,
81
- resume: (bin, opts) => `${bin} --resume ${opts.threadId}${opts.model ? ` -m ${opts.model}` : ''}`,
82
- interactive: (bin) => bin
83
- },
84
- qwen: {
85
- new: (bin, opts) => `${bin}${opts.model ? ` -m ${opts.model}` : ''}`,
86
- resume: (bin, opts) => `${bin} --resume ${opts.threadId}${opts.model ? ` -m ${opts.model}` : ''}`,
87
- interactive: (bin) => bin
88
- }
89
- };
17
+ const KNOWN_FAMILIES = ['claude', 'codex', 'gemini', 'qwen'];
90
18
 
91
19
  class EngineDiscovery {
92
- constructor(providersPath) {
93
- this.providersPath = providersPath || '';
20
+ constructor(config = {}) {
21
+ this.preferredShell = config.preferredShell || '';
22
+ this.extraShellSources = Array.isArray(config.extraShellSources) ? config.extraShellSources : [];
94
23
  this._cache = null;
95
24
  }
96
25
 
97
- /**
98
- * Discover all available engines, aliases, and models
99
- * @returns {Object} engines map
100
- */
101
26
  discover() {
102
27
  if (this._cache) return this._cache;
103
28
 
104
- const engines = {};
29
+ const shellPath = this.getShellPath();
30
+ const shellName = path.basename(shellPath);
31
+ const launchers = [];
32
+ const seen = new Set();
33
+
34
+ const addLauncher = (launcher) => {
35
+ const id = launcher.id || `${launcher.kind}:${launcher.command}`;
36
+ if (!launcher.command || seen.has(id)) return;
37
+ seen.add(id);
38
+ launchers.push({
39
+ id,
40
+ label: launcher.label || launcher.name || launcher.command,
41
+ family: launcher.family || this._detectFamily(launcher.name || launcher.command),
42
+ kind: launcher.kind,
43
+ command: launcher.command,
44
+ commandPreview: launcher.commandPreview || launcher.command,
45
+ source: launcher.source,
46
+ shell: launcher.shell || shellName,
47
+ available: launcher.available !== false,
48
+ runnable: launcher.runnable !== false
49
+ });
50
+ };
105
51
 
106
- // 1. Discover standard CLIs
107
- for (const [name, searchPaths] of Object.entries(CLI_SEARCH_PATHS)) {
108
- engines[name] = this._discoverCli(name, searchPaths);
109
- }
52
+ this._discoverPathBinaries().forEach(addLauncher);
53
+ this._discoverLiveShell(shellPath).forEach(addLauncher);
54
+ this._discoverShellFiles(shellName).forEach(addLauncher);
110
55
 
111
- // 2. Parse providers.zsh for aliases/functions
112
- const aliases = this._parseProviders();
113
- for (const alias of aliases) {
114
- const baseEngine = this._detectBaseEngine(alias.name);
115
- if (baseEngine && engines[baseEngine]) {
116
- engines[baseEngine].aliases.push({
117
- name: alias.name,
118
- command: alias.command,
119
- description: this._describeAlias(alias.name),
120
- modelHint: this._extractModelHint(alias.command)
121
- });
122
- }
123
- }
124
-
125
- // 3. Build model lists for each engine
126
- for (const [name, engine] of Object.entries(engines)) {
127
- engine.models = this._buildModels(name, engine);
128
- }
129
-
130
- this._cache = engines;
131
- return engines;
56
+ launchers.sort((a, b) => a.label.localeCompare(b.label));
57
+ this._cache = {
58
+ shell: shellPath,
59
+ homeDir: os.homedir(),
60
+ launchers
61
+ };
62
+ return this._cache;
132
63
  }
133
64
 
134
- /**
135
- * Get flat list of all available models across all engines
136
- * @returns {Array<{id, name, engine, type, alias?}>}
137
- */
138
- getAllModels() {
139
- const engines = this.discover();
140
- const models = [];
141
- for (const [engineName, engine] of Object.entries(engines)) {
142
- for (const model of engine.models) {
143
- models.push({ ...model, engine: engineName });
144
- }
145
- }
146
- return models;
65
+ getLaunchers() {
66
+ return this.discover().launchers;
147
67
  }
148
68
 
149
- /**
150
- * Get engines formatted for the frontend model selector
151
- */
152
- getForFrontend() {
153
- const engines = this.discover();
154
- const result = {};
155
- for (const [name, engine] of Object.entries(engines)) {
156
- result[name] = {
157
- available: engine.available,
158
- type: engine.type,
159
- path: engine.path,
160
- models: engine.models,
161
- aliases: engine.aliases,
162
- endpoint: '/api/v1/send' // Single unified endpoint
163
- };
164
- }
165
- return result;
69
+ getLauncher(id) {
70
+ return this.getLaunchers().find((launcher) => launcher.id === id) || null;
166
71
  }
167
72
 
168
- // --- Internal ---
169
-
170
- _discoverCli(name, searchPaths) {
171
- const resolved = searchPaths.map(p =>
172
- p.replace(/\$\{HOME\}/g, process.env.HOME || '')
173
- .replace(/\$\{PREFIX\}/g, process.env.PREFIX || '')
174
- );
175
-
176
- let foundPath = null;
177
- for (const p of resolved) {
178
- try {
179
- if (fs.existsSync(p) && fs.statSync(p).mode & 0o111) {
180
- foundPath = p;
181
- break;
182
- }
183
- } catch {}
184
- }
185
-
186
- // Fallback: which
187
- if (!foundPath) {
188
- try {
189
- foundPath = execSync(`which ${name} 2>/dev/null`).toString().trim();
190
- } catch {}
191
- }
73
+ getShellPath() {
74
+ return this.preferredShell || process.env.SHELL || '/bin/sh';
75
+ }
192
76
 
77
+ getRuntimeInfo() {
193
78
  return {
194
- available: !!foundPath,
195
- path: foundPath,
196
- type: ENGINE_TYPES[name] || 'interactive',
197
- aliases: [],
198
- models: []
79
+ homeDir: os.homedir(),
80
+ shell: this.getShellPath(),
81
+ platform: process.platform
199
82
  };
200
83
  }
201
84
 
202
- _parseProviders() {
203
- // Use universal shell discovery instead of single file
204
- return this._discoverShellSources();
85
+ clearCache() {
86
+ this._cache = null;
205
87
  }
206
88
 
207
- /**
208
- * Discover shell aliases and functions from all standard shell config files
209
- * Parses:
210
- * - alias name='command' / alias name="command"
211
- * - name() { ... } function definitions
212
- * - Follows source/. directives (1 level)
213
- * - Extracts model hints from env vars and command args
214
- */
215
- _discoverShellSources() {
216
- const HOME = process.env.HOME || '';
217
- const aliases = [];
218
- const processedFiles = new Set();
219
-
220
- const searchPaths = SHELL_SOURCES.map(p =>
221
- p.replace(/\$\{HOME\}/g, HOME)
222
- );
223
-
224
- for (const filePath of searchPaths) {
225
- if (!fs.existsSync(filePath) || processedFiles.has(filePath)) continue;
226
- processedFiles.add(filePath);
227
-
228
- try {
229
- const content = fs.readFileSync(filePath, 'utf8');
230
- const lines = content.split('\n');
231
-
232
- for (let i = 0; i < lines.length; i++) {
233
- const line = lines[i].trim();
234
-
235
- // Skip comments and empty lines
236
- if (!line || line.startsWith('#')) continue;
237
-
238
- // Follow source/. directives (1 level deep)
239
- if (line.startsWith('source ') || line.startsWith('. ')) {
240
- const sourcedFile = line.replace(/^source\s+|^\.\s+/, '').replace(/['"]/g, '').trim();
241
- const resolvedPath = sourcedFile.startsWith('~')
242
- ? path.join(HOME, sourcedFile.slice(1))
243
- : sourcedFile;
244
-
245
- if (fs.existsSync(resolvedPath) && !processedFiles.has(resolvedPath)) {
246
- processedFiles.add(resolvedPath);
247
- try {
248
- const sourcedContent = fs.readFileSync(resolvedPath, 'utf8');
249
- this._parseShellContent(sourcedContent, aliases, resolvedPath);
250
- } catch (err) {
251
- // Skip unreadable sourced files
252
- }
253
- }
254
- continue;
255
- }
256
-
257
- // Parse current line
258
- this._parseShellLine(line, aliases, filePath);
259
- }
260
- } catch (err) {
261
- // Skip unreadable files
262
- }
263
- }
264
-
265
- return aliases;
89
+ _discoverPathBinaries() {
90
+ return KNOWN_FAMILIES.flatMap((family) => {
91
+ const binPath = this._resolveCommand(family);
92
+ if (!binPath) return [];
93
+ return [{
94
+ id: `binary:${family}`,
95
+ label: family,
96
+ family,
97
+ kind: 'binary',
98
+ command: binPath,
99
+ commandPreview: binPath,
100
+ source: 'path',
101
+ runnable: true
102
+ }];
103
+ });
266
104
  }
267
105
 
268
- /**
269
- * Parse a single line for aliases or functions
270
- */
271
- _parseShellLine(line, aliases, source) {
272
- // Match: alias name='command' or alias name="command"
273
- const aliasRegex = /^alias\s+([a-z0-9][a-z0-9-]*)\s*=\s*['"]([^'"]+)['"]/;
274
- const aliasMatch = line.match(aliasRegex);
275
- if (aliasMatch) {
276
- const name = aliasMatch[1];
277
- if (this._isEngineAlias(name)) {
278
- aliases.push({
279
- name,
280
- command: aliasMatch[2],
281
- source,
282
- modelHint: this._extractModelHint(aliasMatch[2])
283
- });
284
- }
285
- return;
286
- }
287
-
288
- // Match: name() { ... } (function definition - single line)
289
- const funcRegex = /^([a-z0-9][a-z0-9-]*)\(\)\s*\{([^}]*)\}/;
290
- const funcMatch = line.match(funcRegex);
291
- if (funcMatch) {
292
- const name = funcMatch[1];
293
- if (this._isEngineAlias(name)) {
294
- aliases.push({
295
- name,
296
- command: funcMatch[2].trim(),
297
- source,
298
- modelHint: this._extractModelHint(funcMatch[2])
299
- });
300
- }
106
+ _discoverLiveShell(shellPath) {
107
+ const shellName = path.basename(shellPath);
108
+ if (!fs.existsSync(shellPath)) return [];
109
+
110
+ try {
111
+ const script = [
112
+ 'alias -p 2>/dev/null || true',
113
+ 'printf "\\n__NEXUSCREW_FUNCTIONS__\\n"',
114
+ 'typeset -f 2>/dev/null || declare -f 2>/dev/null || true'
115
+ ].join('; ');
116
+
117
+ const output = execFileSync(shellPath, this._shellArgs(shellName, script), {
118
+ encoding: 'utf8',
119
+ stdio: ['ignore', 'pipe', 'pipe']
120
+ });
121
+ return this._parseShellDump(output, shellName, 'live-shell');
122
+ } catch {
123
+ return [];
301
124
  }
302
125
  }
303
126
 
304
- /**
305
- * Parse full file content for aliases and functions
306
- */
307
- _parseShellContent(content, aliases, source) {
308
- const lines = content.split('\n');
309
-
310
- // Multi-line function buffer
311
- let inFunction = false;
312
- let funcName = null;
313
- let funcBody = [];
314
-
315
- for (const line of lines) {
316
- const trimmed = line.trim();
127
+ _discoverShellFiles(shellName) {
128
+ const homeDir = os.homedir();
129
+ const shellFiles = new Set();
317
130
 
318
- // Skip comments and empty lines
319
- if (!trimmed || trimmed.startsWith('#')) continue;
320
-
321
- // Function start
322
- if (!inFunction) {
323
- const funcStart = trimmed.match(/^([a-z0-9][a-z0-9-]*)\(\)\s*\{/);
324
- if (funcStart) {
325
- const name = funcStart[1];
326
- if (this._isEngineAlias(name)) {
327
- inFunction = true;
328
- funcName = name;
329
- funcBody = [];
330
- }
331
- continue;
332
- }
333
-
334
- // Single-line alias/function
335
- this._parseShellLine(trimmed, aliases, source);
336
- } else {
337
- // Function end
338
- if (trimmed === '}') {
339
- inFunction = false;
340
- if (funcName) {
341
- aliases.push({
342
- name: funcName,
343
- command: funcBody.join(' ').trim(),
344
- source,
345
- modelHint: this._extractModelHint(funcBody.join(' '))
346
- });
347
- funcName = null;
348
- funcBody = [];
349
- }
350
- continue;
351
- }
131
+ for (const candidate of this._defaultShellFiles(shellName)) {
132
+ shellFiles.add(path.join(homeDir, candidate));
133
+ }
134
+ for (const candidate of this.extraShellSources) {
135
+ if (candidate) shellFiles.add(candidate);
136
+ }
352
137
 
353
- // Accumulate function body
354
- funcBody.push(trimmed);
355
- }
138
+ const discovered = [];
139
+ for (const filePath of shellFiles) {
140
+ if (!fs.existsSync(filePath)) continue;
141
+ try {
142
+ const content = fs.readFileSync(filePath, 'utf8');
143
+ discovered.push(...this._parseShellFile(content, shellName, filePath));
144
+ } catch {}
356
145
  }
146
+ return discovered;
357
147
  }
358
148
 
359
- /**
360
- * Check if a name matches any engine prefix
361
- */
362
- _isEngineAlias(name) {
363
- return ENGINE_PREFIXES.some(prefix => name.startsWith(prefix));
149
+ _parseShellDump(content, shellName, source) {
150
+ const [aliasSection = '', functionSection = ''] = content.split('\n__NEXUSCREW_FUNCTIONS__\n');
151
+ return [
152
+ ...this._parseAliases(aliasSection, shellName, source),
153
+ ...this._parseFunctions(functionSection, shellName, source)
154
+ ];
364
155
  }
365
156
 
366
- _detectBaseEngine(name) {
367
- if (name.startsWith('claude')) return 'claude';
368
- if (name.startsWith('codex')) return 'codex';
369
- if (name.startsWith('gemini')) return 'gemini';
370
- if (name.startsWith('qwen')) return 'qwen';
371
- return null;
157
+ _parseShellFile(content, shellName, filePath) {
158
+ return [
159
+ ...this._parseAliases(content, shellName, `shell-file:${filePath}`),
160
+ ...this._parseFunctions(content, shellName, `shell-file:${filePath}`)
161
+ ];
372
162
  }
373
163
 
374
- _describeAlias(name) {
375
- // Human-readable description from function name
376
- const parts = name.split('-');
377
- const engine = parts[0];
378
- const rest = parts.slice(1);
379
-
380
- const providerMap = {
381
- 'glm': 'Z.ai GLM',
382
- 'zai': 'Z.ai',
383
- 'alibaba': 'Alibaba',
384
- 'chutes': 'Chutes',
385
- 'minimax': 'MiniMax',
386
- 'minimax27': 'MiniMax M2.7'
387
- };
388
-
389
- const modelMap = {
390
- 'a': '(Key A)',
391
- 'p': '(Key P)',
392
- 'qwen35': 'Qwen 3.5',
393
- 'qwen3max': 'Qwen 3 Max',
394
- 'glm5': 'GLM-5',
395
- 'glm5ist': 'GLM-5 Turbo',
396
- 'kimi': 'Kimi K2.5',
397
- 'minimax': 'MiniMax',
398
- 'minimax27': 'MiniMax M2.7',
399
- 'qwencodex': 'Qwen Coder',
400
- 'qwennext': 'Qwen Coder Next',
401
- 'qwen3next': 'Qwen 3 Next',
402
- 'glm5turbo': 'GLM-5 Turbo',
403
- 'deepseek': 'DeepSeek V3.2'
404
- };
405
-
406
- const descriptions = [];
407
- for (const part of rest) {
408
- if (providerMap[part]) descriptions.push(providerMap[part]);
409
- else if (modelMap[part]) descriptions.push(modelMap[part]);
410
- else descriptions.push(part);
164
+ _parseAliases(content, shellName, source) {
165
+ const aliases = [];
166
+ const regex = /^alias\s+([A-Za-z0-9_.:-]+)=['"]([^'"]+)['"]$/gm;
167
+ let match;
168
+ while ((match = regex.exec(content)) !== null) {
169
+ const name = match[1];
170
+ const body = match[2].trim();
171
+ if (!this._isLauncherCandidate(name, body)) continue;
172
+ aliases.push({
173
+ id: `alias:${name}`,
174
+ label: name,
175
+ name,
176
+ family: this._detectFamily(name, body),
177
+ kind: 'alias',
178
+ command: name,
179
+ commandPreview: body,
180
+ source,
181
+ shell: shellName,
182
+ runnable: source === 'live-shell'
183
+ });
411
184
  }
412
-
413
- return `${engine} → ${descriptions.join(' / ')}`;
185
+ return aliases;
414
186
  }
415
187
 
416
- _extractModelHint(command) {
417
- if (!command) return null;
188
+ _parseFunctions(content, shellName, source) {
189
+ const functions = [];
190
+ const regex = /^([A-Za-z0-9_.:-]+)\s*(?:\(\))?\s*\{/gm;
191
+ let match;
192
+ while ((match = regex.exec(content)) !== null) {
193
+ const name = match[1];
194
+ if (!this._isLauncherCandidate(name, name)) continue;
195
+ functions.push({
196
+ id: `function:${name}`,
197
+ label: name,
198
+ name,
199
+ family: this._detectFamily(name),
200
+ kind: 'function',
201
+ command: name,
202
+ commandPreview: `${name}()`,
203
+ source,
204
+ shell: shellName,
205
+ runnable: source === 'live-shell'
206
+ });
207
+ }
208
+ return functions;
209
+ }
418
210
 
419
- // Try quoted string first
420
- const quotedMatch = command.match(/["']([^"']+)["']/);
421
- if (quotedMatch) {
422
- return quotedMatch[1];
423
- }
211
+ _isLauncherCandidate(name, body = '') {
212
+ const haystack = `${name} ${body}`.toLowerCase();
213
+ return KNOWN_FAMILIES.some((family) => haystack.includes(family));
214
+ }
424
215
 
425
- // Extract from --model flag
426
- const modelFlagMatch = command.match(/--model\s+(\S+)/);
427
- if (modelFlagMatch) {
428
- return modelFlagMatch[1];
429
- }
216
+ _detectFamily(name, body = '') {
217
+ const haystack = `${name || ''} ${body}`.toLowerCase();
218
+ return KNOWN_FAMILIES.find((family) => haystack.includes(family)) || 'custom';
219
+ }
430
220
 
431
- // Extract from ANTHROPIC_MODEL env var
432
- const anthropicMatch = command.match(/ANTHROPIC_MODEL\s*=\s*["']?([^"'\s]+)["']?/);
433
- if (anthropicMatch) {
434
- return anthropicMatch[1];
221
+ _resolveCommand(command) {
222
+ try {
223
+ const resolved = execSync(`command -v ${command} 2>/dev/null`, {
224
+ encoding: 'utf8',
225
+ stdio: ['ignore', 'pipe', 'ignore']
226
+ }).trim();
227
+ return resolved || null;
228
+ } catch {
229
+ return null;
435
230
  }
231
+ }
436
232
 
437
- // Extract from OPENAI_API_BASE (indicates provider)
438
- const openaiBaseMatch = command.match(/OPENAI_API_BASE\s*=\s*["']?([^"'\s]+)["']?/);
439
- if (openaiBaseMatch) {
440
- // Derive model from base URL if it's a known provider
441
- const base = openaiBaseMatch[1];
442
- if (base.includes('deepseek')) return 'deepseek';
443
- if (base.includes('z.ai') || base.includes('glm')) return 'glm';
444
- if (base.includes('alibaba') || base.includes('dashscope')) return 'qwen';
233
+ _defaultShellFiles(shellName) {
234
+ switch (shellName) {
235
+ case 'zsh':
236
+ return ['.zshrc', '.zprofile', '.zshenv'];
237
+ case 'bash':
238
+ return ['.bashrc', '.bash_profile', '.profile'];
239
+ default:
240
+ return ['.profile'];
445
241
  }
446
-
447
- return null;
448
242
  }
449
243
 
450
- _buildModels(engineName, engine) {
451
- const models = [];
452
-
453
- if (!engine.available) return models;
454
-
455
- // Native models per engine
456
- const nativeModels = {
457
- claude: [
458
- { id: 'sonnet', name: 'Claude Sonnet', lane: 'native' },
459
- { id: 'opus', name: 'Claude Opus', lane: 'native' },
460
- { id: 'haiku', name: 'Claude Haiku', lane: 'native' }
461
- ],
462
- codex: [
463
- { id: 'gpt-5.4', name: 'GPT-5.4', lane: 'native' },
464
- { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', lane: 'native' },
465
- { id: 'codex-mini-latest', name: 'Codex Mini', lane: 'native' }
466
- ],
467
- gemini: [
468
- { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro', lane: 'native' },
469
- { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', lane: 'native' },
470
- { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', lane: 'native' }
471
- ],
472
- qwen: [
473
- { id: 'qwen3-coder-plus', name: 'Qwen 3 Coder Plus', lane: 'native' },
474
- { id: 'qwen3.5-plus', name: 'Qwen 3.5 Plus', lane: 'native' },
475
- { id: 'qwen3-max', name: 'Qwen 3 Max', lane: 'native' }
476
- ]
477
- };
478
-
479
- models.push(...(nativeModels[engineName] || []));
480
-
481
- // Alias-based custom models
482
- for (const alias of engine.aliases) {
483
- if (alias.modelHint) {
484
- models.push({
485
- id: `${engineName}-${alias.name}`,
486
- name: alias.description || alias.name,
487
- lane: 'custom',
488
- alias: alias.name,
489
- modelHint: alias.modelHint
490
- });
491
- } else {
492
- models.push({
493
- id: `${engineName}-${alias.name}`,
494
- name: alias.description || alias.name,
495
- lane: 'custom',
496
- alias: alias.name
497
- });
498
- }
244
+ _shellArgs(shellName, script) {
245
+ if (shellName === 'bash' || shellName === 'zsh') {
246
+ return ['-ilc', script];
499
247
  }
500
-
501
- return models;
248
+ return ['-lc', script];
502
249
  }
503
250
  }
504
251