# @se-studio/contentful-cms

A CLI tool for AI agents to read and edit Contentful draft content across all SE Studio apps. Provides a `cms-edit` binary with a snapshot → ref → edit → save workflow.

**Key constraint:** This tool has NO ability to publish, unpublish, archive, or delete published entries. All writes create drafts that a human must review and publish in Contentful.

## Installation

```bash
npm install -g @se-studio/contentful-cms
```

Or from the monorepo root:

```bash
pnpm install
pnpm build
```

## MCP Setup (hosted)

Editors connect Claude via **hosted cms-edit MCP** (OAuth) — no Node.js or local setup wizard.

1. Deploy a thin Next.js host (`cms-edit host scaffold`) or use an existing site host
2. Configure OAuth env vars (see [HOSTED.md](./HOSTED.md))
3. Send editors the `/cms-edit` onboarding URL

Shared route logic lives in `@se-studio/contentful-cms/hosted/next`. Editor walkthrough: [INSTALL.md](./INSTALL.md).

**Local stdio MCP is not supported** — do not register `mcpServers.cms-edit` in Claude Desktop. Developers use the hosted connector URL from the site's `/cms-edit` page, or run `cms-edit` in a repo with `.contentful-cms.json`.

### What hosted MCP exposes

A single `cms_edit` tool that accepts an `args` array matching any `cms-edit` CLI command:

```
["open", "/pricing"]
["snapshot"]
["set", "@c0", "heading", "New heading"]
["save"]
```

All output is JSON. Stdin-mode commands (`rtf` with `-`) are not supported via MCP — use `--file` instead.

## Contentful Role Setup (Required)

Before using this tool, create a dedicated **AI Editor** role in your Contentful space with restricted permissions. This provides defence-in-depth safety on top of the CLI's own restrictions.

### Steps

1. Go to **Settings → Roles & Permissions** in your Contentful web app
2. Click **Add Role** and name it `AI Editor`
3. Under **Content** permissions, set:
   - Entries: ✅ Read, ✅ Create, ✅ Edit — ❌ Publish, ❌ Unpublish, ❌ Archive, ❌ Delete
   - Assets: ✅ Read — ❌ everything else
4. Under **Space** and **Settings** — leave all ❌
5. Save the role
6. Go to **Settings → API Keys** → **Content management tokens**
7. Create a Personal Access Token scoped to this role (or invite a service account user with this role and generate their token)
8. Store the token as an environment variable (e.g. `CMS_EDIT_TOKEN`)

## Configuration

Create `.contentful-cms.json` in your project root (copy from `.contentful-cms.example.json`):

```json
{
  "defaultSpace": "om1",
  "spaces": {
    "om1": {
      "spaceId": "your-space-id",
      "environment": "master",
      "managementToken": "${CMS_EDIT_TOKEN_OM1}",
      "defaultLocale": "en-US",
      "devBaseUrl": "http://localhost:3013"
    },
    "brightline": {
      "spaceId": "another-space-id",
      "environment": "master",
      "managementToken": "${CMS_EDIT_TOKEN_BRIGHTLINE}",
      "defaultLocale": "en-US",
      "devBaseUrl": "${DEV_BASE_URL}",
      "requestParams": {
        "x-vercel-protection-bypass": "${VERCEL_BYPASS_TOKEN}"
      }
    }
  }
}
```

Token values use `${ENV_VAR}` syntax and are resolved from your `.env.local` at runtime.

### Deployment Protection Bypass

When your dev site is deployed behind Vercel deployment protection (or similar), commands that
build URLs from `devBaseUrl` (e.g. `preview url`, `preview showcase`) need to bypass the protection.

**Via query parameter** (Vercel protection bypass):
```json
"requestParams": {
  "x-vercel-protection-bypass": "${VERCEL_BYPASS_TOKEN}"
}
```
The token is appended as a query parameter to every URL built from `devBaseUrl`.

**Via HTTP header** (custom auth headers):
```json
"requestHeaders": {
  "x-custom-header": "${CUSTOM_HEADER_VALUE}"
}
```
`requestHeaders` are read by `cms-capture-screenshots` when driving agent-browser (query bypass uses `requestParams` via `cms-edit preview showcase`).

Both `requestParams` and `requestHeaders` values support `${ENV_VAR}` token syntax.
Set the token values in `.env.local` — they are never committed to version control.

## Workflow

Use **one explicit lookup flag** per `open` (see [docs/help/lookup.md](./docs/help/lookup.md)). Positional `open <slug>` is no longer supported.

```bash
# 1. Open a page by slug — fetches the entry tree and starts a session
cms-edit open --page-slug /pricing

# 2. View the content tree with @refs
cms-edit snapshot

# 3. Read a specific component's fields
cms-edit read @c2

# 4. Read a specific rich text field
cms-edit read @c2 body

# 5. Edit a scalar field
cms-edit set @c2 heading "New heading text"
cms-edit set @c2 showHeading true

# 5b. Set an Object/JSON field from a file
cms-edit set @c2 data --file chart-data.json

# 5c. Set an entry link field (e.g. page template)
cms-edit set @c0 template 3I0HxGKbUd173wIpFCsbVr --link

# 6. Edit a rich text field (Markdown — default format when no flag)
cms-edit rtf @c2 body --markdown "## Why it matters\n\nOur platform helps teams **move faster** with [confidence](https://example.com)."

# 7. Review changes before saving
cms-edit diff

# 8. Save all changes as Contentful drafts (NEVER publishes)
cms-edit save

# 9. Discard changes if needed
cms-edit discard
```

## Command Reference

### Navigation

| Command | Description |
|---------|-------------|
| `open --page-slug <slug>` | Load a page (see [docs/help/lookup.md](./docs/help/lookup.md)) |
| `open --id <id>` | Load by Contentful entry ID |
| `snapshot [-c]` | Re-print content tree (`-c` for compact) |
| `read [ref] [field]` | Read all fields of a ref, or a specific field |
| `diff` | Show unsaved changes |
| `save` | Write all changes to Contentful as drafts |
| `discard [--all]` | Discard session changes |

### Field Editing

| Command | Description |
|---------|-------------|
| `set <ref> <field> <value>` | Set a scalar field (string, boolean, number). Rich Text: optional `--rtf-text` \| `--rtf-markdown` (default) \| `--rtf-html` \| `--rtf-json` |
| `set @c1:field="val" @c2:field="val" …` | Bulk scalar update — same Rich Text flags apply to all assignments |
| `set <ref> <field> --file <path>` | Set from file — for Rich Text fields the JSON must be a full Rich Text document (`nodeType: document`) |
| `set <ref> <field> --json '<json>'` | Inline JSON — for Rich Text fields the value must be a full Rich Text document object |
| `set <ref> <field> <entry-id> --link` | Set an entry link field (e.g. template) |
| `set <ref> <field> <refs-or-ids> --links [--append]` | Set a content array (topContent, content, bottomContent, contents); replace by default, or append with `--append` |
| `rtf <ref> <field> "<content>"` | Set Rich Text — format: `--text` \| `--markdown` (default) \| `--html` \| `--rtf-json` |
| `rtf <ref> <field> --file <path> [--markdown\|--html\|…]` | Set from file with matching format flag |
| `rtf <ref> <field> - [--format]` | Set from stdin (e.g. `cms-edit rtf @c2 body --markdown - < file.md`) |
| `rtf replace <ref> <field> --find "…" --replace-plain "…"` | Surgical replace — replacement: `--replace-plain` \| `--replace` (Markdown) \| `--replace-html` \| `--replace-json` |
| `rtf patch <ref> <field>` | JSON ops — use `replaceIsMarkdown`, `replaceIsHtml`, `replaceIsJson`, or `replaceFormat` per op |
| `rtf embed <ref> <field> <entry-id> [--at N]` | Insert embedded **entry** block; use `--at N` for 0-based position, omit to append |
| `rtf embed <ref> <field> <asset-id> --asset [--at N]` | Insert embedded **asset** block (`embedded-asset-block`); Rich Text only allows `data.target` — no per-block width or alignment in the JSON |

**Rich text embeds — asset vs Media (width / alignment):**

- **Raw asset** (`--asset`): embeds the file as-is. Layout comes from the asset/visual defaults only; you cannot store alignment or width on the block (Contentful validates `embedded-asset-block.data` and rejects extra properties).
- **Media entry** (omit `--asset`, pass a **Media** entry ID): embed a Media entry whose fields include **width** (percentage) and **horizontal position**. The delivery API resolves it to an `IVisual` and apps render it via the same RTF embedded-entry path (`VisualComponent`). Create a Media entry from the CLI (does not replace your session), then embed by ID:
  ```bash
  cms-edit create media --asset-id <asset-id> --name "Figure 1" --position Middle --width 40
  cms-edit rtf embed @c1 body <media-entry-id> --at 4
  ```
  Raw asset for comparison: `cms-edit rtf embed @c1 body <asset-id> --asset`
  If the same image needs **different** width or alignment in **two** places, use **two Media entries** that reference the same underlying asset (layout is on the Media entry, not per RTF occurrence).

### Structure

| Command | Description |
|---------|-------------|
| `add <type> --content-type <ct> [--after <ref>] [--parent <ref>] [--target topContent\|content\|bottomContent]` | Create and link a new entry; `--content-type` is required (e.g. `component`, `collection`, `externalComponent`, `person`). `--parent` derives the target field from the parent's content type: collections use `contents`, pages/articles default to `content` (override with `--target`). |
| `remove <ref>` | Unlink from page (deletes if unreferenced draft) |
| `move <ref> [--after <ref2>] [--before <ref2>]` | Reorder within parent |

**`add` — content type is always explicit:**

```bash
cms-edit add "Hero"           --content-type component
cms-edit add "Card Grid"      --content-type collection
cms-edit add "Research chart" --content-type externalComponent
cms-edit add "Dr. Jane Smith" --content-type person
```

For content types that follow the `${contentType}Type` naming convention (e.g. `component` → `componentType`, `externalComponent` → `externalComponentType`), the `<type>` argument is stored in that field. For content types without a type discriminator the `<type>` is used as the initial `cmsLabel` only.

### Create

| Command | Description |
|---------|-------------|
| `create page --slug /x --title "X" [--description "…"] [--cms-label "…"] [--template-id <id>] [--featured-image <asset-id>] [--indexed] [--hidden]` | Create a new page entry; all fields after `--title` are optional |
| `create article --slug /x --title "X" --article-type-id <id>` | Create a new article |
| `create from-json --file <path>` | Create a complete page or article (with all components) from a declarative JSON file |
| `create from-json --file <path> --dry-run` | Preview the creation plan without writing to Contentful |
| `create from-json --file <path> --strict` | Fail on unknown JSON keys (also strict on `--dry-run`) |
| `create taxonomy-from-json --file <path> [--if-not-exists] [--dry-run]` | Batch-create tagType, articleType, and tag entries from JSON |
| `create person-from-json --file <path> [--if-not-exists] [--dry-run]` | Batch-create person/author entries from JSON |
| `create person --slug <slug> --name "X" [--job-title <t>] [--json-file <path>] [--if-not-exists] [--dry-run]` | Create a person entry (draft) |
| `create person … [--bio-markdown <md>]` or `[--bio-file <path>]` | Bio as Markdown (mutually exclusive paths) |
| `create tag-type --slug <slug> --name "X" [--json-file <path>] [--if-not-exists] [--dry-run]` | Create a tagType entry (draft) |
| `create article-type --slug <slug> --name "X" [--json-file <path>] [--if-not-exists] [--dry-run]` | Create an articleType entry (draft) |
| `create tag --slug <slug> --name "X" --tag-type <tagType-slug> [--tag-type-id <id>] [--description <desc>] [--featured-image <asset-id>] [--json-file <path>] [--if-not-exists] [--dry-run]` | Create a tag entry (draft) |
| `create template --label "X"` | Create a new template entry |
| `create media --asset-id <id> --name "X" [--position Left\|Middle\|Right] [--width <1–100>]` | Create a **Media** entry (draft); does not start a new cms-edit session; prints entry ID for `rtf embed` |

**`create from-json` notes:** Optional `cmsLabel` on each component helps editors tell instances apart. **Asset and Media links** use explicit shorthands (`featuredImage`, `featuredImageAssetFilename`, `visualAssetFilename`, `visualMedia`, …) — see `cms-edit help create-from-json`. Unknown JSON keys warn by default; `--strict` or `--dry-run` fails instead. String `fields` values become Rich Text when they auto-detect as Markdown, HTML, or Rich Text JSON; otherwise use `{ "value", "format" }`. Slugs should not end with `/` (the CLI strips one and warns). `*AssetFilename` keys require `cms-edit index sync`.

**Taxonomy creates:** Use `--json-file` for full field control on tagType, articleType, and tag entries. See `cms-edit help fields-taxonomy` and `cms-edit help taxonomy-from-json`. Idempotent imports: `cms-edit create tag-type … --if-not-exists`.

**Idempotent flags (`--if-not-exists`, `--if-exists-by-filename`):** These use check-then-create against Contentful. They are safe for sequential import scripts, but **not safe under parallel runs** on the same slug or `fileName` — two concurrent commands can both create entries. Serialize imports per slug/fileName, or re-run with the idempotent flag to converge.

| Command | Description |
|---------|-------------|
| `article-tags-from-json --file <path> [--dry-run]` | Set tags on multiple articles from JSON |
| `bulk-rtf-replace --find <text> --replace-plain <text> [--include page,article] [--prefix <slug>] [--limit <n>] [--dry-run]` | Find-replace plain text in Rich Text across page/article trees |

Set `CMS_EDIT_JSON=1` for machine-readable output (`{ ok, id, slug, contentType, status }`).

### Templates

Create a template: `cms-edit create template --label 'Campaign Landing'`. Edit: `cms-edit open --id <template-id>`. List template IDs: `cms-edit list --type template`.

### Links (CTAs)

Put the ref **after the subcommand** (e.g. `links add @c5`).

| Command | Description |
|---------|-------------|
| `links list <ref>` | List CTA links on an entry |
| `links add <ref> --type external --label "X" --href <url>` | Add an external link (requires `--href`) |
| `links add <ref> --type internal --label "X" --slug /page` | Add an internal link by page/article slug |
| `links add <ref> --type internal --label "X" --id <entry-id>` | Add an internal link by Contentful entry ID |
| `links add <ref> --type download --label "X" --asset-id <asset-id>` | Add a download link to an asset (requires `--asset-id`) |
| `links add <ref> … --variant link\|button` | Set link variant on create (see `cms-edit schema link`) |
| `links set <ref> <index> <field> <value>` | Set a scalar field on an existing link entry (e.g. `variant link`) |
| `links remove <ref> <index>` | Remove a link by index |
| `links move <ref> <from> <to>` | Reorder links |

**Examples:**

```bash
cms-edit links list @c5
cms-edit links add @c5 --type external --label "Download PDF" --href "https://www.example.com/whitepaper.pdf"
cms-edit links add @c5 --type internal --label "Pricing" --slug /pricing
cms-edit links add @c5 --type internal --label "About" --id 4xKj2abcDef
cms-edit links add @c5 --type download --label "Download PDF" --asset-id 5xKj2abcDef
cms-edit links set @c5 0 variant link
cms-edit links remove @c5 1
cms-edit links move @c5 2 0
```

### Batch edit (by entry ID)

Edit unrelated standalone entries without a page session. Uses session name **`batch`** by default (`--session` to override). Scalar fields only in v1.

| Command | Description |
|---------|-------------|
| `batch set <entryId>:field=value …` | Queue scalar updates for one or more entry IDs |
| `batch diff` | Show all pending batch changes |
| `batch save` | Write all batch changes as drafts |
| `batch discard` | Clear batch session without saving |

**Example — link variant sweep:**

```bash
cms-edit batch set \
  3my7n2M84OrhOaaSLHVHvo:variant=link \
  7MjsLmOq52CR9C5xrYlcub:variant=button
cms-edit batch diff
cms-edit batch save
```

See `cms-edit help batch` for limits and named sessions.

Content type definitions (field types, enum validations) are cached on the session and under `~/.contentful-cms/content-types/<spaceId>/<environment>/` (24-hour TTL) so batch workflows and repeated sets avoid redundant CMA calls.

### Content index (assets, media, content catalog)

Build a local SQLite index from the **Content Preview API** before filename search, media-by-filename list, asset audit, or fast page/article/taxonomy/nav lookup:

```bash
cms-edit index sync          # draft assets, media, full content catalog (default)
cms-edit index sync --verbose  # progress on stderr (or LOG_CMS_INDEX=1)
cms-edit index status
cms-edit index dump          # browse index in the terminal
cms-edit index dump --reference-only
cms-edit index sync --published   # separate DB for published-only content
```

Catalog types (`page`, `article`, `pageVariant`, `template`, `articleType`, `tagType`, `tag`, `navigation`, `navigationItem`) list from the index when present; use `list --type page --force-cma` to bypass. Scoped filters: `list --type tag --tag-type-name "…"`, `list --type article --article-type-slug …`. Open/resolve use flat flags (`--page-slug`, `--page-cms-label`, `--nav-name`, etc.) — see [docs/help/lookup.md](./docs/help/lookup.md).

**Auto-sync:** Index-backed commands refresh the index automatically when it is missing, on an outdated schema (v3), or older than 24h (incremental upsert when only stale). Manual `index sync` always does a full rebuild. See [docs/help/index-sync.md](./docs/help/index-sync.md).

**Agent skills:** Edit `packages/skills/skills/contentful-cms-*`, then `pnpm --filter @se-studio/contentful-cms sync:skills`. See [docs/SKILLS.md](./docs/SKILLS.md).

Configure optional tokens in `.contentful-cms.json` (`previewAccessToken` for default sync; `deliveryAccessToken`, `deliveryApiKeyName`, `autoCreateDeliveryApiKey` for `--published`) — see [docs/MEDIA_INDEX_PLAN.md](./docs/MEDIA_INDEX_PLAN.md). If unset, `index sync` resolves a preview or delivery key via the management token.

### Assets

| Command | Description |
|---------|-------------|
| `asset search [query]` | Search assets by title (requires index); `--filename` / `--filename-match` for fileName |
| `asset audit` | List image assets missing alt text (requires index) |
| `asset review` | Full visual asset review — filename, alt, dimensions, size, GIF (requires index; use `--json` for spreadsheets) |
| `asset upload --base64 <encoded> --mime <type> [--file-name <name>]` | Upload from base64 (**local CLI only**; rejected on hosted MCP) |
| `asset upload --staged <uploadId> --mime <type> --file-name <name>` | Consume staged upload from `cms_edit_request_staged_upload` (**hosted MCP**) |
| `asset upload --url <https-url> [--mime <type>] [--file-name <name>]` | Fetch and upload from URL |
| `asset upload … --with-media [--media-name "X"] [--media-position Left\|Middle\|Right] [--media-width <1–100>]` | Also create a draft Media wrapper |
| `asset upload … --if-exists-by-filename` | Return existing asset (and media if `--with-media`) when fileName matches |
| `asset info <asset-id>` | Show asset details |
| `asset set <ref> <field> <asset-id>` | Set a visual/asset field |

JSON output for upload (`CMS_EDIT_JSON=1`): `{ ok, id, fileName, url, mediaId?, mediaIds? }`. Uploads are limited to **10MB** per file (local/URL/base64) or **4MB** per staged file on hosted MCP. With `--if-exists-by-filename`, run uploads **one at a time** per `fileName` if your script uses parallelism (see idempotent note above).

### Navigation Entries

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

### Discovery

| Command | Description |
|---------|-------------|
| `schema types <content-type>` | List valid type-discriminator values for any content type (looks up the `${contentType}Type` field) |
| `schema colours` | List valid `backgroundColour` and `textColour` values from the content model (one list each, no session required) |
| `search "<query>"` | Full-text search across entries |
| `list --type <type>` | List **all** entries of a content type (paginates automatically) |
| `list --type media --asset-id <id>` | List Media entries linked to an asset |
| `list --type media --asset-filename <name>` | List Media entries by exact asset fileName |
| `list --type media --asset-filename-match <pattern>` | List Media entries by asset fileName regex (requires index) |
| `resolve --page-slug <slug>` | Resolve entry by flat lookup flag (no session). See [docs/help/lookup.md](./docs/help/lookup.md) |

Asset search, media list by filename, and `asset audit` use the local index (no bulk CMA asset scans). Run `cms-edit index sync` first — see [docs/help/index-sync.md](./docs/help/index-sync.md).

**`schema types` examples:**

```bash
cms-edit schema types component          # lists componentType values
cms-edit schema types collection         # lists collectionType values
cms-edit schema types externalComponent  # lists externalComponentType values
```

### Preview URLs

Generate URLs for visual checks with a browser MCP (no capture built into cms-edit):

| Target | Command |
|--------|---------|
| Page slug | `cms-edit preview url /pricing` (or `preview url` with an open session) |
| Component showcase | `cms-edit preview showcase --component HeroSimple` |
| Collection showcase | `cms-edit preview showcase --collection CardGrid --param backgroundColour=Navy` |

Requires `devBaseUrl` in `.contentful-cms.json`. Use `--json` for machine-readable output.

**CMS guidelines batch capture:** `@se-studio/project-build` ships `cms-capture-screenshots` for the guidelines pipeline only (requires agent-browser). Run from the site repo root: `cms-capture-screenshots --variants /tmp/type-variants.json --app-dir <app-dir>` (e.g. `apps/example-empty` here, or the app path in a customer repo).

## Machine-readable Output (`--json`)

All commands support JSON output via the global `--json-output` flag or the `CMS_EDIT_JSON=1` environment variable (see [JSON Mode](#json-mode-llm--machine-readable-output) above). In addition, a subset of data-query commands also accept a per-command `--json` flag:

| Command | `--json` output |
|---------|-----------------|
| `list --type <type> --json` | JSON array of entry objects including all fields |
| `search "<query>" --json` | JSON array of entry objects including all fields |
| `read <ref> --json` | JSON object for one entry including all fields |
| `read <ref> <field> --json` | JSON value for a single field |
| `asset info <id> --json` | JSON object for one asset |

**Entry JSON shape** (for `list`, `search`, `read`):

```json
{
  "id": "abc123",
  "contentType": "article",
  "title": "My Article",
  "slug": "my-article",
  "status": "published",
  "updatedAt": "2024-01-01T00:00:00Z",
  "fields": {
    "title": "My Article",
    "slug": "my-article",
    "articleType": { "id": "entryId", "linkType": "Entry" },
    "download": { "id": "assetId", "linkType": "Asset" },
    "tags": ["tag1", "tag2"],
    "body": "## Heading\n\nRich text rendered as Markdown."
  }
}
```

Fields are flattened to the space's default locale. Link fields become `{ id, linkType }` objects. Rich text fields become Markdown strings.

**Asset JSON shape** (for `asset info`):

```json
{
  "id": "assetId",
  "title": "My PDF",
  "fileName": "report-2024.pdf",
  "contentType": "application/pdf",
  "url": "https://assets.ctfassets.net/...",
  "width": null,
  "height": null,
  "size": 102400
}
```

Progress messages from `list` are always written to stderr, so they do not contaminate the JSON on stdout.

### Scripting example: build an article–asset mapping

```bash
# 1. Get all articles as JSON (no session required)
cms-edit list --type article --json > articles.json

# 2. For each article, fetch the download asset filename
jq -r '.[].fields.download.id // empty' articles.json | while read assetId; do
  # Requires an open session for space resolution — run `cms-edit open --page-slug /any-page` first
  cms-edit asset info "$assetId" --json
done
```

## Global Options

```
--space <name>    Override the default space (from config)
--config <path>   Custom config file path
--json            Output all results as machine-readable JSON (see below)
--docs            Print absolute path to this README and exit (for LLM or script tooling)
```

Use `cms-edit --docs` to get the path to the README so an LLM or script can read it.

## JSON Mode (LLM / machine-readable output)

Enable JSON mode to get machine-readable output from every command. Two ways to activate:

```bash
# Global flag (placed before the subcommand name):
cms-edit --json-output snapshot
cms-edit --json-output diff
cms-edit --json-output set @c2 heading "New title"
cms-edit --json-output add "Research chart" --content-type externalComponent --target content

# Environment variable (set once for the whole session):
export CMS_EDIT_JSON=1
cms-edit snapshot      # → JSON
cms-edit diff          # → JSON
cms-edit set @c2 data --file chart.json  # → JSON
```

### JSON output shapes

**Action commands** (`set`, `rtf`, `add`, `remove`, `move`, `save`, `discard`, `links`, …):
```json
{"ok": true, "message": "Set heading on @c2 to: New title"}
{"ok": false, "error": "Ref @c99 not found. Available refs: @c0, @c1, @c2"}
{"warn": "Entry is saved to Contentful but changes to the parent page are unsaved."}
```
Multiple lines may appear for a single command (one per message). Errors go to stderr; warnings and results go to stdout.

**`snapshot` / `tree`**:
```json
{
  "rootType": "article",
  "rootSlug": "/publications/study-1",
  "rootId": "4xKj2abc",
  "rootStatus": "published",
  "spaceKey": "om1",
  "fetchedAt": "2026-03-05T10:00:00.000Z",
  "unsavedChanges": 1,
  "pendingDeletions": 0,
  "entries": [
    {
      "ref": "@c0",
      "entryId": "abc123",
      "contentType": "component",
      "type": "RichText",
      "label": "Introduction",
      "status": "published",
      "depth": 1,
      "parentRef": null,
      "parentField": "content"
    },
    {
      "ref": "@c1",
      "entryId": "def456",
      "contentType": "externalComponent",
      "type": "Research chart",
      "label": "Figure 1: Cost-Efficiency",
      "status": "draft",
      "depth": 1,
      "parentRef": null,
      "parentField": "content"
    }
  ]
}
```

**`diff`**:
```json
{
  "hasChanges": true,
  "modified": [
    {
      "ref": "@c1",
      "entryId": "def456",
      "contentType": "externalComponent",
      "type": "Research chart",
      "label": "Figure 1",
      "isNew": false,
      "fields": {
        "heading": {"before": null, "after": "Figure 1: Cost-Efficiency"},
        "data": {"before": null, "after": {"type": "bar", "categories": ["A", "B"]}}
      }
    }
  ],
  "deletions": []
}
```

**`read`**, **`list`**, **`search`**, **`asset info`**: same JSON shapes as their existing per-command `--json` flag.

## Order of operations: article + CTA

To add body content and a CTA (e.g. PDF download) to an article:

1. **Open the article:** `cms-edit open --article-slug <slug>` or `cms-edit open --id <id>`
2. **Set or add body:** If the article has a rich text component, use `cms-edit rtf @<ref> body --markdown "..."` or `cms-edit rtf @<ref> body --markdown --file path/to.md`. If you need to add a new body component first, use `add` then `rtf`.
3. **Add CTA at bottom:** `cms-edit add CTA --content-type component --target bottomContent` (or add to main content with `--after @<ref>` if you prefer)
4. **Set CTA links:** Use one of:
   - External (e.g. PDF URL): `cms-edit links add @<ctaRef> --type external --label "Download PDF" --href <url>`
   - Download (Contentful asset): `cms-edit links add @<ctaRef> --type download --label "Download PDF" --asset-id <asset-id>` (get the ID from `cms-edit asset search "..."` or `cms-edit asset info <id>`)
5. **Save:** `cms-edit save`

## Order of operations: article + Research chart

To populate an article with a Research chart (external component):

```bash
# 1. Open the article
cms-edit open --article-slug <slug>

# 2. Discover valid externalComponentType values
cms-edit schema types externalComponent

# 3. Add a Research chart to the content array
cms-edit add "Research chart" --content-type externalComponent --target content

# 4. Set the heading (scalar field)
cms-edit set @c2 heading "Figure 1: Cost-Efficiency of Data Automation"

# 5. Set the data field from a JSON file
cms-edit set @c2 data --file figure1-data.json

# 6. Set the additionalCopy footnote (rich text)
cms-edit rtf @c2 additionalCopy --markdown --file footnote.md

# 7. Review and save
cms-edit diff
cms-edit save
```

## Snapshot Format

```
Page: /pricing  [4xKj2abc | published]  ·  om1

  @c0   Component[HeroSimple]             "Transparent Pricing"         [changed]
  @c1   Component[RichText]               "How it works"                [published]
  @c2   Collection[CardGrid]              "Plans"                       [published]
    @c3   Component[Card]                   "Starter"                   [published]
    @c4   Component[Card]                   "Pro"                       [draft]
  @c5   ExternalComponent[Research chart] "Figure 1: Cost-Efficiency"   [draft]
  @c6   Component[CTA]                    "Get started today"           [published]
  @c7   Person                            "Dr. Jane Smith"              [published]
```

The type label follows the `${contentType}Type` convention:
- `component` → `Component[<componentType>]`
- `collection` → `Collection[<collectionType>]`
- `externalComponent` → `ExternalComponent[<externalComponentType>]`
- Any content type without a type discriminator → capitalised content type ID (e.g. `Person`)

**Status badges:**
- `[published]` — live, no pending changes
- `[draft]` — never published
- `[pending]` — published but has newer draft in Contentful
- `[changed]` — has unsaved local changes (run `save`)

## Rich Text input formats

Use exactly one of `--text`, `--markdown` (default), `--html`, or `--rtf-json` with `cms-edit rtf` and `rtf edit`. For scalar `cms-edit set` on Rich Text fields, use the `--rtf-*` flags.

**Markdown** (`--markdown`) supports:

```
# Heading 1      → heading-1
## Heading 2     → heading-2
**bold**         → bold mark
_italic_         → italic mark
***bold italic***
[text](url)      → hyperlink
- item           → unordered list
1. item          → ordered list
> quote          → blockquote
---              → horizontal rule
`code`           → code mark
```

**HTML** (`--html`): subset including `h1`–`h6`, `p`, `ul`/`ol`/`li`, `blockquote`, `table`, `a`, `strong`/`em`, `br`.

**JSON** (`--json`): full Contentful Rich Text document JSON.

**Plain text** (`--text`): paragraph breaks from `\n\n` or `\n`; inline `**bold**` etc. still parsed per Markdown inline rules.

## Multi-space Usage

```bash
cms-edit --space om1 open --page-slug /pricing
cms-edit --space brightline open --page-slug /home
```

## What This Tool Cannot Do

By design, the following operations are **not available**:

- Publish entries (no `publish` command)
- Unpublish entries
- Archive entries
- Delete published entries
- Upload new assets

These restrictions are enforced at two levels:

1. **Contentful Role** — the management token should use a role with no publish permissions
2. **CLI** — no publish/delete commands exist in this tool

All saves create draft versions that remain invisible to visitors until a human reviews and publishes them in the Contentful web app.
