# Functional Specification: cms-edit

## 1. Purpose

`cms-edit` is a CLI tool and MCP server for reading and writing draft content in a Contentful CMS space. It is designed to be operated by an AI agent or a human via the command line. All write operations produce Contentful drafts; no publish, unpublish, archive, or delete operation is exposed.

---

## 2. Interfaces

**CLI binary (`cms-edit`)** — invoked with subcommands and arguments from a terminal or shell script.

**Hosted MCP server** — deployed per site at `/api/mcp` (OAuth). Exposes `cms_edit`, `cms_edit_help`, `cms_edit_customer`, guide/customer resources, and onboarding prompts. Local stdio MCP is not supported; IDE/terminal agents use the `cms-edit` CLI directly.

---

## 3. Configuration

A project-level `.contentful-cms.json` file defines one or more Contentful spaces. Each space entry holds:

- `spaceId` — Contentful space identifier
- `environment` — Contentful environment name (e.g., `master`)
- `managementToken` — Contentful Management API token, expressed as an env var reference (e.g., `${CMS_EDIT_TOKEN_OM1}`)
- `defaultLocale` — locale string (e.g., `en-US`)
- `devBaseUrl` — local app URL for `preview url` and `preview showcase`

A `defaultSpace` key selects the active space when none is specified. The `--space` flag overrides per invocation.

**Environment variables:**

| Variable | Purpose |
|---|---|
| `CMS_EDIT_TOKEN_<SPACE>` | Management token per space |
| `CMS_EDIT_JSON=1` | Force JSON output mode globally |
| `CONTENTFUL_CMS_SESSION=<name>` | Select a named session for concurrent workflows |

---

## 4. Session Model

A session is the unit of work. It is created when content is opened and persists on the local filesystem until saved or discarded. Multiple named sessions can be active concurrently.

Each session holds:
- The loaded entry tree with assigned `@ref` labels (`@c0`, `@c1`, …)
- A change log (set of modified fields per entry)
- Entries queued for deletion (drafts unlinked during the session)

Commands within a session reference entries by their `@ref` label. The session is explicitly ended by `save` or `discard`.

---

## 5. Supported Contentful Entity Types

The tool operates on the following Contentful content types:

| Type | Discriminator field | Notes |
|---|---|---|
| Page | — | Top-level container; identified by `slug` |
| Article | — | Blog/editorial entry; identified by `slug` |
| Component | `componentType` | Inline content block (Hero, RichText, CTA, Card, …) |
| Collection | `collectionType` | Container with a `contents` array of child entries |
| ExternalComponent | `externalComponentType` | Embeds (data viz, research charts) |
| Template | — | Layout with `preContent`/`postContent` arrays |
| Navigation | — | Menu tree with nested `items`/`navigationItems` |
| Person | — | Author/team member; `bio` is rich text (`rtf` in cms-edit) |
| ArticleType | — | Article taxonomy tag |
| Tag | — | General taxonomy |
| Media | — | Wrapper entry around an asset |
| Asset | — | Image, PDF, or file (read-only; no upload capability) |

---

## 6. Entry Tree Resolution

When content is opened, the tool recursively fetches linked entries to build a tree:

- Content arrays: `topContent`, `content`, `bottomContent` (on pages/articles/templates), `contents` (on collections)
- Single-link fields: `template`, `articleType`, `author`, `menu`, `footer`
- Navigation trees: `items`, `navigationItems`
- Template layout: `preContent`, `postContent`

Each resolved entry is assigned a stable `@ref` label (`@c0` = the root, `@c1`, `@c2`, … for children in tree order). The tree is rebuilt on each `snapshot` call.

---

## 7. Command Reference

### 7.1 Discovery and Navigation

| Command | Function |
|---|---|
| `open --page-slug <slug>` | Load entry by flat lookup flag (see `docs/help/lookup.md`) |
| `open --id <id>` | Load by Contentful entry ID |
| `peek --page-slug <slug>` | Fetch and display content tree and root field values without creating a session |
| `peek --id <id>` | Same as peek, but look up by Contentful entry ID |
| `snapshot` | Print the current session's entry tree with @refs, entry types, field labels, and publish status |
| `read <ref>` | Print all fields of an entry in the active session |
| `read <ref> <field>` | Print a specific field (rich text rendered as Markdown) |
| `read <id> --id [field]` | Print entry fields by ID without an open session |
| `search <query>` | Full-text search across entries; returns matching entries |
| `list --type <type>` | List all entries of a given content type |
| `index sync` | Rebuild local SQLite media index from Content Preview API (draft; default). Use `--published` for Delivery API only |
| `index status` | Show index path, sync time, and row counts |
| `index dump` | Pretty-print index contents (assets, media, type breakdown) for terminal inspection |
| `list --type media --asset-id <id>` | List Media entries linked to an asset (index) |
| `list --type media --asset-filename <name>` | List Media entries by exact asset fileName (index) |
| `list --type page` | List pages from the content catalog (index-backed) |
| `resolve --id <id>` | Resolve an entry or asset ID to its details |
| `resolve --page-slug <slug>` | Resolve by flat lookup flag (no session). See `docs/help/lookup.md` |
| `open --page-slug <slug>` | Open a page session (one lookup flag required) |

### 7.2 Session Management

| Command | Function |
|---|---|
| `diff` | Show all unsaved field changes across all modified entries in the session |
| `save` | Write all queued changes to Contentful as drafts; delete queued draft-only entries |
| `discard` | Clear all unsaved changes; end the session |
| `discard --all` | Clear all active sessions |

### 7.3 Field Mutations

| Command | Function |
|---|---|
| `set <ref> <field> <value>` | Set a scalar field (string, boolean, number, date) |
| `set <ref> <field> <id> --link` | Set a single entry-link field |
| `set <ref> <field> @c1,@c2 --links` | Replace a content-array field with the given refs |
| `set <ref> <field> @c1,@c2 --links --append` | Append refs to an existing content array |
| `set <ref> <field> --file <path>` | Set a JSON/Object field from a local file |
| `set <ref> <field> --json '<json>'` | Set a JSON/Object field from an inline string |
| `set @c1:field="val" @c2:field="val" …` | Bulk set scalar fields across multiple entries in one command |
| `rtf <ref> <field> "<markdown>"` | Set a rich text field from Markdown |
| `rtf <ref> <field> --file <path>` | Set a rich text field from a Markdown file |
| `rtf <ref> <field> -` | Set a rich text field from stdin |
| `rtf replace <ref> <field> --find "X" --replace-plain "Y"` | Surgical per-text-node replacement within existing rich text (does not rewrite the entire field) |
| `rtf embed <ref> <field> <entry-id>` | Embed an entry block into an existing rich text field |
| `rtf embed <ref> <field> <asset-id> --asset` | Embed an asset block into an existing rich text field |
| `asset set <ref> <field> <asset-id>` | Set a visual/asset link field |

### 7.4 Entry Creation

| Command | Function |
|---|---|
| `create page --slug /x --title "X"` | Create a new page entry and open it in a session |
| `create article --slug /x --title "X" --article-type-id <id>` | Create a new article entry and open it in a session |
| `create template --label "X"` | Create a new template entry |
| `create media --asset-id <id> --name "X"` | Create a Media wrapper entry around an existing asset |
| `create from-json --file <path>` | Bulk-create a page or article with all its components from a declarative JSON file |
| `create taxonomy-from-json --file <path>` | Batch-create tagType, articleType, and tag entries from JSON |
| `create tag-type --slug <slug> --name "X"` | Create a tagType entry (draft) |
| `create article-type --slug <slug> --name "X"` | Create an articleType entry (draft) |
| `create tag --slug <slug> --name "X" --tag-type <slug>` | Create a tag entry (draft); `--tag-type-slug`, `--tag-type-name`; `--description`, `--featured-image`; cmsLabel auto `"<tagType> — <name>"` |
| `create tag-type\|tag\|article-type … --if-not-exists` | Idempotent taxonomy create (returns existing entry when slug matches) |
| `add <entry-type> --content-type <ct>` | Create a new child entry of a given content type, link it into the current open entry's content array, and assign it a @ref |

### 7.5 Entry Removal and Reordering

| Command | Function |
|---|---|
| `remove <ref>` | Unlink an entry from its parent. If the entry is a draft that has never been published, it is queued for deletion on `save`. Published entries are only unlinked. |
| `move <ref> --after <ref2>` | Move an entry to a new position within its parent content array |

### 7.6 CTA Links

CTA link lists are nested within component entries.

| Command | Function |
|---|---|
| `links list <ref>` | List all CTA links on an entry |
| `links add <ref> --type external --label "X" --href <url>` | Add an external URL link |
| `links add <ref> --type internal --label "X" --slug /page` | Add an internal link by page slug |
| `links add <ref> --type internal --label "X" --id <id>` | Add an internal link by entry ID |
| `links add <ref> --type download --label "X" --asset-id <id>` | Add a download link to an asset |
| `links add <ref> … --variant <value>` | Set link variant on create |
| `links set <ref> <index> <field> <value>` | Set a scalar field on an existing link entry |
| `links move <ref> <from-index> <to-index>` | Reorder links by index |
| `links remove <ref> <index>` | Remove a link by index |

### 7.7 Batch edit (by entry ID)

| Command | Function |
|---|---|
| `batch set <entryId>:field=value …` | Queue scalar field updates for unrelated entries |
| `batch diff` | Show pending batch session changes |
| `batch save` | Write all batch changes as drafts |
| `batch discard` | Clear batch session without saving |

Default session name: `batch`. Scalar fields only in v1.

### 7.8 Navigation

| Command | Function |
|---|---|
| `nav open --nav-name <name>` | Open a navigation entry in a session (`--nav-id` also supported) |
| `nav add --label "X" --slug /page` | Add a navigation item |

---

## 8. Rich Text Conversion

The `rtf` command sets Rich Text fields from one of four input modes (exactly one flag; **`--markdown` is the default** when omitted):

| Mode | Flag | Behaviour |
|---|---|---|
| Plain text | `--text` | Paragraphs from `\n\n` or `\n`; inline markers (`**bold**`, links, …) use the same inline rules as Markdown. |
| Markdown | `--markdown` | Full document Markdown (see table below). |
| HTML | `--html` | Subset of HTML (`h1`–`h6`, `p`, lists, `blockquote`, `table`, inline tags). |
| Rich Text JSON | `--json` | Full Contentful Rich Text document JSON (`nodeType: document`). |

Input may be passed as positional argument, `--content`, `--base64`, `--file`, or stdin (`-`). The same modes apply to `cms-edit set` on Rich Text fields via `--rtf-text`, `--rtf-markdown`, `--rtf-html`, `--rtf-json`; `create from-json` auto-detects Markdown / HTML / JSON document strings or accepts `{ "value", "format" }`; `rtf patch` ops support `replaceIsMarkdown`, `replaceIsHtml`, `replaceIsJson`, or `replaceFormat`.

Markdown syntax (when using `--markdown`):

| Markdown | Rich text output |
|---|---|
| `#` through `######` | heading-1 through heading-6 |
| `**text**` | bold |
| `_text_` | italic |
| `***text***` | bold-italic |
| `` `code` `` | inline code |
| `[text](url)` | hyperlink |
| `- item` | unordered list item |
| `1. item` | ordered list item |
| Indented list items | nested lists |
| `> text` | blockquote |
| `---` | horizontal rule |
| `\| col \| col \|` (GFM) | table |

The `rtf replace` command performs surgical replacement at the text-node level. Replacements may be supplied as plain text, inline Markdown, inline HTML, or a JSON array of inline nodes (`--replace-plain`, `--replace`, `--replace-html`, `--replace-json`).

---

## 9. Write Behaviour and Optimistic Locking

On `save`:

1. For each modified entry, the tool fetches the current live version from Contentful.
2. Local changes are merged on top of the fetched version (handles concurrent edits from other sources).
3. The merged entry is written using the Contentful Management API `update` method (not `publish`).
4. Draft-only entries queued for deletion are deleted.
5. Success and failure counts are reported.

If a version conflict cannot be resolved cleanly, an error is returned and the entry is not written.

---

## 10. Permission Constraints

The tool enforces a hard boundary between editing and publishing:

**Permitted:**
- Read entries and assets
- Create draft entries
- Update draft and published entries (saving as new draft versions)
- Delete draft-only entries (never-published)

**Not permitted (not exposed by the tool):**
- Publish entries
- Unpublish entries
- Archive entries
- Delete published entries

This boundary is enforced at two levels: the tool exposes no publish/unpublish/archive/delete commands, and the expected Contentful management token role should not have those permissions either.

---

## 11. Output Modes

**Human-readable mode (default):** Coloured console output with info, success, warning, and error messages.

**JSON mode** (`--json-output` flag or `CMS_EDIT_JSON=1`): Structured JSON on stdout. Schema varies by command:

| Command | JSON output |
|---|---|
| `snapshot` | Entry tree with refs, types, labels, publish status |
| `read` | Entry object with all fields, or single field value |
| `diff` | Array of changed entries, each with before/after field values |
| `search` / `list` | Array of matching entry objects |
| `save` | Success/failure counts and per-entry messages |
| `resolve` | Asset or entry metadata |

---

## 12. Preview URLs

When `devBaseUrl` is configured, `preview url` and `preview showcase` build URLs for pages and showcase renders. Visual verification is done via a browser MCP (e.g. Chrome DevTools), not built into cms-edit. CMS guidelines batch capture uses `cms-capture-screenshots` from `@se-studio/project-build` (separate tool, requires agent-browser).

---

## 13. Telemetry

Opt in with `CMS_EDIT_TELEMETRY=1` (opt out with `0`). The tool emits telemetry events recording the command name, outcome (`success`, `error`, or `exit`), duration, package version, and deployment `project_key`. No user ids, emails, slugs, field values, entry IDs, query text, or error messages are included.

- **Local CLI:** newline-delimited JSON at `~/.contentful-cms/telemetry.log`
- **Hosted (Vercel):** batched ingest to Axiom when `AXIOM_TOKEN` and `AXIOM_DATASET` are set

See [TELEMETRY_AXIOM.md](./TELEMETRY_AXIOM.md) for env vars, schema, and example queries.

---

## 14. Concurrency

Multiple sessions can be active simultaneously using the `--session <name>` flag or `CONTENTFUL_CMS_SESSION` env var. Sessions are isolated; changes in one session do not affect another. The optimistic locking on `save` handles the case where two sessions modify the same entry.
