mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-20 08:06:35 +03:00
Merge pull request 'feat: add ghost-cli skill — Ghost CMS CLI' (#12) from feat/ghost-cli-skill into main
Reviewed-on: https://git.brandyapple.com/magnus/agent-skills/pulls/12
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: ghost-cli
|
||||
description: >-
|
||||
Manage Ghost CMS content from the terminal — create and list posts, pages,
|
||||
and tags, and fetch site info via the Ghost Admin API (v5/v6). Use when the
|
||||
user asks about ghost, cms, blog, blogging, posts, pages, tags, publishing,
|
||||
or site configuration.
|
||||
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://ghost.org/docs/admin-api/
|
||||
- https://ghost.org/docs/
|
||||
---
|
||||
|
||||
# ghost-cli — Ghost CMS from the Terminal
|
||||
|
||||
Manage content on a Ghost CMS site: view site info, list and create posts and pages, manage tags — all via the Ghost Admin API (v5/v6).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Get your Admin API key from **Ghost Admin → Settings → Advanced → Integrations** (or **Ghost Admin → Integrations**). Create a custom integration to get a key in `id:secret` format.
|
||||
2. Set these environment variables:
|
||||
|
||||
```bash
|
||||
export GHOST_URL="https://your-ghost-site.com" # your Ghost site URL
|
||||
export GHOST_ADMIN_KEY="your-id:your-secret" # from Ghost Admin → Integrations
|
||||
```
|
||||
|
||||
`--help` and `--dry-run` work without credentials (lazy auth).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### site — Get site information
|
||||
|
||||
```bash
|
||||
ghost-cli site # show site title, URL, description
|
||||
ghost-cli --json site # machine-readable JSON
|
||||
ghost-cli --dry-run site # preview without API call
|
||||
```
|
||||
|
||||
Shows: site title, URL, description.
|
||||
|
||||
### posts — List blog posts
|
||||
|
||||
```bash
|
||||
ghost-cli posts # 20 most recent posts
|
||||
ghost-cli posts --limit 50 # more results
|
||||
ghost-cli posts --status published # only published posts
|
||||
ghost-cli posts --status draft # only draft posts
|
||||
ghost-cli posts --status scheduled # only scheduled posts
|
||||
ghost-cli posts --limit 10 --json # 10 most recent as JSON
|
||||
```
|
||||
|
||||
Shows: title, status, slug, and last-updated date for each post.
|
||||
|
||||
### create-post — Create a new blog post
|
||||
|
||||
```bash
|
||||
ghost-cli create-post --title "My First Post" # draft, no HTML
|
||||
ghost-cli create-post --title "Hello World" --html "<p>Hello!</p>" # with HTML content
|
||||
ghost-cli create-post --title "Ready" --html "<p>Published</p>" --status published # publish immediately
|
||||
ghost-cli create-post --title "Scheduled" --html "<p>Later</p>" --status scheduled # schedule
|
||||
ghost-cli create-post --title "Custom Slug" --slug "my-custom-url" # custom URL slug
|
||||
ghost-cli create-post --title "Draft" --dry-run # preview without creating
|
||||
```
|
||||
|
||||
Creates the post and returns its title, slug, and status.
|
||||
|
||||
### pages — List pages
|
||||
|
||||
```bash
|
||||
ghost-cli pages # 20 most recent pages
|
||||
ghost-cli pages --limit 50 # more results
|
||||
ghost-cli pages --json # machine-readable JSON
|
||||
```
|
||||
|
||||
Shows: title, status, slug, and last-updated date for each page.
|
||||
|
||||
### tags — List tags
|
||||
|
||||
```bash
|
||||
ghost-cli tags # 50 tags with post counts
|
||||
ghost-cli tags --limit 100 # more results
|
||||
ghost-cli tags --json # machine-readable JSON
|
||||
```
|
||||
|
||||
Shows: tag name, slug, and number of posts using each tag.
|
||||
|
||||
## Global Flags
|
||||
|
||||
These flags work anywhere in the command — before or after the subcommand:
|
||||
|
||||
```bash
|
||||
ghost-cli --json posts # JSON output
|
||||
ghost-cli posts --json # same result, after subcommand
|
||||
ghost-cli --dry-run create-post --title "Test" # preview without API call
|
||||
ghost-cli --quiet posts # suppress diagnostic output
|
||||
ghost-cli --verbose site # verbose logging
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--json` | Output machine-readable JSON instead of human-readable text |
|
||||
| `--dry-run` | Show what API call would be made without executing it |
|
||||
| `--quiet` | Suppress non-essential diagnostic output |
|
||||
| `--verbose` | Enable verbose/debug logging |
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- **Admin API key format** — The `GHOST_ADMIN_KEY` must be in `id:secret` format (e.g. `644a4c1a2b3c4d5e6f7g8h9i:abcd1234efgh5678ijkl9012`). This is the format Ghost generates when you create a Custom Integration. A plain token or JWT will not work.
|
||||
- **JWT token auto-generated** — The CLI generates a short-lived JWT (HS256, 5-minute expiry) internally from the Admin API key on each request. You don't need to create or manage JWT tokens yourself.
|
||||
- **5-minute JWT window** — Each JWT is valid for 300 seconds (5 minutes). If your system clock is significantly skewed, requests may fail. Ensure NTP is synced.
|
||||
- **API version v6** — The CLI sends `Accept-Version: v6.0` on all requests, targeting the Ghost Admin API v6. Response shapes follow the v6 spec. May also work against v5 sites.
|
||||
- **HTML content format** — Post and page content must be provided as raw HTML strings via `--html`. Markdown is not auto-converted. If you write in Markdown, convert it to HTML first (e.g. with a markdown-to-html tool).
|
||||
- **No update or delete commands** — The current CLI supports listing and creating posts/pages/tags, but does not include update or delete operations. Use the Ghost Admin UI or direct API calls for those.
|
||||
- **No tag creation via CLI** — Tag listing works, but `create-tag` is not exposed as a subcommand. The GhostClient class has a `create_tag` method internally but it is not wired to a CLI command.
|
||||
- **Rate limiting** — Ghost Admin API enforces rate limits. For heavy operations, stagger your requests.
|
||||
- **Error output** — API errors (4xx/5xx) include the response body in the error message for debugging. Auth errors (401/403) explicitly tell you to check `GHOST_ADMIN_KEY`.
|
||||
|
||||
## References
|
||||
|
||||
- [scripts/ghost-cli](scripts/ghost-cli) — The CLI binary. Built following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth, structured logging.
|
||||
- [Ghost Admin API Docs](https://ghost.org/docs/admin-api/) — Official Ghost Admin API documentation.
|
||||
- [Ghost Integrations](https://ghost.org/docs/integrations/) — How to create Custom Integrations and get your Admin API key.
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ghost-cli — Ghost CMS from the terminal.
|
||||
|
||||
Manage content on a Ghost CMS site: create and edit posts and pages,
|
||||
manage tags, and configure metadata. Requires GHOST_URL and GHOST_ADMIN_KEY.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
warnings.simplefilter("ignore")
|
||||
|
||||
import requests
|
||||
|
||||
ENV_URL = os.getenv("GHOST_URL", "")
|
||||
ENV_ADMIN_KEY = os.getenv("GHOST_ADMIN_KEY", "")
|
||||
|
||||
QUIET = False
|
||||
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False}
|
||||
|
||||
|
||||
def log(msg):
|
||||
if not QUIET and not GLOBAL_FLAGS.get("json", False):
|
||||
print(msg)
|
||||
|
||||
|
||||
def warn(msg):
|
||||
print(f"Warning: {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def die(msg, exit_code=1):
|
||||
print(f"Error: {msg}", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
def emit(human, data):
|
||||
if GLOBAL_FLAGS.get("json", False):
|
||||
print(json.dumps(data, default=str))
|
||||
else:
|
||||
print(human)
|
||||
|
||||
|
||||
def _preparse_global_flags(argv):
|
||||
GLOBAL_BOOLS = {"--json", "--dry-run", "--quiet", "--verbose"}
|
||||
flags, filtered = {}, [argv[0]]
|
||||
i = 1
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg in GLOBAL_BOOLS:
|
||||
flags[arg.lstrip("-").replace("-", "_")] = True
|
||||
i += 1
|
||||
elif arg in ("--help", "-h"):
|
||||
return flags, argv
|
||||
elif arg == "--":
|
||||
filtered.extend(argv[i:])
|
||||
break
|
||||
else:
|
||||
filtered.append(arg)
|
||||
i += 1
|
||||
return flags, filtered
|
||||
|
||||
|
||||
class GhostClient:
|
||||
"""Ghost Admin API client (v5/v6)."""
|
||||
|
||||
def __init__(self, url="", key="", dry_run=False):
|
||||
self.url = (url or ENV_URL).rstrip("/")
|
||||
self.key = key or ENV_ADMIN_KEY
|
||||
self.dry_run = dry_run
|
||||
|
||||
def _jwt_token(self):
|
||||
"""Generate a short-lived JWT from the Admin API key (id:secret format)."""
|
||||
if not self.key or ":" not in self.key:
|
||||
die("GHOST_ADMIN_KEY must be in 'id:secret' format. Get it from Ghost Admin → Integrations.")
|
||||
key_id, secret = self.key.split(":", 1)
|
||||
now = int(time.time())
|
||||
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "kid": key_id, "typ": "JWT"}).encode()).rstrip(b"=").decode()
|
||||
payload = base64.urlsafe_b64encode(json.dumps({"iat": now, "exp": now + 300, "aud": "/admin/"}).encode()).rstrip(b"=").decode()
|
||||
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
|
||||
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
|
||||
return f"{header}.{payload}.{sig_b64}"
|
||||
|
||||
def _get(self, path, params=None):
|
||||
url = f"{self.url}/ghost/api/admin{path}"
|
||||
if self.dry_run:
|
||||
return {"dry_run": True, "url": url, "params": params}
|
||||
try:
|
||||
resp = requests.get(url, params=params,
|
||||
headers={"Authorization": f"Ghost {self._jwt_token()}",
|
||||
"Accept-Version": "v6.0", "Accept": "application/json"},
|
||||
timeout=30)
|
||||
except requests.ConnectionError as e:
|
||||
die(f"Cannot connect to {self.url}: {e}")
|
||||
if resp.status_code in (401, 403):
|
||||
die(f"Auth failed ({resp.status_code}). Check GHOST_ADMIN_KEY.")
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = resp.text[:200]
|
||||
die(f"API error ({resp.status_code}): {detail}")
|
||||
return resp.json()
|
||||
|
||||
def _post(self, path, json_data):
|
||||
url = f"{self.url}/ghost/api/admin{path}"
|
||||
if self.dry_run:
|
||||
return {"dry_run": True, "url": url, "json": json_data}
|
||||
try:
|
||||
resp = requests.post(url, json=json_data,
|
||||
headers={"Authorization": f"Ghost {self._jwt_token()}",
|
||||
"Accept-Version": "v6.0",
|
||||
"Content-Type": "application/json"},
|
||||
timeout=30)
|
||||
except requests.ConnectionError as e:
|
||||
die(f"Cannot connect: {e}")
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = resp.text[:200]
|
||||
die(f"API error ({resp.status_code}): {detail}")
|
||||
return resp.json()
|
||||
|
||||
def _put(self, path, json_data):
|
||||
url = f"{self.url}/ghost/api/admin{path}"
|
||||
if self.dry_run:
|
||||
return {"dry_run": True, "url": url, "json": json_data}
|
||||
try:
|
||||
resp = requests.put(url, json=json_data,
|
||||
headers={"Authorization": f"Ghost {self._jwt_token()}",
|
||||
"Accept-Version": "v6.0",
|
||||
"Content-Type": "application/json"},
|
||||
timeout=30)
|
||||
except requests.ConnectionError as e:
|
||||
die(f"Cannot connect: {e}")
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
detail = resp.json()
|
||||
except Exception:
|
||||
detail = resp.text[:200]
|
||||
die(f"API error ({resp.status_code}): {detail}")
|
||||
return resp.json()
|
||||
|
||||
def get_posts(self, limit=20, status=None):
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
if status:
|
||||
params["filter"] = f"status:{status}"
|
||||
return self._get("/posts", params)
|
||||
|
||||
def get_post(self, post_id):
|
||||
return self._get(f"/posts/{post_id}")
|
||||
|
||||
def create_post(self, title, html="", status="draft", slug=""):
|
||||
post = {"title": title, "status": status}
|
||||
if html:
|
||||
post["html"] = html
|
||||
if slug:
|
||||
post["slug"] = slug
|
||||
return self._post("/posts", {"posts": [post]})
|
||||
|
||||
def update_post(self, post_id, **kwargs):
|
||||
return self._put(f"/posts/{post_id}", {"posts": [kwargs]})
|
||||
|
||||
def get_pages(self, limit=20):
|
||||
return self._get("/pages", {"limit": limit})
|
||||
|
||||
def create_page(self, title, html="", status="draft", slug=""):
|
||||
page = {"title": title, "status": status}
|
||||
if html:
|
||||
page["html"] = html
|
||||
if slug:
|
||||
page["slug"] = slug
|
||||
return self._post("/pages", {"pages": [page]})
|
||||
|
||||
def get_tags(self, limit=50):
|
||||
return self._get("/tags", {"limit": limit, "include": "count.posts"})
|
||||
|
||||
def create_tag(self, name, slug="", description=""):
|
||||
tag = {"name": name}
|
||||
if slug:
|
||||
tag["slug"] = slug
|
||||
if description:
|
||||
tag["description"] = description
|
||||
return self._post("/tags", {"tags": [tag]})
|
||||
|
||||
def get_site(self):
|
||||
return self._get("/site")
|
||||
|
||||
|
||||
def fmt_post(p):
|
||||
title = p.get("title", "?")
|
||||
status = p.get("status", "?")
|
||||
slug = p.get("slug", "")
|
||||
updated = (p.get("updated_at") or "")[:10]
|
||||
return f" {title:45} [{status:7}] /{slug} updated {updated}"
|
||||
|
||||
|
||||
def cmd_posts(client, args):
|
||||
p = argparse.ArgumentParser(prog="ghost-cli posts")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
p.add_argument("--status", choices=["published", "draft", "scheduled"])
|
||||
parsed, _ = p.parse_known_args(args)
|
||||
|
||||
if client.dry_run:
|
||||
return emit("[dry-run] List posts", {"dry_run": True})
|
||||
|
||||
data = client.get_posts(limit=parsed.limit, status=parsed.status) or {}
|
||||
posts = data.get("posts", [])
|
||||
if not posts:
|
||||
return emit("No posts found.", {"posts": []})
|
||||
|
||||
lines = [fmt_post(p) for p in posts]
|
||||
total = data.get("meta", {}).get("pagination", {}).get("total", len(posts))
|
||||
emit(f"{total} post(s):\n" + "\n".join(lines),
|
||||
{"total": total, "posts": posts})
|
||||
|
||||
|
||||
def cmd_create_post(client, args):
|
||||
p = argparse.ArgumentParser(prog="ghost-cli posts create")
|
||||
p.add_argument("--title", required=True)
|
||||
p.add_argument("--html", default="")
|
||||
p.add_argument("--status", default="draft", choices=["draft", "published", "scheduled"])
|
||||
p.add_argument("--slug", default="")
|
||||
parsed, _ = p.parse_known_args(args)
|
||||
|
||||
if client.dry_run:
|
||||
return emit(f"[dry-run] Create post: {parsed.title}", {"dry_run": True, **vars(parsed)})
|
||||
|
||||
data = client.create_post(parsed.title, html=parsed.html, status=parsed.status, slug=parsed.slug) or {}
|
||||
posts = data.get("posts", [])
|
||||
if posts:
|
||||
post = posts[0]
|
||||
emit(f"✅ Created: {post.get('title')} (/{post.get('slug')}) [{post.get('status')}]",
|
||||
{"status": "created", "post": post})
|
||||
else:
|
||||
emit("Post created but no data returned.", {"status": "created"})
|
||||
|
||||
|
||||
def cmd_pages(client, args):
|
||||
p = argparse.ArgumentParser(prog="ghost-cli pages")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
parsed, _ = p.parse_known_args(args)
|
||||
|
||||
if client.dry_run:
|
||||
return emit("[dry-run] List pages", {"dry_run": True})
|
||||
|
||||
data = client.get_pages(limit=parsed.limit) or {}
|
||||
pages = data.get("pages", [])
|
||||
if not pages:
|
||||
return emit("No pages found.", {"pages": []})
|
||||
|
||||
lines = [fmt_post(p) for p in pages]
|
||||
emit(f"{len(pages)} page(s):\n" + "\n".join(lines), {"pages": pages})
|
||||
|
||||
|
||||
def cmd_tags(client, args):
|
||||
p = argparse.ArgumentParser(prog="ghost-cli tags")
|
||||
p.add_argument("--limit", type=int, default=50)
|
||||
parsed, _ = p.parse_known_args(args)
|
||||
|
||||
if client.dry_run:
|
||||
return emit("[dry-run] List tags", {"dry_run": True})
|
||||
|
||||
data = client.get_tags(limit=parsed.limit) or {}
|
||||
tags = data.get("tags", [])
|
||||
if not tags:
|
||||
return emit("No tags found.", {"tags": []})
|
||||
|
||||
lines = []
|
||||
for t in tags:
|
||||
name = t.get("name", "?")
|
||||
slug = t.get("slug", "")
|
||||
count = (t.get("count", {}) or {}).get("posts", 0)
|
||||
lines.append(f" {name:25} /{slug} ({count} posts)")
|
||||
emit(f"{len(tags)} tag(s):\n" + "\n".join(lines), {"tags": tags})
|
||||
|
||||
|
||||
def cmd_site(client, args):
|
||||
if client.dry_run:
|
||||
return emit("[dry-run] Get site info", {"dry_run": True})
|
||||
data = client.get_site() or {}
|
||||
site = data.get("site", {})
|
||||
title = site.get("title", "?")
|
||||
desc = site.get("description", "")
|
||||
url = site.get("url", "?")
|
||||
emit(f"🌐 {title}\n {url}\n {desc}", {"site": site})
|
||||
|
||||
|
||||
def main():
|
||||
global GLOBAL_FLAGS, QUIET
|
||||
GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
|
||||
if GLOBAL_FLAGS.get("quiet", False):
|
||||
QUIET = True
|
||||
if GLOBAL_FLAGS.get("json", False):
|
||||
warnings.simplefilter("ignore")
|
||||
|
||||
parser = argparse.ArgumentParser(prog="ghost-cli", description="Ghost CMS CLI.",
|
||||
epilog="Set GHOST_URL and GHOST_ADMIN_KEY. Key format: id:secret from Integrations.")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
sub.add_parser("site", help="Site info")
|
||||
|
||||
pp = sub.add_parser("posts", help="List posts")
|
||||
pp.add_argument("--limit", type=int, default=20)
|
||||
pp.add_argument("--status", choices=["published", "draft", "scheduled"])
|
||||
|
||||
cp = sub.add_parser("create-post", help="Create a post")
|
||||
cp.add_argument("--title", required=True)
|
||||
cp.add_argument("--html", default="")
|
||||
cp.add_argument("--status", default="draft", choices=["draft", "published", "scheduled"])
|
||||
cp.add_argument("--slug", default="")
|
||||
|
||||
sub.add_parser("pages", help="List pages").add_argument("--limit", type=int, default=20)
|
||||
sub.add_parser("tags", help="List tags").add_argument("--limit", type=int, default=50)
|
||||
|
||||
args = parser.parse_args(filtered_argv[1:])
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
client = GhostClient(dry_run=GLOBAL_FLAGS.get("dry_run", False))
|
||||
|
||||
cmd_map = {
|
||||
"site": cmd_site, "posts": cmd_posts, "create-post": cmd_create_post,
|
||||
"pages": cmd_pages, "tags": cmd_tags,
|
||||
}
|
||||
handler = cmd_map.get(args.command)
|
||||
if not handler:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
remaining = filtered_argv[filtered_argv.index(args.command) + 1:]
|
||||
handler(client, remaining)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user