natureco-cli 5.7.0 → 5.7.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,95 @@
2
2
 
3
3
  All notable changes to NatureCo CLI will be documented in this file.
4
4
 
5
+ ## [5.7.1] - 2026-06-25 — "BUG FIX SPRINT"
6
+
7
+ Comprehensive audit-driven sprint: 7 real runtime bugs fixed, 6 new
8
+ utility modules with ~90%+ test coverage, ESLint + flat config installed,
9
+ test scripts wired to vitest (previously `npm test` was just running
10
+ `--help`). Pure quality / stability — no public API change.
11
+
12
+ ### 🐛 Fixed (runtime bugs)
13
+ - **REPL `/system <text>` crashed with TypeError** (`commands/repl.js`):
14
+ systemPrompt was `const` but the slash handler reassigned it. → `let`.
15
+ - **Telegram + IRC + SMS message handlers ReferenceError on every inbound**
16
+ (`commands/gateway-server.js`): `cleanCommand` variable never declared
17
+ in those scopes. Added `stripSlashPrefix(text)` helper mirroring the
18
+ v5.6.41+ WhatsApp transform; all three channels now derive it correctly.
19
+ - **Tool-alias rewrites threw ReferenceError** (`utils/tools.js`): typo
20
+ `TOOL_ALIASES[t.name]` where the local was `ALIAS_MAP`. Fixed.
21
+ - **5 silent `no-undef` ReferenceErrors** across `commands/{chat,nodes,
22
+ gateway,config}.js` + `utils/error.js` — missing `require()` calls
23
+ that had been hidden by CommonJS load-order side effects (would
24
+ crash on a fresh process or worker restart).
25
+
26
+ ### 🔒 Security
27
+ - **exec-approvals.json was world-readable (0644)** — local privilege
28
+ escalation hedef. Now 0o600 (file) + 0o700 (parent dir), with
29
+ auto-tightening of pre-existing loose installs. Removed dangling
30
+ `APPROVALS_SOCKET_PATH` constant + unused `net` require (socket never
31
+ existed; storage is the JSON file).
32
+ - **Anthropic `system` field sent as `''` or `undefined`** (api.js): now
33
+ always non-empty via `extractSystemForAnthropic(messages)` helper with
34
+ a meaningful default. Prevents 400 "system: cannot be empty" on
35
+ recent Messages API revisions + unanchored-model drift.
36
+
37
+ ### ⚙️ Reliability
38
+ - **Crash-safe atomic file writes** for sessions, history, memory,
39
+ approvals (new `utils/atomic-file.js`: temp-write + rename(2)).
40
+ Prior `fs.writeFileSync` left truncated JSON on SIGTERM / OOM /
41
+ power loss.
42
+ - **Memory fact cap silent fail fixed** (`tools/memory_write.js`): the
43
+ hardcoded `slice(0, 15)` ran BEFORE push, so once 15 high-score
44
+ facts were saved, every new write was the next iteration's eviction
45
+ victim — silently. Now: `MAX_FACTS_PER_USER` default 50 (env
46
+ `NATURECO_MAX_FACTS`), cap applied AFTER push, just-written fact
47
+ pinned at top, `console.warn` on breach (`NATURECO_QUIET_MEMORY=1`
48
+ to silence).
49
+ - **Global `unhandledRejection` + `uncaughtException` handlers**
50
+ (`utils/process-errors.js`, installed as the first statement in
51
+ `bin/natureco.js`): structured audit log entry + friendly Turkish
52
+ stderr + exit 1, instead of Node's default ugly stack dump.
53
+ - **Dashboard port + host de-hardcoded** (`utils/ports.js`):
54
+ `NATURECO_DASHBOARD_PORT` + `NATURECO_DASHBOARD_HOST` env overrides
55
+ with range/format validation. Previously 7421 was inlined in two
56
+ separate modules; drift risk eliminated.
57
+
58
+ ### 🧹 Refactor (DRY)
59
+ - **Streaming tool-call delta accumulator** extracted to
60
+ `utils/streaming-tools.js`. The per-index buffer + string-concat
61
+ pattern was duplicated in `utils/api.js` and `commands/repl.js` —
62
+ any drift between them would silently break tool calling on
63
+ Groq / MiniMax / DeepSeek / OpenAI.
64
+ - **Provider detection** centralized in `utils/provider-detect.js`.
65
+ Three call sites (`utils/api.js`, `commands/setup.js`) used three
66
+ different versions of the URL→provider mapping; the setup.js variant
67
+ was already incorrect (missed `minimax.cn`). Helper makes
68
+ `detectProvider`, `isMiniMax`, `isAnthropic`, `isGroq`, `isOllama`
69
+ the single source of truth.
70
+
71
+ ### 🧪 Testing
72
+ - **`npm test` actually runs tests now** — was previously just
73
+ `node bin/natureco.js help` (a load-smoke). Wired to `vitest run`.
74
+ - **+95 unit tests** across 9 new spec files. Coverage of the new
75
+ utility modules: streaming-tools 97%, provider-detect 100%,
76
+ process-errors 88%, ports ~93%, atomic-file ~93%, memory_write
77
+ internals ~85%.
78
+ - **Test suite: 12 files / 270 tests → 21 files / 365 tests.**
79
+ - `@vitest/coverage-v8` dev dep added; `npm run test:coverage` works.
80
+ - **prepublishOnly gate strengthened**: now runs `node --check` +
81
+ `eslint --quiet` + `vitest run` in sequence. A broken publish to
82
+ `npm install -g natureco-cli` users is now strictly blocked.
83
+
84
+ ### 🔧 Tooling
85
+ - **ESLint v9 flat config added** (`eslint.config.js`):
86
+ `@eslint/js` recommended + warn-level checks for unused-vars,
87
+ useless-escape, case-declarations, control-regex. Test files get
88
+ ES-module sourceType + vitest globals; `src/tools/browser*.js`
89
+ get browser globals for Playwright page.evaluate context.
90
+ Scripts: `npm run lint`, `lint:fix`, `lint:errors-only`.
91
+ After the no-undef fixes: 0 errors (293 unused-vars warnings
92
+ remain for a follow-up sprint).
93
+
5
94
  ## [5.7.0] - 2026-06-24 - SOUL SCRUBBED (MINOR)
6
95
 
7
96
  ### Security
package/bin/natureco.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "natureco-cli",
3
- "version": "5.7.0",
3
+ "version": "5.7.2",
4
4
  "description": "OpenClaw'dan daha güvenli, daha hızlı, daha ucuz AI agent CLI. Multi-agent, self-evolving skills, audit log, maliyet optimizasyonu ve NatureCo platform-native.",
5
5
  "bin": {
6
6
  "natureco": "bin/natureco.js"
@@ -15,12 +15,16 @@
15
15
  "DEPLOY_v2.0.0.md"
16
16
  ],
17
17
  "scripts": {
18
- "test": "node bin/natureco.js help",
19
- "test:unit": "node --check bin/natureco.js && node -e \"console.log('All files OK')\"",
20
- "test:watch": "node --watch bin/natureco.js",
21
- "test:coverage": "node --check bin/natureco.js",
18
+ "test": "vitest run",
19
+ "test:unit": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:coverage": "vitest run --coverage",
22
+ "lint": "eslint src/ bin/ test/",
23
+ "lint:fix": "eslint src/ bin/ test/ --fix",
24
+ "lint:errors-only": "eslint src/ bin/ test/ --quiet",
25
+ "smoke": "node --check bin/natureco.js && node bin/natureco.js help",
22
26
  "postinstall": "node bin/natureco.js doctor || true",
23
- "prepublishOnly": "node --check bin/natureco.js"
27
+ "prepublishOnly": "node --check bin/natureco.js && eslint src/ bin/ test/ --quiet && vitest run"
24
28
  },
25
29
  "keywords": [
26
30
  "natureco",
@@ -81,6 +85,10 @@
81
85
  "ws": "^8.20.0"
82
86
  },
83
87
  "devDependencies": {
88
+ "@eslint/js": "^9.39.4",
89
+ "@vitest/coverage-v8": "^4.1.9",
90
+ "eslint": "^9.39.4",
91
+ "globals": "^15.15.0",
84
92
  "vitest": "^4.1.9"
85
93
  }
86
94
  }
@@ -17,6 +17,11 @@ const { runHooks } = require('../utils/hooks');
17
17
  const { createSession, loadSession, getLatestSession, addMessageToSession, loadLastSession, listSessions, saveSession } = require('../utils/sessions');
18
18
  const { NatureCoError, ApiError, handleError } = require('../utils/errors');
19
19
  const { getSessionStats, resetSessionStats } = require('../utils/tool-runner');
20
+ // getBots + sendMessage / _sendMessage are referenced later (loadProviders
21
+ // pre-warm, fallback non-streaming path) but the require was missing —
22
+ // the code only worked when something else in the load order had already
23
+ // required api.js.
24
+ const { getBots, sendMessage, _sendMessage } = require('../utils/api');
20
25
 
21
26
  const ASCII_LOGO = [
22
27
  '███╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ███████╗ ██████╗ ██████╗',
@@ -4,6 +4,10 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const { getConfig, setConfigValue, getAllConfig, listBackups, restoreConfig, saveConfig, CONFIG_FILE, CONFIG_BACKUP_DIR } = require('../utils/config');
6
6
  const TB = require('../utils/token-budget');
7
+ // `tui` is referenced 25× below for styled output. Was relying on a
8
+ // side-effect global from elsewhere in the load order; require it
9
+ // explicitly so the file works in isolation too (caught by no-undef).
10
+ const tui = require('../utils/tui');
7
11
 
8
12
  function config(args) {
9
13
  const [action, key, ...valueParts] = args;
@@ -10,6 +10,31 @@ const { ApiError } = require('../utils/errors');
10
10
  const PID_FILE = path.join(os.homedir(), '.natureco', 'gateway.pid');
11
11
  const LOG_FILE = path.join(os.homedir(), '.natureco', 'gateway.log');
12
12
 
13
+ // `https` is used in the webhook delivery path (~line 570). The require was
14
+ // missing; the module only worked because Node's CommonJS cache happens to
15
+ // have it loaded from elsewhere, but in a fresh process or after a worker
16
+ // restart this would crash. Explicit.
17
+ const https = require('https');
18
+ // saveConfig (and the loaded `config` value, when not shadowed in scope)
19
+ // referenced in the Discord ready handler / Mattermost HTTP path. Same
20
+ // side-effect-global story as above.
21
+ const { saveConfig } = require('../utils/config');
22
+
23
+ /**
24
+ * Strip a leading slash command prefix from a messaging-channel inbound.
25
+ * v5.6.41+ added slash-prefix routing (e.g. `/ask`, `/help`, `/translate`)
26
+ * for iMessage + WhatsApp; the Telegram/IRC/SMS handlers were referencing
27
+ * a `cleanCommand` variable that no longer existed (3 ReferenceError sites,
28
+ * caught by ESLint no-undef). This helper restores the missing transform:
29
+ * "/ask merhaba" → "merhaba"
30
+ * "merhaba" → "merhaba"
31
+ * "" / undefined → ""
32
+ */
33
+ function stripSlashPrefix(text) {
34
+ if (!text || typeof text !== 'string') return '';
35
+ return text.replace(/^\/[a-zA-Z0-9_-]+\s*/, '').trim() || text.trim();
36
+ }
37
+
13
38
  // Silent logger for Baileys
14
39
  const silentLogger = {
15
40
  level: 'silent',
@@ -655,9 +680,10 @@ async function startTelegramProvider(config) {
655
680
  systemPrompt += '\n\n' + memoryPrompt;
656
681
  }
657
682
 
683
+ const cleanCommand = stripSlashPrefix(messageText);
658
684
  const response = await sendMessage(cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand, conversationId, systemPrompt);
659
685
  const reply = response?.reply || response?.message || '';
660
-
686
+
661
687
  if (reply) {
662
688
  log('telegram', 'Sending reply...', 'cyan');
663
689
 
@@ -1171,6 +1197,8 @@ async function processIrcMessage(config, sender, target, text, socket) {
1171
1197
  let systemPrompt = `You are a helpful IRC assistant. Keep responses concise. Use IRC-friendly formatting.`;
1172
1198
  if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1173
1199
 
1200
+ // IRC handler's parameter is `text` (see signature above), not `messageText`.
1201
+ const cleanCommand = stripSlashPrefix(text);
1174
1202
  const response = await sendMessage(
1175
1203
  cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand,
1176
1204
  conversationId, systemPrompt
@@ -1732,6 +1760,9 @@ async function handleSmsWebhook(config, body, req) {
1732
1760
  let systemPrompt = `You are a helpful SMS assistant. Keep responses concise (SMS format).`;
1733
1761
  if (memoryPrompt) systemPrompt += '\n\n' + memoryPrompt;
1734
1762
 
1763
+ // SMS handler binds the inbound to `text` (Twilio webhook body), unlike
1764
+ // Telegram/IRC which use `messageText`.
1765
+ const cleanCommand = stripSlashPrefix(text);
1735
1766
  const response = await sendMessage(
1736
1767
  cfg.providerApiKey || cfg.apiKey || '', botId, cleanCommand,
1737
1768
  conversationId, systemPrompt
@@ -1880,7 +1911,10 @@ function startHttpServer() {
1880
1911
  res.end(JSON.stringify({ error: 'Mattermost not connected' }));
1881
1912
  return;
1882
1913
  }
1883
- await sendMattermostMessage(config, target, message);
1914
+ // Load fresh config inside the handler — the surrounding
1915
+ // `startHttpServer` scope does not bind `config`.
1916
+ const { getConfig: _gc1 } = require('../utils/config');
1917
+ await sendMattermostMessage(_gc1(), target, message);
1884
1918
  log('http', `Mattermost message sent to channel ${target}`, 'green');
1885
1919
  res.writeHead(200, { 'Content-Type': 'application/json' });
1886
1920
  res.end(JSON.stringify({ success: true, channel: 'mattermost', target }));
@@ -1907,7 +1941,8 @@ function startHttpServer() {
1907
1941
  res.end(JSON.stringify({ error: 'SMS not connected' }));
1908
1942
  return;
1909
1943
  }
1910
- await sendSmsMessage(config, target, message);
1944
+ const { getConfig: _gc2 } = require('../utils/config');
1945
+ await sendSmsMessage(_gc2(), target, message);
1911
1946
  log('http', `SMS sent to ${target}`, 'green');
1912
1947
  res.writeHead(200, { 'Content-Type': 'application/json' });
1913
1948
  res.end(JSON.stringify({ success: true, channel: 'sms', target }));
@@ -12,6 +12,8 @@ const { loadBaileys } = require('../utils/baileys');
12
12
  const { NatureCoError, GatewayError, handleError } = require('../utils/errors');
13
13
  const pino = require('pino');
14
14
  const logger = pino({ level: 'silent' });
15
+ // Version banner pulls packageJson at line 315 — was missing the require.
16
+ const packageJson = require('../../package.json');
15
17
 
16
18
  async function gateway(action, ...args) {
17
19
  if (action === 'start') {
@@ -4,6 +4,10 @@ const F = require('../utils/format');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ // getConfig + saveConfig referenced 8× below — missing require relied on
8
+ // side-effect global from another module's load order.
9
+ const { getConfig, saveConfig } = require('../utils/config');
10
+
7
11
  function nodes(args) {
8
12
  const [action, ...params] = args || [];
9
13
 
@@ -23,6 +23,8 @@ const { spawn } = require('child_process');
23
23
  const chalk = require('chalk');
24
24
  const tui = require('../utils/tui');
25
25
  const { loadToolDefinitions, toOpenAIFormat, executeTool } = require('../utils/tools');
26
+ const { accumulateToolCallDeltas, finalizeToolCalls } = require('../utils/streaming-tools');
27
+ const { createPasteSafeInput, enableBracketedPaste, disableBracketedPaste, restoreNewlines } = require('../utils/paste-safe-input');
26
28
 
27
29
  // v5.4.6: Model adi sizintisini engelle — global'e ata, callback'lerden erisebilir olsun
28
30
  const MODEL_NAMES_TO_HIDE = ['MiniMax-M2.5', 'MiniMaxM2.5', 'minimaxm25', 'Claude-3', 'GPT-4', 'ChatGPT'];
@@ -313,17 +315,10 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
313
315
  onChunk(delta.content);
314
316
  }
315
317
 
316
- // Tool calls (streaming delta)
318
+ // Tool calls (streaming delta) — shared accumulator,
319
+ // see src/utils/streaming-tools.js for the per-index pattern.
317
320
  if (delta.tool_calls) {
318
- for (const tcDelta of delta.tool_calls) {
319
- const idx = tcDelta.index;
320
- if (!toolCalls[idx]) {
321
- toolCalls[idx] = { index: idx, id: '', name: '', args: '' };
322
- }
323
- if (tcDelta.id) toolCalls[idx].id = tcDelta.id;
324
- if (tcDelta.function?.name) toolCalls[idx].name += tcDelta.function.name;
325
- if (tcDelta.function?.arguments) toolCalls[idx].args += tcDelta.function.arguments;
326
- }
321
+ accumulateToolCallDeltas(toolCalls, delta.tool_calls);
327
322
  }
328
323
  } catch {}
329
324
  }
@@ -338,17 +333,15 @@ async function sendStreaming(providerUrl, providerApiKey, messages, model, onChu
338
333
 
339
334
  fullText = result.streamText;
340
335
 
341
- // Tool call var mı?
342
- if (result.toolCalls && result.toolCalls.length > 0 && result.toolCalls[0].name) {
336
+ // Tool call var mı? finalizeToolCalls drops empty entries + synthesizes ids,
337
+ // so we only need to check the resulting length.
338
+ const finalized = finalizeToolCalls(result.toolCalls);
339
+ if (finalized.length > 0) {
343
340
  // Assistant mesajını ekle (tool_calls ile)
344
341
  currentMessages.push({
345
342
  role: 'assistant',
346
343
  content: result.streamText || null,
347
- tool_calls: result.toolCalls.map(tc => ({
348
- id: tc.id || `call_${Date.now()}_${tc.index}`,
349
- type: 'function',
350
- function: { name: tc.name, arguments: tc.args },
351
- })),
344
+ tool_calls: finalized,
352
345
  });
353
346
  // Her tool call'ı çalıştır, sonuçları tool mesajı olarak ekle
354
347
  const toolResults = await processToolCalls(result.toolCalls, onToolCall);
@@ -530,7 +523,10 @@ async function startRepl(args) {
530
523
  (cfg.providerUrl || '').includes('mistral.ai') ||
531
524
  (cfg.providerUrl || '').includes('localhost') ||
532
525
  (cfg.providerUrl || '').includes('ollama');
533
- const systemPrompt = [
526
+ // `let` (not `const`) because /system <text> reassigns it at line ~796.
527
+ // Before this fix, /system would throw "Assignment to constant variable"
528
+ // and tear down the REPL session mid-conversation.
529
+ let systemPrompt = [
534
530
  // === v5.4.14: EN KRITIK KIMLIK BILGILERI (her zaman ilk) ===
535
531
  `SENIN ADIN: ${botName}. SADECE ${botName} adini kullan, model adi SOYLEME.`,
536
532
  `PATRONUN: Gencay (Parton) — NatureCo CEO'sudur. Sana "Parton" diye hitap eder.`,
@@ -620,8 +616,10 @@ async function startRepl(args) {
620
616
  let totalInputTokens = 0;
621
617
  let totalOutputTokens = 0;
622
618
 
619
+ enableBracketedPaste(process.stdout);
620
+
623
621
  const rl = readline.createInterface({
624
- input: process.stdin,
622
+ input: createPasteSafeInput(process.stdin),
625
623
  output: process.stdout,
626
624
  prompt: tui.styled('\n You ', { color: tui.PALETTE.primary, bold: true }),
627
625
  terminal: true,
@@ -648,6 +646,7 @@ async function startRepl(args) {
648
646
  }
649
647
  // Global buffer temizle
650
648
  if (global._fixBuffer) global._fixBuffer = '';
649
+ disableBracketedPaste(process.stdout);
651
650
  console.log(chalk.gray('\n 👋 Görüşürüz!\n'));
652
651
  process.exit(exitCode);
653
652
  };
@@ -726,7 +725,7 @@ async function startRepl(args) {
726
725
  process.on('SIGTERM', () => cleanup(0));
727
726
 
728
727
  rl.on('line', async (input) => {
729
- const line = input.trim();
728
+ const line = restoreNewlines(input).trim();
730
729
  if (!line) { rl.prompt(); return; }
731
730
 
732
731
  // Slash komutlar
@@ -631,22 +631,23 @@ module.exports = setup;
631
631
  async function validateApiKey(providerUrl, apiKey) {
632
632
  return new Promise((resolve) => {
633
633
  const https = require('https');
634
- const url = new URL(providerUrl);
635
- const isMM = url.hostname.includes('minimax') || url.hostname.includes('minimaxi');
636
- const endpoint = isMM
634
+ const { isMiniMax, isGroq, isAnthropic } = require('../utils/provider-detect');
635
+ const isMM = isMiniMax(providerUrl);
636
+ const endpoint = isMM
637
637
  ? providerUrl.replace(/\/+$/, '') + '/v1/text/chatcompletion_v2'
638
638
  : providerUrl.replace(/\/+$/, '') + '/chat/completions';
639
639
 
640
640
  // Her provider icin test modeli
641
641
  let testModel = 'gpt-3.5-turbo';
642
- if (providerUrl.includes('groq.com')) testModel = 'llama-3.1-8b-instant';
643
- else if (providerUrl.includes('anthropic.com')) testModel = 'claude-3-haiku-20240307';
642
+ if (isGroq(providerUrl)) testModel = 'llama-3.1-8b-instant';
643
+ else if (isAnthropic(providerUrl)) testModel = 'claude-3-haiku-20240307';
644
644
  else if (isMM) testModel = 'MiniMax-M2.5';
645
645
  else if (providerUrl.includes('gemini')) testModel = 'gemini-1.5-flash';
646
646
  else if (providerUrl.includes('mistral.ai')) testModel = 'mistral-tiny';
647
647
  else if (providerUrl.includes('openrouter.ai')) testModel = 'meta-llama/llama-3.1-8b-instruct:free';
648
648
  else if (providerUrl.includes('deepseek.com')) testModel = 'deepseek-chat';
649
-
649
+
650
+
650
651
  const data = JSON.stringify({
651
652
  model: testModel,
652
653
  messages: [{ role: 'user', content: 'hi' }],
@@ -15,8 +15,9 @@ const http = require("http");
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
17
  const os = require("os");
18
+ const { getDashboardPort } = require("../utils/ports");
18
19
 
19
- const PORT = 7421;
20
+ const PORT = getDashboardPort();
20
21
  const DASHBOARD_HTML = `
21
22
  <!DOCTYPE html>
22
23
  <html lang="tr">
@@ -0,0 +1,143 @@
1
+ /**
2
+ * edit_file — Precise string replacement in an existing file.
3
+ *
4
+ * Modeled after Claude Code's `Edit` tool semantics, with three rules
5
+ * that catch ~all "the agent broke my file" failure modes:
6
+ *
7
+ * 1. The file must exist (no accidental file creation via Edit — use
8
+ * write_file for that).
9
+ * 2. `old_string` must appear in the file. If it doesn't, return an
10
+ * error pointing at the file path; do NOT silently no-op.
11
+ * 3. `old_string` must be UNIQUE in the file unless `replace_all: true`
12
+ * is passed. Otherwise the agent would mutate an unintended site
13
+ * without realizing it.
14
+ *
15
+ * On success: writes via atomic rename (no half-edited files on crash),
16
+ * returns the patched line count + a small diff summary.
17
+ *
18
+ * Why this exists in natureco: until this commit, natureco's only file
19
+ * mutation tool was `write_file`, which overwrites the entire file. Any
20
+ * "rename X to Y in src/foo.js" request meant the agent had to read the
21
+ * file, do the substitution in its head, and re-emit the whole file —
22
+ * a process that loses indentation, drops trailing newlines, and
23
+ * occasionally hallucinates unrelated edits. `edit_file` makes the
24
+ * mutation a 2-3 line delta the agent can't get wrong.
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const { writeFileAtomicSync } = require('../utils/atomic-file');
29
+
30
+ function _expand(p) {
31
+ if (!p) return p;
32
+ if (p.startsWith('~')) {
33
+ return path.join(require('os').homedir(), p.slice(1));
34
+ }
35
+ return path.resolve(p);
36
+ }
37
+
38
+ async function editFile({ path: filePath, old_string, new_string, replace_all = false }) {
39
+ if (!filePath || typeof filePath !== 'string') {
40
+ return { success: false, error: 'path is required (absolute path or ~-prefixed)' };
41
+ }
42
+ if (typeof old_string !== 'string' || old_string.length === 0) {
43
+ return { success: false, error: 'old_string is required and cannot be empty' };
44
+ }
45
+ if (typeof new_string !== 'string') {
46
+ return { success: false, error: 'new_string is required (use empty string to delete)' };
47
+ }
48
+ if (old_string === new_string) {
49
+ return { success: false, error: 'old_string and new_string are identical — nothing to do' };
50
+ }
51
+
52
+ const target = _expand(filePath);
53
+ if (!fs.existsSync(target)) {
54
+ return {
55
+ success: false,
56
+ error: `file not found: ${target}. Edit cannot create files; use write_file for that.`,
57
+ };
58
+ }
59
+ let original;
60
+ try {
61
+ original = fs.readFileSync(target, 'utf8');
62
+ } catch (e) {
63
+ return { success: false, error: `read failed: ${e.message}` };
64
+ }
65
+
66
+ // Find occurrences. Use indexOf loop for an accurate count without
67
+ // pulling in regex escaping bugs (old_string is raw text, not a pattern).
68
+ let count = 0;
69
+ let i = 0;
70
+ while ((i = original.indexOf(old_string, i)) !== -1) {
71
+ count++;
72
+ i += old_string.length;
73
+ }
74
+
75
+ if (count === 0) {
76
+ // Help the agent debug: include a small excerpt so it sees how close
77
+ // its old_string is to reality (whitespace, casing).
78
+ const excerpt = original.length > 400 ? original.slice(0, 400) + '\n... (truncated)' : original;
79
+ return {
80
+ success: false,
81
+ error: 'old_string not found in file',
82
+ path: target,
83
+ hint: 'Verify exact whitespace, casing, and that you Read the file recently.',
84
+ file_excerpt: excerpt,
85
+ };
86
+ }
87
+
88
+ if (count > 1 && !replace_all) {
89
+ return {
90
+ success: false,
91
+ error: `old_string is not unique (${count} occurrences). Pass replace_all: true to change every instance, or extend old_string with more surrounding context to make it unique.`,
92
+ path: target,
93
+ occurrences: count,
94
+ };
95
+ }
96
+
97
+ const updated = replace_all
98
+ ? original.split(old_string).join(new_string)
99
+ : original.replace(old_string, new_string);
100
+
101
+ try {
102
+ writeFileAtomicSync(target, updated);
103
+ } catch (e) {
104
+ return { success: false, error: `write failed: ${e.message}` };
105
+ }
106
+
107
+ const linesBefore = original.split('\n').length;
108
+ const linesAfter = updated.split('\n').length;
109
+ const lineDelta = linesAfter - linesBefore;
110
+
111
+ return {
112
+ success: true,
113
+ path: target,
114
+ replacements: count,
115
+ replace_all: !!replace_all,
116
+ lines_before: linesBefore,
117
+ lines_after: linesAfter,
118
+ line_delta: lineDelta,
119
+ };
120
+ }
121
+
122
+ module.exports = {
123
+ name: 'edit_file',
124
+ description:
125
+ 'Performs exact string replacement in an existing file. The old_string ' +
126
+ 'must match the file content EXACTLY (including whitespace and indentation) ' +
127
+ 'and must be UNIQUE unless replace_all=true. Use this for any targeted ' +
128
+ 'rename/refactor/patch instead of rewriting the file with write_file. ' +
129
+ 'Fails with a helpful hint when old_string is missing or ambiguous.',
130
+ inputSchema: {
131
+ type: 'object',
132
+ properties: {
133
+ path: { type: 'string', description: 'Absolute path of file to edit (~/-prefix allowed)' },
134
+ old_string: { type: 'string', description: 'Exact substring to replace (case-sensitive, whitespace-sensitive)' },
135
+ new_string: { type: 'string', description: 'Replacement text (must differ from old_string; pass "" to delete)' },
136
+ replace_all: { type: 'boolean', description: 'Replace every occurrence instead of failing on multiple matches', default: false },
137
+ },
138
+ required: ['path', 'old_string', 'new_string'],
139
+ },
140
+ execute: editFile,
141
+ // Exposed for unit tests.
142
+ _internals: { editFile },
143
+ };