Skip to main content
Glama
rianprei
by rianprei
README.md
# snapedit

Snapshot-based file editing for AI agents — **send only the NEW content, never the old block.**

[![Node.js](https://img.shields.io/badge/node-%3E%3D18-339933)](https://nodejs.org/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![MCP](https://img.shields.io/badge/protocol-MCP-%23f59e0b)](docs/PROTOCOL.md)
[![Tests](https://img.shields.io/badge/tests-55%2F55%20passing-brightgreen)](test/snapedit.test.js)

`snapedit` lets an editing agent (Claude Code, OpenCode, or any MCP client)
edit files by sending **only the new content** — never the old text. Files are
captured as immutable, content-addressed snapshots; edits reference the
snapshot's 1-based line numbers, so lines shifted by prior edits elsewhere
never silently misapply.

```
Agent                            snapedit                     Filesystem
  │  read_snapshot(path)            │
  ├────────────────────────────────►│  sha256(bytes) → blob_id
  │◄── blob_id + numbered page ────┤
  │                                 │   …(agent thinks)…
  │  apply_snapshot_patch(blob,     │
  │    from, to, NEW_CONTENT) ──────►│  base=snapshot · ours=live
  │                                 │  3-way merge OR splice
  │◄── ok:true / conflict / err ────┤
  │                                 │  atomic write + lock
```

## Why

Direct rewrite tools force the agent to transmit the old text to locate a
change; when a file changes between reads, misalignment silently corrupts
files. `snapedit` replaces that with:

- **Immutable snapshots** — `blob_id = sha256(bytes)`. The agent carries only
  the id; content is never re-sent.
- **3-way merge on apply** — base = snapshot, ours = live file. Earlier edits
  (even insertions above the hunk) are preserved; the patch lands exactly
  where the snapshot said it should.
- **Explicit conflicts** — if the live file changed *inside* the hunk with
  different content, no write happens; a structured conflict is returned with
  previews of both sides.
- **Safety over guesswork** — ambiguous patches (hunk text duplicated in the
  live file) are **refused with zero writes**, never silently applied to the
  wrong occurrence.
- **No shell transport** — all bodies travel over stdio MCP (JSON-RPC lines).
  Large files are paged, never truncated by a shell.

## Features

| Feature | Details |
|---|---|
| Content-addressed store | `~/.snapedit/blobs`, sha256 verified on read, recency index |
| Cross-process safety | advisory locks, `EEXIST`-tolerant writes, stale-lock recovery |
| Concurrency | parallel edits: disjoint ranges both land; same range → clean conflict |
| Ambiguity safety | duplicated hunk text ⇒ refused, file proven byte-identical |
| Store GC | `snapedit store prune [--days N] [--dry-run]` (blobs never garbage-collected automatically) |
| Zero dependencies | plain Node.js ≥ 18, ESM |

## Install

```bash
git clone https://github.com/rianprei/snapedit.git
cd snapedit
npm test          # 55 tests, no deps required
npm link          # optional: expose `snapedit` on PATH
```

## Quick start (CLI)

```bash
# snapshot + numbered page (default page 0, 300 lines)
snapedit read path/to/file.txt

# apply NEW content to snapshot lines 2..4 (1-based, inclusive)
snapedit apply path/to/file.txt <blob_id> 2 4 --content $'wrote new lines\n'

# where does the hunk map in the live file today?
snapedit locate path/to/file.txt <blob_id> 2 4

# run the MCP server over stdio
snapedit mcp
```

## Claude Code configuration

Add an MCP server entry (Claude Code `~/.claude.json`, or OpenCode's `"type": "local"` MCP):

```json
{
  "mcpServers": {
    "snapedit": {
      "command": "/abs/path/to/snapedit/bin/snapedit",
      "args": ["mcp"]
    }
  }
}
```

## Agent edit protocol (keep this short)

1. `read_snapshot` (page containing region) — remember `blob_id`.
2. `apply_snapshot_patch(path, blob_id, from, to, new_content)`.
   - `ok:true` → done.
   - `mode:"conflict"` → re-`read_snapshot`, retry on the new snapshot.
   - `kind:"snapshot_missing"` → re-`read_snapshot`.
3. Never send the old text; never concatenate whole-file bodies yourself.

`new_content` is the exact replacement for snapshot lines `[from..to]`
(1-based, inclusive). Multi-line edits work as a single `new_content`; the
merge replays the hunk in snapshot coordinates, so earlier shifts don't break
it.

## Documentation

- [Protocol](docs/PROTOCOL.md) — full JSON-RPC tool contracts, results, errors
- [Architecture](docs/ARCHITECTURE.md) — modules, data flow, concurrency model
- [Safety & invariants](docs/SAFETY.md) — ambiguity gate, conflict rules,
  corruption defenses, GC, locking
- [Changelog](CHANGELOG.md)
- [Contributing](CONTRIBUTING.md)

## Security

See [docs/SAFETY.md](docs/SAFETY.md) for the full property list. Highlights:

- Refuses ambiguous applies deterministically (`match_count != 1 → write == false`, proven byte-identical by sha256 in tests).
- Path-like `blob_id`s rejected (no filesystem traversal via the store).
- MCP input validation: malformed JSON-RPC → `-32600`, invalid tool args → `bad_arg`, never a crash of the server.
- Atomic write: temp file in same dir + `rename` → readers never observe partial content.

## License

MIT — see [LICENSE](LICENSE).