ghost
Manage Ghost CMS content over the Admin API — browse posts, pages,
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/ghost
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Ghost CMS content management from the terminal
Let your agent browse, draft, publish, and schedule content on a Ghost blog or newsletter site through the Admin API — no web editor required.
Why Install This Skill
Editing a Ghost site usually means clicking through the admin UI. This skill hands your agent direct, scripted control instead:
- See the whole editorial state — published posts, plus the drafts and scheduled queue that public site feeds never show.
- Publish programmatically — create posts and pages as drafts, then flip them live after review, with safe collision-checked updates.
- Schedule content — stage posts to appear at future times.
- Keep tags tidy — list tags with usage counts, add new ones, drive exports.
It speaks Ghost's exact authentication dialect automatically: your Admin API key (id:secret) becomes a fresh short-lived signed JWT for every command, so there are no tokens to mint, rotate, or paste anywhere.
Not to be confused with Ghost's official npm ghost-cli tool, which installs and operates Ghost servers (ghost install, nginx/SSL setup, upgrades). This skill manages content on an already-running site; that one manages servers.
What You Get
| Path | Purpose |
|---|---|
SKILL.md |
Command reference: setup, browse/create/publish recipes, gotchas |
scripts/ghost |
CLI tool covering site info, post/page/tag operations, JSON + dry-run modes |
scripts/test_ghost.py |
Offline test suite for the CLI (all network mocked) |
references/admin-auth-and-basics.md |
Full JWT signing walkthrough with auth error signatures |
references/content-vs-admin-api.md |
Content vs Admin API choice guide and draft-visibility trap |
references/posts-pages-tags-endpoints.md |
Endpoint map, pagination loop patterns, error envelope |
references/worked-recipes.md |
Copy-paste workflows: draft→publish, exports, scheduling |
references/gotchas-field-guide.md |
Symptom-first troubleshooting for common failures |
Quick Start
export GHOST_URL="https://your-ghost-site.com"
export GHOST_ADMIN_KEY="<RECORD_KEY>" # Ghost Admin → Integrations → Custom integration
ghost site # connectivity check
ghost posts --status draft
ghost create-post --title "Hello from the terminal" --html "<p>First!</p>"
Preview any write safely by adding --dry-run before the subcommand.
Triggers
Load this skill when working with Ghost CMS content: listing posts/pages/tags, drafting or publishing blog content, scheduling posts, exporting site content, fixing Ghost API authentication errors, or investigating why drafts don't show up in a Ghost site feed.
Requirements
- Python 3.8+ with the
requestslibrary. - A running Ghost 5.x/6.x site where you can create a Custom Integration (Ghost Admin → Settings → Integrations).
- The integration's Admin API Key exported as
GHOST_ADMIN_KEY, plus the site URL asGHOST_URL. Keep the key server-side; it signs mutations and must never ship in client code or CI logs.
Skill manifest
ghost — Ghost CMS content from the terminal
Drive a Ghost CMS site's Admin API (v5/v6, Accept-Version: v6.0): list posts by status including drafts, create and publish pages and posts, manage tags, and check site info. Drafts, scheduled posts, and published content are all visible here because every call authenticates with a per-request Admin JWT built from your id:secret integration key.
Setup
export GHOST_URL="https://your-ghost-site.com"
export GHOST_ADMIN_KEY="<RECORD_KEY>" # id:secret from Ghost Admin → Integrations
- In Ghost Admin → Settings → Integrations, create (or open) a Custom Integration.
- Copy its Admin API Key — one string, two colon-separated hex halves (
id:secret). The separate Content API key from the same screen will NOT let you see drafts; see Known Gotchas. - At request time the CLI signs a short-lived JWT per call: HS256 signature keyed by the secret half after hex-decoding it to raw bytes,
kidheader carrying the id half, audience/admin/,expfive minutes afteriat, sent asAuthorization: Ghost <token>. You never handle the token yourself. --helpand--dry-runwork without credentials (lazy auth).
Essential commands
Inspect
ghost site # title, url, description, version
ghost get-post POST_ID # full record incl. exact updated_at for edits
Browse (intent: find content)
ghost posts # latest 20
ghost posts --status draft # unpublished work queue
ghost posts --status scheduled # what publishes next
ghost posts --limit 100 --page 2 # paginate (max page size 100)
ghost posts --order "updated_at desc" # SQL-style ordering
ghost pages # static pages
ghost tags # tags with usage counts
Create and publish
ghost create-post --title "Notes" # safe default: draft
ghost create-post --title "Hello" --html "<p>Hi</p>"
ghost create-post --title "Launch" --status published --html "<p>We're live</p>"
ghost create-post --title "Later" --status scheduled \
--published-at "2026-09-01T09:00:00.000Z" # future ISO-8601 required together
ghost create-page --title "About" --html "<p>…</p>" --slug about
ghost create-tag --name "Engineering" --description "Technical posts"
Edit and remove
ghost update-post POST_ID --title "New title" \
--updated-at "<RECORD_UPDATED_AT>" # REQUIRED: latest updated_at, re-read first
ghost update-post POST_ID --status published --updated-at "<RECORD_UPDATED_AT>"
ghost delete-post POST_ID # permanent, 204-style removal
Pipeline recipes
Draft now, publish after review
ghost --json create-post --title "Release notes" > /tmp/post.json
id=$(jq -r '.post.id // .post_id // empty' /tmp/post.json)
ghost get-post "$id" # read fresh updated_at
ghost update-post "$id" --status published \
--updated-at "<exact string from get-post output>"
Never fabricate updated_at; copy it verbatim from a fresh read or Ghost rejects the edit with HTTP 409 UpdateCollisionError.
Review queue across statuses
for s in draft scheduled; do
ghost posts --status "$s" --json | jq -r '.posts[] | "\(.status)\t\(.title)\t\(.slug)"'
done
Complete export
Loop pages by meta.pagination.next (surfaced as .page.next) instead of trusting totals:
page=1
while :; do
ghost posts --limit 100 --page "$page" --json > "/tmp/posts-$page.json"
jq -r '.posts[].id' "/tmp/posts-$page.json"
next=$(jq -r '.page.next // empty' "/tmp/posts-$page.json")
[ -z "$next" ] && break
page=$next; sleep 0.2
done
JSON output and jq
--json works before or after the subcommand:
ghost --json posts # same as: ghost posts --json
JSON shapes worth knowing:
- Lists emit
{"total", "page": {pagination}, "posts": [...]}; detail/create emit the resource under its noun (post,page,tag,site). - Pagination mirrors the API:
.page = {"page", "limit", "pages", "total", "next", "prev"};next/prevare numbers ornull. --dry-run --jsonemits the executed plan instead of results:{"dry_run": true, "method", "url", "params"/"json"}— preview the exact request before running it live.- Errors exit non-zero with the API's own message plus code on stderr; JSON mode never wraps errors in stdout JSON.
Global flags
| Flag | Effect |
|---|---|
--json |
Machine-readable JSON (position-independent) |
--dry-run |
Print the planned API call (method, URL, payload) without executing |
--quiet |
Suppress diagnostics |
--verbose |
Debug logging |
Known gotchas
- Drafts need the Admin plane. The public Content API (that key-as-query-param API) serves published posts only and hides drafts silently — no error, just absent, even with a perfectly valid key. Its filters like
status:draftare ignored rather than rejected. Everything this CLI does goes through the Admin API precisely so drafts and scheduled posts stay reachable. - Two keys, same integration screen. The Content key is browser-safe but read-only-public; the Admin key (
GHOST_ADMIN_KEY) signs mutations and reaches drafts. Never point scripts at the Content key and expect draft visibility. - Five-minute tokens. Each JWT lives at most 300 seconds (
exp ≤ iat + 300) and the verifier caps token age too, so long batch jobs must re-sign per request (the CLI does). Skewed clocks break signing windows; keep NTP healthy. - HS256 only, decoded-secret keying. Tokens signed with HS512 are refused ("invalid algorithm"); signing without first hex-decoding the secret half produces "valid-looking" garbage that 401s. The CLI handles both rules.
Authorization: Ghost, not Bearer.Bearerscheme answers 401INVALID_AUTH_HEADER.- Edits require collision guards. PUTs without the post's current
updated_atfail with 409; relation arrays (tags,authors) replace wholesale rather than merge. --htmlrequiressource=htmland stays lossy. Every write carrying anhtmlpayload (create-post, update-post, create-page) must send the?source=htmlquery flag — the CLI attaches it automatically, and dry-run plans show it inparams— or Ghost parses the body as mobiledoc/lexical. Even with the flag, conversion is lossy: send proper Lexical, or wrap fixed markup in HTML card comments.- Pagination caps at 100 since Ghost 6 removed
limit=all; oversized limits silently return ≤100 rows, so always loop bynext. - Deletion is permanent and takes effect on the public site immediately.
When to use
Use this skill whenever the task is content workflow against a running Ghost site: browsing or exporting posts, drafting, publishing, scheduling, tag upkeep, page creation, or diagnosing those flows (auth errors, pagination, missing drafts).
When not to use
Do not use it to install, host, or operate a Ghost server (ghost install, nginx/SSL/systemd setup, upgrades, backups) — that is Ghost's official npm ghost-cli site-management tool, unrelated despite the shared name. Not for other publishing platforms (WordPress, Hugo have their own tooling), not for theme development, and not for site configuration better done once in the Admin dashboard (staff accounts, membership tiers).
Reference files
| File | Use it for |
|---|---|
| references/admin-auth-and-basics.md | Full JWT signing walkthrough, key format, audience/expiry rules, auth error table |
| references/content-vs-admin-api.md | Choosing between planes; the draft-visibility trap; diagnostic checklist |
| references/posts-pages-tags-endpoints.md | Endpoint map, field semantics, pagination loop, error envelope |
| references/worked-recipes.md | Copy-paste workflows: draft→publish, exports, scheduling, triage |
| references/gotchas-field-guide.md | Symptom-first incident lookup for auth, editing, volume problems |
Scripts and prerequisites
scripts/ghost— executable Python CLI (stdlib + requests only). Flags above; lazy auth; structured logging.scripts/test_ghost.py— offline test suite (mocked HTTP, zero network).- Python 3.8+,
requests. Nothing listens, nothing installs; scope limited to one configured site viaGHOST_URL.
Files (agent-skills)
-
evals
-
evals.json 2.7 KB
{ "schema_version": 1, "skill_name": "ghost", "evals": [ { "id": "list-draft-posts", "prompt": "Show me the draft posts on my Ghost blog so I can see what is waiting to be finished.", "expected_output": "Use ghost posts --status draft, which routes to the Admin API because drafts are invisible to the Content API.", "assertions": ["uses ghost posts command", "filters status draft", "mentions Admin API for drafts"] }, { "id": "draft-then-publish-pipeline", "prompt": "Create a post called Release notes on my Ghost site, then publish it once the content is reviewed.", "expected_output": "Create with ghost create-post --title 'Release notes', then re-read it and run ghost update-post <id> --updated-at <latest> --status published.", "assertions": ["creates as draft first", "update sends latest updated_at", "publishes via status published"] }, { "id": "jwt-auth-gotcha", "prompt": "My Ghost Admin API script keeps failing with 401 INVALID_JWT even though my id:secret key looks right. What is going on?", "expected_output": "Explain the Admin JWT contract: HS256 signature over the hex-decoded secret half, kid header set to the ID half, aud /admin/, exp at most five minutes after iat; check clock skew and that the secret is hex-decoded before signing. HS512-signed tokens are rejected with invalid algorithm.", "assertions": ["explains HS256 with hex-decoded secret", "names kid header and aud", "states five minute token window"] }, { "id": "content-vs-admin-keys", "prompt": "I have a Ghost Content API key. Can I use it to list my unpublished posts?", "expected_output": "No. The Content API serves published content only and ignores non-public filters, so drafts never appear no matter how valid the key is; switch to an Admin API key with ghost posts --status draft.", "assertions": ["states content key cannot see drafts", "recommends admin api"] }, { "id": "not-for-ghost-cli-install", "prompt": "Run ghost install to set up a new Ghost production server with nginx and ssl on this machine.", "expected_output": "Do not route this here: ghost install belongs to Ghost's official npm ghost-cli site-management tooling, not this Ghost Admin API content skill.", "assertions": ["must not trigger ghost skill", "routes server installs to npm ghost-cli"] }, { "id": "pagination-loop", "prompt": "Export every post from my Ghost site as JSON, there are several hundred.", "expected_output": "Loop with page and limit 100 following meta.pagination.next until next is null, since limit=all is gone in Ghost 6 and pages cap at 100.", "assertions": ["uses pagination next loop", "caps limit at 100"] } ] }
-
-
references
-
admin-auth-and-basics.md 6.4 KB
# Ghost Admin API Authentication and Basics The Admin API is Ghost's management plane at `https://{admin_domain}/ghost/api/admin/`. It handles full CRUD on posts, pages, tags, and more, including drafts and scheduled content. Every request below assumes you have an Admin API key from **Ghost Admin → Settings → Integrations → Custom Integration**. ## The Admin API key An Admin API key is a single string of two colon-separated halves: ``` {id}:{secret} ``` - `{id}` — a 24-character hexadecimal identifier (a Ghost ObjectID). - `{secret}` — a 64-character hexadecimal string encoding 32 random bytes. Parse the key by splitting on the first `:`. Never assume total length; both halves are hex, but treat them as opaque strings until the moment you use them. Regenerating the key in Ghost Admin immediately invalidates every script holding the old one. Treat the whole key as a server-side secret: it signs tokens that can create, edit, and delete content. Use placeholders like `<RECORD_KEY>` in examples and CI; never paste real keys into code review tools. ## JWT token contract, end to end Ghost does not accept the Admin API key directly. You exchange it for a short-lived JSON Web Token per request: 1. Split the key on `:` into `id` and `secret`. 2. **Hex-decode the secret** into its 32 raw bytes. Signing with the literal hex characters produces an invalid signature; this is the single most common integration bug. 3. Build a JWT header with `alg: HS256`, `kid: <id>`, `typ: JWT`. ```json { "alg": "HS256", "kid": "<API_KEY_ID>", "typ": "JWT" } ``` 4. Build a payload with integer-second timestamps and the audience claim: ```json { "iat": 1700000000, "exp": 1700000300, "aud": "/admin/" } ``` 5. Base64url-encode each segment without padding (`=` stripped), sign the `header.payload` string with HMAC-SHA256 keyed by the decoded bytes, append the base64url signature as the third dot-separated segment. 6. Send it as `Authorization: Ghost <token>` — the scheme is `Ghost`, not `Bearer`. 7. Include `Accept-Version: v6.0` and, for JSON writes, `Content-Type: application/json`. Python equivalent of the bundled script's signer: ```python import base64, hashlib, hmac, json, time def admin_token(key: str, request_path: str = "/ghost/api/admin/") -> str: key_id, secret_hex = key.split(":", 1) hmac_key = bytes.fromhex(secret_hex) # decode hex to raw bytes now = int(time.time()) def b64url(obj) -> str: return base64.urlsafe_b64encode( json.dumps(obj, separators=(",", ":")).encode()).rstrip(b"=").decode() header = b64url({"alg": "HS256", "typ": "JWT", "kid": key_id}) audience = "/admin/" # see audience rules below payload = b64url({"iat": now, "exp": now + 300, "aud": audience}) signing_input = f"{header}.{payload}".encode() signature = hmac.new(hmac_key, signing_input, hashlib.sha256).digest() return f"{header}.{payload}." + base64.urlsafe_b64encode(signature).rstrip(b"=").decode() ``` ### Rules that decide whether a token works - **Algorithm must be HS256.** A token signed with HS512 is rejected outright (`Invalid token: invalid algorithm`). Do not "upgrade" the algorithm; Ghost's verifier allow-lists HS256 only. - **aud (audience)** for current unversioned URLs (`/ghost/api/admin/...`) is exactly `/admin/`. Legacy versioned routes scope the audience to their URL version (`/v3/admin/`, `/v4/admin/`; v5 has no such form — Ghost 5 removed versioned URLs entirely). Sending `Accept-Version: v6.0` does not change the audience. - **exp ≤ iat + 300.** Five minutes is the documented maximum token lifetime. The server additionally enforces a five-minute maximum age measured from `iat`, so a long-lived token fails even mid-window. Mint a fresh token for each request rather than caching them. - **Timestamps are seconds**, not milliseconds. Millisecond values produce oversized `iat`/`exp` and fail validation. - **NTP matters.** A skewed system clock shifts `iat` outside the acceptance window even though your code looks correct. ## Error signatures for auth failures Ghost returns JSON errors shaped like `{"errors": [{"message", "context", "type", "code", ...}]}`. Distinct auth failure modes have distinct signatures worth memorizing: | Symptom | Status | Meaning | | --- | --- | --- | | `Invalid token: jwt expired` / `maxAge exceeded`, code `INVALID_JWT` | 401 | Token lifetime violated — mint fresher tokens | | `Invalid token: invalid algorithm`, `INVALID_JWT` | 401 | Wrong alg (e.g. HS512); sign HS256 | | `jwt audience invalid`, `INVALID_JWT` | 401 | Wrong aud; use `/admin/` for unversioned URLs | | `Admin API kid missing.`, `MISSING_ADMIN_API_KID` | 400 | JWT header lacks `kid` | | `Unknown Admin API Key`, `UNKNOWN_ADMIN_API_KEY` | 401 | kid does not match any integration; key regenerated? | | `Authorization header format is "Authorization: Ghost [token]"`, `INVALID_AUTH_HEADER` | 401 | Used `Bearer` instead of the `Ghost` scheme | | Malformed token JSON/base64, `INVALID_JWT` | 400 | Structurally undecodable token | | No auth at all → `Authorization failed`, type `NoPermissionError` | 403 | Missing `Authorization` header entirely | The CLI surfaces each of these on stderr with the server message plus a hint, mapped to exit codes by failure class: `401` and `403` exit `2` (auth/permission), `404` exits `3` (missing resource), `409` exits `4` (update collision), and `429` exits `5` (rate limited). ## Request conventions ```http GET /ghost/api/admin/posts/?limit=15&page=1 HTTP/1.1 Host: example.com Authorization: Ghost <token> Accept-Version: v6.0 Accept: application/json ``` - All resources ride in plural envelopes: `{"posts": [...], "meta": {...}}`. Writes must wrap payloads the same way: `{"posts": [{...}]}`. `/site/` and `/settings/` are the sole exceptions (single objects). - Pagination defaults to `page=1&limit=15`; Ghost 6 caps page size at 100 and no longer honors `limit=all`. - Filter syntax follows NQL: `filter=status:draft` uses URL-encoded `property:value`, comma is OR, parentheses group, `-` negates. - `include=tags,authors` hydrates relations; `fields=title,slug,status` slims responses. ## Sources - https://docs.ghost.org/admin-api - https://docs.ghost.org/admin-api/#token-generation - https://docs.ghost.org/admin-api/#accept-version-header - https://docs.ghost.org/faq/api-versioning - https://docs.ghost.org/content-api/pagination - https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/server/services/auth/api-key/admin.js -
content-vs-admin-api.md 3.9 KB
# Content API vs Admin API: Which Plane, Which Key Ghost exposes two REST APIs with different credentials, scopes, and content visibility. Picking the wrong one produces the classic failure: everything looks configured, yet drafts are nowhere to be found and nothing errors. ## The split at a glance | Aspect | Content API | Admin API | | --- | --- | --- | | Base path | `/ghost/api/content/` | `/ghost/api/admin/` | | Credential | Content key as `?key=<RECORD_KEY>` query param | JWT in `Authorization: Ghost <token>` | | Verbs | GET only (Browse, Read) | Full REST per resource | | Scope | Published posts/pages/tags/authors/tiers/settings | Everything public **plus drafts, scheduled posts, members, webhooks, images, themes** | | Key safety | Safe for browsers and clients (public data only) | Server-side only; signs mutations | | Cacheability | Designed to be cached/CDN-fronted | Mutating; publish busts front-end caches | | Typical consumers | Site themes, headless frontends, mobile apps | Editorial automation, migrations, scheduling bots | Both key types come from the same Custom Integration screen; an integration has a Content API key and an Admin API key side by side. They are not interchangeable. ## Draft-visibility asymmetry (the trap) The Content API **delivers published content only**. Its docs state the key "only ever provide[s] access to public data," and Ghost enforces this at the model layer: public-context post queries carry a non-overridable `status:published` filter. Consequences worth internalizing: 1. **Drafts are unreachable via Content API regardless of key validity.** A valid key does not make drafts visible; the request simply never matches them. 2. **It fails silent, not loud.** Browsing with a valid Content key returns HTTP 200 with only published posts — an empty or partial list, no error, no hint. There is no 403 saying "you can't see drafts." 3. **Filtering does not bypass it.** `filter=status:draft` against the Content API returns the same published collection; the disallowed filter is ignored rather than rejected. 4. **Direct reads of non-public posts 404.** Reading `/content/posts/<draft-id>/` behaves as if the post does not exist — consistent with the documented 404 category "data which is not public." The bundled CLI is Admin-API-first precisely because of this asymmetry: `ghost posts --status draft` works only because it authenticates with the Admin JWT, which sees drafts, scheduled, and published posts alike. ### Diagnostic checklist when "posts are missing" - Authenticated with the **Content** key? Switch to the Admin key workflow (`GHOST_ADMIN_KEY`); drafts will appear. - Using Admin and still missing them? Check `filter=status:` values (`draft`, `scheduled`, `published`) and page through with `--page`. - Post visible in Admin UI but 404s from your site code? That is the same asymmetry in reverse: unpublished content never appears on the public plane. ## When to use which Use the **Content API** for anything that renders your site to the world: headless frontend builds, static-site generators, search indexes, feeds, mobile apps. It is read-only, key-as-query-param, and cache-friendly. Use the **Admin API** for anything that changes content or needs non-public data: creating and editing posts, publishing/scheduling, managing tags and pages, uploading images, working with drafts before they go live. Keep Admin keys out of browsers, client bundles, and CI logs. A frequent pattern pairs both: editorial automation writes through Admin; the public site reads through Content plus CDN. If a workflow only ever reads published content, prefer Content — smaller blast radius, browser-safe key. ## Sources - https://docs.ghost.org/content-api - https://docs.ghost.org/content-api/#key - https://docs.ghost.org/admin-api - https://docs.ghost.org/admin-api/#choosing-an-authentication-method - https://docs.ghost.org/content-api/errors - https://github.com/TryGhost/Ghost/blob/main/ghost/core/core/server/models/post.js -
gotchas-field-guide.md 4.8 KB
# Ghost CMS Gotchas Field Guide Real-world failure modes, their symptoms, and their fixes. Each entry states the symptom first so this file can be scanned mid-incident. ## Auth and keys **Symptom: 401 with `Invalid token: jwt expired` or `maxAge exceeded`.** Your token outlived its five-minute window. Ghost both rejects `exp` more than 300 seconds past `iat` *and* independently caps token age at five minutes from `iat`, so caching tokens across a long batch job fails midway. Mint a fresh token per request — the bundled CLI does this automatically. **Symptom: 401 `Invalid token: invalid algorithm`.** The token was signed HS512 (or another algorithm). Ghost's verifier allow-lists exactly `['HS256']`; "stronger" algorithms are rejected, not gracefully accepted. Sign HS256. **Symptom: signature looks right but still 401.** You signed the HMAC key with the literal hex characters of the secret half instead of the raw bytes they encode. Hex-decode first (`bytes.fromhex(secret_hex)` in Python; `-macopt hexkey:$SECRET` in the official OpenSSL example). This is the most common hand-rolled signer bug. **Symptom: used `Authorization: Bearer ...` → 401 `INVALID_AUTH_HEADER`.** Ghost's scheme is `Ghost`: `Authorization: Ghost <token>`. **Symptom: worked for months, suddenly 401 `UNKNOWN_ADMIN_API_KEY`.** Someone regenerated the integration key in Ghost Admin. Old scripts keep signing tokens under a kid the server no longer knows. Update every deployment holding the old key. **Symptom: fails only on one machine.** Clock skew. Tokens are valid within tight iat/exp windows and NTP drift breaks them. Sync the clock. ## Content visibility **Drafts invisible even though everything authenticates:** you are hitting the Content API (Content key, `/ghost/api/content/`). It serves published posts only, silently ignoring filters like `status:draft`, and it never errors about what it hides. Use the Admin API (Admin key + JWT) to reach drafts and scheduled posts. See [content-vs-admin-api.md](content-vs-admin-api.md). **Reading a known post id returns 404 from site code but renders in Admin:** same asymmetry from the other side — non-public content simply does not exist on the public plane. ## Editing **409 `UpdateCollisionError` ("Saving failed! Someone else is editing this post."):** your PUT carried a stale `updated_at`. Every edit payload must include the version you actually read; re-GET immediately before PUT and pass its exact timestamp string. Concurrent editors and parallel automation make this likelier, not less. **Tags/authors vanished after an edit:** relation arrays REPLACE on update rather than merge. `PUT {"tags":["news"]}` deletes every other tag. Fetch-modify-send the complete array. **HTML came through mangled or stripped:** native post source is Lexical. Passing `html` requires the `?source=html` flag, and Ghost's HTML→Lexical conversion is lossy — inline styles and exotic tags get normalized. Preserve verbatim markup inside an HTML card (`<!--kg-card-begin: html-->…<!--kg-card-end: html-->`) or send proper Lexical JSON. **Slug differed from what you sent:** slugs are uniquified (`my-post-2`) and sanitized (lowercase, hyphens) server-side. Read back `slug` from the create/edit response rather than assuming echo. ## Pagination and volume **Only ever see ~15 (or at most 100) results:** default page size is 15; Ghost 6 caps limit at 100 and removed `limit=all` (oversized limits no longer error — they silently return ≤100 rows). Loop pages via `meta.pagination.next` until null. Treat totals as advisory; iterate by `next`. **Bulk script gets slow/rate-limited on big exports:** stagger requests between pages. There is no single documented universal rate limit; throttling is host-dependent and appears as 429 `TooManyRequestsError`. Back off exponentially and honor any Retry-After header seen in practice. ## Versions **Requests work locally but docs examples conflict:** send `Accept-Version: v6.0` on every request. Versioning lives in headers since Ghost 5 removed versioned URLs; legacy URLs redirect internally and mark responses `Deprecation`. Breaking changes arrive only with major versions (~annually), and Ghost emails admins if a client sends unservable versions — another reason stale CI integrations suddenly "stop working" after upgrades. **Mobiledoc field errors after an upgrade:** Ghost 5+ replaced Mobiledoc with Lexical as the canonical content format. Integrations writing Mobiledoc must migrate to `lexical` (or use HTML cards). ## Skill boundary reminder This skill drives the REST APIs. Server installation, nginx/SSL/systemd setup, `ghost start/stop/update/backup/doctor`, and theme-file editing belong to Ghost's separate npm `ghost-cli` ops tooling — different tool entirely. ## Sources - https://docs.ghost.org/admin-api - https://docs.ghost.org/content-api - https://docs.ghost.org/changes - https://docs.ghost.org/faq/api-versioning -
posts-pages-tags-endpoints.md 5.6 KB
# Admin Endpoint Guide: Posts, Pages, Tags, Pagination, Errors All paths relative to `https://{admin_domain}/ghost/api/admin/`. Authentication per [admin-auth-and-basics.md](admin-auth-and-basics.md). ## Posts ```text GET /posts/ browse (filter, limit, page, order, include, fields, formats) GET /posts/{id}/ read by id GET /posts/slug/{slug}/ read by slug POST /posts/ create PUT /posts/{id}/ edit DELETE /posts/{id}/ delete (204 No Content) ``` Post fields include `id`, `uuid`, `title`, `slug`, `html` (rendered), `lexical` (Ghost 5+ source format, replacing Mobiledoc), `status`, `visibility`, `created_at`, `updated_at`, `published_at`, plus tag/author relations and computed `url`/`excerpt`. Ghost 6 returns `lexical` by default; pass `formats=html,lexical` when you need rendered HTML alongside the source. ### Creating posts Only `title` is required. Omitting `status` creates a draft — the safest default for automation. ```bash curl -sS -X POST "$BASE/posts/" \ -H "Authorization: Ghost $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept-Version: v6.0" \ --data '{"posts":[{"title":"Release notes"}]}' ``` Add content either as a JSON-encoded Lexical document in `lexical`, or with `html` **plus the `?source=html` query flag**, which converts HTML to Lexical server-side. The conversion is lossy; wrap markup you must preserve verbatim in an HTML card (`<!--kg-card-begin: html--> ... <!--kg-card-end: html-->`). Scheduled posts require `status: scheduled` and a future ISO 8601 `published_at`. ### Editing posts: the collision guard PUT updates are partial, but every edit payload MUST carry the resource's current `updated_at`. Treat it as proof you read the latest version. The recommended sequence is GET immediately before PUT. ```json {"posts": [{"updated_at": "<RECORD_UPDATED_AT>", "status": "published"}]} ``` Sending a stale `updated_at` fails with HTTP 409 `UpdateCollisionError` ("Saving failed! Someone else is editing this post."). A second editor (or another script) racing your automation is the usual trigger; re-GET, merge, retry. **Tag and author relations replace, never merge:** PUTting `tags:["news"]` removes all other tags. Fetch the post, modify the complete array, send it back whole. ### Status lifecycle `draft` → editable, invisible to public plane → `scheduled` (requires future `published_at`; Ghost flips it to `published` automatically) → `published` → back to `draft` via PUT if needed. Email-only posts report `sent` after dispatch. ## Pages Same verb set as posts at `/pages/`, plus `POST /pages/{id}/copy/` to duplicate. Pages are API-shape-identical to posts but render outside collection channels (about pages, landing pages). Creation is identical: only `title` required, omitting status drafts it. ## Tags ```text GET /tags/ browse (add include=count.posts for usage counts) POST /tags/ create PUT /tags/{id}/ edit DELETE /tags/{id}/ delete ``` Creating a tag that already exists by name/slug errors with a validation message rather than deduplicating; browse first when unsure. Hidden/internal tags carry `visibility: internal` and code-style slugs (`hash-...`). ## Site info `GET /site/` is unauthenticated and returns a single object (no envelope): `{title, description, logo, url, version}`. Handy as a connectivity check before sending authenticated requests. ## Pagination Browse endpoints default to `page=1&limit=15`. Ghost 6 caps page size at 100 — `limit=all` and `limit=9999` no longer error but silently return at most 100 rows. Consumers MUST loop: ```python page = 1 while True: doc = get_posts(page=page, limit=100) yield from doc["posts"] nxt = doc["meta"]["pagination"]["next"] if nxt is None: break page = nxt ``` `meta.pagination` shape: `{"page": 1, "limit": 100, "pages": 7, "total": 624, "next": 2, "prev": null}`. Drive loops from `next` (null terminates), never from precomputed arithmetic on `total`, which can move mid-run under concurrent edits. Add a small delay between pages on large exports; hosts throttle aggressive crawlers even though Ghost itself documents no fixed rate limit. ## Error envelope and status codes ```json { "errors": [{ "message": "...", "context": null, "type": "NotFoundError", "details": null, "property": null, "help": null, "code": null, "id": "...", "ghostErrorCode": null }] } ``` | Status | type | When | | --- | --- | --- | | 400 | ValidationError / BadRequestError | Malformed query or payload, invalid field values | | 401 | UnauthorizedError | Bad/expired/malformed JWT (see auth reference table) | | 403 | NoPermissionError | Missing header, insufficient integration permissions | | 404 | NotFoundError | Unknown id/slug, or non-public resource via Content API | | 409 | UpdateCollisionError | Stale `updated_at` on PUT | | 429 | TooManyRequestsError | Host throttling; back off, honor any Retry-After | | 500 |ServerError | Ghost-side failure; safe to retry idempotent reads | Match errors on `errors[].code` where present (`UPDATE_COLLISION`, `INVALID_JWT`); messages change copy between releases less often than types, but codes are most stable of all. ## Sources - https://docs.ghost.org/admin-api/posts/overview - https://docs.ghost.org/admin-api/posts/creating-a-post - https://docs.ghost.org/admin-api/posts/updating-a-post - https://docs.ghost.org/admin-api/posts/publishing-a-post - https://docs.ghost.org/admin-api/posts/scheduling-a-post - https://docs.ghost.org/admin-api/pages/overview - https://docs.ghost.org/admin-api/site/overview - https://docs.ghost.org/content-api/pagination - https://docs.ghost.org/content-api/errors - https://docs.ghost.org/changes -
worked-recipes.md 4.9 KB
# Worked Recipes: CLI and Raw API Recipes combining the bundled `scripts/ghost` CLI with raw Admin API calls. Every recipe is executable end-to-end with only `GHOST_URL` and `GHOST_ADMIN_KEY` set (and `jq` for JSON plumbing). HTML/Lexical examples use placeholder tokens, never real credentials. ## Recipe 1: Draft a post now, publish after review Draft-first keeps half-written work off the public site while still letting you preview with admin themes. ```bash # 1. Create the draft ghost --json create-post --title "Release notes" \ --html "<p>What shipped this week…</p>" > /tmp/draft.json post_id=$(jq -r '.post.id' /tmp/draft.json) # 2. Later: re-read to fetch the CURRENT updated_at (collision guard) ghost get-post "$post_id" | grep updated_at # 3. Publish, passing the fresh timestamp ghost update-post "$post_id" \ --status published \ --updated-at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" # NO — use the value from step 2 ``` The last line above is deliberately wrong: never synthesize `updated_at`. Copy the exact string from step 2's output; the server compares timestamps literally, and a mismatch means 409. Raw-API equivalent of step 3: ```bash curl -sS -X PUT "$BASE/posts/$POST_ID/" \ -H "Authorization: Ghost $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept-Version: v6.0" \ --data "{\"posts\":[{\"updated_at\":\"$UPDATED_AT\",\"status\":\"published\"}]}" ``` ## Recipe 2: Review queue — everything unpublished Drafts, scheduled, and published live on different filters; one call per status: ```bash ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"' ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t\(.published_at // "-")"' ``` Pair it with an authoring-wide sanity check via jq types before feeding slugs onward: ```bash ghost posts --status draft --json | jq '{count: (.posts|length), all_slugs_strings: ([.posts[].slug | type] | all(. == "string"))}' ``` ## Recipe 3: Full export, page by page Ghost 6 caps pages at 100 rows; `limit=all` is gone. ```bash page=1 while :; do ghost posts --limit 100 --page "$page" --json > "/tmp/posts-$page.json" next=$(jq -r '.page.next // empty' "/tmp/posts-$page.json") jq -r '.posts[].id' "/tmp/posts-$page.json" [ -z "$next" ] && break page=$next sleep 0.2 done ``` Note the loop reads `meta.pagination.next` from the CLI's `.page` field rather than computing `(page * limit) < total`; totals shift under concurrent edits. ## Recipe 4: Publish at a future time (scheduling) ```bash ghost create-post --title "Launch day" \ --html "<p>We're live.</p>" \ --status scheduled \ --published-at "2026-09-01T09:00:00.000Z" ``` Requirements: the timestamp must be in the future and ISO 8601; Ghost processes the queue on its own schedule (typically within five minutes of the mark). The post remains visible as `scheduled` in the Admin plane, invisible publicly until flip time. CLI enforces the pairing (`--status scheduled` without `--published-at` errors before any request is made). ## Recipe 5: Slug-based handoff between systems External systems key content by slug. Resolve slug → id → full record: ```bash curl -sS "$BASE/posts/slug/$SLUG/" \ -H "Authorization: Ghost $TOKEN" \ -H "Accept-Version: v6.0" | jq -r '.posts[0].id' ``` Then read or edit by that id. The CLI reads by id (`ghost get-post <id>`); for slug lookups use the curl form above. ## Recipe 6: Tag hygiene pass Find tags nobody uses, then create missing ones for a new series: ```bash # Usage-counted listing ghost tags --limit 200 --json | jq -r '.tags[] | select((.count.posts // 0) == 0) | .slug' # Create two series tags (idempotency: check existence first, creation duplicates error out) ghost create-tag --name "Engineering" --slug engineering --description "Technical posts" ``` Remember relation semantics when tagging posts programmatically: PUT replaces the tag array wholesale, so send `[...existing_slugs, "engineering"]`, not just the addition. ## Recipe 7: Connectivity triage When nothing works, descend this ladder: ```bash # 1. Is Ghost up? (no auth required) curl -sS "$GHOST_URL/ghost/api/admin/site/" | jq . # 2. Does our JWT authenticate? ghost site # 3. Can we browse? (exercises query params + permissions) ghost posts --limit 1 ``` Step 1 failing = wrong URL/site down. Step 2 failing = auth contract problem (see the auth reference's signature table: expired vs invalid-algorithm vs audience). Step 3 failing while step 2 passes usually means integration permission gaps rather than token problems. ## Sources - https://docs.ghost.org/admin-api/#token-generation-examples - https://docs.ghost.org/admin-api/posts/creating-a-post - https://docs.ghost.org/admin-api/posts/updating-a-post - https://docs.ghost.org/admin-api/posts/publishing-a-post - https://docs.ghost.org/admin-api/posts/scheduling-a-post - https://docs.ghost.org/content-api/pagination - https://docs.ghost.org/changes
-
-
scripts
-
ghost 23 KB · in bundle
-
test_ghost.py 25.4 KB
"""Offline suite for ghost/scripts/ghost — zero network egress by construction. Covers four behavior classes (--help, argument errors, --dry-run, mocked-client logic) plus JWT known-answer signing checks with fixed inputs, per-skill pipeline consumability chains exercised end-to-end through subprocesses and jq (pipeline stages feed each other's outputs verbatim; every stage's exit code is asserted), and Ghost-specific error signatures (409 update collision, 404 draft read, INVALID_AUTH_HEADER, 204 No Content deletes). All HTTP is short-circuited by --dry-run or replaced with unittest mocks bound at the cli.requests call site. No sockets are opened. """ import contextlib import importlib.machinery import importlib.util import io import json import os import subprocess import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch SCRIPT = Path(__file__).with_name("ghost") # Fixed Admin API key in the official {id}:{secret} shape: 24-hex ObjectID half, # 64-hex 32-byte secret half. Both halves are SYNTHETIC placeholder patterns, # not credentials; nothing here authenticates anywhere. KID = "5f9d4b1c8e2a43d7b6c0a1e9" SECRET_HEX = "00ff" * 16 FIXED_KEY = f"{KID}:{SECRET_HEX}" # Known-answer fixture computed once with iat frozen at 1700000000: # header/payload are compact-JSON base64url segments; SIG_B64 is HMAC-SHA256 # over "<header>.<payload>" keyed with bytes.fromhex(SECRET_HEX). HEADER_B64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjVmOWQ0YjFjOGUyYTQzZDdiNmMwYTFlOSJ9" PAYLOAD_B64 = "eyJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMDMwMCwiYXVkIjoiL2FkbWluLyJ9" SIG_B64 = "nxiluHMEVbp05Gi4kDYp28CCyOrwWuirtSzZeuWoisg" TOKEN_TTL = 300 def load_cli(): loader = importlib.machinery.SourceFileLoader("ghost_cli", str(SCRIPT)) spec = importlib.util.spec_from_loader("ghost_cli", loader) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def clean_env(): env = os.environ.copy() env.pop("GHOST_URL", None) env.pop("GHOST_ADMIN_KEY", None) return env class GhostCliTests(unittest.TestCase): """Subprocess-level CLI surface: help, arg errors, dry-run plans.""" def run_cli(self, *args): return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=clean_env()) def test_help_lists_all_subcommands(self): result = self.run_cli("--help") self.assertEqual(result.returncode, 0) for noun in ("site", "posts", "pages", "tags", "create-post", "get-post", "update-post", "delete-post", "create-page", "create-tag"): self.assertIn(noun, result.stdout) def test_no_subcommand_prints_help_and_fails(self): result = self.run_cli() self.assertNotEqual(result.returncode, 0) def test_missing_title_is_argument_error(self): result = self.run_cli("--json", "create-post") self.assertNotEqual(result.returncode, 0) self.assertIn("--title", result.stderr) def test_invalid_status_choice_rejected_without_crash(self): result = self.run_cli("--json", "create-post", "--title", "T", "--status", "archived") self.assertNotEqual(result.returncode, 0) self.assertNotIn("Traceback", result.stderr) def test_update_post_requires_updated_at_flag(self): result = self.run_cli("--json", "update-post", "abc123") self.assertNotEqual(result.returncode, 0) self.assertIn("--updated-at", result.stderr) def test_dry_run_site_plan_is_valid_json_without_credentials(self): result = self.run_cli("--dry-run", "--json", "site") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) self.assertTrue(payload["dry_run"]) def test_dry_run_human_output_requires_explicit_json_flag(self): # Without --json the same command stays human-readable; the flag dict # is per-invocation, so callers must pass --json explicitly each run. result = self.run_cli("--dry-run", "site") self.assertEqual(result.returncode, 0) self.assertIn("[dry-run]", result.stdout) with self.assertRaises(json.JSONDecodeError): json.loads(result.stdout) def test_create_post_dry_run_plan_includes_url_and_envelope(self): result = self.run_cli("--dry-run", "--json", "create-post", "--title", "Draft A") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) self.assertTrue(payload["dry_run"]) self.assertIn("/ghost/api/admin/posts", payload["url"]) self.assertEqual(payload["json"], {"posts": [{"title": "Draft A", "status": "draft"}]}) def test_update_post_dry_run_carries_collision_guard_field(self): result = self.run_cli("--dry-run", "--json", "update-post", "abc123", "--status", "published", "--updated-at", "2026-08-26T12:00:00.000Z") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) fields = payload["fields"] self.assertEqual(fields["updated_at"], "2026-08-26T12:00:00.000Z") self.assertEqual(fields["status"], "published") def test_create_post_dry_run_sends_source_html_only_with_html_payload(self): # Ghost parses html write payloads as mobiledoc/lexical unless the # docs-required ?source=html query flag rides along; the plan must # preview exactly that request. result = self.run_cli("--dry-run", "--json", "create-post", "--title", "Doc", "--html", "<p>Hi</p>") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) self.assertEqual(payload["params"], {"source": "html"}) self.assertIn("html", payload["json"]["posts"][0]) no_html = self.run_cli("--dry-run", "--json", "create-post", "--title", "Doc") self.assertEqual(no_html.returncode, 0) no_html_payload = json.loads(no_html.stdout) self.assertNotIn("html", no_html_payload["json"]["posts"][0]) self.assertNotIn("source=html", no_html_payload["url"]) self.assertIsNone(no_html_payload["params"]) def test_update_post_dry_run_sends_source_html_only_with_html_payload(self): result = self.run_cli("--dry-run", "--json", "update-post", "abc123", "--html", "<p>Edited</p>", "--updated-at", "2026-08-26T12:00:00.000Z") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) self.assertEqual(payload["params"], {"source": "html"}) self.assertIn("html", payload["fields"]) no_html = self.run_cli("--dry-run", "--json", "update-post", "abc123", "--status", "draft", "--updated-at", "2026-08-26T12:00:00.000Z") self.assertEqual(no_html.returncode, 0) no_html_payload = json.loads(no_html.stdout) self.assertNotIn("html", no_html_payload["fields"]) self.assertNotIn("source=html", no_html_payload["url"]) self.assertIsNone(no_html_payload["params"]) def test_create_page_dry_run_sends_source_html_only_with_html_payload(self): result = self.run_cli("--dry-run", "--json", "create-page", "--title", "About", "--html", "<p>About us</p>") self.assertEqual(result.returncode, 0) payload = json.loads(result.stdout) self.assertEqual(payload["params"], {"source": "html"}) self.assertIn("html", payload["json"]["pages"][0]) no_html = self.run_cli("--dry-run", "--json", "create-page", "--title", "About") self.assertEqual(no_html.returncode, 0) no_html_payload = json.loads(no_html.stdout) self.assertNotIn("html", no_html_payload["json"]["pages"][0]) self.assertNotIn("source=html", no_html_payload["url"]) self.assertIsNone(no_html_payload["params"]) def test_scheduled_post_requires_published_at_even_in_dry_run(self): result = self.run_cli("--dry-run", "--json", "create-post", "--title", "Later", "--status", "scheduled") self.assertNotEqual(result.returncode, 0) self.assertIn("--published-at", result.stderr) class JwtSigningTests(unittest.TestCase): """Known-answer JWT checks against fixed inputs (no network, no real keys).""" def setUp(self): self.cli = load_cli() def sign_with_fixed_clock(self, key=FIXED_KEY): original_time = self.cli.time.time self.cli.time.time = lambda: 1700000000 try: client = self.cli.GhostClient(url="https://example.com", key=key) token = client._jwt_token() finally: self.cli.time.time = original_time return token @staticmethod def decode_segment(segment): import base64 padded = segment + "=" * (-len(segment) % 4) return json.loads(base64.urlsafe_b64decode(padded)) def test_jwt_matches_known_answer_token_exactly(self): token = self.sign_with_fixed_clock() self.assertEqual(token, f"{HEADER_B64}.{PAYLOAD_B64}.{SIG_B64}") def test_header_uses_hs256_kid_and_typ(self): header = self.decode_segment(self.sign_with_fixed_clock().split(".")[0]) self.assertEqual(header["alg"], "HS256") self.assertEqual(header["typ"], "JWT") self.assertEqual(header["kid"], KID) def test_payload_audience_and_five_minute_expiry(self): payload = self.decode_segment(self.sign_with_fixed_clock().split(".")[1]) self.assertEqual(payload["aud"], "/admin/") self.assertEqual(payload["exp"] - payload["iat"], TOKEN_TTL) self.assertEqual(payload["iat"], 1700000000) def test_signature_keys_hex_decoded_secret_not_literal_chars(self): import base64 as b64 import hashlib as hl import hmac as hm header_b64, payload_b64, sig_b64 = self.sign_with_fixed_clock().split(".") expected = b64.urlsafe_b64encode( hm.new(bytes.fromhex(SECRET_HEX), f"{header_b64}.{payload_b64}".encode(), hl.sha256).digest() ).rstrip(b"=").decode() self.assertEqual(sig_b64, expected) literal_hex_signature = b64.urlsafe_b64encode( hm.new(SECRET_HEX.encode(), f"{header_b64}.{payload_b64}".encode(), hl.sha256).digest() ).rstrip(b"=").decode() self.assertNotEqual(sig_b64, literal_hex_signature) def test_malformed_secret_half_is_graceful_error_not_traceback(self): client = self.cli.GhostClient(url="https://example.com", key="5f9d4b1c8e2a43d7b6c0a1e9:not-hex!") stderr = io.StringIO() with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): client._jwt_token() self.assertIn("hexadecimal", stderr.getvalue()) def test_key_without_colon_names_required_format(self): client = self.cli.GhostClient(url="https://example.com", key="justonepart") stderr = io.StringIO() with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): client._jwt_token() self.assertIn("id:secret", stderr.getvalue()) class AdminApiAudienceTests(unittest.TestCase): cli = load_cli() def test_unversioned_admin_path_uses_root_admin_audience(self): self.assertEqual(self.cli.admin_api_audience("/ghost/api/admin/posts/"), "/admin/") self.assertEqual(self.cli.admin_api_audience("/ghost/api/admin/"), "/admin/") self.assertEqual(self.cli.admin_api_audience("nonsense"), "/admin/") def test_legacy_versioned_paths_scope_the_audience(self): self.assertEqual(self.cli.admin_api_audience("/ghost/api/v3/admin/posts/"), "/v3/admin/") self.assertEqual(self.cli.admin_api_audience("/ghost/api/v4/admin/"), "/v4/admin/") class PipelineChainTests(unittest.TestCase): """Per-skill contract: documented multi-step pipelines must execute stage by stage, with each stage consuming the previous stage's emitted output.""" @classmethod def setUpClass(cls): cls.tmpdir = tempfile.TemporaryDirectory(prefix="ghost-pipeline-") @classmethod def tearDownClass(cls): cls.tmpdir.cleanup() def run_cli(self, *args, creds=False): env = clean_env() if creds: env["GHOST_URL"] = "https://example.com" env["GHOST_ADMIN_KEY"] = FIXED_KEY return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env, cwd=self.tmpdir.name) def run_jq(self, *jq_args): return subprocess.run(["jq", *jq_args], text=True, capture_output=True, env=clean_env(), cwd=self.tmpdir.name) def write_stage_file(self, name, document): path = Path(self.tmpdir.name) / name path.write_text(json.dumps(document)) return path.name def test_draft_then_publish_then_delete_chain_consumability(self): post_id = "624c2b3fc1a5b7e9d4a0f2aa" # Stage 1: mint the draft plan; jq extracts method + URL + title field. r1 = self.run_cli("--dry-run", "--json", "create-post", "--title", "Chain Post") self.assertEqual(r1.returncode, 0) stage1 = self.write_stage_file("stage1.json", json.loads(r1.stdout)) check = self.run_jq("-r", ".method | select(. == \"post\") // empty", stage1) self.assertEqual(check.stdout.strip(), "post") url = self.run_jq("-r", ".url", stage1).stdout.strip() self.assertIn("/ghost/api/admin/posts", url) # Stage 2: publish plan consumes a hand-built id + updated_at guard; # jq asserts the collision-guard field travels into the request body. r2 = self.run_cli("--dry-run", "--json", "update-post", post_id, "--status", "published", "--updated-at", "2026-08-26T12:00:00.000Z") self.assertEqual(r2.returncode, 0) stage2 = self.write_stage_file("stage2.json", json.loads(r2.stdout)) guarded_at = self.run_jq("-r", ".fields.updated_at", stage2).stdout.strip() self.assertRegex(guarded_at, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") body_type = self.run_jq("-r", '.fields.status | select(. == "published") // empty', stage2) self.assertEqual(body_type.stdout.strip(), "published") # Stage 3: teardown plan for the same post id consumed from stage 2's # positional argument (verbatim string, not re-typed). r3 = self.run_cli("--dry-run", "--json", "delete-post", post_id) self.assertEqual(r3.returncode, 0) stage3 = self.write_stage_file("stage3.json", json.loads(r3.stdout)) self.assertEqual(self.run_jq("-r", ".method", stage3).stdout.strip(), "delete") def test_list_to_get_post_chain_type_contract(self): row = {"title": "Hello World", "slug": "hello-world", "id": "abc123"} # Stage 1: a listing document (as ghost posts --json would produce); # jq extracts .posts[0].id and asserts its JSON type is string. listing_name = self.write_stage_file( "listing.json", {"total": 1, "page": {}, "posts": [row]}) extracted = self.run_jq("-r", ".posts[0].id", listing_name) self.assertEqual(extracted.returncode, 0, extracted.stderr) self.assertEqual(extracted.stdout.strip(), row["id"]) type_check = self.run_jq("-r", ".posts[0].id | type", listing_name) self.assertEqual(type_check.stdout.strip(), "string") slug_check = self.run_jq("-r", ".posts[0].slug | type", listing_name) self.assertEqual(slug_check.stdout.strip(), "string") # Stage 2: get-post plan consumes exactly that id string positionally. r2 = self.run_cli("--dry-run", "--json", "get-post", extracted.stdout.strip(), creds=True) self.assertEqual(r2.returncode, 0) plan = json.loads(r2.stdout) self.assertTrue(plan["dry_run"]) self.assertTrue(str(row["id"]) in plan["url"], plan["url"]) self.assertTrue(plan["url"].startswith("https://")) class MockedClientTests(unittest.TestCase): """In-process handler logic with requests mocked at the call site.""" def load_with_flags(self, json_mode=True): cli = load_cli() cli.GLOBAL_FLAGS = {"json": json_mode, "dry_run": False, "quiet": False, "verbose": False} return cli def test_cmd_posts_parses_envelope_and_pagination_totals(self): cli = self.load_with_flags() client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) client.get_posts = Mock(return_value={ "posts": [ {"title": "First", "slug": "first", "status": "draft"}, {"title": "Second", "slug": "second", "status": "published"}, ], "meta": {"pagination": {"page": 2, "limit": 20, "pages": 7, "total": 124, "next": 3, "prev": 1}}, }) captured = [] def capture_print(*args): captured.append(args) with patch("builtins.print", capture_print): cli.cmd_posts(client, ["--limit", "20"]) client.get_posts.assert_called_once_with(limit=20, status=None, page=None, order=None) self.assertEqual(len(captured), 1, captured) emitted = captured[0][0] # emit() prints one argument in json mode payload = json.loads(emitted) self.assertEqual(payload["total"], 124) self.assertEqual(len(payload["posts"]), 2) self.assertEqual(payload["page"]["pages"], 7) def test_client_get_posts_builds_status_filter_param(self): cli = self.load_with_flags() client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) seen = {} def fake_get(path, params=None): seen["path"] = path seen["params"] = params return {"posts": [], "meta": {"pagination": {}}} client._get = fake_get data = client.get_posts(limit=50, status="draft") self.assertEqual(data["posts"], []) self.assertEqual(seen["path"], "/posts") self.assertEqual(seen["params"]["filter"], "status:draft") self.assertEqual(seen["params"]["limit"], 50) def test_delete_post_tolerates_204_empty_body(self): cli = self.load_with_flags(json_mode=False) ok_empty = Mock(status_code=204) del ok_empty.json # a real 204 carries no JSON body at all cli.requests.delete = Mock(return_value=ok_empty) client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) captured = [] with patch("builtins.print", lambda *a, **k: captured.append(a)): cli.cmd_delete_post(client, ["abc123"]) cli.requests.delete.assert_called_once() called_url = cli.requests.delete.call_args.args[0] self.assertEqual(called_url, "https://example.com/ghost/api/admin/posts/abc123") auth_header = cli.requests.delete.call_args.kwargs["headers"]["Authorization"] self.assertTrue(auth_header.startswith("Ghost eyJ")) def test_update_collision_error_message_advises_reget(self): cli = self.load_with_flags() collision = Mock(status_code=409, text="conflict") collision.json = Mock(return_value={ "errors": [{"message": "Saving failed! Someone else is editing this post.", "type": "UpdateCollisionError", "code": "UPDATE_COLLISION"}], }) cli.requests.put = Mock(return_value=collision) client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) stderr = io.StringIO() with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): client.update_post("abc123", status="published", updated_at="2026-01-01T00:00:00.000Z") message = stderr.getvalue() self.assertIn("409", message) self.assertIn("Someone else is editing this post", message) self.assertIn("Re-GET", message) def test_draft_read_on_content_api_style_404_routes_to_admin_guidance(self): cli = self.load_with_flags() missing = Mock(status_code=404, text="not found") missing.json = Mock(return_value={ "errors": [{"message": "Resource not found error.", "type": "NotFoundError", "code": None}]}) cli.requests.get = Mock(return_value=missing) client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) stderr = io.StringIO() with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): client._get("/posts/does-not-exist") message = stderr.getvalue() self.assertIn("404", message) self.assertIn("Admin API", message) def test_auth_header_scheme_mistake_surfaces_ghost_scheme_hint(self): cli = self.load_with_flags() bad_scheme = Mock(status_code=401) bad_scheme.text = ('{"errors":[{"message":"Authorization header format is ' '"Authorization: Ghost [token]","context":null,' '"type":"UnauthorizedError","code":"INVALID_AUTH_HEADER"}]}') bad_scheme.json = Mock(return_value={"errors": [{ "message": "Authorization header format is \"Authorization: Ghost [token]\"", "type": "UnauthorizedError", "code": "INVALID_AUTH_HEADER"}]}) cli.requests.get = Mock(return_value=bad_scheme) client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) stderr = io.StringIO() with self.assertRaises(SystemExit), contextlib.redirect_stderr(stderr): client._get("/posts") self.assertIn("Ghost [token]", stderr.getvalue()) def test_authorization_header_uses_ghost_scheme_and_version_headers(self): cli = self.load_with_flags() ok = Mock(status_code=200) ok.json = Mock(return_value={"posts": []}) cli.requests.get = Mock(return_value=ok) client = cli.GhostClient(url="https://example.com", key=FIXED_KEY) client._get("/posts") headers = cli.requests.get.call_args.kwargs["headers"] self.assertTrue(headers["Authorization"].startswith("Ghost ")) self.assertEqual(headers["Accept-Version"], "v6.0") def test_request_paths_target_unversioned_admin_api(self): cli = self.load_with_flags() ok = Mock(status_code=200) ok.json = Mock(return_value={}) cli.requests.get = Mock(return_value=ok) client = cli.GhostClient(url="https://example.com/", key=FIXED_KEY) client._get("/site") called_url = cli.requests.get.call_args.args[0] self.assertEqual(called_url, "https://example.com/ghost/api/admin/site") class HtmlSourceFlagTests(unittest.TestCase): """Regression: html write payloads must carry the docs-required ?source=html query flag; mobiledoc/lexical writes must not send it.""" def setUp(self): self.cli = load_cli() self.cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False} ok = Mock(status_code=201) ok.json = Mock(return_value={"posts": []}) self.cli.requests.post = Mock(return_value=ok) self.client = self.cli.GhostClient(url="https://example.com", key=FIXED_KEY) def _put_ok(self): ok = Mock(status_code=200) ok.json = Mock(return_value={"posts": []}) self.cli.requests.put = Mock(return_value=ok) return self.cli.requests.put def test_create_post_with_html_sends_source_html_query_param(self): self.client.create_post("Doc", html="<p>Hi</p>") call = self.cli.requests.post.call_args self.assertEqual(call.kwargs["params"], {"source": "html"}) self.assertIn("html", call.kwargs["json"]["posts"][0]) def test_create_post_without_html_omits_source_flag(self): self.client.create_post("Doc") call = self.cli.requests.post.call_args self.assertNotIn("source", (call.kwargs.get("params") or {})) self.assertNotIn("html", call.kwargs["json"]["posts"][0]) def test_update_post_with_html_sends_source_html_query_param(self): put = self._put_ok() self.client.update_post("abc123", html="<p>Edited</p>", updated_at="2026-08-26T12:00:00.000Z") call = put.call_args self.assertEqual(call.kwargs["params"], {"source": "html"}) self.assertIn("html", call.kwargs["json"]["posts"][0]) def test_update_post_without_html_omits_source_flag(self): put = self._put_ok() self.client.update_post("abc123", status="published", updated_at="2026-08-26T12:00:00.000Z") call = put.call_args self.assertNotIn("source", (call.kwargs.get("params") or {})) self.assertNotIn("html", call.kwargs["json"]["posts"][0]) def test_create_page_with_html_sends_source_html_query_param(self): self.client.create_page("About", html="<p>About us</p>") call = self.cli.requests.post.call_args self.assertEqual(call.kwargs["params"], {"source": "html"}) self.assertEqual(call.args[0], "https://example.com/ghost/api/admin/pages") self.assertIn("html", call.kwargs["json"]["pages"][0]) def test_create_page_without_html_omits_source_flag(self): self.client.create_page("About") call = self.cli.requests.post.call_args self.assertNotIn("source", (call.kwargs.get("params") or {})) self.assertNotIn("html", call.kwargs["json"]["pages"][0]) if __name__ == "__main__": unittest.main()
-
-
README.md 3 KB
# Ghost CMS content management from the terminal Let your agent browse, draft, publish, and schedule content on a Ghost blog or newsletter site through the Admin API — no web editor required. ## Why Install This Skill Editing a Ghost site usually means clicking through the admin UI. This skill hands your agent direct, scripted control instead: - **See the whole editorial state** — published posts, plus the drafts and scheduled queue that public site feeds never show. - **Publish programmatically** — create posts and pages as drafts, then flip them live after review, with safe collision-checked updates. - **Schedule content** — stage posts to appear at future times. - **Keep tags tidy** — list tags with usage counts, add new ones, drive exports. It speaks Ghost's exact authentication dialect automatically: your Admin API key (`id:secret`) becomes a fresh short-lived signed JWT for every command, so there are no tokens to mint, rotate, or paste anywhere. Not to be confused with Ghost's official npm `ghost-cli` tool, which installs and operates Ghost servers (`ghost install`, nginx/SSL setup, upgrades). This skill manages *content* on an already-running site; that one manages *servers*. ## What You Get | Path | Purpose | |------|---------| | `SKILL.md` | Command reference: setup, browse/create/publish recipes, gotchas | | `scripts/ghost` | CLI tool covering site info, post/page/tag operations, JSON + dry-run modes | | `scripts/test_ghost.py` | Offline test suite for the CLI (all network mocked) | | `references/admin-auth-and-basics.md` | Full JWT signing walkthrough with auth error signatures | | `references/content-vs-admin-api.md` | Content vs Admin API choice guide and draft-visibility trap | | `references/posts-pages-tags-endpoints.md` | Endpoint map, pagination loop patterns, error envelope | | `references/worked-recipes.md` | Copy-paste workflows: draft→publish, exports, scheduling | | `references/gotchas-field-guide.md` | Symptom-first troubleshooting for common failures | ## Quick Start ```bash export GHOST_URL="https://your-ghost-site.com" export GHOST_ADMIN_KEY="<RECORD_KEY>" # Ghost Admin → Integrations → Custom integration ghost site # connectivity check ghost posts --status draft ghost create-post --title "Hello from the terminal" --html "<p>First!</p>" ``` Preview any write safely by adding `--dry-run` before the subcommand. ## Triggers Load this skill when working with Ghost CMS content: listing posts/pages/tags, drafting or publishing blog content, scheduling posts, exporting site content, fixing Ghost API authentication errors, or investigating why drafts don't show up in a Ghost site feed. ## Requirements - Python 3.8+ with the `requests` library. - A running Ghost 5.x/6.x site where you can create a Custom Integration (Ghost Admin → Settings → Integrations). - The integration's **Admin API Key** exported as `GHOST_ADMIN_KEY`, plus the site URL as `GHOST_URL`. Keep the key server-side; it signs mutations and must never ship in client code or CI logs. -
SKILL.md 9.2 KB
--- name: ghost description: Manage Ghost CMS content over the Admin API — browse posts, pages, and tags, draft and publish content, schedule posts, and inspect site info from the terminal. Do not use this skill for Ghost server installation or site administration (installing, nginx, SSL, systemd, updates); those belong to the official npm ghost-cli tooling. license: MIT compatibility: Requires GHOST_URL and GHOST_ADMIN_KEY env vars. Admin key in "id:secret" format from Ghost Admin → Integrations. Python 3.8+ and the `requests` library. metadata: tags: ghost, cms, blog, blogging, post, page, tag, ghost-cms, content-management, api-client sources: https://docs.ghost.org/admin-api/, https://docs.ghost.org/content-api/ --- # ghost — Ghost CMS content from the terminal Drive a Ghost CMS site's Admin API (v5/v6, `Accept-Version: v6.0`): list posts by status including drafts, create and publish pages and posts, manage tags, and check site info. Drafts, scheduled posts, and published content are all visible here because every call authenticates with a per-request Admin JWT built from your `id:secret` integration key. ## Setup ```bash export GHOST_URL="https://your-ghost-site.com" export GHOST_ADMIN_KEY="<RECORD_KEY>" # id:secret from Ghost Admin → Integrations ``` 1. In **Ghost Admin → Settings → Integrations**, create (or open) a Custom Integration. 2. Copy its **Admin API Key** — one string, two colon-separated hex halves (`id:secret`). The separate **Content API key** from the same screen will NOT let you see drafts; see Known Gotchas. 3. At request time the CLI signs a short-lived JWT per call: HS256 signature keyed by the secret half **after hex-decoding it to raw bytes**, `kid` header carrying the id half, audience `/admin/`, `exp` five minutes after `iat`, sent as `Authorization: Ghost <token>`. You never handle the token yourself. 4. `--help` and `--dry-run` work without credentials (lazy auth). ## Essential commands ### Inspect ```bash ghost site # title, url, description, version ghost get-post POST_ID # full record incl. exact updated_at for edits ``` ### Browse (intent: find content) ```bash ghost posts # latest 20 ghost posts --status draft # unpublished work queue ghost posts --status scheduled # what publishes next ghost posts --limit 100 --page 2 # paginate (max page size 100) ghost posts --order "updated_at desc" # SQL-style ordering ghost pages # static pages ghost tags # tags with usage counts ``` ### Create and publish ```bash ghost create-post --title "Notes" # safe default: draft ghost create-post --title "Hello" --html "<p>Hi</p>" ghost create-post --title "Launch" --status published --html "<p>We're live</p>" ghost create-post --title "Later" --status scheduled \ --published-at "2026-09-01T09:00:00.000Z" # future ISO-8601 required together ghost create-page --title "About" --html "<p>…</p>" --slug about ghost create-tag --name "Engineering" --description "Technical posts" ``` ### Edit and remove ```bash ghost update-post POST_ID --title "New title" \ --updated-at "<RECORD_UPDATED_AT>" # REQUIRED: latest updated_at, re-read first ghost update-post POST_ID --status published --updated-at "<RECORD_UPDATED_AT>" ghost delete-post POST_ID # permanent, 204-style removal ``` ## Pipeline recipes ### Draft now, publish after review ```bash ghost --json create-post --title "Release notes" > /tmp/post.json id=$(jq -r '.post.id // .post_id // empty' /tmp/post.json) ghost get-post "$id" # read fresh updated_at ghost update-post "$id" --status published \ --updated-at "<exact string from get-post output>" ``` Never fabricate `updated_at`; copy it verbatim from a fresh read or Ghost rejects the edit with HTTP 409 `UpdateCollisionError`. ### Review queue across statuses ```bash for s in draft scheduled; do ghost posts --status "$s" --json | jq -r '.posts[] | "\(.status)\t\(.title)\t\(.slug)"' done ``` ### Complete export Loop pages by `meta.pagination.next` (surfaced as `.page.next`) instead of trusting totals: ```bash page=1 while :; do ghost posts --limit 100 --page "$page" --json > "/tmp/posts-$page.json" jq -r '.posts[].id' "/tmp/posts-$page.json" next=$(jq -r '.page.next // empty' "/tmp/posts-$page.json") [ -z "$next" ] && break page=$next; sleep 0.2 done ``` ## JSON output and jq `--json` works before or after the subcommand: ```bash ghost --json posts # same as: ghost posts --json ``` JSON shapes worth knowing: - Lists emit `{"total", "page": {pagination}, "posts": [...]}`; detail/create emit the resource under its noun (`post`, `page`, `tag`, `site`). - Pagination mirrors the API: `.page = {"page", "limit", "pages", "total", "next", "prev"}`; `next`/`prev` are numbers or `null`. - `--dry-run --json` emits the executed plan instead of results: `{"dry_run": true, "method", "url", "params"/"json"}` — preview the exact request before running it live. - Errors exit non-zero with the API's own message plus code on stderr; JSON mode never wraps errors in stdout JSON. ## Global flags | Flag | Effect | |------|--------| | `--json` | Machine-readable JSON (position-independent) | | `--dry-run` | Print the planned API call (method, URL, payload) without executing | | `--quiet` | Suppress diagnostics | | `--verbose` | Debug logging | ## Known gotchas - **Drafts need the Admin plane.** The public Content API (that key-as-query-param API) serves published posts only and hides drafts silently — no error, just absent, even with a perfectly valid key. Its filters like `status:draft` are ignored rather than rejected. Everything this CLI does goes through the Admin API precisely so drafts and scheduled posts stay reachable. - **Two keys, same integration screen.** The Content key is browser-safe but read-only-public; the Admin key (`GHOST_ADMIN_KEY`) signs mutations and reaches drafts. Never point scripts at the Content key and expect draft visibility. - **Five-minute tokens.** Each JWT lives at most 300 seconds (`exp ≤ iat + 300`) and the verifier caps token age too, so long batch jobs must re-sign per request (the CLI does). Skewed clocks break signing windows; keep NTP healthy. - **HS256 only, decoded-secret keying.** Tokens signed with HS512 are refused ("invalid algorithm"); signing without first hex-decoding the secret half produces "valid-looking" garbage that 401s. The CLI handles both rules. - **`Authorization: Ghost`, not Bearer.** `Bearer` scheme answers 401 `INVALID_AUTH_HEADER`. - **Edits require collision guards.** PUTs without the post's current `updated_at` fail with 409; relation arrays (`tags`, `authors`) replace wholesale rather than merge. - **`--html` requires `source=html` and stays lossy.** Every write carrying an `html` payload (create-post, update-post, create-page) must send the `?source=html` query flag — the CLI attaches it automatically, and dry-run plans show it in `params` — or Ghost parses the body as mobiledoc/lexical. Even with the flag, conversion is lossy: send proper Lexical, or wrap fixed markup in HTML card comments. - **Pagination caps at 100** since Ghost 6 removed `limit=all`; oversized limits silently return ≤100 rows, so always loop by `next`. - **Deletion is permanent** and takes effect on the public site immediately. ## When to use Use this skill whenever the task is content workflow against a running Ghost site: browsing or exporting posts, drafting, publishing, scheduling, tag upkeep, page creation, or diagnosing those flows (auth errors, pagination, missing drafts). ## When not to use Do not use it to install, host, or operate a Ghost server (`ghost install`, nginx/SSL/systemd setup, upgrades, backups) — that is Ghost's official npm ghost-cli site-management tool, unrelated despite the shared name. Not for other publishing platforms (WordPress, Hugo have their own tooling), not for theme development, and not for site configuration better done once in the Admin dashboard (staff accounts, membership tiers). ## Reference files | File | Use it for | | ---- | ---------- | | [references/admin-auth-and-basics.md](references/admin-auth-and-basics.md) | Full JWT signing walkthrough, key format, audience/expiry rules, auth error table | | [references/content-vs-admin-api.md](references/content-vs-admin-api.md) | Choosing between planes; the draft-visibility trap; diagnostic checklist | | [references/posts-pages-tags-endpoints.md](references/posts-pages-tags-endpoints.md) | Endpoint map, field semantics, pagination loop, error envelope | | [references/worked-recipes.md](references/worked-recipes.md) | Copy-paste workflows: draft→publish, exports, scheduling, triage | | [references/gotchas-field-guide.md](references/gotchas-field-guide.md) | Symptom-first incident lookup for auth, editing, volume problems | ## Scripts and prerequisites - `scripts/ghost` — executable Python CLI (stdlib + requests only). Flags above; lazy auth; structured logging. - `scripts/test_ghost.py` — offline test suite (mocked HTTP, zero network). - Python 3.8+, `requests`. Nothing listens, nothing installs; scope limited to one configured site via `GHOST_URL`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.