EDITING-FLOW.md
# Editing Flow
How content editing works through the CLI. Covers Portable Text conversion, `_rev` tokens, and raw mode.
## Portable Text and Markdown
EmDash stores rich text as [Portable Text](https://portabletext.org/) (PT) — a structured JSON format. The CLI automatically converts between PT and markdown so you work with a familiar text format.
### Automatic Conversion
- **On read**: PT arrays in `portableText` fields are converted to markdown strings
- **On write**: markdown strings in `portableText` fields are converted back to PT arrays
- **Non-PT fields** (string, text, number, etc.) pass through unchanged
The CLI detects which fields need conversion by fetching the collection's field schema.
### Supported Markdown Syntax
Standard blocks (lossless round-trip):
| Markdown | PT block |
| ---------------------------- | ------------------------------------------ |
| `# Heading` through `######` | h1-h6 blocks |
| Plain paragraph | normal block |
| `> Quote` | blockquote |
| `- item` / `* item` | bullet list (nesting via 2-space indent) |
| `1. item` | numbered list (nesting via 2-space indent) |
| ` ``` ```lang``` ` | code block with language |
| `` | image block |
Inline marks:
| Markdown | PT mark |
| ------------- | --------------- |
| `**bold**` | `strong` |
| `_italic_` | `em` |
| `` `code` `` | `code` |
| `~~strike~~` | `strikethrough` |
| `[text](url)` | link annotation |
### Unknown Blocks (Opaque Fences)
Blocks the converter doesn't recognize (custom blocks, embeds, etc.) are serialized as HTML comments:
```markdown
<!--ec:block {"_type":"callout","level":"warning","text":"Be careful"} -->
```
These survive round-trips intact. You can see and move them, but editing the JSON risks corruption. On write, they're deserialized back to the original PT block.
### Raw Mode
Skip markdown conversion entirely to work with raw PT JSON:
```bash
npx emdash content get posts 01ABC123 --raw
```
Use raw mode when:
- You need exact control over PT structure
- You're working with custom block types
- You're copying PT between items without transformation
### Writing Content
When creating or updating content, each field is checked:
- `portableText` field + **string value** → converts markdown to PT before sending
- `portableText` field + **array value** → sends as raw PT (no conversion)
- Any other field type → sends as-is
```bash
# Markdown string — converted to PT automatically
npx emdash content create posts --data '{"title": "Hello", "body": "# Welcome\n\nThis is **bold**."}'
# Raw PT array — passed through as-is
npx emdash content create posts --data '{"title": "Hello", "body": [{"_type": "block", "children": [{"_type": "span", "text": "Welcome"}]}]}'
```
## Auto-Publishing
The CLI is designed for agents. It auto-publishes on `create` and `update` by default so agents get read-after-write consistency without managing the draft/publish lifecycle.
### How It Works
- **`create`** — creates the item, then publishes it. The returned item is in `published` status.
- **`update`** — updates the item. If the collection uses revisions and the update created a draft revision, it auto-publishes to promote the draft to the content table. The returned item reflects the updated data.
- **`get`** — returns the latest state. If a pending draft exists (e.g. someone edited in the admin UI but didn't publish), the draft data is returned instead of the published data. Use `--published` to see only published data.
Use `--draft` on create/update to skip auto-publishing.
### Why Auto-Publish?
EmDash collections can support draft revisions. When they do, `update` writes data to a draft revision instead of the content table. Without auto-publish, an agent would update, then `get` the item, and see stale published data — not the changes it just made. Auto-publish eliminates this confusion.
## Read-Before-Write
Updates use `_rev` tokens for optimistic concurrency — the same principle as a file editing tool that requires you to read a file before you can edit it. You must see what you're overwriting.
### The Analogy
Think of it like a filesystem edit tool:
1. You **read** the file to see its current contents
2. You decide what to change
3. You **write** with a reference to the version you read
If someone else changed the file between your read and your write, the write fails — you can't overwrite changes you haven't seen. The `_rev` token is your proof that you've seen the current state.
### How It Works
1. `content get` returns the item with a `_rev` token in the output
2. You pass that `_rev` back to `content update` via `--rev`
3. The server checks: if the item has changed since your read, it returns **409 Conflict**
4. A successful update returns a new `_rev` for subsequent edits
### What Is a `_rev` Token?
An opaque base64 string. Don't parse it — just pass it back.
### CLI Workflow
The CLI **requires** `--rev` on updates. The typical workflow:
```bash
# 1. Read the item — note the _rev in the output
npx emdash content get posts 01ABC123
# Output includes: _rev: MToyMDI2LTAyLTE0...
# 2. Update with the _rev you received — auto-publishes by default
npx emdash content update posts 01ABC123 \
--rev MToyMDI2LTAyLTE0... \
--data '{"title": "New Title"}'
# Output shows updated item with new _rev
```
If you try to update without `--rev`, the CLI rejects the command. This ensures you always know what you're overwriting.
### Conflict Handling
If someone else updated the item between your read and write, the CLI exits with code 1 and prints:
```text
ERROR Content has been modified since last read (version conflict)
```
Re-read with `get`, inspect the new state, then `update` with the fresh `_rev`.
### Locked Entries
A 409 does not always mean the item changed. If someone has the entry open in the admin, the CLI exits with code 1 and prints the lock holder's name:
```text
ERROR Ada is holding this entry
```
The CLI prints only the error message, not the HTTP status or error code. REST API consumers can distinguish this refusal by its `ENTRY_LOCKED` code. Re-reading does not clear it: the item has not changed, so a fresh `_rev` produces the same refusal. Either wait, or pass `--override-lock` to write anyway. An editor releases the lock when they close the entry, and a lock left behind by a crashed tab lapses seven minutes after their last heartbeat.
Overriding does not take the lock. The editor keeps it and their next save is rejected as a version conflict, so wait unless you know they have gone.
`--override-lock` is accepted by `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. Collections with edit locking switched off never return `ENTRY_LOCKED`.
### Which Operations Need `_rev`?
Only `update`. All other operations are either idempotent or non-destructive:
| Command | `--rev` needed? | Why |
| ------------------- | --------------- | ------------------------ |
| `content create` | No | Nothing exists yet |
| `content update` | **Yes** | Overwrites existing data |
| `content delete` | No | Soft delete, reversible |
| `content publish` | No | Idempotent status change |
| `content unpublish` | No | Idempotent status change |
| `content schedule` | No | Only changes metadata |
| `content restore` | No | Restores from trash |
SKILL.md
---
name: emdash-cli
description: Use the EmDash CLI to inspect and manage an EmDash instance from the command line, including content, schema, media, taxonomies, menus, search, authentication, seeds, migrations, generated types, and whole-site export and import.
---
# EmDash CLI
The EmDash CLI (`emdash`, with the short alias `em`) manages EmDash CMS instances. Commands fall into two categories:
- **Local commands** work with project files or a configured database: `init`, `doctor`, `seed`, `migrate`, `export-seed`, `import`, and `secrets`.
- **Remote commands** talk to a running EmDash instance: `types`, `login`, `logout`, `whoami`, `content`, `schema`, `media`, `search`, `taxonomy`, `menu`, and `site`.
Run `npx emdash --help` and `npx emdash <command> --help` for the installed version's exact commands and flags. Resolve the current target with a read command before a destructive or bulk mutation; examples in this skill do not authorize changing an instance the user did not place in scope.
## Authentication
Remote commands resolve auth automatically:
1. `--token` flag
2. `EMDASH_TOKEN` env var
3. Stored credentials from `emdash login`
4. Dev bypass (localhost only — no token needed)
For a localhost development server with the development bypass enabled, the client can authenticate automatically. For a remote instance, run `emdash login --url https://example.com` or provide a scoped token.
## Custom Headers & Reverse Proxies
Sites behind Cloudflare Access or other reverse proxies need auth headers on every request. The CLI supports this via `--header` flags and environment variables.
### Service tokens for automation
```bash
# Provide sensitive headers through the environment in CI.
export EMDASH_HEADERS="CF-Access-Client-Id: xxx
CF-Access-Client-Secret: yyy"
npx emdash whoami --url https://example.com
```
`emdash login --header` persists custom headers to `~/.config/emdash/auth.json` for later commands. Prefer environment-provided headers in CI so a service secret is not written to the credential file or shell history.
### Cloudflare Access Browser Flow
If you don't have service tokens and `cloudflared` is installed, the CLI will automatically:
1. Detect when Access blocks the request
2. Try to get a cached JWT via `cloudflared access token`
3. Fall back to `cloudflared access login` for browser-based auth
This works for interactive use but isn't suitable for CI. Use service tokens for automation.
### Generic Reverse Proxy Auth
The `--header` flag works with any auth scheme:
```bash
# Basic auth
npx emdash login --url https://example.com -H "Authorization: Basic dXNlcjpwYXNz"
# Custom auth header
npx emdash login --url https://example.com -H "X-API-Key: secret123"
```
## Quick Reference
### Database Setup
For normal site startup, use the project's package script. The first request runs pending migrations and, before setup is completed, applies the bundled seed's schema and structure once; sample content comes from the setup wizard, `/_emdash/api/setup/dev-bypass`, or `emdash seed`. The Astro integration generates `emdash-env.d.ts` when the server starts.
```bash
# Start the site with its package script
pnpm dev
# Export an existing database as a seed file
# (the runtime auto-discovers .emdash/seed.json on first boot;
# `mkdir -p` because the directory may not exist yet)
mkdir -p .emdash
npx emdash export-seed > .emdash/seed.json
npx emdash export-seed --with-content=all > .emdash/seed.json
```
### Type Generation
```bash
# Generate types from local dev server
npx emdash types
# Generate from remote
npx emdash types --url https://example.com
# Custom output path
npx emdash types --output src/types/cms.ts
```
Writes `.emdash/types.ts` (TypeScript interfaces) and `.emdash/schema.json`.
### Authentication
```bash
# Login (OAuth Device Flow)
npx emdash login --url https://example.com
# Check current user
npx emdash whoami
# Logout
npx emdash logout
# Generate an encryption key for deployment
npx emdash secrets generate
```
### Content CRUD
The CLI is designed for agents. Create and update auto-publish by default so agents get read-after-write consistency without managing drafts.
```bash
# List content
npx emdash content list posts
npx emdash content list posts --status published --limit 10
# Get a single item (Portable Text fields converted to markdown)
# Returns draft data if a pending draft exists
npx emdash content get posts 01ABC123
npx emdash content get posts 01ABC123 --raw # skip PT->markdown conversion
npx emdash content get posts 01ABC123 --published # ignore pending drafts
# Create content (auto-publishes by default)
npx emdash content create posts --data '{"title": "Hello", "body": "# World"}'
npx emdash content create posts --file post.json --slug hello-world
npx emdash content create posts --draft --data '...' # keep as draft
cat post.json | npx emdash content create posts --stdin
# Update (requires --rev from a prior get, auto-publishes by default)
npx emdash content update posts 01ABC123 --rev MToyMDI2... --data '{"title": "Updated"}'
npx emdash content update posts 01ABC123 --rev MToyMDI2... --draft --data '...' # keep as draft
# Delete (soft delete)
npx emdash content delete posts 01ABC123
# Lifecycle
npx emdash content publish posts 01ABC123
npx emdash content unpublish posts 01ABC123
npx emdash content schedule posts 01ABC123 --at 2026-03-01T09:00:00Z
npx emdash content restore posts 01ABC123
```
### Schema Management
```bash
# List collections
npx emdash schema list
# Get collection with fields
npx emdash schema get posts
# Create collection
npx emdash schema create articles --label Articles --description "Blog articles"
# Delete a collection after inspecting it and confirming the target
npx emdash schema get articles
npx emdash schema delete articles
# Add field
npx emdash schema add-field posts body --type portableText --label "Body Content"
npx emdash schema add-field posts featured --type boolean --required
# Remove field
npx emdash schema remove-field posts featured
```
`schema add-field` supports the field types printed by `npx emdash schema add-field --help`. The full product schema supports additional field types that are not necessarily creatable through this command.
### Media
```bash
# List media
npx emdash media list
npx emdash media list --mime image/png
# Upload
npx emdash media upload ./photo.jpg --alt "A sunset" --caption "Bristol, 2026"
# Get / delete
npx emdash media get 01MEDIA123
npx emdash media delete 01MEDIA123
```
### Search
```bash
npx emdash search "hello world"
npx emdash search "hello" --collection posts --limit 5
```
### Taxonomies
```bash
npx emdash taxonomy list
npx emdash taxonomy terms categories
npx emdash taxonomy add-term categories --name "Tech" --slug tech
npx emdash taxonomy add-term categories --name "Frontend" --parent 01PARENT123
```
### Menus
```bash
npx emdash menu list
npx emdash menu get primary
```
### Site Export and Import
`emdash site` copies a whole site (content model, content, history, settings, and media, but no users or secrets) into a `.emdash` site package, and imports a package into an empty site. The token needs `admin`, which the `emdash login` token has, or the `transfer:export`, `transfer:analyze`, and `transfer:execute` scopes. An `INSUFFICIENT_SCOPE` error means the token has neither. A package holds every entry and the email addresses of authors and commenters: treat it like a database backup.
```bash
# Export (re-run the same command to resume after an interruption)
npx emdash site export --url https://old.example.com --output site.emdash
npx emdash site export --url https://old.example.com --output site.emdash --no-comments
# Import, step 1: upload and analyze; prints the plan and its digest
npx emdash site import site.emdash --url https://new.example.com --analyze
npx emdash site import site.emdash --url https://new.example.com --analyze \
--map-principal editor@example.com=editor@example.com --use-target-title
# Import, step 2: execute exactly the reviewed plan
npx emdash site import site.emdash --url https://new.example.com --plan sha256:<hex> --confirm
# Follow up on an import by operation id
npx emdash site import status <operation-id> --url https://new.example.com
npx emdash site import resume <operation-id> [site.emdash] --url https://new.example.com
npx emdash site import receipt <operation-id> --url https://new.example.com
# Stop an import, or lift the write block a failed or cancelled import leaves
# (neither deletes what the import wrote; --yes skips the prompt)
npx emdash site import cancel <operation-id> --url https://new.example.com
npx emdash site import abandon <operation-id> --url https://new.example.com
```
Show the user the plan (differences from the source site, warnings, blockers, principal mappings) and get their confirmation before running `--confirm`; it writes to the target site and blocks other writes there until it finishes. `site import` exits `2` when the plan has blockers, and `site import status` exits `1` for an import that failed, was cancelled or abandoned, or expired. Only cancel or abandon an import when the user asks: abandoning leaves partial data on the site, which then has to be reset before another import. `--map-principal` takes `<principal id or email>=<user id, email, or none>` and is repeatable; decision flags only work with `--analyze`, and each change produces a new plan digest. The public site is not hidden during an import, so the target should stay private until the command prints a receipt with `receiptDigestValid: true`.
## Drafts and Publishing
The CLI auto-publishes on `create` and `update` by default. This means:
- **`create`** creates the item and immediately publishes it
- **`update`** updates the item and publishes if a draft revision was created
- **`get`** returns draft data if a pending draft exists (e.g. from the admin UI)
Use `--draft` on create/update to skip auto-publishing. Use `--published` on get to ignore pending drafts.
Collections that support revisions store edits as draft revisions. The CLI handles this transparently — agents don't need to know whether a collection uses revisions or not.
## JSON Output
All remote commands support `--json` for machine-readable output. It's auto-enabled when stdout is piped. `emdash site` always writes progress to stderr, so stdout holds only the JSON result; errors are `{ "error": { "code", "message" } }`.
```bash
# Pipe to jq
npx emdash content list posts --json | jq '.items[].slug'
# Use in scripts
ID=$(npx emdash content create posts --data '{"title":"Hello"}' --json | jq -r '.id')
```
## Editing Flow
For details on how content editing works — Portable Text/markdown conversion, `_rev` tokens, and raw mode — see **[EDITING-FLOW.md](./EDITING-FLOW.md)**.