natureco-cli 5.7.0 → 5.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/api.js CHANGED
@@ -9,26 +9,17 @@ const { getConfig } = require('./config');
9
9
  const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
10
  const { MCPClient } = require('./mcp-client');
11
11
  const TB = require('./token-budget');
12
+ const { accumulateToolCallDeltas } = require('./streaming-tools');
12
13
 
13
14
  /**
14
15
  * v5.5.0: Provider-specific format detection
15
16
  * Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
17
+ *
18
+ * Canonical implementation lives in src/utils/provider-detect.js;
19
+ * re-exported here so the historical `detectProvider` reference inside
20
+ * api.js continues to work without touching every call site.
16
21
  */
17
- function detectProvider(providerUrl, model) {
18
- const url = (providerUrl || '').toLowerCase();
19
- const m = (model || '').toLowerCase();
20
- if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
21
- if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
22
- if (url.includes('openrouter.ai')) return 'openrouter';
23
- if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
24
- if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
25
- if (url.includes('together.xyz') || m.includes('together')) return 'together';
26
- if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
27
- if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
28
- if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
29
- if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
30
- return 'openai'; // default
31
- }
22
+ const { detectProvider, isMiniMax } = require('./provider-detect');
32
23
 
33
24
  /**
34
25
  * v5.5.0: Tool definitions'ı provider'a göre normalize et
@@ -81,6 +72,29 @@ function parseToolCallsFromResponse(message, provider) {
81
72
  return message.tool_calls || [];
82
73
  }
83
74
 
75
+ /**
76
+ * Anthropic Messages API requires `system` to be either a non-empty string
77
+ * or omitted entirely. Sending `undefined` (which JSON.stringify drops to
78
+ * silent absence) leaves the model unanchored; sending `''` returns 400
79
+ * "system: cannot be empty" on recent API revisions. Always pass a
80
+ * meaningful default when no system message is present.
81
+ */
82
+ const DEFAULT_ANTHROPIC_SYSTEM =
83
+ 'You are a helpful AI assistant running inside the natureco CLI.';
84
+
85
+ function extractSystemForAnthropic(messages) {
86
+ const systemMsg = messages.find(m => m.role === 'system');
87
+ if (!systemMsg) return DEFAULT_ANTHROPIC_SYSTEM;
88
+ // content may be a string or an array of content blocks; both round-trip.
89
+ if (typeof systemMsg.content === 'string') {
90
+ return systemMsg.content.trim() || DEFAULT_ANTHROPIC_SYSTEM;
91
+ }
92
+ if (Array.isArray(systemMsg.content) && systemMsg.content.length > 0) {
93
+ return systemMsg.content;
94
+ }
95
+ return DEFAULT_ANTHROPIC_SYSTEM;
96
+ }
97
+
84
98
  /**
85
99
  * v5.5.0: System mesajı provider'a göre ayarla
86
100
  * - OpenAI: messages[].role=system
@@ -88,8 +102,6 @@ function parseToolCallsFromResponse(message, provider) {
88
102
  */
89
103
  function buildRequestBody(messages, model, options, provider) {
90
104
  if (provider === 'anthropic') {
91
- // System mesajını ayır
92
- const systemMsg = messages.find(m => m.role === 'system');
93
105
  const userMsgs = messages.filter(m => m.role !== 'system');
94
106
  return {
95
107
  model,
@@ -97,7 +109,7 @@ function buildRequestBody(messages, model, options, provider) {
97
109
  role: m.role,
98
110
  content: m.content
99
111
  })),
100
- system: systemMsg ? systemMsg.content : undefined,
112
+ system: extractSystemForAnthropic(messages),
101
113
  max_tokens: options.max_tokens || 4096,
102
114
  temperature: options.temperature || 0.7,
103
115
  ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
@@ -534,9 +546,8 @@ function formatToolsForAnthropic() {
534
546
  */
535
547
  async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
536
548
  const baseUrl = providerConfig.url.replace(/\/+$/, '');
537
- // MiniMax özel endpoint tespiti
538
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
539
- const endpoint = isMiniMax
549
+ // MiniMax özel endpoint tespiti — provider-detect.js'deki kanonik tanım.
550
+ const endpoint = isMiniMax(baseUrl)
540
551
  ? `${baseUrl}/v1/text/chatcompletion_v2`
541
552
  : `${baseUrl}/chat/completions`;
542
553
  const requestBody = {
@@ -587,11 +598,10 @@ async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
587
598
  */
588
599
  async function sendMessageAnthropic(providerConfig, messages, tools) {
589
600
  const endpoint = `${providerConfig.url}/v1/messages`;
590
-
591
- // Anthropic requires system message separate
592
- const systemMessage = messages.find(m => m.role === 'system');
601
+
602
+ // Anthropic requires system message separate; never send empty string.
593
603
  const userMessages = messages.filter(m => m.role !== 'system');
594
-
604
+
595
605
  const response = await fetch(endpoint, {
596
606
  method: 'POST',
597
607
  headers: {
@@ -602,7 +612,7 @@ async function sendMessageAnthropic(providerConfig, messages, tools) {
602
612
  body: JSON.stringify({
603
613
  model: providerConfig.model,
604
614
  max_tokens: 2000,
605
- system: systemMessage?.content || '',
615
+ system: extractSystemForAnthropic(messages),
606
616
  messages: userMessages,
607
617
  tools: tools,
608
618
  }),
@@ -992,9 +1002,8 @@ async function streamProviderCompletion(providerConfig, messages, tools) {
992
1002
 
993
1003
  async function streamOpenAICompletion(providerConfig, messages, tools) {
994
1004
  const baseUrl = providerConfig.url.replace(/\/+$/, '');
995
- // MiniMax özel endpoint tespiti (streaming için de aynı)
996
- const isMiniMax = baseUrl.includes('minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes('minimax.cn');
997
- const endpoint = isMiniMax
1005
+ // MiniMax özel endpoint tespiti (streaming için de aynı) — provider-detect.js.
1006
+ const endpoint = isMiniMax(baseUrl)
998
1007
  ? `${baseUrl}/v1/text/chatcompletion_v2`
999
1008
  : `${baseUrl}/chat/completions`;
1000
1009
 
@@ -1046,15 +1055,7 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
1046
1055
 
1047
1056
  if (delta.tool_calls) {
1048
1057
  hasToolCalls = true;
1049
- for (const tc of delta.tool_calls) {
1050
- const idx = tc.index;
1051
- if (!toolCalls[idx]) {
1052
- toolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
1053
- }
1054
- if (tc.id) toolCalls[idx].id = tc.id;
1055
- if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
1056
- if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
1057
- }
1058
+ accumulateToolCallDeltas(toolCalls, delta.tool_calls);
1058
1059
  }
1059
1060
 
1060
1061
  const token = delta.content || '';
@@ -1089,7 +1090,6 @@ async function streamOpenAICompletion(providerConfig, messages, tools) {
1089
1090
  async function streamAnthropicCompletion(providerConfig, messages) {
1090
1091
  const endpoint = `${providerConfig.url}/v1/messages`;
1091
1092
 
1092
- const systemMessage = messages.find(m => m.role === 'system');
1093
1093
  const userMessages = messages.filter(m => m.role !== 'system');
1094
1094
 
1095
1095
  const response = await fetch(endpoint, {
@@ -1102,7 +1102,7 @@ async function streamAnthropicCompletion(providerConfig, messages) {
1102
1102
  body: JSON.stringify({
1103
1103
  model: providerConfig.model,
1104
1104
  max_tokens: 2000,
1105
- system: systemMessage?.content || '',
1105
+ system: extractSystemForAnthropic(messages),
1106
1106
  messages: userMessages,
1107
1107
  stream: true,
1108
1108
  }),
@@ -1154,5 +1154,12 @@ module.exports = {
1154
1154
  streamProviderCompletion,
1155
1155
  streamOpenAICompletion,
1156
1156
  streamAnthropicCompletion,
1157
+ // Exposed for tests + advanced consumers (does not appear in the
1158
+ // public API surface of natureco's user-facing docs).
1159
+ _internals: {
1160
+ extractSystemForAnthropic,
1161
+ DEFAULT_ANTHROPIC_SYSTEM,
1162
+ buildRequestBody,
1163
+ },
1157
1164
  _sendMessage: sendMessage,
1158
1165
  };
@@ -1,15 +1,25 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
- const net = require('net');
5
4
  const chalk = require('chalk');
6
5
  const inquirer = require('./inquirer-wrapper');
7
6
  const { NatureCoError } = require('./errors');
7
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
8
8
 
9
9
  const APPROVALS_FILE = path.join(os.homedir(), '.natureco', 'exec-approvals.json');
10
- const APPROVALS_SOCKET_PATH = path.join(os.homedir(), '.natureco', 'exec-approvals.sock');
11
10
  const DEFAULT_TIMEOUT_MS = 1800000; // 30 min
12
11
 
12
+ // 0o600 = owner read/write only. The allowlist controls which commands
13
+ // auto-execute without prompting; on a shared machine, world-read leaks
14
+ // the user's automation surface and world-write lets another local
15
+ // account inject auto-approved commands. Treat it like ssh keys.
16
+ const APPROVALS_FILE_MODE = 0o600;
17
+ const APPROVALS_DIR_MODE = 0o700;
18
+
19
+ function _emptyApprovals() {
20
+ return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
21
+ }
22
+
13
23
  class ExecApprovalError extends NatureCoError {
14
24
  constructor(message, options = {}) {
15
25
  super(message, options);
@@ -33,20 +43,22 @@ function getApprovalsPath() {
33
43
  }
34
44
 
35
45
  function loadApprovals() {
36
- if (!fs.existsSync(APPROVALS_FILE)) {
37
- return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
38
- }
39
- try {
40
- return JSON.parse(fs.readFileSync(APPROVALS_FILE, 'utf8'));
41
- } catch {
42
- return { version: 1, defaults: { security: 'full', ask: 'off' }, agents: {} };
43
- }
46
+ return readJsonSafeSync(APPROVALS_FILE, _emptyApprovals());
44
47
  }
45
48
 
46
49
  function saveApprovals(data) {
47
50
  const dir = path.dirname(APPROVALS_FILE);
48
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
49
- fs.writeFileSync(APPROVALS_FILE, JSON.stringify(data, null, 2), 'utf8');
51
+ if (!fs.existsSync(dir)) {
52
+ fs.mkdirSync(dir, { recursive: true, mode: APPROVALS_DIR_MODE });
53
+ } else {
54
+ // Tighten the dir even if it pre-existed with looser bits.
55
+ try { fs.chmodSync(dir, APPROVALS_DIR_MODE); } catch { /* best-effort */ }
56
+ }
57
+ writeJsonAtomicSync(APPROVALS_FILE, data, { mode: APPROVALS_FILE_MODE });
58
+ // Tighten the file even if it pre-existed with looser bits (the rename
59
+ // in writeJsonAtomicSync preserves the temp's mode, but only when we
60
+ // pass it through — defensively chmod again in case of older installs).
61
+ try { fs.chmodSync(APPROVALS_FILE, APPROVALS_FILE_MODE); } catch { /* best-effort */ }
50
62
  }
51
63
 
52
64
  function resolveEffectivePolicy(agentId) {
@@ -294,4 +306,7 @@ module.exports = {
294
306
  getApprovalsPath,
295
307
  DANGEROUS_PATTERNS,
296
308
  SAFE_COMMANDS,
309
+ APPROVALS_FILE,
310
+ APPROVALS_FILE_MODE,
311
+ APPROVALS_DIR_MODE,
297
312
  };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Crash-safe file write + safe read.
3
+ *
4
+ * Why this exists: `fs.writeFileSync(path, data)` is NOT atomic. If the
5
+ * process is killed mid-write (SIGTERM, OOM, power loss) the file is
6
+ * left truncated, which becomes a "corrupted session" on next load
7
+ * (JSON.parse throws and session history is lost).
8
+ *
9
+ * `writeFileAtomicSync(path, data)` writes to a temp sibling first, then
10
+ * `rename(2)`s it into place — atomic on POSIX when both paths are on
11
+ * the same filesystem (always true here, since the temp is in the same
12
+ * directory).
13
+ *
14
+ * `readFileSafeSync(path, fallback)` returns the parsed JSON or falls
15
+ * back gracefully on missing/corrupted files instead of throwing.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ function _tempName(target) {
22
+ // Same directory so rename(2) is atomic. Include pid+rand to avoid
23
+ // collisions if two processes write the same file.
24
+ const dir = path.dirname(target);
25
+ const base = path.basename(target);
26
+ const suffix = `${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
27
+ return path.join(dir, `.${base}.${suffix}`);
28
+ }
29
+
30
+ /**
31
+ * Atomically write a string/Buffer to `filePath`.
32
+ * On error the temp file is cleaned up and the original is untouched.
33
+ *
34
+ * @param {string} filePath
35
+ * @param {string|Buffer} data
36
+ * @param {{encoding?: BufferEncoding, mode?: number}} [opts]
37
+ */
38
+ function writeFileAtomicSync(filePath, data, opts = {}) {
39
+ const encoding = opts.encoding ?? 'utf-8';
40
+ const mode = opts.mode; // undefined → fs default
41
+ const tmp = _tempName(filePath);
42
+ try {
43
+ if (mode !== undefined) {
44
+ fs.writeFileSync(tmp, data, { encoding, mode });
45
+ } else {
46
+ fs.writeFileSync(tmp, data, encoding);
47
+ }
48
+ fs.renameSync(tmp, filePath);
49
+ } catch (err) {
50
+ // Best-effort cleanup; if the temp already vanished, ignore.
51
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read `filePath` as JSON; return `fallback` on missing or corrupt.
58
+ *
59
+ * @template T
60
+ * @param {string} filePath
61
+ * @param {T} fallback
62
+ * @returns {T | object | Array<any>}
63
+ */
64
+ function readJsonSafeSync(filePath, fallback) {
65
+ if (!fs.existsSync(filePath)) return fallback;
66
+ try {
67
+ const raw = fs.readFileSync(filePath, 'utf-8');
68
+ if (!raw.trim()) return fallback;
69
+ return JSON.parse(raw);
70
+ } catch {
71
+ return fallback;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Convenience: stringify + atomic write. Pretty-printed for human-edit
77
+ * friendliness (matches the existing JSON.stringify(..., null, 2) calls).
78
+ *
79
+ * @param {string} filePath
80
+ * @param {any} value
81
+ * @param {{mode?: number}} [opts] e.g. {mode: 0o600} for secrets/PII
82
+ */
83
+ function writeJsonAtomicSync(filePath, value, opts = {}) {
84
+ writeFileAtomicSync(filePath, JSON.stringify(value, null, 2), opts);
85
+ }
86
+
87
+ module.exports = {
88
+ writeFileAtomicSync,
89
+ writeJsonAtomicSync,
90
+ readJsonSafeSync,
91
+ };
@@ -18,9 +18,10 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const os = require('os');
20
20
  const url = require('url');
21
+ const { getDashboardPort, getDashboardHost } = require('./ports');
21
22
 
22
- const PORT = 7421;
23
- const HOST = '127.0.0.1';
23
+ const PORT = getDashboardPort();
24
+ const HOST = getDashboardHost();
24
25
 
25
26
  const NATURECO_DIR = path.join(os.homedir(), '.natureco');
26
27
 
@@ -9,6 +9,10 @@
9
9
  * - Error codes
10
10
  */
11
11
 
12
+ // checkMacPermission below uses os.platform() — was relying on a global
13
+ // `os` from another module's load order.
14
+ const os = require('os');
15
+
12
16
  class ToolError extends Error {
13
17
  constructor(message, code = "TOOL_ERROR", details = {}) {
14
18
  super(message);
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { CONFIG_DIR } = require('./config');
4
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
4
5
 
5
6
  const HISTORY_DIR = path.join(CONFIG_DIR, 'history');
6
7
 
@@ -20,26 +21,17 @@ function getHistoryFilePath(botId) {
20
21
  // Load conversation history for a bot
21
22
  function loadHistory(botId) {
22
23
  const filePath = getHistoryFilePath(botId);
23
-
24
- if (!fs.existsSync(filePath)) {
25
- return [];
26
- }
27
-
28
- try {
29
- const content = fs.readFileSync(filePath, 'utf8');
30
- return JSON.parse(content);
31
- } catch {
32
- return [];
33
- }
24
+ const data = readJsonSafeSync(filePath, []);
25
+ // Normalize: older versions may have saved non-array; defensive cast.
26
+ return Array.isArray(data) ? data : [];
34
27
  }
35
28
 
36
29
  // Save conversation history for a bot
37
30
  function saveHistory(botId, history) {
38
31
  const filePath = getHistoryFilePath(botId);
39
-
40
32
  try {
41
- fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf8');
42
- } catch (err) {
33
+ writeJsonAtomicSync(filePath, history);
34
+ } catch {
43
35
  // Silently fail - history is not critical
44
36
  }
45
37
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Centralized port + host defaults with env-var overrides.
3
+ *
4
+ * Hardcoding `7421` across multiple modules invited "port already in use"
5
+ * crashes in environments where the user runs natureco alongside other
6
+ * services on that port, or wants to run multiple natureco dashboards
7
+ * (e.g. one per project worktree). This module is the single source of
8
+ * truth — set NATURECO_DASHBOARD_PORT / NATURECO_DASHBOARD_HOST once and
9
+ * all callers pick it up.
10
+ */
11
+
12
+ const DEFAULT_DASHBOARD_PORT = 7421;
13
+ const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
14
+
15
+ function _parsePort(raw, fallback) {
16
+ if (raw === undefined || raw === null || raw === '') return fallback;
17
+ const n = parseInt(String(raw).trim(), 10);
18
+ // Bind to a reserved port (≤1024) usually fails and is almost never
19
+ // what the user meant; clamp out-of-range values to the default.
20
+ if (!Number.isFinite(n) || n < 1 || n > 65535) return fallback;
21
+ return n;
22
+ }
23
+
24
+ function getDashboardPort() {
25
+ return _parsePort(process.env.NATURECO_DASHBOARD_PORT, DEFAULT_DASHBOARD_PORT);
26
+ }
27
+
28
+ function getDashboardHost() {
29
+ const raw = process.env.NATURECO_DASHBOARD_HOST;
30
+ return (raw && String(raw).trim()) || DEFAULT_DASHBOARD_HOST;
31
+ }
32
+
33
+ function getDashboardUrl() {
34
+ return `http://${getDashboardHost()}:${getDashboardPort()}`;
35
+ }
36
+
37
+ module.exports = {
38
+ DEFAULT_DASHBOARD_PORT,
39
+ DEFAULT_DASHBOARD_HOST,
40
+ getDashboardPort,
41
+ getDashboardHost,
42
+ getDashboardUrl,
43
+ _internals: { _parsePort },
44
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Top-level process error handlers.
3
+ *
4
+ * Node's default behavior for unhandled rejections is to print a deprecated
5
+ * warning and (since Node 15) exit with code 1. The default for uncaught
6
+ * exceptions is to print the stack and exit 1. Both bypass natureco's audit
7
+ * trail and dump raw Node output that's useless to a CLI user.
8
+ *
9
+ * `install({ audit, exit, stderr })` registers handlers that:
10
+ * 1. Append a structured entry to the audit log (synchronous — the
11
+ * process is about to die, async fire-and-forget would race),
12
+ * 2. Print a single friendly Turkish line + log path to stderr,
13
+ * 3. Exit with code 1 (configurable via `exit` for tests).
14
+ *
15
+ * Idempotent: a second call replaces the previous handlers (so the test
16
+ * suite can re-install with fresh spies).
17
+ */
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const ERROR_LOG_PATH = path.join(os.homedir(), '.natureco', 'logs', 'crash.log');
22
+
23
+ let _installed = false;
24
+ let _registered = { rejection: null, exception: null, warning: null };
25
+
26
+ function _defaultAudit() {
27
+ try {
28
+ return require('./audit');
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function _serializeError(err) {
35
+ if (!err) return { type: 'unknown', message: 'null' };
36
+ if (err instanceof Error) {
37
+ return {
38
+ type: err.constructor.name,
39
+ message: err.message,
40
+ stack: err.stack ? err.stack.split('\n').slice(0, 20).join('\n') : null,
41
+ code: err.code,
42
+ };
43
+ }
44
+ if (typeof err === 'object') {
45
+ try { return { type: 'object', message: JSON.stringify(err).slice(0, 1000) }; }
46
+ catch { return { type: 'object', message: String(err) }; }
47
+ }
48
+ return { type: typeof err, message: String(err) };
49
+ }
50
+
51
+ /**
52
+ * Install global handlers. Returns an `uninstall()` function.
53
+ *
54
+ * @param {{
55
+ * audit?: { logSync: Function, ACTIONS?: Record<string,string> } | null,
56
+ * exit?: (code: number) => void,
57
+ * stderr?: (msg: string) => void,
58
+ * }} [opts]
59
+ */
60
+ function install(opts = {}) {
61
+ const audit = opts.audit === undefined ? _defaultAudit() : opts.audit;
62
+ const exit = opts.exit || ((code) => process.exit(code));
63
+ const stderr = opts.stderr || ((msg) => process.stderr.write(msg));
64
+
65
+ // Replace any previous handlers (idempotency for tests).
66
+ if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
67
+ if (_registered.exception) process.off('uncaughtException', _registered.exception);
68
+
69
+ const onRejection = (reason) => {
70
+ const payload = { kind: 'unhandledRejection', error: _serializeError(reason) };
71
+ try { audit?.logSync('error', payload); } catch { /* ignore */ }
72
+ stderr(
73
+ `\n ✗ Beklenmedik bir hata oluştu (unhandled promise rejection).\n` +
74
+ ` Detay: ${payload.error.message}\n` +
75
+ ` Log: ${ERROR_LOG_PATH}\n` +
76
+ ` Hata raporu için: github.com/natureco-official/natureco-cli/issues\n\n`,
77
+ );
78
+ exit(1);
79
+ };
80
+
81
+ const onException = (err) => {
82
+ const payload = { kind: 'uncaughtException', error: _serializeError(err) };
83
+ try { audit?.logSync('error', payload); } catch { /* ignore */ }
84
+ stderr(
85
+ `\n ✗ Beklenmedik bir hata oluştu (uncaught exception).\n` +
86
+ ` Detay: ${payload.error.message}\n` +
87
+ ` Log: ${ERROR_LOG_PATH}\n` +
88
+ ` Hata raporu için: github.com/natureco-official/natureco-cli/issues\n\n`,
89
+ );
90
+ exit(1);
91
+ };
92
+
93
+ process.on('unhandledRejection', onRejection);
94
+ process.on('uncaughtException', onException);
95
+ _registered = { rejection: onRejection, exception: onException, warning: null };
96
+ _installed = true;
97
+
98
+ return function uninstall() {
99
+ if (_registered.rejection) process.off('unhandledRejection', _registered.rejection);
100
+ if (_registered.exception) process.off('uncaughtException', _registered.exception);
101
+ _registered = { rejection: null, exception: null, warning: null };
102
+ _installed = false;
103
+ };
104
+ }
105
+
106
+ function isInstalled() {
107
+ return _installed;
108
+ }
109
+
110
+ module.exports = {
111
+ install,
112
+ isInstalled,
113
+ ERROR_LOG_PATH,
114
+ _internals: { _serializeError },
115
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Provider detection — single source of truth for "given a base URL
3
+ * (and optionally a model name) which provider family is this?"
4
+ *
5
+ * Previously this logic lived inline in three places:
6
+ * - src/utils/api.js (the canonical detectProvider, exported only
7
+ * informally via its module surface)
8
+ * - src/commands/setup.js (string contains checks against minimax /
9
+ * anthropic / groq, scattered through the OAuth + test flows)
10
+ * - src/utils/api.js again, with `const isMiniMax = baseUrl.includes(
11
+ * 'minimax.io') || baseUrl.includes('minimaxi.com') || baseUrl.includes(
12
+ * 'minimax.cn')` repeated twice (lines 560 + 1017)
13
+ *
14
+ * Refactoring opportunity from the v5.7 audit. This module preserves
15
+ * EXACTLY the same detection rules as the original detectProvider so
16
+ * the migration is a behavioral no-op verifiable by tests.
17
+ */
18
+
19
+ /**
20
+ * Family of an inference provider.
21
+ *
22
+ * @typedef {'openai'|'anthropic'|'groq'|'openrouter'|'deepseek'|'mistral'
23
+ * |'together'|'fireworks'|'perplexity'|'ollama'|'minimax'} ProviderName
24
+ */
25
+
26
+ /**
27
+ * @param {string} providerUrl
28
+ * @param {string} [model]
29
+ * @returns {ProviderName}
30
+ */
31
+ function detectProvider(providerUrl, model) {
32
+ const url = (providerUrl || '').toLowerCase();
33
+ const m = (model || '').toLowerCase();
34
+ if (url.includes('anthropic.com') || m.includes('claude')) return 'anthropic';
35
+ if (url.includes('groq.com') || m.includes('groq') || m.includes('llama-3') || m.includes('mixtral')) return 'groq';
36
+ if (url.includes('openrouter.ai')) return 'openrouter';
37
+ if (url.includes('api.deepseek.com') || m.includes('deepseek')) return 'deepseek';
38
+ if (url.includes('mistral.ai') || m.includes('mistral') || m.includes('codestral')) return 'mistral';
39
+ if (url.includes('together.xyz') || m.includes('together')) return 'together';
40
+ if (url.includes('fireworks.ai') || m.includes('fireworks')) return 'fireworks';
41
+ if (url.includes('perplexity.ai') || m.includes('pplx') || m.includes('sonar')) return 'perplexity';
42
+ if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('ollama')) return 'ollama';
43
+ if (url.includes('minimax.io') || url.includes('minimax')) return 'minimax';
44
+ return 'openai';
45
+ }
46
+
47
+ /** Convenience predicates — each one short-circuits on hostname only. */
48
+ function isAnthropic(url) {
49
+ return (url || '').toLowerCase().includes('anthropic.com');
50
+ }
51
+
52
+ function isGroq(url) {
53
+ return (url || '').toLowerCase().includes('groq.com');
54
+ }
55
+
56
+ /**
57
+ * MiniMax has three known production hosts:
58
+ * api.minimax.io — global English-facing
59
+ * api.minimaxi.com — China-facing
60
+ * api.minimax.cn — legacy CN domain seen in some auth tokens
61
+ */
62
+ function isMiniMax(url) {
63
+ const u = (url || '').toLowerCase();
64
+ return u.includes('minimax.io') || u.includes('minimaxi.com') || u.includes('minimax.cn');
65
+ }
66
+
67
+ function isOllama(url) {
68
+ const u = (url || '').toLowerCase();
69
+ return u.includes('localhost') || u.includes('127.0.0.1') || u.includes('ollama');
70
+ }
71
+
72
+ module.exports = {
73
+ detectProvider,
74
+ isAnthropic,
75
+ isGroq,
76
+ isMiniMax,
77
+ isOllama,
78
+ };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const { writeJsonAtomicSync, readJsonSafeSync } = require('./atomic-file');
4
5
 
5
6
  // ── Legacy botId-based sessions ─────────────────────────────────────────────────
6
7
 
@@ -36,20 +37,12 @@ function createSession(botId, botName) {
36
37
  function _saveBotSession(botId, session) {
37
38
  ensureSessionsDir(botId);
38
39
  const sessionFile = path.join(getSessionsDir(botId), `${session.id}.json`);
39
- fs.writeFileSync(sessionFile, JSON.stringify(session, null, 2), 'utf-8');
40
+ writeJsonAtomicSync(sessionFile, session);
40
41
  }
41
42
 
42
43
  function loadSession(botId, sessionId) {
43
44
  const sessionFile = path.join(getSessionsDir(botId), `${sessionId}.json`);
44
- if (!fs.existsSync(sessionFile)) {
45
- return null;
46
- }
47
- try {
48
- const data = fs.readFileSync(sessionFile, 'utf-8');
49
- return JSON.parse(data);
50
- } catch {
51
- return null;
52
- }
45
+ return readJsonSafeSync(sessionFile, null);
53
46
  }
54
47
 
55
48
  function getLatestSession(botId) {
@@ -110,10 +103,10 @@ function saveSession(commandName, messages, metadata = {}) {
110
103
  }
111
104
  const id = Date.now().toString(36);
112
105
  const filename = path.join(SESSIONS_DIR, `${commandName}-${id}.json`);
113
- fs.writeFileSync(filename, JSON.stringify({
106
+ writeJsonAtomicSync(filename, {
114
107
  id, commandName, messages, metadata,
115
- savedAt: new Date().toISOString()
116
- }, null, 2));
108
+ savedAt: new Date().toISOString(),
109
+ });
117
110
  return filename;
118
111
  }
119
112