npm package: @se-studio/contentful-cms
Description: CLI tool for AI agents to read and edit Contentful draft content without publish permissions
Latest version: 1.0.36

# @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 similar to `agent-browser`.

**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 (Claude Desktop / Claude Code)

`cms-edit` ships an MCP server so Claude can call it as a tool directly — no manual CLI use needed.

### Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):

```json
{
  "mcpServers": {
    "cms-edit": {
      "command": "npx",
      "args": ["-y", "@se-studio/contentful-cms"],
      "cwd": "/path/to/your/project",
      "env": {
        "CMS_EDIT_TOKEN_OM1": "your-management-token"
      }
    }
  }
}
```

- `cwd` must point to the directory containing your `.contentful-cms.json` (or omit `cwd` and place the file in your home directory — `~/.contentful-cms.json` on macOS/Linux, `C:\Users\<you>\.contentful-cms.json` on Windows).
- Add one `CMS_EDIT_TOKEN_<SPACE>` env var per space defined in your config. Alternatively, set `CMS_EDIT_CONFIG` to the absolute path of your config file if you need to point at a specific location.
- `npx -y @se-studio/contentful-cms` always pulls the latest published version — no global install needed.

If you have the package installed globally, you can use `"command": "cms-edit-mcp"` (no `args`) instead.

### Claude Code

```bash
claude mcp add cms-edit -- npx -y @se-studio/contentful-cms
```

Then set env vars in your project's `.env.local` (they are picked up automatically via the config's `${ENV_VAR}` syntax).

### What the MCP server 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), `cms-edit`
commands that navigate to `devBaseUrl` (e.g. `screenshot`) 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}"
}
```
The headers are injected into the `agent-browser open` call via `--headers`.

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

`cms-edit open <slug>` resolves `fields.slug` on **page**, **article**, **articleType**, and **tag** entries (in that order; first match wins). Templates and navigation are not slug-indexed here—use `open <id> --id` or `cms-edit nav open`.

```bash
# 1. Open a page by slug — fetches the entry tree and starts a session
cms-edit open /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 input)
cms-edit rtf @c2 body "## 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 <slug>` | Load by slug: page, article, articleType, or tag (see note above) |
| `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) |
| `set <ref> <field> --file <path>` | Set an Object/JSON field from a file (e.g. `data`) |
| `set <ref> <field> --json '<json>'` | Set an Object/JSON field from an inline JSON string |
| `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> "<markdown>"` | Set a rich text field from Markdown |
| `rtf <ref> <field> --file <path>` | Set rich text from a file |
| `rtf <ref> <field> -` | Set rich text from stdin (e.g. `cms-edit rtf @c2 body - < file.md`) |
| `rtf replace <ref> <field> --find "…" --replace-plain "…"` | Surgical plain-text replace inside rich text (use `--replace-plain` for quidget tokens `{*…*}`; `--mode all` or `exactlyOne`) |
| `rtf replace <ref> <field> --find "…" --replace "**bold**"` | Same, but replacement parsed as inline Markdown |
| `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`) |
| `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"` | Create a new page entry |
| `create article --slug /x --title "X" --article-type-id <id>` | Create a new article |
| `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` |

### Templates

Create a template: `cms-edit create template --label 'Campaign Landing'`. Edit: `cms-edit open <template-id> --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 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 remove @c5 1
cms-edit links move @c5 2 0
```

### Assets

| Command | Description |
|---------|-------------|
| `asset search "<query>"` | Search assets by title |
| `asset info <asset-id>` | Show asset details |
| `asset set <ref> <field> <asset-id>` | Set a visual/asset field |

### Navigation Entries

| Command | Description |
|---------|-------------|
| `nav open <slug-or-id>` | Load a navigation entry |
| `nav add --label "X" --slug /page` | Add a navigation item |

### Discovery

| Command | Description |
|---------|-------------|
| `types <content-type>` | List valid type-discriminator values for any content type (looks up the `${contentType}Type` field) |
| `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) |

**`types` examples:**

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

### Screenshot

Capture a PNG of a component, collection, external component, person, or page. **Requires [agent-browser](https://github.com/vercel-labs/agent-browser)** (`npm install -g agent-browser && agent-browser install`). For `@ref` and `--json-file`, the app must be running at `devBaseUrl` (see `.contentful-cms.json`).

| Target | Command |
|--------|---------|
| Session ref (full-fidelity) | `cms-edit screenshot @c0` — all types (component, collection, externalComponent, person) via convert API and `/cms/preview/render-json` |
| JSON file (no Contentful) | `cms-edit screenshot --json-file path/to/entry.json` — IBase* JSON; validates or screenshots without a session |
| By type (mock, no session) | `cms-edit screenshot --component HeroSimple`, `cms-edit screenshot --collection CardGrid` |
| Page | `cms-edit screenshot` (current page) or `cms-edit screenshot /pricing` |

**Options:** `--out <path>`, `--full`, `--embedded`, `--wait <ms>`, `--url-only` (print URL only), `--json` (machine-readable output). For `--component` / `--collection`: **`--param key=value`** (repeatable) to override showcase controls (e.g. `--param backgroundColour=Navy --param textColour="Off White"`). **`--width <px>`** and **`--height <px>`** set viewport size before capture (e.g. `--width 375` for mobile).

Use `--out before.png` / `--out after.png` with `agent-browser diff screenshot` for visual diffing. See the **screenshots** skill for details.

**Batch screenshots:** The `cms-capture-screenshots` script (same package) captures multiple variants to `<app-dir>/docs/cms-guidelines/screenshots/`. Run it from the **monorepo root** so the output path is correct: `node packages/contentful-cms/dist/bin/cms-capture-screenshots.js --variants /tmp/type-variants.json --app-dir apps/example-se2026`.

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

All commands support JSON output via the global `--json` 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 /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 snapshot
cms-edit --json diff
cms-edit --json set @c2 heading "New title"
cms-edit --json 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>` 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 "..."` or `cms-edit rtf @<ref> body --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>

# 2. Discover valid externalComponentType values
cms-edit 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 --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 (Markdown) Support

The `rtf` command accepts basic Markdown:

```
# 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
```

## Multi-space Usage

```bash
cms-edit --space om1 open /pricing
cms-edit --space brightline open /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.