@mmmbuto/nexuscrew 0.7.7 → 0.8.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 (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,381 @@
1
+ 'use strict';
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
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const { execFile } = require('node:child_process');
9
+
10
+ const OLLAMA_CLOUD_MODELS = Object.freeze([
11
+ 'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
12
+ 'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
13
+ ]);
14
+ const OLLAMA_CONTEXT = Object.freeze({
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,
18
+ });
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
+
35
+ const CATALOG = Object.freeze([
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 },
72
+ ]);
73
+
74
+ function profileFor(client, provider, credentialProfile) {
75
+ return CATALOG.find((p) => p.client === client && p.provider === provider
76
+ && (p.credentialProfile || '') === (credentialProfile || '')) || null;
77
+ }
78
+
79
+ function normalizeManagedSpec(value) {
80
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
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);
92
+ if (!profile) return null;
93
+ const model = value.model === undefined ? (profile.model || '') : value.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 ? '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;
114
+ }
115
+
116
+ function defaultDefinitions() {
117
+ return {
118
+ schemaVersion: 1,
119
+ engines: CATALOG.filter((p) => p.default).map((p) => ({
120
+ id: p.id, label: p.label, rc: !!p.rc,
121
+ managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: 'standard' },
122
+ })),
123
+ cells: [],
124
+ };
125
+ }
126
+
127
+ function parseEnvFile(file) {
128
+ const out = {};
129
+ let raw;
130
+ try {
131
+ const st = fs.lstatSync(file);
132
+ if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o077)) return out;
133
+ raw = fs.readFileSync(file, 'utf8');
134
+ } catch (_) { return out; }
135
+ for (const line of raw.split(/\r?\n/)) {
136
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
137
+ if (!m) continue;
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;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function binaryCandidates(client, home) {
146
+ const prefix = process.env.PREFIX || '';
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))];
153
+ }
154
+
155
+ function findBinary(client, home) {
156
+ for (const candidate of binaryCandidates(client, home)) {
157
+ try {
158
+ const real = fs.realpathSync(candidate); const st = fs.lstatSync(real);
159
+ if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
160
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
161
+ return real;
162
+ } catch (_) { /* next */ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function secretsPath(cfg, home) {
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 };
179
+ }
180
+
181
+ let ollamaCache = { at: 0, models: [] };
182
+ async function discoverOllamaModels(opts = {}) {
183
+ const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
184
+ if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
185
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
186
+ if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
187
+ try {
188
+ const home = opts.home || require('node:os').homedir();
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) });
192
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
193
+ const body = await response.json(); const available = new Set();
194
+ for (const item of Array.isArray(body.models) ? body.models : []) {
195
+ const name = typeof item?.name === 'string' ? item.name : '';
196
+ if (/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) && name.length <= 128) available.add(name);
197
+ }
198
+ const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
199
+ if (!models.length) throw new Error('no cloud models');
200
+ ollamaCache = { at: now, models }; return [...models];
201
+ } catch (_) {
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 {}; }
236
+ }
237
+ piInFlight = load().catch(() => ({})).finally(() => { piInFlight = null; });
238
+ return piInFlight;
239
+ }
240
+
241
+ function describeManaged(spec, cfg = {}) {
242
+ const normalized = normalizeManagedSpec(spec);
243
+ if (!normalized) return { configured: false, reason: 'invalid managed profile' };
244
+ const home = cfg.home || require('node:os').homedir();
245
+ const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
246
+ const binary = findBinary(normalized.client, home);
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;
252
+ return {
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'),
260
+ };
261
+ }
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
+
312
+ function resolveManagedEngine(engine, cell, cfg = {}) {
313
+ const spec = normalizeManagedSpec(engine.managed);
314
+ const info = describeManaged(spec, cfg);
315
+ if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
316
+ const home = cfg.home || require('node:os').homedir();
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') {
326
+ if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
327
+ } else {
328
+ const endpoint = spec.baseUrl || profile.endpoint;
329
+ const token = profile.auth === 'none' ? 'ollama' : cred.value;
330
+ Object.assign(env, {
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)),
336
+ });
337
+ }
338
+ if (model) args.push('--model', 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}`);
345
+ const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
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));
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);
362
+ }
363
+ if (cell?.prompt) args.push(cell.prompt);
364
+ return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
365
+ }
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
+ rc: !!p.rc, custom: !!p.custom, default: !!p.default,
374
+ }));
375
+ }
376
+
377
+ module.exports = {
378
+ CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
379
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
380
+ discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
381
+ };
@@ -78,24 +78,25 @@ function fleetRoutes(fleetP, cfg = {}) {
78
78
  r.post('/up', guard((f, b) => {
79
79
  const cell = String(b.cell || '');
80
80
  if (b.engine && capList(f).includes('edit')) {
81
- return f.engine(cell, String(b.engine)).then(() => f.up(cell));
81
+ return f.engine(cell, String(b.engine), { model: typeof b.model === 'string' ? b.model : '' }).then(() => f.up(cell));
82
82
  }
83
83
  return f.up(cell, { engine: b.engine, boot: !!b.boot });
84
84
  }, { mutate: true }));
85
85
  r.post('/down', guard((f, b) => f.down(String(b.cell || ''), { boot: !!b.boot }), { mutate: true }));
86
86
  r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
87
- r.post('/engine', guard((f, b) => f.engine(String(b.cell || ''), String(b.engine || '')), { mutate: true }));
87
+ r.post('/engine', guard((f, b) => f.engine(String(b.cell || ''), String(b.engine || ''), { model: typeof b.model === 'string' ? b.model : '' }), { mutate: true }));
88
88
  r.post('/boot', guard((f, b) => f.boot(String(b.cell || ''), b.enabled === true), { mutate: true }));
89
89
 
90
90
  // --- Estensione B4.2: schema + define/edit/remove (engine e cell) ---
91
91
  // Ogni route negozia la capability del provider: mancante → 501 (§9c).
92
92
  r.get('/schema', guard((f) => { requireCap(f, 'schema'); return f.schema(); }));
93
+ r.get('/definitions', guard((f) => { requireCap(f, 'definitions'); return f.definitions(); }));
93
94
  r.post('/define-engine', guard((f, b) => { requireCap(f, 'define'); return f.defineEngine(b.def); }, { mutate: true }));
94
- r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch); }, { mutate: true }));
95
+ r.post('/edit-engine', guard((f, b) => { requireCap(f, 'edit'); return f.editEngine(b.id, b.patch, b.envChanges); }, { mutate: true }));
95
96
  r.post('/remove-engine', guard((f, b) => { requireCap(f, 'remove'); return f.removeEngine(b.id); }, { mutate: true }));
96
97
  r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
97
98
  r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
98
- r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id); }, { mutate: true }));
99
+ r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
99
100
 
100
101
  return r;
101
102
  }