Skill: kryzl19/ghost-cms-agent (version 1.0.0)

--- name: ghost-cms description: Manage Ghost CMS content via the REST API. Create and publish posts, manage tags, and fetch site analytics. Supports both the Content API (public data) and Admin API (authenticated management). metadata: { "openclaw": { "emoji": "👻", "requires": { "env": ["GHOST_URL", "GHOST_ADMIN_API_KEY"], }, }, } --- # Ghost CMS Manage Ghost CMS content via its REST API. Works with self-hosted and Ghost Pro sites. ## Configuration ```bash # Your Ghost site URL export GHOST_URL="https://example.com" # Admin API key (from Ghost Admin > Settings > Integrations) export GHOST_ADMIN_API_KEY="your-admin-api-key" # Optional: Content API key (for public data) export GHOST_CONTENT_API_KEY="your-content-api-key" ``` ## Get Posts ```bash bash skills/ghost-cms/scripts/posts.sh [--limit 10] [--page 1] [--status published] ``` Options: - `--limit` — Number of posts to return (default: 10) - `--page` — Page number (default: 1) - `--status` — Filter by status: `published`, `draft`, `scheduled`, `all` (default: published) - `--format` — Output format: `json` or `table` (default: json) ## Create a New Post ```bash bash skills/ghost-cms/scripts/new-post.sh \ --title "My New Post" \ --content "## Hello World This is the post content in Markdown." \ --tags "news,updates" \ --publish ``` Options: - `--title` — Post title (required) - `--content` — Post content in Markdown (required) - `--excerpt` — Short excerpt/summary (optional) - `--tags` — Comma-separated tag names (optional, auto-creates) - `--publish` — Publish immediately (omit to save as draft) - `--featured` — Mark as featured (optional) ## Manage Tags ```bash # List all tags bash skills/ghost-cms/scripts/tags.sh --list # Create a tag bash skills/ghost-cms/scripts/tags.sh --create --name "Tutorials" --description "How-to guides" --slug "tutorials" # Get a tag by slug bash skills/ghost-cms/scripts/tags.sh --slug "tutorials" ``` ## Get Site Stats ```bash bash skills/ghost-cms/scripts/stats.sh ``` Returns: total posts, total published posts, total draft posts, total members, total paid members, total pageviews (if stats addon is enabled) ## Notes - Admin API key format: `[id]:[apiKey]` — split on the colon, use the second part as the bearer token - Tags are created automatically if they don't exist when creating posts - Ghost uses a `Content-Type: application/json` header for all API calls - All scripts output JSON by default; use `--format table` for human-readable output where supported

path: scripts/new-post.sh
content: #!/usr/bin/env bash
# new-post.sh — Create or publish a Ghost post via the Admin API
# Usage: new-post.sh --title "Title" --content "Markdown content" [--excerpt "..."] [--tags "news,updates"] [--publish] [--featured]

set -euo pipefail

GHOST_URL="${GHOST_URL:-}"
ADMIN_API_KEY="${GHOST_ADMIN_API_KEY:-}"
TITLE=""
CONTENT=""
EXCERPT=""
TAGS=""
PUBLISH="false"
FEATURED="false"
POST_ID=""

usage() {
  cat <<EOF
Usage: new-post.sh [options]
Options:
  --title     Post title (required)
  --content   Post content in Markdown (required)
  --excerpt   Short excerpt/summary (optional)
  --tags      Comma-separated tag names (optional)
  --publish   Publish immediately (omit for draft)
  --featured  Mark as featured (optional)
  --id        Update existing post by ID
  --ghost-url  Ghost site URL (env: GHOST_URL)
  --api-key    Admin API key (env: GHOST_ADMIN_API_KEY)
EOF
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --title) TITLE="${2:-}"; shift 2 ;;
    --content) CONTENT="${2:-}"; shift 2 ;;
    --excerpt) EXCERPT="${2:-}"; shift 2 ;;
    --tags) TAGS="${2:-}"; shift 2 ;;
    --publish) PUBLISH="true"; shift ;;
    --featured) FEATURED="true"; shift ;;
    --id) POST_ID="${2:-}"; shift 2 ;;
    --ghost-url) GHOST_URL="${2:-}"; shift 2 ;;
    --api-key) ADMIN_API_KEY="${2:-}"; shift 2 ;;
    -h|--help) usage ;;
    *) echo "Unknown option: $1"; usage ;;
  esac
done

if [[ -z "$GHOST_URL" ]]; then
  echo "Error: --ghost-url or GHOST_URL is required" >&2
  exit 1
fi

if [[ -z "$ADMIN_API_KEY" ]]; then
  echo "Error: --api-key or GHOST_ADMIN_API_KEY is required" >&2
  exit 1
fi

if [[ -z "$TITLE" ]] || [[ -z "$CONTENT" ]]; then
  echo "Error: --title and --content are required" >&2
  usage
fi

KEY="${ADMIN_API_KEY##*:}"

# Build post object
POST_JSON=$(jq -n \
  --arg title "$TITLE" \
  --arg content "$CONTENT" \
  --arg excerpt "$EXCERPT" \
  --arg publish "$PUBLISH" \
  --arg featured "$FEATURED" \
  '{
    posts: [{
      title: $title,
      html: null,
      markdown: $content,
      excerpt: (if $excerpt != "" then $excerpt else null end),
      status: (if $publish == "true" then "published" else "draft" end),
      featured: ($featured == "true")
    }]
  }')

# If tags provided, include them
if [[ -n "$TAGS" ]]; then
  TAGSLUGS=$(echo "$TAGS" | tr ',' '\n' | xargs -I{} echo "\"$(echo {} | xargs | tr ' ' '-' | tr '[:upper:]' '[:lower:]')\"" | tr '\n' ',' | sed 's/,$//')
  POST_JSON=$(echo "$POST_JSON" | jq --argjson tags "[$TAGSLUGS]" '.posts[0].tags = tags')
fi

# Determine endpoint
if [[ -n "$POST_ID" ]]; then
  ENDPOINT="${GHOST_URL}/ghost/api/admin/posts/${POST_ID}/"
  METHOD="PUT"
else
  ENDPOINT="${GHOST_URL}/ghost/api/admin/posts/"
  METHOD="POST"
fi

RESPONSE=$(curl -s -w "\n%{http_code}" -X "$METHOD" \
  -H "Authorization: Ghost ${KEY}" \
  -H "Content-Type: application/json" \
  -d "$POST_JSON" \
  "$ENDPOINT")

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')

if [[ "$HTTP_CODE" != "200" ]] && [[ "$HTTP_CODE" != "201" ]]; then
  echo "Error creating post (HTTP $HTTP_CODE): $BODY" >&2
  exit 1
fi

echo "$BODY" | jq -r '.posts[0] | "Created: \(.title) (ID: \(.id), Status: \(.status))"'
echo ""
echo "$BODY" | jq .


path: scripts/posts.sh
content: #!/usr/bin/env bash
# posts.sh — List Ghost posts via the Admin API
# Usage: posts.sh [--limit 10] [--page 1] [--status published] [--format json|table]

set -euo pipefail

GHOST_URL="${GHOST_URL:-}"
ADMIN_API_KEY="${GHOST_ADMIN_API_KEY:-}"
LIMIT="10"
PAGE="1"
STATUS="published"
FORMAT="json"

usage() {
  cat <<EOF
Usage: posts.sh [options]
Options:
  --limit    Number of posts (default: 10)
  --page     Page number (default: 1)
  --status   Status filter: published, draft, scheduled, all (default: published)
  --format   Output format: json, table (default: json)
  --ghost-url  Ghost site URL (env: GHOST_URL)
  --api-key    Admin API key (env: GHOST_ADMIN_API_KEY)
EOF
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --limit) LIMIT="${2:-}"; shift 2 ;;
    --page) PAGE="${2:-}"; shift 2 ;;
    --status) STATUS="${2:-}"; shift 2 ;;
    --format) FORMAT="${2:-}"; shift 2 ;;
    --ghost-url) GHOST_URL="${2:-}"; shift 2 ;;
    --api-key) ADMIN_API_KEY="${2:-}"; shift 2 ;;
    -h|--help) usage ;;
    *) echo "Unknown option: $1"; usage ;;
  esac
done

if [[ -z "$GHOST_URL" ]]; then
  echo "Error: --ghost-url or GHOST_URL is required" >&2
  exit 1
fi

if [[ -z "$ADMIN_API_KEY" ]]; then
  echo "Error: --api-key or GHOST_ADMIN_API_KEY is required" >&2
  exit 1
fi

# Extract actual key from id:key format
KEY="${ADMIN_API_KEY##*:}"

RESPONSE=$(curl -s -w "\n%{http_code}" \
  -H "Authorization: Ghost ${KEY}" \
  -H "Content-Type: application/json" \
  "${GHOST_URL}/ghost/api/admin/posts/?limit=${LIMIT}&page=${PAGE}&filter=status:${STATUS}&include=tags,authors")

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')

if [[ "$HTTP_CODE" != "200" ]]; then
  echo "Error fetching posts (HTTP $HTTP_CODE): $BODY" >&2
  exit 1
fi

if [[ "$FORMAT" == "table" ]]; then
  echo "$BODY" | jq -r '.posts[] | "\(.id)\t\(.title)\t\(.status)\t\(.published_at // "draft"}\t\(.visibility // "public")"' | \
    column -t -s $'\t' -N ID,TITLE,STATUS,PUBLISHED,VISIBILITY
else
  echo "$BODY" | jq .
fi


path: scripts/stats.sh
content: #!/usr/bin/env bash
# stats.sh — Get Ghost site stats (posts, members, and pageviews if available)
# Usage: stats.sh [--format json|table]

set -euo pipefail

GHOST_URL="${GHOST_URL:-}"
ADMIN_API_KEY="${GHOST_ADMIN_API_KEY:-}"
FORMAT="json"

usage() {
  cat <<EOF
Usage: stats.sh [options]
Options:
  --format     Output format: json, table (default: json)
  --ghost-url  Ghost site URL (env: GHOST_URL)
  --api-key    Admin API key (env: GHOST_ADMIN_API_KEY)
EOF
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --format) FORMAT="${2:-}"; shift 2 ;;
    --ghost-url) GHOST_URL="${2:-}"; shift 2 ;;
    --api-key) ADMIN_API_KEY="${2:-}"; shift 2 ;;
    -h|--help) usage ;;
    *) echo "Unknown option: $1"; usage ;;
  esac
done

if [[ -z "$GHOST_URL" ]]; then
  echo "Error: --ghost-url or GHOST_URL is required" >&2
  exit 1
fi

if [[ -z "$ADMIN_API_KEY" ]]; then
  echo "Error: --api-key or GHOST_ADMIN_API_KEY is required" >&2
  exit 1
fi

KEY="${ADMIN_API_KEY##*:}"

# Fetch posts count
POSTS_RESPONSE=$(curl -s -w "\n%{http_code}" \
  -H "Authorization: Ghost ${KEY}" \
  -H "Content-Type: application/json" \
  "${GHOST_URL}/ghost/api/admin/posts/?limit=1&include=tags,authors")

POSTS_HTTP=$(echo "$POSTS_RESPONSE" | tail -1)
POSTS_BODY=$(echo "$POSTS_RESPONSE" | sed '$d')

# Fetch members
MEMBERS_RESPONSE=$(curl -s -w "\n%{http_code}" \
  -H "Authorization: Ghost ${KEY}" \
  -H "Content-Type: application/json" \
  "${GHOST_URL}/ghost/api/admin/members/?limit=1")

MEMBERS_HTTP=$(echo "$MEMBERS_RESPONSE" | tail -1)
MEMBERS_BODY=$(echo "$MEMBERS_RESPONSE" | sed '$d')

# Fetch site
SITE_RESPONSE=$(curl -s -w "\n%{http_code}" \
  -H "Authorization: Ghost ${KEY}" \
  -H "Content-Type: application/json" \
  "${GHOST_URL}/ghost/api/admin/site/")

SITE_HTTP=$(echo "$SITE_RESPONSE" | tail -1)
SITE_BODY=$(echo "$SITE_RESPONSE" | sed '$d')

if [[ "$POSTS_HTTP" != "200" ]]; then
  echo "Error fetching posts (HTTP $POSTS_HTTP): $POSTS_BODY" >&2
  exit 1
fi

if [[ "$FORMAT" == "table" ]]; then
  TOTAL_POSTS=$(echo "$POSTS_BODY" | jq -r '.meta.pagination.total // 0')
  PUBLISHED=$(echo "$POSTS_BODY" | jq -r '[.posts[] | select(.status == "published")] | length')
  DRAFTS=$(echo "$POSTS_BODY" | jq -r '[.posts[] | select(.status == "draft")] | length')

  TOTAL_MEMBERS=$(echo "$MEMBERS_BODY" | jq -r '.meta.pagination.total // 0')
  PAID_MEMBERS=$(echo "$MEMBERS_BODY" | jq -r '[.members[] | select(.subscribed == true)] | length')

  echo "Ghost Site Statistics"
  echo "===================="
  echo ""
  echo "Posts:"
  echo "  Total:    $TOTAL_POSTS"
  echo "  Published: $PUBLISHED"
  echo "  Drafts:   $DRAFTS"
  echo ""
  echo "Members:"
  echo "  Total:    $TOTAL_MEMBERS"
  echo "  Paid:     $PAID_MEMBERS"
else
  # Build combined JSON output
  jq -n \
    --argjson posts_total "$(echo "$POSTS_BODY" | jq '.meta.pagination.total // 0')" \
    --argjson posts_published "$(echo "$POSTS_BODY" | jq '[.posts[] | select(.status == "published")] | length')" \
    --argjson posts_draft "$(echo "$POSTS_BODY" | jq '[.posts[] | select(.status == "draft")] | length')" \
    --argjson members_total "$(echo "$MEMBERS_BODY" | jq '.meta.pagination.total // 0')" \
    --argjson members_paid "$(echo "$MEMBERS_BODY" | jq '[.members[] | select(.subscribed == true)] | length')" \
    '{
      posts: {
        total: $posts_total,
        published: $posts_published,
        draft: $posts_draft
      },
      members: {
        total: $members_total,
        paid: $members_paid
      }
    }' 
fi


path: scripts/tags.sh
content: #!/usr/bin/env bash
# tags.sh — List, create, and get Ghost tags via the Admin API
# Usage: tags.sh --list [--limit 20]
#   or:  tags.sh --create --name "Tag Name" [--description "..."] [--slug "tag-slug"]
#   or:  tags.sh --slug "tag-slug"

set -euo pipefail

GHOST_URL="${GHOST_URL:-}"
ADMIN_API_KEY="${GHOST_ADMIN_API_KEY:-}"
ACTION="list"
LIMIT="20"
NAME=""
DESCRIPTION=""
SLUG=""
FORMAT="json"

usage() {
  cat <<EOF
Usage: tags.sh [options]
Actions:
  --list              List all tags (default)
  --create            Create a new tag
  --slug              Get tag by slug
Options:
  --limit    Number of tags for --list (default: 20)
  --name     Tag name (required for --create)
  --description  Tag description (optional for --create)
  --slug     Tag slug (optional for --create, auto-generated if omitted)
  --format   Output format: json, table (default: json)
  --ghost-url  Ghost site URL (env: GHOST_URL)
  --api-key    Admin API key (env: GHOST_ADMIN_API_KEY)
EOF
  exit 1
}

while [[ $# -gt 0 ]]; do
  case "$1" in
    --list) ACTION="list"; shift ;;
    --create) ACTION="create"; shift ;;
    --slug)
      ACTION="get"
      SLUG="${2:-}"
      shift 2
      ;;
    --limit) LIMIT="${2:-}"; shift 2 ;;
    --name) NAME="${2:-}"; shift 2 ;;
    --description) DESCRIPTION="${2:-}"; shift 2 ;;
    --slug-only) SLUG="${2:-}"; shift 2 ;;
    --format) FORMAT="${2:-}"; shift 2 ;;
    --ghost-url) GHOST_URL="${2:-}"; shift 2 ;;
    --api-key) ADMIN_API_KEY="${2:-}"; shift 2 ;;
    -h|--help) usage ;;
    *) echo "Unknown option: $1"; usage ;;
  esac
done

if [[ -z "$GHOST_URL" ]]; then
  echo "Error: --ghost-url or GHOST_URL is required" >&2
  exit 1
fi

if [[ -z "$ADMIN_API_KEY" ]]; then
  echo "Error: --api-key or GHOST_ADMIN_API_KEY is required" >&2
  exit 1
fi

KEY="${ADMIN_API_KEY##*:}"

case "$ACTION" in
  list)
    RESPONSE=$(curl -s -w "\n%{http_code}" \
      -H "Authorization: Ghost ${KEY}" \
      -H "Content-Type: application/json" \
      "${GHOST_URL}/ghost/api/admin/tags/?limit=${LIMIT}&include=count.posts")

    HTTP_CODE=$(echo "$RESPONSE" | tail -1)
    BODY=$(echo "$RESPONSE" | sed '$d')

    if [[ "$HTTP_CODE" != "200" ]]; then
      echo "Error listing tags (HTTP $HTTP_CODE): $BODY" >&2
      exit 1
    fi

    if [[ "$FORMAT" == "table" ]]; then
      echo "$BODY" | jq -r '.tags[] | "\(.slug)\t\(.name)\t\(.count.posts) posts\t\(.description // "")"' | \
        column -t -s $'\t' -N SLUG,NAME,POSTS,DESCRIPTION
    else
      echo "$BODY" | jq .
    fi
    ;;

  get)
    if [[ -z "$SLUG" ]]; then
      echo "Error: --slug is required for --get action" >&2
      exit 1
    fi

    RESPONSE=$(curl -s -w "\n%{http_code}" \
      -H "Authorization: Ghost ${KEY}" \
      -H "Content-Type: application/json" \
      "${GHOST_URL}/ghost/api/admin/tags/slug/${SLUG}/?include=count.posts")

    HTTP_CODE=$(echo "$RESPONSE" | tail -1)
    BODY=$(echo "$RESPONSE" | sed '$d')

    if [[ "$HTTP_CODE" != "200" ]]; then
      echo "Error fetching tag (HTTP $HTTP_CODE): $BODY" >&2
      exit 1
    fi

    echo "$BODY" | jq .
    ;;

  create)
    if [[ -z "$NAME" ]]; then
      echo "Error: --name is required for --create" >&2
      exit 1
    fi

    # Auto-generate slug if not provided
    if [[ -z "$SLUG" ]]; then
      SLUG=$(echo "$NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')
    fi

    TAG_JSON=$(jq -n \
      --arg name "$NAME" \
      --arg desc "$DESCRIPTION" \
      --arg slug "$SLUG" \
      '{
        tags: [{
          name: $name,
          description: (if $desc != "" then $desc else null end),
          slug: $slug
        }]
      }')

    RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
      -H "Authorization: Ghost ${KEY}" \
      -H "Content-Type: application/json" \
      -d "$TAG_JSON" \
      "${GHOST_URL}/ghost/api/admin/tags/")

    HTTP_CODE=$(echo "$RESPONSE" | tail -1)
    BODY=$(echo "$RESPONSE" | sed '$d')

    if [[ "$HTTP_CODE" != "201" ]] && [[ "$HTTP_CODE" != "200" ]]; then
      echo "Error creating tag (HTTP $HTTP_CODE): $BODY" >&2
      exit 1
    fi

    echo "$BODY" | jq -r '.tags[0] | "Created tag: \(.name) (slug: \(.slug), ID: \(.id))"'
    echo ""
    echo "$BODY" | jq .
    ;;
esac


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)