fedit: A Deterministic CLI + MCP File Editor for LLMs That Can’t Stop Mangling Your Configs

The Problem: LLMs Are Terrible at Surgical File Edits

The first time an LLM helpfully rewrote my entire nginx.conf because I asked it to add a single proxy_pass directive, I didn’t notice until the site started returning 502s. The model had quietly dropped two map blocks, reordered the server contexts, and normalized all my tab indentation to spaces. The “change” it made was correct. Everything it touched on the way there was not.

This isn’t a fluke — it’s structural. LLMs generate tokens sequentially with no persistent parse tree, no structural lock on what they haven’t changed, and no concept of “leave this alone.” When you ask a model to modify a file, the path of least resistance is to regenerate the whole thing from its internal probability distribution over what that file should look like. That distribution is trained on millions of config files, not on your config file. So you get something plausible-looking that silently diverges. With Terraform, “silently diverges” means terraform plan showing a destroy on a resource you never touched — because the model dropped a lifecycle block or reordered an argument inside a dynamic block in a way that changes the diff.

The operational cost on a self-hosted Ollama setup compounds this fast. On my 32GB VRAM workstation I run a fast model for throughput and a larger quality model for complex reasoning — but either way, a full-file rewrite on a 400-line main.tf burns real inference time and produces a diff that is almost entirely noise. You spend the next ten minutes reading a wall of green and red in git diff trying to find the one semantic change buried in the whitespace churn. That’s not a workflow — that’s QA theater. The model isn’t helping you edit; it’s authoring a replacement and calling it an edit.

The failure modes cluster around a few specific patterns:

  • Comment evaporation — inline comments explaining why a timeout is set to a weird value, or why a particular CIDR is hardcoded, just disappear because they weren’t in the model’s training signal as “load-bearing.”
  • Block reordering — HCL and nginx are order-sensitive in ways that aren’t always obvious; the model reorders blocks to match what looks “clean” to it, and suddenly your depends_on implicit ordering is broken.
  • Whitespace normalization — harmless on its own until you have a yamllint step in CI, or until the whitespace diff obscures the actual semantic change in review.
  • Silent omission — a stanza the model didn’t understand gets quietly dropped rather than preserved verbatim. No warning, no marker, just gone.

The fix isn’t prompting harder. “Only change line 47” is not a reliable constraint on a token generator — it’s a suggestion that degrades under longer context, model quantization, or any rephrasing. What you actually need is a tool with a contract: given a precise location specifier, make exactly that mutation and prove it. That’s the gap fedit was built to close. For a broader map of where file editing fits among the full spectrum of AI coding tools — cloud copilots, local models, agent loops — the AI Coding Tools in 2026: Cloud Copilots vs Local Models guide covers the space well.

What fedit Actually Does (and Doesn’t Do)

The thing that finally broke me was watching a well-prompted GPT-4 response confidently rewrite my entire nginx.conf to fix a single proxy_pass line — and silently drop three location blocks in the process. The model didn’t hallucinate the fix, it just regenerated the whole file from context and lost fidelity on everything it didn’t care about. fedit exists because that failure mode is structural, not fixable with better prompting.

The core mechanic is deliberately narrow: fedit takes a file path and an operation, then executes it exactly. Operations are either line-addressed (replace lines 42–44 with this block, delete line 17, insert after line 9) or anchor-based (insert after the first line matching /upstream backend/, replace the block between # BEGIN fedit and # END fedit). Nothing is inferred. Nothing is regenerated. If you say replace lines 42–44, those three lines are replaced and the rest of the file is untouched, byte for byte. This matters enormously for Terraform HCL, Kubernetes manifests, and nginx configs where a stray newline or a reordered key can break a plan or fail a reload.

fedit ships two interfaces that serve different callers. The CLI is a direct shell binary — cron-safe, scriptable, composable with grep and awk to locate line numbers before passing them in:

# anchor-based insert — no line number required
fedit insert-after \
  --file /etc/nginx/sites-enabled/app.conf \
  --match "location /api {" \
  --content "    proxy_read_timeout 120s;"

# line-addressed replace — deterministic, no pattern ambiguity
fedit replace-lines \
  --file ./terraform/main.tf \
  --start 42 --end 44 \
  --content 'instance_type = "t3.medium"'

The MCP server interface is the other entry point, and it’s the reason fedit is useful inside an agent loop rather than just as a personal shell alias. Model Context Protocol lets an LLM call fedit as a structured tool rather than streaming raw file content. Instead of the model saying “here is the full updated file, please write it to disk,” it emits a tool call like fedit_replace_lines(file, start, end, content) — and the MCP server executes that operation. The file changes exactly what the operation specifies. The model never sees, touches, or regenerates the surrounding content. On my n8n flows that manage config drift across a handful of services, this is the difference between a 4,000-token round-trip that risks corruption and a 60-token tool call that changes one value.

What fedit explicitly does not do is worth being specific about. No LLM inference — it has no model, no embeddings, nothing generative. No syntax validation — if you replace a YAML key with malformed indentation, fedit applies the edit and moves on; catching that is your linter’s job, not fedit’s. No git operations — it doesn’t commit, stage, or checkpoint anything before editing. That last point is intentional: git is already a scalpel for version control, and composing fedit with a pre-edit git stash or a post-edit git diff is a one-liner. Baking git into fedit would mean making assumptions about your workflow that would be wrong half the time.

Installing fedit and Wiring It to Your Local Stack

The first thing that surprised me: fedit ships as a plain npm package, so there’s no binary download dance or architecture-specific release to track. Global install is one line, but global installs in automated environments are a trap — you get whatever version npm resolves at build time unless you pin explicitly. For my PM2 and Docker setups I install it locally into a dedicated tooling directory and commit the package-lock.json. That lock file is what actually guarantees reproducibility; the version field in package.json alone won’t save you if the registry gets a patch release overnight.

# Global install (fine for workstation use)
npm install -g [email protected]

# Pinned local install for Docker/PM2 — do this instead
mkdir -p /opt/fedit-tools && cd /opt/fedit-tools
npm init -y
npm install [email protected]
# reference via: ./node_modules/.bin/fedit

# In a Dockerfile, layer it after your package copy so Docker cache is useful
RUN npm ci --prefix /opt/fedit-tools

The CLI contract is intentionally boring, which is exactly what you want when scripting. Exit code 0 means the operation applied cleanly. Exit code 1 means the anchor pattern wasn’t found or the file couldn’t be written — and the error goes to stderr, nothing to stdout. That separation matters: in an n8n Execute Command node or a bash pipeline you can capture stdout for confirmation messages and route stderr to your error handler without regex-parsing combined output. A real invocation against a live nginx config looks like this:

# Replace a server_name line in-place
fedit replace \
  --file ./nginx/site.conf \
  --anchor 'server_name' \
  --with 'server_name homelab.local;'

# stdout on success:
# replaced 1 occurrence(s) in ./nginx/site.conf

# stderr on anchor-not-found:
# error: anchor pattern 'server_name' not found in ./nginx/site.conf — no changes written

The “no changes written” behavior on a failed anchor match is the key safety property. A grepping sed pipeline will silently succeed and write an empty file. fedit refuses to touch the file at all if the anchor resolves to nothing. I wrap every invocation in a check: if ! fedit replace ...; then notify and abort. For Terraform and nginx configs that are a reboot away from breaking prod, silent partial edits are worse than a failed deployment.

MCP server mode is where fedit earns its keep in an agent pipeline. Start it with fedit serve --port 3400 and it exposes a JSON-RPC endpoint that any MCP-compatible client can call. The schema it registers covers three operations — replace, insert-after, and delete-range — each with typed parameters the agent model can fill without hallucinating flags. To point an Ollama-backed agent at it, you register the server URL in your agent’s tool config. With llama3.1 or qwen2.5-coder on my 32GB box I run the agent model via Ollama and fedit handles the actual disk writes so the model never gets a chance to generate a mangled file directly:

# Start the MCP server (keep it running under PM2)
fedit serve --port 3400 --allowed-roots /opt/configs

# Example MCP tool call the agent emits (JSON-RPC 2.0):
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "replace",
    "arguments": {
      "file": "/opt/configs/nginx/site.conf",
      "anchor": "server_name",
      "with": "server_name homelab.local;"
    }
  },
  "id": 1
}

# fedit returns structured result — agent reads confirmation, never raw file content

The gotcha that will burn you exactly once: file path resolution is relative to the process CWD at invocation, not the config file and not the fedit binary location. This is standard Node.js behavior but it’s invisible until you run fedit from an n8n Execute Command node where the working directory defaults to whatever n8n’s process root is — usually something like /home/node or the Docker container’s WORKDIR, not your config directory. The fix is either always use absolute paths in your --file argument, or set the working directory explicitly in the Execute Command node before the fedit call. I use absolute paths everywhere now and keep a CONFIG_ROOT env var in my n8n container that I prepend to every fedit invocation. Relative paths in automated pipelines are a deferred debugging session.

Three Non-Obvious Behaviors Worth Knowing Before You Rely on This in Production

The first one bites people silently, which makes it the most dangerous: anchor matching is literal substring search, not exact key match. If your YAML has both timeout: 30 and connect_timeout: 10 on separate lines, running fedit --anchor timeout matches the first occurrence — which is connect_timeout if it appears earlier in the file. The edit goes through, no error, wrong line modified. The fix is either a more specific pattern (--anchor "^timeout:" with a regex flag if your version supports it) or the positional override:

# Target the second match when the first is a false positive
fedit --anchor "timeout" --anchor-nth 2 --replace "timeout: 60" config.yaml

# Better: use enough surrounding context to be unambiguous
fedit --anchor "  timeout: 30" --replace "  timeout: 60" config.yaml

The leading-whitespace approach in the second example is underused. YAML indentation is structural, so " timeout: 30" (two spaces) only matches that key at that nesting depth. It’s not elegant, but it’s deterministic in a way that bare key names aren’t. Check your actual indentation with cat -A config.yaml before building the pattern — tabs vs spaces will break the match entirely and fedit will exit with a no-match error rather than guessing.

The concurrency issue is easy to dismiss until it isn’t. fedit holds no file lock during its read-modify-write cycle. On my n8n setup, parallel branches that both need to update the same nginx.conf or a shared .env file will race, and the last writer wins — except sometimes you get a partially written file instead. The fix isn’t complicated: a Mutex node or a simple lockfile wrapper around both fedit calls keeps the operations sequential.

# Lockfile wrapper — drop this in a shell Execute node before any fedit call
LOCKFILE=/tmp/config-edit.lock

(
  flock -x 200
  fedit --anchor "worker_processes" --replace "worker_processes 4;" /etc/nginx/nginx.conf
) 200>"$LOCKFILE"

In n8n specifically, a Wait node with a webhook resume is overkill for this. A single Execute Command node that wraps the flock pattern above is enough. The important thing is that both branches in your workflow go through the same lock path — if one branch bypasses it, you’re back to races.

Dry-run mode is the feature that makes automated pipelines defensible. --dry-run prints the proposed diff to stdout and exits without writing anything. Pipe that output into whatever your config validator accepts:

# Apply the edit to a temp copy, validate, then write for real
fedit --dry-run --anchor "server_name" --replace "server_name example.com;" nginx.conf \
  | patch --dry-run -p0 nginx.conf -

# Or: write to a temp file, validate, then move it into place
cp nginx.conf /tmp/nginx.conf.staging
fedit --anchor "server_name" --replace "server_name example.com;" /tmp/nginx.conf.staging
nginx -t -c /tmp/nginx.conf.staging && mv /tmp/nginx.conf.staging nginx.conf

The temp-file pattern is more reliable than piping diffs for most validators, because tools like nginx -t and terraform validate need to read the actual file off disk. Build this as a two-step sequence in your automation: fedit writes to a .staging copy, the validator checks it, and only a passing validation triggers the final mv. A failed validation leaves the original untouched and drops an error you can alert on.

The MCP schema version-lock is the one that causes the most confusion during upgrades. The tool manifest fedit generates describes the exact operation names, required fields, and accepted enum values for the version that generated it. Upgrade fedit from one minor version to another and the manifest is stale — the agent sends an operation shape the new binary doesn’t recognize and gets back a schema validation error that reads like a bug rather than a version mismatch. The fix is one command, but you have to remember to run it:

# Regenerate after any fedit upgrade
fedit --export-mcp-manifest > ~/.config/fedit/mcp-manifest.json

# Confirm the agent's tool registry picks up the new file —
# in most MCP setups this means restarting the agent process or
# triggering a tool-reload if your host supports hot reload

If you version-control your manifest (which you should, since it’s the contract between fedit and whatever agent calls it), the diff between old and new manifests will show exactly which operation fields changed. That’s a much faster debugging path than reading agent logs trying to figure out why a previously working tool call suddenly fails validation.

Real Pipeline: Ollama Agent → fedit MCP → Config Reload

The Full Flow: From Natural-Language Instruction to Reloaded Service

The surprising part of running this in production isn’t the LLM step — it’s how much the agent’s behavior improves once you replace write_file with a structured editor. On my 32GB workstation I run qwen2.5-coder:32b in the fast-model slot (it fits comfortably; the model weights land around 19GB in Q4_K_M quantization). When the agent receives something like “set worker_processes to 4 and add proxy_read_timeout 120s to the upstream block in nginx.conf”, the decision path is: understand intent → emit a fedit tool call → wait for structured confirmation → conditionally reload. The key word is “conditionally.” Without structured feedback from the editor, the agent has no reliable branch point.

Here’s what the MCP tool call JSON actually looks like when qwen2.5-coder decides to invoke fedit:

{
  "tool": "fedit",
  "arguments": {
    "file": "/etc/nginx/nginx.conf",
    "operation": "replace",
    "anchor": "worker_processes auto;",
    "replacement": "worker_processes 4;",
    "context_lines": 2
  }
}

On success, fedit returns the anchor it matched, the line range it touched, and a SHA-256 of the post-edit file. On failure — anchor not found, ambiguous match, file locked — it returns a structured error that includes the nearest fuzzy match it did find. That error is the thing that changes agent behavior. Instead of the model deciding “anchor failed, I’ll just rewrite the whole file from memory,” it gets back something like "anchor_not_found": true, "nearest_match": "worker_processes auto;" (two spaces, as it happens, from a copy-paste). The agent can correct the anchor and retry without ever touching file content it didn’t intend to touch. Compare that to the hallucinated full-file rewrite: I’ve watched a model given only read_file + write_file silently drop SSL certificate paths and upstream health-check directives because they were outside the context window’s attention at write time.

The numbers on the naive approach matter. A typical nginx.conf read costs the model 800–1200 tokens depending on comment density. Writing it back costs another 800–1200. A fedit call for the same single-line change costs roughly 80–120 tokens total — the tool call JSON plus the structured response. That’s a 10× reduction per operation, which compounds fast when an agent is making 5–8 config changes in a single task. Auditability also collapses with write_file: your only record is “model wrote a file.” With fedit you get a structured log of exactly which anchor was targeted, which line range changed, and the before/after content of only those lines. That log is what you show in a post-incident review when someone asks what the automation changed.

After fedit confirms success, the shell step is a one-liner that keeps the blast radius tight:

# nginx path; swap for `terraform validate` in infra flows
nginx -t 2>&1 && nginx -s reload || echo "VALIDATION_FAILED"

The 2>&1 redirect matters because nginx writes its test output to stderr; without it the downstream step sees empty stdout and misreads a failure as success. For Terraform flows the same pattern holds: terraform validate -json gives you machine-readable output you can parse rather than scraping human-readable text.

Wiring It Into n8n

The n8n integration sits between the agent’s tool call and the shell step. An HTTP Request node POSTs the fedit tool call payload to the MCP endpoint — I run fedit’s MCP server on port 3742, nothing special about that number, just not 3000 or 8080 where something else is already listening. The response feeds into a Merge node configured in “Wait for all inputs” mode. One branch processes the fedit response status; the other is a simple timer node set to a 2-second delay (enough for filesystem flush on ext4, though honestly any non-zero delay works here). The Merge output routes on {{ $json.success === true }}: true goes to the Execute Command node running nginx -t && nginx -s reload, false goes to a Slack node that sends the structured error verbatim. The verbatim error is deliberate — don’t summarize it, don’t let another LLM rephrase it. The person reading the Slack alert needs the exact anchor that failed so they can fix the instruction upstream.

// n8n HTTP Request node — Body (JSON)
{
  "file": "{{ $json.file_path }}",
  "operation": "{{ $json.operation }}",
  "anchor": "{{ $json.anchor }}",
  "replacement": "{{ $json.replacement }}",
  "context_lines": 2
}

// n8n IF node expression routing reload vs. alert
{{ $node["fedit_mcp"].json.success === true }}

One gotcha that took me a few runs to catch: n8n’s HTTP Request node will follow redirects by default, and if the MCP server ever restarts mid-request and the process manager (I use PM2) briefly binds to a different ephemeral port before settling, you’ll get a 200 from the wrong thing. Set "allowUnauthorized": false and pin the MCP endpoint to a fixed port via PM2’s ecosystem.config.js rather than letting the process choose. The workflow is stateless enough that a failed HTTP Request simply routes to the Slack branch — no partial edit gets applied because fedit’s write is atomic at the OS level, using a write-to-temp-then-rename pattern rather than in-place overwrite.

When fedit Is the Wrong Tool

The honest answer is that fedit was built for a narrow problem: deterministic, automated, text-based edits in a pipeline where you can’t babysit a terminal. Every design choice that makes it good at that job makes it actively bad at other jobs.

The clearest failure mode is structural reorganization. If you need to reorder nginx location blocks so more-specific prefixes sort above catch-all patterns, fedit can’t help you — it doesn’t parse nginx syntax, it doesn’t understand that location /api/v2/ should precede location /api/ which should precede location /. It sees lines, not semantics. Same story with Terraform: refactoring a multi-resource module so locals blocks land before resource blocks that reference them requires understanding the HCL graph, not just splicing text. For that work, reach for hcledit (HCL-aware, composable with pipes) or yq for YAML restructuring. These tools parse the actual structure and can query or mutate it relationally.

# hcledit can do what fedit cannot — address by HCL path, not line
hcledit attribute get aws_instance.web.instance_type -f main.tf

# yq can reorder YAML keys semantically
yq eval '.spec.containers |= sort_by(.name)' deployment.yaml

Auto-generated files are a different trap. Helm chart renders, Pulumi stack outputs, any file where the line count and content shifts every time you run the generator — fedit’s anchor system relies on stable surrounding context. If the anchor text itself gets regenerated, your edit either misses the target or lands in the wrong place silently. This is the category of failure that’s hardest to catch in CI because it doesn’t error out, it just edits the wrong line. If you’re touching generated files at all, you’re usually better off modifying the template or the generator config upstream rather than patching the output.

For one-off interactive edits, fedit is strictly worse than vim. Constructing the flags to target a specific block, running the command, checking the diff — that sequence takes longer than vim +/search_term file and a couple of keystrokes. fedit’s ceremony pays off when the same edit runs in a cron job, an n8n workflow node, or an MCP tool call where there’s no interactive session. If you’re sitting at a terminal and the edit is a one-time thing, just open the file.

The encoding edge case is real and annoying. fedit processes text as UTF-8 byte streams. Files with Windows-style \r\n line endings, BOM headers (common in files touched by certain .NET or older Windows editors), or mixed encodings will produce edits that look correct in the diff but corrupt surrounding bytes in ways that the target application notices. Nginx will refuse to load a config with a stray \r before a semicolon. Terraform will throw a parse error on a BOM. Before running fedit on any file that’s been through a Windows toolchain, run it through file first:

# Check before you commit to a fedit pipeline on unknown-origin files
file nginx.conf
# "ASCII text" or "UTF-8 Unicode text" — safe
# "ASCII text, with CRLF line terminators" — strip first
# "UTF-8 Unicode (with BOM) text" — strip first

dos2unix nginx.conf   # handles both CRLF and BOM in one shot

The shape of fedit’s usefulness is narrow by design: known file format, stable content, automated pipeline, repeatable edit. Push it outside that shape and you’re not fighting the tool’s limitations, you’re just using the wrong tool.

Verdict: Narrow Tool, Right Job

The thing that pushed me to build fedit wasn’t ambition — it was watching a well-prompted LLM silently replace a 200-line nginx config with a structurally correct but semantically wrong one, because the only write primitive available was “here is the entire new file.” Once you’ve chased that class of bug, you stop wanting flexible file-write tools and start wanting constrained ones.

fedit solves exactly one thing: an LLM agent can hand it an anchor string, a replacement block, and a target file, and the tool will refuse to touch anything it can’t deterministically locate. No anchor match, no write. That’s the whole contract. The MCP surface exposes this as a structured call so the agent never has to reason about file state — it just gets a success or a clean failure it can report back. On my self-hosted stack, this collapses what used to be a “read full file → generate new full file → write” token cycle into a minimal targeted operation. For large Terraform modules or nginx configs that are hundreds of lines, the token savings per operation are material, not cosmetic.

The dry-run gate matters more than it sounds. Before anything lands on disk, you can see exactly which line range would be affected, what the outgoing text looks like, and what the replacement is. That’s the diff you want to log. In my n8n flows, I pipe the dry-run output to a simple approval node for any file that’s in a production path — the actual write only fires if the diff looks sane. Without a primitive like this, you’re either trusting the LLM’s full-file output blindly or writing your own diffing shim, and the latter is how you get subtle off-by-one bugs in the write logic.

The honest adoption path: don’t retrofit your entire agent architecture around fedit. Drop it into pipelines that already function but have a file-write step you watch nervously. The config-edit loop in my TypeScript/Node publishing engine was exactly that — everything upstream worked, but the “patch the front matter” step was one bad generation away from corrupting a file. Replacing that step with a fedit call took under an hour and immediately gave me audit logs and dry-run safety. That’s the right integration scope. Where fedit will disappoint you is on files with low-structure or repetitive content — if your anchor string appears four times in the file, you need to understand how fedit resolves that ambiguity before trusting it at scale. Test anchor-matching behavior on your actual file corpus, not on clean examples. The tool is only as reliable as the anchors your LLM learns to generate for your specific file shapes.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Eric Woo

Written by Eric Woo

Self-Hosted AI & Automation Engineer

Eric runs his own self-hosted stack: local LLM pipelines on Ollama with dual-model VRAM scheduling on a single 32GB workstation, n8n workflows in Docker, and a TypeScript automation engine that publishes to WordPress on cron. He writes about the systems he actually operates — configs, failure modes, and GPU bills included.

Leave a Comment