@heartidentity/datoeditsync
v0.5.2
Published
Mirror a DatoCMS project as local text files so LLMs can edit content with plain file tools — with diff review before writing back.
Maintainers
Readme
@heartidentity/datoeditsync
Mirror a DatoCMS project as local text files so LLMs (Claude Code / Cowork) can edit content with normal file tools — with a diff review before anything is written back, three-way conflict detection like git, and drafts-only writes.
Editing DatoCMS content through the API/MCP is slow and unreviewed: every read
and write is a round-trip, changes go live immediately, and there's no full-text
search over the content. datoeditsync pulls the content down as JSON files. The
LLM edits files (Read / Edit / Grep), the tool shows a diff before pushing,
checks for conflicts, and writes back only after confirmation — always as drafts.
Quick start
npx @heartidentity/datoeditsync initThis prompts for a DatoCMS Content Management API token, validates it, pulls
the schema and content, generates a Claude skill + CLAUDE.md tailored to the
project, and offers to git init (recommended — content edits are hard to undo
without it). Then just edit files under content/ and sync back.
Commands
| Command | What it does |
|---|---|
| init | Interactive setup: token, schema + content pull, skill/CLAUDE.md, .gitignore. Flags: --token, --environment, --dir, --git, --hook. |
| pull [record] | Remote → local. Refreshes content/, schema/ and the base snapshot; merges concurrent remote edits into the working copy. --model <api_key>, --force (scoped to [record] when given). |
| status | Local changes vs base: new / modified / deleted, per record with affected fields. |
| diff [target] | Detailed field diff (coloured in a TTY, plain with --no-color). |
| validate | Check local files against the schema: required fields, enums, allowed block types, item limits, tree placement. Runs automatically before every push. |
| push | Dry-run by default — shows exactly what would change and checks conflicts. --apply executes. Writes drafts only. --record <id>, --model, --delete <id>. |
| publish | Separate, explicit step. Dry-run by default; --record <id> / --all with --apply publishes. |
| new <api_key> | Create a local record template (id: "$new") from the schema. For a block model the template is printed instead of written. |
| regen | Rewrite the generated files (skill, CLAUDE.md, schema overview) from local state — offline and idempotent. --check (CI), --diff, --force. |
| changelog | What changed in datoeditsync since this project was last generated. --since <version>, --all. |
Every command accepts --json for machine-readable output. Exit codes:
0 ok · 1 error · 2 conflict · 3 validation failure · 4 not a project ·
5 generated files out of date.
Invocation
Nothing is installed: the generated SKILL.md and CLAUDE.md spell every command
as npx @heartidentity/datoeditsync@latest …. A documented command therefore works
on any machine that has npx, and the tool that runs is always the one the
instructions were written for — there is no installed copy that can drift.
init also offers a SessionStart hook (--hook / --no-hook) which runs pull
whenever a Claude Code session starts in the project, so an editor never has to
remember to sync. It is opt-in: the harness then runs that command unattended, and
it needs network access.
Staying current
init sets a project up; pull and regen keep it up to date. The generated
files carry a provenance stamp (<!-- generated by datoeditsync X.Y.Z -->) and
.datoeditsync/config.json records the hash of each one as it was written.
pullregenerates them whenever the tool or the schema has moved on — but only files that still match what it wrote. Anything edited by hand is left alone and reported.statusandpushwarn when they are out of date, so drift surfaces where you already are.regen --checkexits5on drift, for CI.
This matters more than ordinary documentation rot: SKILL.md is the contract an
agent acts on. When push changed from "conflict → skip" to "merge → MERGE", an
agent on the old skill would have kept reporting conflicts that no longer exist —
confidently.
How it works
Three-way model like git: base (snapshot from the last sync), local (your working copy), remote (DatoCMS).
- Push compares local vs base to find changes, then merges them against the
current remote field by field — and for localized fields locale by locale.
Only the fields you actually changed are written (with the record's
current_versionas an optimistic lock), so a colleague editing other fields of the same record at the same time loses nothing; such a record is reported asMERGErather thanUPDATE. - A conflict is the same field — same locale — changed on both sides. Those
records are skipped;
push --jsonreportsbase,localandremotefor each colliding field, so a human or an LLM can resolve it semantically. Take the remote version for one record withpull --force <id>. - Not merged automatically: two edits inside one string, one locale of a block-bearing field (rich/structured text), or one plain object field (SEO, file references). Those are real conflicts and are shown.
- Pull uses the same merge: it folds remote changes into records you have edited locally instead of refusing, and reports only genuine collisions.
- New records use
id: "$new"→ created remotely, with the real id written back and the file renamed. - Deletions are explicit: set
"_delete": truein the file or usepush --delete <id>. Deleting a file on disk does nothing. - Blocks are stored inline as
{ "id", "type", "fields" }. Keep theidto update, use"$new"to add, remove the object to delete. - Tree models keep their placement in the file:
parent_id(the parent record's id, ornullat the root) andpositionsit next toid, outsidefields. Editing them moves the record on the next push; they are merged and conflict-checked like any other value. Deletion ignores them, since DatoCMS renumberspositionwhenever a sibling moves. - Assets: existing assets are referenced by upload id. New files go into
media/and are referenced as{ "$path": "media/<file>", "alt": "…" };push --applyuploads them (deduplicated by content hash, tracked in.datoeditsync/uploads.json) and rewrites the reference to the realupload_id.
File layout
my-project/
├── .datoeditsync/
│ ├── config.json # project id, environment, options, generated-file
│ │ # provenance (never the token)
│ ├── base/<model>/<id>.json # three-way base snapshot
│ ├── meta.json # updated_at + hash per record at last sync
│ └── uploads.json # md5 → upload id (media dedup manifest)
├── content/<model>/<id>__<slug>.json
├── media/ # new assets, referenced via { "$path": "media/…" }
├── schema/<model>.json + _overview.md
├── .claude/skills/dato-edit/SKILL.md
├── CLAUDE.md
└── .env # DATOCMS_API_TOKEN (gitignored)Auth
v1 uses a CMA API token (full-access or a custom role), stored in .env as
DATOCMS_API_TOKEN and read at runtime. It is never written to config.json
and never logged.
Development
npm install
npm run build # compile to dist/
npm test # unit tests for the diff / merge / conflict engine
npm run dev -- status # run the CLI from source
npm run test:integration # live end-to-end test against a real DatoCMS projectThe integration test needs DATOCMS_TEST_TOKEN (a CMA token for a dedicated
test project, with schema + environment rights) — set it in the environment or
put DATOCMS_TEST_TOKEN=… into a .env in the repo root (gitignored). Without
the token it skips cleanly.
Non-goals (v1)
No asset editing/deletion (uploading new files via $path is supported; managing
the media library is not), no schema migrations (schema is read-only), no
realtime/watch mode, no multi-user locking — the three-way merge plus DatoCMS'
current_version optimistic lock is the answer to concurrent editing.
License
MIT
