@mmmbuto/nexuscrew 0.8.0 → 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.
@@ -70,7 +70,6 @@ After=network-online.target
70
70
  [Service]
71
71
  Type=simple
72
72
  WorkingDirectory=${repo}
73
- Environment=NEXUSCREW_PORT=${ctx.port}
74
73
  Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
75
74
  ExecStart=${node} ${repoBin} serve
76
75
  Restart=on-failure
@@ -86,7 +85,6 @@ function generateMac(ctx) {
86
85
  const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
87
86
  const repoXml = escapeXml(ctx.repoRoot);
88
87
  const homeXml = escapeXml(ctx.home);
89
- const port = ctx.port;
90
88
  const launchPath = [...new Set([
91
89
  path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
92
90
  ])].join(':');
@@ -107,8 +105,6 @@ function generateMac(ctx) {
107
105
  <string>${repoXml}</string>
108
106
  <key>EnvironmentVariables</key>
109
107
  <dict>
110
- <key>NEXUSCREW_PORT</key>
111
- <string>${port}</string>
112
108
  <key>PATH</key>
113
109
  <string>${launchPathXml}</string>
114
110
  </dict>
@@ -136,7 +132,6 @@ function generateTermux(ctx) {
136
132
  # NexusCrew boot (Termux) - loopback, localhost del telefono
137
133
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
138
134
  export HOME=/data/data/com.termux/files/home
139
- export NEXUSCREW_PORT=${ctx.port}
140
135
  cd -- ${repoQ}
141
136
  termux-wake-lock 2>/dev/null || true
142
137
  mkdir -p "$HOME/.nexuscrew"
@@ -8,7 +8,13 @@ const SCHEMA_VERSION = 1;
8
8
  const MAX_DECKS = 24;
9
9
  const MAX_TILES = 9;
10
10
  const NAME_RE = /^[a-z0-9-]{1,32}$/;
11
- const NODE_RE = /^[a-z0-9-]{1,32}$/;
11
+ const NODE_RE = /^[a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32}){0,3}$/;
12
+
13
+ function validNodeRoute(node) {
14
+ if (!NODE_RE.test(node)) return false;
15
+ const parts = node.split('/');
16
+ return new Set(parts).size === parts.length;
17
+ }
12
18
 
13
19
  function defaultDecksPath(home) {
14
20
  return path.join(home || os.homedir(), '.nexuscrew', 'decks.json');
@@ -41,7 +47,7 @@ function parseLayout(raw) {
41
47
  const tiles = [];
42
48
  for (const t of c.tiles) {
43
49
  if (!t || typeof t !== 'object' || Array.isArray(t) || !validText(t.session, 128)) return null;
44
- if (t.node !== undefined && (typeof t.node !== 'string' || !NODE_RE.test(t.node))) return null;
50
+ if (t.node !== undefined && (typeof t.node !== 'string' || !validNodeRoute(t.node))) return null;
45
51
  const height = Number(t.height);
46
52
  const fontSize = Number(t.fontSize);
47
53
  if (!Number.isFinite(height) || height < 0.2 || height > 100) return null;
@@ -9,7 +9,7 @@ const store = require('./store.js');
9
9
 
10
10
  // Router file-exchange. Nessuno stato: tutto deriva da cfg + filesystem.
11
11
  // notifier (opzionale, MCP bridge): emette la notify di consegna file outbox.
12
- function filesRoutes({ cfg, sessionExists, paste, notifier }) {
12
+ function filesRoutes({ cfg, sessionExists, paste, notifier, readonly = () => false }) {
13
13
  const router = Router();
14
14
  const upload = multer({
15
15
  storage: multer.memoryStorage(),
@@ -17,6 +17,7 @@ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
17
17
  });
18
18
 
19
19
  router.post('/upload', (req, res) => {
20
+ if (readonly()) return res.status(403).json({ error: 'READONLY: upload bloccato' });
20
21
  upload.single('file')(req, res, async (err) => {
21
22
  if (err) {
22
23
  const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
@@ -104,6 +105,7 @@ function filesRoutes({ cfg, sessionExists, paste, notifier }) {
104
105
  });
105
106
 
106
107
  router.delete('/', (req, res) => {
108
+ if (readonly()) return res.status(403).json({ error: 'READONLY: eliminazione file bloccata' });
107
109
  const ok = store.removeFile(
108
110
  cfg.filesRoot, String(req.query.session || ''), String(req.query.box || ''), String(req.query.name || ''),
109
111
  );
@@ -29,7 +29,7 @@ const {
29
29
  loadDefinitions, atomicWrite, CAPS,
30
30
  } = require('./definitions.js');
31
31
  const {
32
- CATALOG: MANAGED_CATALOG, describeManaged, resolveManagedEngine, discoverOllamaModels,
32
+ publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
33
33
  } = require('./managed.js');
34
34
 
35
35
  const STATUS_TTL_MS = 2000;
@@ -273,6 +273,8 @@ async function createBuiltinFleet(cfg = {}) {
273
273
  });
274
274
  const needsOllama = cache.defs.engines.some((e) => e.managed?.provider === 'ollama-cloud');
275
275
  const ollamaModels = needsOllama ? await discoverOllamaModels({ ...cfg, home }) : [];
276
+ const needsPi = cache.defs.engines.some((e) => e.managed?.client === 'pi');
277
+ const piModels = needsPi ? await discoverPiModels({ ...cfg, home }) : {};
276
278
  const engines = cache.defs.engines.map((e) => {
277
279
  const managed = e.managed ? describeManaged(e.managed, { ...cfg, home }) : null;
278
280
  return {
@@ -280,7 +282,8 @@ async function createBuiltinFleet(cfg = {}) {
280
282
  ...(managed ? {
281
283
  kind: 'managed', client: managed.client, provider: managed.provider,
282
284
  model: e.managed.model || managed.defaultModel || '',
283
- models: managed.provider === 'ollama-cloud' ? ollamaModels : managed.models,
285
+ models: managed.provider === 'ollama-cloud' ? ollamaModels
286
+ : (managed.client === 'pi' ? (e.managed.provider === 'custom' ? [e.managed.model] : (piModels[e.managed.provider] || [])) : managed.models),
284
287
  configured: managed.configured, reason: managed.reason,
285
288
  } : { kind: 'custom', configured: true, model: e.model?.value || '', models: [] }),
286
289
  };
@@ -504,7 +507,7 @@ async function createBuiltinFleet(cfg = {}) {
504
507
  return out;
505
508
  }),
506
509
  cells: defs.cells.map((c) => ({ ...c, ...(c.models ? { models: { ...c.models } } : {}) })),
507
- managedCatalog: MANAGED_CATALOG.map((p) => ({ ...p })),
510
+ managedCatalog: publicCatalog(),
508
511
  managedConfig: { providerSecretsPath: cfg.providerSecretsPath || path.join(home, '.nexuscrew', 'providers.env') },
509
512
  };
510
513
  }
@@ -536,9 +539,16 @@ async function createBuiltinFleet(cfg = {}) {
536
539
  rc: { type: 'boolean', required: false, default: false },
537
540
  managed: {
538
541
  type: 'object', requiredFor: 'managed',
539
- client: { type: 'enum', values: ['claude', 'codex-vl'] },
540
- provider: { type: 'enum', values: ['native', 'ollama-cloud', 'zai-a', 'zai-p'] },
542
+ client: { type: 'enum', values: ['claude', 'codex', 'codex-vl', 'pi'] },
543
+ provider: { type: 'catalog', source: 'managedCatalog' },
544
+ credentialProfile: { type: 'string', required: false, max: 32 },
541
545
  model: { type: 'string', required: false, max: CAPS.MAX_MODEL_VAL_LEN },
546
+ permissionPolicy: { type: 'enum', values: ['standard', 'unsafe'], default: 'standard' },
547
+ displayName: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_LABEL_LEN },
548
+ protocol: { type: 'catalog', source: 'managedCatalog.protocol' },
549
+ baseUrl: { type: 'string', requiredFor: 'provider=custom', max: CAPS.MAX_COMMAND_LEN },
550
+ envKey: { type: 'string', requiredFor: 'provider=custom', pattern: '^[A-Za-z_][A-Za-z0-9_]{0,63}$' },
551
+ providerId: { type: 'string', requiredFor: 'provider=custom', pattern: '^[a-z][a-z0-9_-]{0,31}$' },
542
552
  },
543
553
  command: { type: 'string', requiredFor: 'custom', max: CAPS.MAX_COMMAND_LEN, absolute: true },
544
554
  args: { type: 'array', of: 'string', required: false, max: CAPS.MAX_ARGS, itemMax: CAPS.MAX_ARG_LEN },
@@ -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 ? '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: '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,217 @@ 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
+ rc: !!p.rc, custom: !!p.custom, default: !!p.default,
374
+ }));
375
+ }
376
+
207
377
  module.exports = {
208
- CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, normalizeManagedSpec, defaultDefinitions, describeManaged,
209
- discoverOllamaModels,
210
- resolveManagedEngine, parseEnvFile, findBinary,
378
+ CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
379
+ defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
380
+ discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
211
381
  };