@heartidentity/datoeditsync
v0.6.0
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, --media. |
| 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), --prune (drop records that no longer exist remotely), --media (also mirror the media library into uploads/, and remember the choice). |
| status | Local changes vs base: new / modified / deleted, per record with affected fields — plus records that were deleted in DatoCMS and can only be discarded, and (with the media mirror on) changed assets and the ones no record references. |
| 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> (repeatable), --model, --delete <id> (repeatable), --continue (keep going after a record fails). |
| publish | Separate, explicit step. Dry-run by default; --apply publishes. Select with --record <id> (repeatable), --records-from <file>, --status draft\|updated or --all; --locale <locale> publishes single locales, --recursive publishes unpublished parents too. |
| discard <id...> | Throw away local changes to a record or an asset: back to the last synced state, or remove the file when it is gone from DatoCMS. Dry-run by default; --apply executes. |
| 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 and answers with a single JSON object on stdout;
ok is the field to branch on. Exit codes: 0 ok · 1 error · 2 conflict
(or a publish blocked by a draft parent) · 3 validation failure · 4 not a
project · 5 generated files out of date.
| Command | --json payload |
|---|---|
| status | { ok, generatedDrift, changes[], deletedRemotely[], uploads?, orphanedBase[] } — uploads is { changes[], unreferenced[] } |
| diff | { ok, changes[] } — before/after per field |
| push (dry-run) | { ok, uploads[], media[], planned[] }; each plan entry { model, id, action, changedFields[], reason?, merged?, fromRemote?, conflict? }, action create\|update\|delete\|sync\|skip. media[] is the same for assets, action update\|replace\|delete\|sync\|skip. ok is false only for a real collision — a record skipped because it is gone remotely is still ok. |
| push --apply | { ok, applied, merged, skipped, remaining, failed[], uploads, media } — applied is what was really written |
| publish (dry-run) | { ok, wouldPublish[], blocked[] } — a blocked entry names the draft parent in blockedBy |
| publish --apply | { ok, published[], blocked[], failed[] } |
| pull | { ok, pulled, merged, skippedLocal, deleted, conflicts[], uploads?, generated } |
| validate | { ok, errors[], warnings[] } |
| regen | { ok, toolVersion, written[], skipped[] } |
| discard | { ok, reverted, dropped, discarded[] } |
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. In a localized block field every locale holds its own block tree with its own ids — filling a second locale means cloning the structure with every"id"set to"$new", never reusing the other locale's ids. - 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. - A push is record by record, not one transaction: DatoCMS has no transaction
spanning records. If one record fails, the ones written before it stay written,
and the summary says exactly how many went through, which record stopped it and
how many were never attempted.
--continuefinishes the rest and lists every failure at the end. - Publishing works on sets:
publish --all(or--status updated,--records-from <file>, repeated--record) goes through the CMA bulk endpoint, 200 records per request and one request per tree level — parents before children, so nothing races its own parent. A record whose parent is still a draft is reported by the dry-run instead of failing the apply. - Locales are separate publications:
publish --locale en --locale frreleases those locales and leaves the others untouched. - A record deleted in DatoCMS while the local copy carries changes is not a
modification — it is listed under
deleted remotelyinstatusand cleared withdiscard <id>orpull --prune. - 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. Thealtandtitlegiven there are written to the new asset's own metadata as well, in the primary locale.
The media library
Alt text and title of an image live in DatoCMS at the upload, not at the
record that uses it — localized, one value per locale. Anything built on the
mirror is therefore blind to them: a translation pass over content/ never sees
a single caption, and the first sign that it missed them is German alt text on
the English site.
pull --media mirrors the library into uploads/<id>__<basename>.json — metadata
only, never the binaries — and records the choice in config.json, so every later
pull, status, diff and push includes it. It is opt-in per project because
a mid-sized library is a few thousand files.
{
"id": "OY7kQfWsQ4-7CiDxVqcwVQ",
"filename": "gymnasium.jpg",
"mime_type": "image/jpeg",
"is_image": true,
"width": 2000, "height": 1333,
"basename": "gymnasium",
"tags": [],
"fields": {
"alt": { "de": "Gymnasium in Uetikon", "en": "Grammar school in Uetikon" },
"title": { "de": null, "en": null },
"custom_data": { "de": {}, "en": {} },
"focal_point": null,
"poster_time": null
}
}fields is the upload's default_field_metadata, and it deliberately looks like
any other record's field map: alt, title and custom_data are localized, so
the same diff, the same per-locale merge and the same conflict rules apply — and
any tooling that already walks content/ walks this without a special case.
- Editable:
fields.*,basename(part of the asset URL, so SEO-relevant),tags,author,copyright,notes,upload_collection. A push writes only the metadata keys that changed; the rest of the asset's metadata is left alone. - Read-only mirror:
filename,mime_type,is_image,width,height,size,url,md5,smart_tags,meta. - Choosing an asset is
grep, not an API call: searchuploads/by file name, alt text, tag, mime type or dimensions to get anupload_idfor a record.statusalso names the assets no record in the mirror references. - Replacing an asset's file: put
"$path": "media/<file>"next to the existing"id". The id survives, so every record referencing it shows the new file without re-linking;"$replace_strategy": "create_new_url"opts out of trying to keep the URL. ($pathwithout an id still means "create", and that form belongs in a record's file field.) - Renaming is changing
basename; deleting is"_delete": true— which, unlike a record deletion, takes the asset away from every record still using it. - Assets cannot be created from
uploads/: a new one enters throughmedia/plus a$pathreference in a record, which is where the binary actually is.
Note on the API: default_field_metadata is keyed by field with a locale map
inside ({ "alt": { "de": … } }). The generated CMA TypeScript types claim the
opposite nesting; a payload in that shape is rejected with 422 INVALID_FORMAT.
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
│ ├── base/_uploads/<id>.json # …and the same for mirrored assets
│ ├── meta.json # updated_at, hash and version token per record
│ └── uploads.json # md5 → upload id (media dedup manifest)
├── content/<model>/<id>__<slug>.json
├── uploads/<id>__<basename>.json # asset metadata (opt-in: `pull --media`)
├── 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 schema migrations (the 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. The binaries themselves are
not mirrored either: uploads/ holds metadata and references, and media/ stays
what it is, a drop-zone for files still to be uploaded.
License
MIT
