@mmmbuto/nexuscrew 0.8.0 → 0.8.3

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.
@@ -1,53 +1,124 @@
1
1
  'use strict';
2
- // Profili engine gestiti da NexusCrew. La configurazione descrive client e
3
- // provider; command/argv/env effettivi vengono composti qui, senza shell e senza
4
- // salvare segreti in fleet.json. I profili custom v1 restano supportati.
2
+ // Managed AI engine registry. Definitions contain adapter/provider metadata but
3
+ // never secret values. Commands are direct argv; no shell or chat-protocol
4
+ // compatibility fallback is allowed.
5
5
  const fs = require('node:fs');
6
6
  const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { execFile } = require('node:child_process');
7
9
 
8
10
  const OLLAMA_CLOUD_MODELS = Object.freeze([
9
11
  'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
10
12
  'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
11
13
  ]);
12
14
  const OLLAMA_CONTEXT = Object.freeze({
13
- 'glm-5.2': 1000000,
14
- 'kimi-k2.7-code': 262144,
15
- 'deepseek-v4-pro': 524288,
16
- 'minimax-m3': 524288,
17
- 'qwen3.5:397b': 262144,
18
- 'deepseek-v4-flash': 1048576,
19
- 'mistral-large-3:675b': 262144,
20
- 'gemma4:31b': 262144,
15
+ 'glm-5.2': 1000000, 'kimi-k2.7-code': 262144, 'deepseek-v4-pro': 524288,
16
+ 'minimax-m3': 524288, 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
17
+ 'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
21
18
  });
22
19
 
20
+ const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
21
+ const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
22
+ const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
23
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
24
+ const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
25
+
26
+ function validBaseUrl(value) {
27
+ if (typeof value !== 'string' || value.length > 512 || /\s|[\x00-\x1f\x7f]/.test(value)) return false;
28
+ try {
29
+ const parsed = new URL(value);
30
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
31
+ && !parsed.username && !parsed.password && !parsed.hash;
32
+ } catch (_) { return false; }
33
+ }
34
+
23
35
  const CATALOG = Object.freeze([
24
- { id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Native', auth: 'login', endpoint: 'Anthropic account', rc: true, default: true },
25
- { id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · Native', auth: 'login', endpoint: 'OpenAI account', rc: false, default: true },
26
- { id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
27
- { id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud Direct', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, rc: false, default: false },
28
- { id: 'claude.zai-a', client: 'claude', provider: 'zai-a', label: 'Claude · Z.AI A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
29
- { id: 'claude.zai-p', client: 'claude', provider: 'zai-p', label: 'Claude · Z.AI P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], rc: false, default: false },
36
+ // Claude Code
37
+ { id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Anthropic', auth: 'login', endpoint: 'Anthropic account', protocol: 'anthropic_messages', rc: true, default: true },
38
+ { id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
39
+ { id: 'claude.ollama', client: 'claude', provider: 'ollama', label: 'Claude · Ollama local', auth: 'none', endpoint: 'http://127.0.0.1:11434', protocol: 'anthropic_messages' },
40
+ { id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Claude · Z.AI · profile A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a' },
41
+ { id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Claude · Z.AI · profile P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p' },
42
+ { id: 'claude.custom', client: 'claude', provider: 'custom', label: 'Claude · Custom', auth: 'dynamic', protocol: 'anthropic_messages', protocols: ['anthropic_messages'], custom: true },
43
+
44
+ // Codex family. OpenAI Responses is the only remote custom wire API.
45
+ { id: 'codex.native', client: 'codex', provider: 'native', label: 'Codex · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
46
+ { id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
47
+ { id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Codex · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
48
+ { id: 'codex-vl.ollama', client: 'codex-vl', provider: 'ollama', label: 'Codex-VL · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
49
+ { id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'Codex · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
50
+ { id: 'codex-vl.lmstudio', client: 'codex-vl', provider: 'lmstudio', label: 'Codex-VL · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
51
+ { id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Codex · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
52
+ { id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
53
+ { id: 'codex.custom', client: 'codex', provider: 'custom', label: 'Codex · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
54
+ { id: 'codex-vl.custom', client: 'codex-vl', provider: 'custom', label: 'Codex-VL · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
55
+
56
+ // Pi uses its real provider IDs directly. OAuth providers do not need env keys.
57
+ { id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Pi · Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic' },
58
+ { id: 'pi.openai', client: 'pi', provider: 'openai', label: 'Pi · OpenAI', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai' },
59
+ { id: 'pi.google', client: 'pi', provider: 'google', label: 'Pi · Google Gemini', auth: 'GEMINI_API_KEY', protocol: 'pi_native', piProvider: 'google' },
60
+ { id: 'pi.openrouter', client: 'pi', provider: 'openrouter', label: 'Pi · OpenRouter', auth: 'OPENROUTER_API_KEY', protocol: 'pi_native', piProvider: 'openrouter' },
61
+ { id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'Pi · GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot' },
62
+ { id: 'pi.fireworks', client: 'pi', provider: 'fireworks', label: 'Pi · Fireworks AI', auth: 'FIREWORKS_API_KEY', protocol: 'pi_native', piProvider: 'fireworks' },
63
+ { id: 'pi.huggingface', client: 'pi', provider: 'huggingface', label: 'Pi · Hugging Face', auth: 'HF_TOKEN', protocol: 'pi_native', piProvider: 'huggingface' },
64
+ { id: 'pi.minimax', client: 'pi', provider: 'minimax', label: 'Pi · MiniMax', auth: 'MINIMAX_API_KEY', protocol: 'pi_native', piProvider: 'minimax' },
65
+ { id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: 'Pi · DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek' },
66
+ { id: 'pi.kimi-coding', client: 'pi', provider: 'kimi-coding', label: 'Pi · Kimi For Coding', auth: 'KIMI_API_KEY', protocol: 'pi_native', piProvider: 'kimi-coding' },
67
+ { id: 'pi.mistral', client: 'pi', provider: 'mistral', label: 'Pi · Mistral', auth: 'MISTRAL_API_KEY', protocol: 'pi_native', piProvider: 'mistral' },
68
+ { id: 'pi.together', client: 'pi', provider: 'together', label: 'Pi · Together AI', auth: 'TOGETHER_API_KEY', protocol: 'pi_native', piProvider: 'together' },
69
+ { id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Pi · Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
70
+ { id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Pi · Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai' },
71
+ { id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Pi · Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true },
30
72
  ]);
31
73
 
32
- const byPair = (client, provider) => CATALOG.find((p) => p.client === client && p.provider === provider) || null;
74
+ function profileFor(client, provider, credentialProfile) {
75
+ return CATALOG.find((p) => p.client === client && p.provider === provider
76
+ && (p.credentialProfile || '') === (credentialProfile || '')) || null;
77
+ }
33
78
 
34
79
  function normalizeManagedSpec(value) {
35
80
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
36
- const keys = Object.keys(value);
37
- if (keys.some((k) => !['client', 'provider', 'model'].includes(k))) return null;
38
- const profile = byPair(value.client, value.provider);
81
+ if (Object.keys(value).some((k) => !MANAGED_KEYS.has(k))) return null;
82
+ if (!CLIENT_LABELS[value.client]) return null;
83
+ let provider = value.provider;
84
+ let credentialProfile = value.credentialProfile || '';
85
+ // 0.8.0 compatibility: zai-a/zai-p were encoded as providers.
86
+ if (value.client === 'claude' && (provider === 'zai-a' || provider === 'zai-p')) {
87
+ credentialProfile = provider.slice(-1);
88
+ provider = 'zai';
89
+ }
90
+ if (typeof provider !== 'string') return null;
91
+ const profile = profileFor(value.client, provider, credentialProfile);
39
92
  if (!profile) return null;
40
93
  const model = value.model === undefined ? (profile.model || '') : value.model;
41
- if (typeof model !== 'string' || model.length > 128) return null;
42
- return { client: profile.client, provider: profile.provider, model };
94
+ if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
95
+ if (profile.requiresModel && !model) return null;
96
+ const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
97
+ if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
98
+ if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
99
+ const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
100
+ if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
101
+ if (profile.custom) {
102
+ const displayName = typeof value.displayName === 'string' ? value.displayName.trim() : '';
103
+ const baseUrl = typeof value.baseUrl === 'string' ? value.baseUrl.trim() : '';
104
+ const envKey = typeof value.envKey === 'string' ? value.envKey.trim() : '';
105
+ const protocol = value.protocol || profile.protocol;
106
+ const providerId = typeof value.providerId === 'string' && value.providerId ? value.providerId : 'nexuscrew-custom';
107
+ if (!displayName || displayName.length > 64 || /[\x00-\x1f\x7f]/.test(displayName)) return null;
108
+ if (!validBaseUrl(baseUrl)) return null;
109
+ if (!ENV_KEY_RE.test(envKey) || !PROVIDER_ID_RE.test(providerId)) return null;
110
+ if (!model || !(profile.protocols || [profile.protocol]).includes(protocol)) return null;
111
+ Object.assign(out, { displayName, baseUrl, envKey, protocol, providerId });
112
+ }
113
+ return out;
43
114
  }
44
115
 
45
116
  function defaultDefinitions() {
46
117
  return {
47
118
  schemaVersion: 1,
48
119
  engines: CATALOG.filter((p) => p.default).map((p) => ({
49
- id: p.id, label: p.label, rc: p.rc,
50
- managed: { client: p.client, provider: p.provider, model: p.model || '' },
120
+ id: p.id, label: p.label, rc: !!p.rc,
121
+ managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: p.client === 'claude' ? 'unsafe' : 'standard' },
51
122
  })),
52
123
  cells: [],
53
124
  };
@@ -64,24 +135,27 @@ function parseEnvFile(file) {
64
135
  for (const line of raw.split(/\r?\n/)) {
65
136
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
66
137
  if (!m) continue;
67
- let value = m[2];
68
- if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) value = value.slice(1, -1);
69
- out[m[1]] = value;
138
+ let v = m[2];
139
+ if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
140
+ out[m[1]] = v;
70
141
  }
71
142
  return out;
72
143
  }
73
144
 
74
145
  function binaryCandidates(client, home) {
75
146
  const prefix = process.env.PREFIX || '';
76
- if (client === 'claude') return [path.join(home, '.local', 'bin', 'claude'), '/usr/local/bin/claude', '/opt/homebrew/bin/claude', prefix && path.join(prefix, 'bin', 'claude')].filter(Boolean);
77
- return [path.join(home, '.local', 'bin', 'codex-vl'), '/usr/local/bin/codex-vl', '/opt/homebrew/bin/codex-vl', prefix && path.join(prefix, 'bin', 'codex-vl')].filter(Boolean);
147
+ const bin = client;
148
+ return [...new Set([
149
+ path.join(home, '.local', 'bin', bin), path.join(path.dirname(process.execPath), bin),
150
+ `/usr/local/bin/${bin}`, `/opt/homebrew/bin/${bin}`,
151
+ prefix && path.join(prefix, 'bin', bin),
152
+ ].filter(Boolean))];
78
153
  }
79
154
 
80
155
  function findBinary(client, home) {
81
156
  for (const candidate of binaryCandidates(client, home)) {
82
157
  try {
83
- const real = fs.realpathSync(candidate);
84
- const st = fs.lstatSync(real);
158
+ const real = fs.realpathSync(candidate); const st = fs.lstatSync(real);
85
159
  if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
86
160
  if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
87
161
  return real;
@@ -91,121 +165,218 @@ function findBinary(client, home) {
91
165
  }
92
166
 
93
167
  function secretsPath(cfg, home) {
94
- return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS
95
- || path.join(home, '.nexuscrew', 'providers.env');
168
+ return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
169
+ }
170
+
171
+ function credential(profile, spec, cfg, home) {
172
+ if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
173
+ const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
174
+ const runtimeEnv = cfg.env || process.env;
175
+ let value = runtimeEnv[envKey] || '';
176
+ // Preserve 0.8.0 Z.AI/Ollama Cloud behavior, but new Custom never reads disk.
177
+ if (!value && profile.legacySecrets) value = parseEnvFile(secretsPath(cfg, home))[envKey] || '';
178
+ return { envKey, value };
96
179
  }
97
180
 
98
181
  let ollamaCache = { at: 0, models: [] };
99
182
  async function discoverOllamaModels(opts = {}) {
100
- const now = Date.now();
101
- const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
183
+ const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
102
184
  if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
103
185
  const fetchImpl = opts.fetchImpl || globalThis.fetch;
104
186
  if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
105
187
  try {
106
188
  const home = opts.home || require('node:os').homedir();
107
- const apiKey = opts.apiKey || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
108
- if (!apiKey) throw new Error('OLLAMA_API_KEY mancante');
109
- const response = await fetchImpl('https://ollama.com/api/tags', {
110
- headers: { authorization: `Bearer ${apiKey}` },
111
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined,
112
- });
189
+ const apiKey = opts.apiKey || (opts.env || process.env).OLLAMA_API_KEY || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
190
+ if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
191
+ const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
113
192
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
114
- const body = await response.json();
115
- const available = new Set();
193
+ const body = await response.json(); const available = new Set();
116
194
  for (const item of Array.isArray(body.models) ? body.models : []) {
117
195
  const name = typeof item?.name === 'string' ? item.name : '';
118
- if (!/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) || name.length > 128) continue;
119
- available.add(name);
196
+ if (/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) && name.length <= 128) available.add(name);
120
197
  }
121
- // La API diretta espone molti modelli, inclusi modelli in deprecazione. La UI
122
- // mantiene la shortlist TOP curata, ma solo se ancora realmente disponibile.
123
198
  const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
124
- if (!models.length) throw new Error('nessun modello cloud');
125
- ollamaCache = { at: now, models };
126
- return [...models];
199
+ if (!models.length) throw new Error('no cloud models');
200
+ ollamaCache = { at: now, models }; return [...models];
127
201
  } catch (_) {
128
- ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] };
129
- return [...OLLAMA_CLOUD_MODELS];
202
+ ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] }; return [...OLLAMA_CLOUD_MODELS];
203
+ }
204
+ }
205
+
206
+ let piCache = { at: 0, providers: {} };
207
+ let piInFlight = null;
208
+ async function discoverPiModels(opts = {}) {
209
+ const now = Date.now(); const ttl = opts.ttlMs === undefined ? 300000 : opts.ttlMs;
210
+ if (!opts.noCache && Object.keys(piCache.providers).length && now - piCache.at < ttl) {
211
+ return Object.fromEntries(Object.entries(piCache.providers).map(([k, v]) => [k, [...v]]));
212
+ }
213
+ const home = opts.home || require('node:os').homedir();
214
+ const binary = opts.binary || findBinary('pi', home);
215
+ if (!binary) return {};
216
+ if (!opts.noCache && piInFlight) return piInFlight;
217
+ const execFileImpl = opts.execFileImpl || execFile;
218
+ const load = async () => {
219
+ const stdout = await new Promise((resolve, reject) => {
220
+ execFileImpl(binary, ['--list-models'], { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 }, (err, out) => {
221
+ if (err) reject(err); else resolve(String(out || ''));
222
+ });
223
+ });
224
+ const providers = {};
225
+ for (const line of stdout.split(/\r?\n/).slice(1)) {
226
+ const [provider, model] = line.trim().split(/\s+/);
227
+ if (!PROVIDER_ID_RE.test(provider || '') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model || '')) continue;
228
+ (providers[provider] ||= []).push(model);
229
+ }
230
+ for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
231
+ piCache = { at: now, providers };
232
+ return Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, [...v]]));
233
+ };
234
+ if (opts.noCache) {
235
+ try { return await load(); } catch (_) { return {}; }
130
236
  }
237
+ piInFlight = load().catch(() => ({})).finally(() => { piInFlight = null; });
238
+ return piInFlight;
131
239
  }
132
240
 
133
241
  function describeManaged(spec, cfg = {}) {
134
242
  const normalized = normalizeManagedSpec(spec);
135
- if (!normalized) return { configured: false, reason: 'profilo managed non valido' };
243
+ if (!normalized) return { configured: false, reason: 'invalid managed profile' };
136
244
  const home = cfg.home || require('node:os').homedir();
137
- const profile = byPair(normalized.client, normalized.provider);
245
+ const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
138
246
  const binary = findBinary(normalized.client, home);
139
- const secretFile = secretsPath(cfg, home);
140
- const secrets = profile.auth === 'login' || profile.auth === 'none' ? {} : parseEnvFile(secretFile);
141
- const authConfigured = profile.auth === 'login' || profile.auth === 'none' || !!secrets[profile.auth];
142
- const configured = !!binary && authConfigured;
247
+ const cred = credential(profile, normalized, cfg, home);
248
+ // Pi can resolve credentials from its own documented /login auth store. Do
249
+ // not inspect or copy that store; delegate native-provider auth to Pi.
250
+ const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom';
251
+ const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
143
252
  return {
144
- client: profile.client, provider: profile.provider, model: normalized.model,
145
- endpoint: profile.endpoint, auth: profile.auth, authConfigured, configured,
146
- models: [...(profile.models || [])], defaultModel: profile.model || '',
147
- binary: binary || '',
148
- reason: !binary ? `client ${profile.client} non trovato` : (!authConfigured ? `credenziale ${profile.auth} mancante` : 'ready'),
253
+ client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
254
+ credentialProfile: normalized.credentialProfile || '', model: normalized.model,
255
+ permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
256
+ endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
257
+ configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
258
+ binary: binary || '', displayName: normalized.displayName || profile.label,
259
+ reason: !binary ? `client ${profile.client} not found` : (!authConfigured ? `environment variable ${cred.envKey} missing` : 'ready'),
149
260
  };
150
261
  }
151
262
 
263
+ function codexProviderArgs(id, name, baseUrl, envKey) {
264
+ return [
265
+ '-c', `model_provider=${JSON.stringify(id)}`, '-c', `model_providers.${id}.name=${JSON.stringify(name)}`,
266
+ '-c', `model_providers.${id}.base_url=${JSON.stringify(baseUrl)}`, '-c', `model_providers.${id}.env_key=${JSON.stringify(envKey)}`,
267
+ '-c', `model_providers.${id}.wire_api="responses"`,
268
+ ];
269
+ }
270
+
271
+ function writePiProviderExtension(spec, home) {
272
+ const dir = path.join(home, '.nexuscrew', 'pi-providers');
273
+ try {
274
+ const st = fs.lstatSync(dir);
275
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe Pi provider extension directory');
276
+ } catch (e) {
277
+ if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
278
+ else throw e;
279
+ }
280
+ fs.chmodSync(dir, 0o700);
281
+ const target = path.join(dir, `${spec.providerId}.ts`);
282
+ try {
283
+ if (fs.lstatSync(target).isSymbolicLink()) throw new Error('refusing symlink Pi provider extension');
284
+ } catch (e) {
285
+ if (e.code !== 'ENOENT') throw e;
286
+ }
287
+ const definition = {
288
+ name: spec.displayName,
289
+ baseUrl: spec.baseUrl,
290
+ apiKey: spec.apiKey || `$${spec.envKey}`,
291
+ authHeader: true,
292
+ api: spec.protocol,
293
+ models: [{
294
+ id: spec.model, name: spec.model, reasoning: false, input: ['text'],
295
+ contextWindow: 128000, maxTokens: 16384,
296
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
297
+ }],
298
+ };
299
+ const source = `// Generated by NexusCrew. Contains environment references only, never secret values.\nexport default function (pi) {\n pi.registerProvider(${JSON.stringify(spec.providerId)}, ${JSON.stringify(definition, null, 2)});\n}\n`;
300
+ const tmp = path.join(dir, `.${spec.providerId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
301
+ try {
302
+ fs.writeFileSync(tmp, source, { mode: 0o600 });
303
+ fs.chmodSync(tmp, 0o600);
304
+ fs.renameSync(tmp, target);
305
+ } catch (e) {
306
+ try { fs.unlinkSync(tmp); } catch (_) {}
307
+ throw e;
308
+ }
309
+ return target;
310
+ }
311
+
152
312
  function resolveManagedEngine(engine, cell, cfg = {}) {
153
- const info = describeManaged(engine.managed, cfg);
154
- if (!info.configured) return { ok: false, reason: info.reason, info };
313
+ const spec = normalizeManagedSpec(engine.managed);
314
+ const info = describeManaged(spec, cfg);
315
+ if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
155
316
  const home = cfg.home || require('node:os').homedir();
156
- const env = {};
157
- const args = [];
158
- const model = (cell && cell.model) || info.model;
159
- if (info.client === 'claude') {
160
- args.push('--dangerously-skip-permissions');
161
- if (info.provider === 'native') {
317
+ const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
318
+ const cred = credential(profile, spec, cfg, home);
319
+ const env = {}; const args = []; const model = cell?.model || spec.model;
320
+ if (spec.permissionPolicy === 'unsafe') {
321
+ if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
322
+ if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
323
+ }
324
+ if (spec.client === 'claude') {
325
+ if (spec.provider === 'native') {
162
326
  if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
163
- } else if (info.provider === 'ollama-cloud') {
164
- const secrets = parseEnvFile(secretsPath(cfg, home));
165
- Object.assign(env, {
166
- ANTHROPIC_BASE_URL: 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: secrets.OLLAMA_API_KEY,
167
- ANTHROPIC_API_KEY: '',
168
- ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model, API_TIMEOUT_MS: '3000000',
169
- CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || 200000),
170
- });
171
327
  } else {
172
- const profile = byPair(info.client, info.provider);
173
- const secrets = parseEnvFile(secretsPath(cfg, home));
328
+ const endpoint = spec.baseUrl || profile.endpoint;
329
+ const token = profile.auth === 'none' ? 'ollama' : cred.value;
174
330
  Object.assign(env, {
175
- ANTHROPIC_BASE_URL: profile.endpoint, ANTHROPIC_AUTH_TOKEN: secrets[profile.auth],
176
- ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model,
177
- API_TIMEOUT_MS: '3000000', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '1000000',
331
+ ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
332
+ ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
333
+ ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
334
+ CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
335
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000)),
178
336
  });
179
337
  }
180
338
  if (model) args.push('--model', model);
181
- } else {
182
- args.push('--dangerously-bypass-approvals-and-sandbox');
183
- if (info.provider === 'ollama-cloud') {
184
- const secrets = parseEnvFile(secretsPath(cfg, home));
185
- env.OPENAI_API_KEY = secrets.OLLAMA_API_KEY;
186
- if (model) args.push('-m', model);
339
+ } else if (spec.client === 'codex' || spec.client === 'codex-vl') {
340
+ if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
341
+ else if (spec.provider === 'ollama-cloud') {
342
+ env.OPENAI_API_KEY = cred.value;
343
+ args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
344
+ args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`);
187
345
  const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
188
- args.push(
189
- '-c', 'model_provider="ollama_cloud"',
190
- '-c', 'model_providers.ollama_cloud.name="Ollama Cloud"',
191
- '-c', 'model_providers.ollama_cloud.base_url="https://ollama.com/v1"',
192
- '-c', 'model_providers.ollama_cloud.wire_api="responses"',
193
- '-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000',
194
- '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`,
195
- );
196
- // Codex carica il catalogo solo all'avvio. Il file e' locale e opzionale:
197
- // sui device che non lo hanno resta valido il fallback context-window sopra.
198
346
  if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
347
+ } else if (spec.provider === 'custom') {
348
+ env[spec.envKey] = cred.value;
349
+ args.push(...codexProviderArgs(spec.providerId, spec.displayName, spec.baseUrl, spec.envKey));
199
350
  }
351
+ if (model) args.push('-m', model);
352
+ } else if (spec.client === 'pi') {
353
+ if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
354
+ if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home));
355
+ else if (profile.piExtension) args.push('--extension', writePiProviderExtension({
356
+ providerId: profile.piProvider, displayName: profile.label.replace(/^Pi · /, ''),
357
+ baseUrl: profile.piExtension.baseUrl, apiKey: profile.piExtension.apiKey,
358
+ protocol: profile.protocol, model,
359
+ }, home));
360
+ args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
361
+ if (model) args.push('--model', model);
200
362
  }
201
- // Entrambi i client accettano il bootstrap come argomento posizionale. Questo
202
- // evita di digitare nella TUI prima che sia pronta; argv e' diretto, mai shell.
203
- if (cell && cell.prompt) args.push(cell.prompt);
363
+ if (cell?.prompt) args.push(cell.prompt);
204
364
  return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
205
365
  }
206
366
 
367
+ function publicCatalog() {
368
+ return CATALOG.map((p) => ({
369
+ id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
370
+ credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
371
+ auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
372
+ protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
373
+ permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
374
+ rc: !!p.rc, custom: !!p.custom, default: !!p.default,
375
+ }));
376
+ }
377
+
207
378
  module.exports = {
208
- CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, normalizeManagedSpec, defaultDefinitions, describeManaged,
209
- discoverOllamaModels,
210
- resolveManagedEngine, parseEnvFile, findBinary,
379
+ CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
380
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
381
+ discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
211
382
  };
package/lib/mcp/server.js CHANGED
@@ -340,10 +340,28 @@ function createMcpServer(opts = {}) {
340
340
  }
341
341
 
342
342
  let rl = null;
343
+ const inFlight = new Set();
344
+ let inputClosed = false;
345
+ let drainResolve;
346
+ const drained = new Promise((resolve) => { drainResolve = resolve; });
347
+ const maybeDrained = () => {
348
+ if (inputClosed && inFlight.size === 0) drainResolve();
349
+ };
343
350
  function start() {
344
351
  rl = readline.createInterface({ input, crlfDelay: Infinity });
345
- rl.on('line', (line) => { handleLine(line); });
346
- return { close: () => { try { rl.close(); } catch (_) {} } };
352
+ rl.on('line', (line) => {
353
+ const pending = handleLine(line);
354
+ inFlight.add(pending);
355
+ pending.finally(() => {
356
+ inFlight.delete(pending);
357
+ maybeDrained();
358
+ });
359
+ });
360
+ rl.on('close', () => {
361
+ inputClosed = true;
362
+ maybeDrained();
363
+ });
364
+ return { close: () => { try { rl.close(); } catch (_) {} }, drained };
347
365
  }
348
366
 
349
367
  return { start, handleLine, toolsList, ctx, cfg: { port: cfg.port, tmuxBin: cfg.tmuxBin } };
@@ -353,9 +371,10 @@ function createMcpServer(opts = {}) {
353
371
  // e' aperto (il client MCP chiude la pipe per terminare il server).
354
372
  function startMcp(opts = {}) {
355
373
  const srv = createMcpServer(opts);
356
- srv.start();
357
- // Quando il client chiude stdin il processo esce pulito.
358
- (opts.input || process.stdin).on('end', () => process.exit(0));
374
+ const lifecycle = srv.start();
375
+ // Non forzare process.exit su EOF: una tools/call asincrona puo' essere
376
+ // ancora in volo. Una volta drenate le richieste, Node termina naturalmente.
377
+ srv.drained = lifecycle.drained;
359
378
  return srv;
360
379
  }
361
380
 
@@ -93,19 +93,31 @@ function nodesAdd(opts) {
93
93
  const { home, nodesPath } = resolveNodePaths(opts);
94
94
  const name = opts.name;
95
95
  const ssh = opts.ssh;
96
- if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
96
+ if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--ssh-port N] [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
97
97
  if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
98
98
 
99
99
  const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
100
+ const sshPort = opts.sshPort === undefined ? undefined : Number(opts.sshPort);
100
101
 
101
102
  let st;
102
103
  try { st = store.loadOrInitStore(nodesPath); }
103
104
  catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'store invalido' }; }
104
105
 
105
106
  const localPort = opts.localPort ? Number(opts.localPort) : assignLocalPort(st);
106
- const keyPath = opts.key || defaultKeyPath(home, name);
107
-
108
- const entry = { name, ssh, remotePort, localPort, keyPath, roles: { client: true, node: false } };
107
+ const keyPath = opts.identityFile || opts.key || (typeof opts.keygen === 'function' ? defaultKeyPath(home, name) : undefined);
108
+
109
+ const entry = {
110
+ name, ssh, remotePort, localPort,
111
+ direction: opts.direction || 'outbound',
112
+ transport: opts.transport || 'auto',
113
+ autostart: opts.autostart !== false,
114
+ visibility: opts.visibility || 'network',
115
+ roles: { client: true, node: false },
116
+ };
117
+ if (keyPath) entry.identityFile = keyPath;
118
+ if (opts.token) entry.token = opts.token;
119
+ if (opts.reversePort) entry.reversePort = Number(opts.reversePort);
120
+ if (sshPort !== undefined) entry.sshPort = sshPort;
109
121
  if (opts.nodeId) entry.nodeId = opts.nodeId;
110
122
 
111
123
  // Valida + inserisci in memoria (dup name/id, self-reference, schema) PRIMA di
@@ -115,18 +127,20 @@ function nodesAdd(opts) {
115
127
  catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'add rifiutato' }; }
116
128
 
117
129
  // chiave dedicata per-tunnel (genera o riusa)
118
- let pub;
119
- try { pub = ensureKey(keyPath, name, opts); }
120
- catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
130
+ let pub = null;
131
+ if (keyPath && typeof opts.keygen === 'function') {
132
+ try { pub = ensureKey(keyPath, name, opts); }
133
+ catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) — nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
134
+ }
121
135
 
122
136
  try { store.atomicWriteStore(nodesPath, next); }
123
137
  catch (e) { log(`nodes add: scrittura nodes.json fallita: ${e.message}`); return { code: 1, reason: 'write fallita' }; }
124
138
 
125
- log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}, remote ${remotePort} -> local ${localPort})`);
139
+ log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
126
140
  log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
127
141
  // permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
128
- log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
129
- return { code: 0, name, localPort, remotePort };
142
+ if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
143
+ return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
130
144
  }
131
145
 
132
146
  // --- nodes list ------------------------------------------------------------
@@ -150,7 +164,7 @@ function nodesList(opts) {
150
164
  if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
151
165
  if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
152
166
  for (const n of nodes) {
153
- log(`${n.name}\t${n.ssh}\tlocal:${n.localPort} -> remote:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
167
+ log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
154
168
  }
155
169
  return { code: 0, nodes };
156
170
  }
@@ -253,7 +267,8 @@ function nodesUp(opts) {
253
267
  const log = opts.log || console.log;
254
268
  const ctx = loadNodeOrFail(opts, log);
255
269
  if (!ctx) return { code: 1 };
256
- const r = tunnel.startForward({ home: ctx.home, node: ctx.node, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
270
+ if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
271
+ const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
257
272
  if (r.started) {
258
273
  log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
259
274
  return { code: 0, started: true, pid: r.pid };