Compare commits

..

No commits in common. "d6f049b08b18ac0cd247f1fb3519f4816fba5cd3" and "9cc8ab9344bef643f0ffde815f71bd097719f275" have entirely different histories.

2 changed files with 56 additions and 189 deletions

View file

@ -1,7 +1,7 @@
--- ---
name: weekly-review name: weekly-review
description: Use this skill when the user asks to generate a "weekly review", create a "review article", run the "weekly review" process, or mentions generating content for AI, Intelligence Augmentation, Longevity, Resource Abundance, Energy, Space, or Robotics on their scheduled days. The skill generates the article AND publishes it to Ghost — publishing is part of the skill, not a separate step the user must request. description: Use this skill when the user asks to generate a "weekly review", create a "review article", run the "weekly review" process, or mentions generating content for AI, Intelligence Augmentation, Longevity, Resource Abundance, Energy, or Space subjects on their scheduled days.
version: 1.4.4 version: 1.3.1
--- ---
# Weekly Review Article Generator # Weekly Review Article Generator
@ -84,7 +84,7 @@ Generate weekly review articles covering the best 10 items from the past week in
| `resource-abundance/` | Resource Abundance | | `resource-abundance/` | Resource Abundance |
| `energy/` | Energy | | `energy/` | Energy |
| `space/` | Space | | `space/` | Space |
| `robotics/` | Robotics | | 'robotics/' | Robotics |
@ -128,8 +128,7 @@ Source: [Publication Name](url)
7. **Save the article** 7. **Save the article**
- Write to the appropriate subject directory - Write to the appropriate subject directory
- Confirm completion with the filename
8. **Publish to Ghost** — run the script described in "Publish via Ghost Admin API" below. This step is not optional. Do not ask the user for confirmation; do not stop after step 7. The skill is not complete until the script has exited successfully. If the script exits non-zero, report the error to the user; otherwise report the published URL.
## Writing Style ## Writing Style
@ -140,23 +139,62 @@ Source: [Publication Name](url)
- Connect items to broader trends and implications - Connect items to broader trends and implications
- When a claim depends on a specific number or date, attribute it to a source rather than presenting it as established fact - When a claim depends on a specific number or date, attribute it to a source rather than presenting it as established fact
## Publish via Ghost Admin API ## Future: Publishing via Ghost Admin API
Publishing is handled by `publish_to_ghost.py` in this skill directory. It mints a short-lived JWT from the Ghost Admin API key (returned by `pass show fulfillment/api-key` in `id:secret` form), strips the leading H1 from the article, converts the rest to HTML with pandoc, and runs Ghost's required two-step draft-then-publish flow so the newsletter email is sent. This skill's scope ends at "file saved to disk." A separate publisher script handles the Ghost API integration. Documenting the contract here so the invariant doesn't drift.
Invoke it with the article path: ### The invariant
The filename-to-title chain established above extends naturally into publishing:
- Filename stem (minus `.md`) → Ghost `title` field in the API body
- First H1 of the markdown → same string, duplicated for local readability
- Everything after the first H1 → Ghost post body (HTML-converted, first H1 stripped)
The Ghost newsletter slug is a separate piece of configuration — a single value identifying which newsletter in Ghost Admin the post should be emailed through. Pipeline-level config, not per-file. Each newsletter must be created once in Ghost Admin (Settings → Email newsletter → Newsletters) before the first automated publish; the API cannot create newsletters.
### Two-step publish pattern
Ghost's Admin API requires a draft-then-publish flow to trigger newsletter email send. Single POST with `status: published` and a `newsletter` query parameter silently publishes without emailing. Confirmed pattern:
**Step 1 — create draft:**
```
POST /admin/posts/?source=html
Body: {
"posts": [{
"title": "<filename stem>",
"html": "<body HTML, first H1 stripped>",
"status": "draft",
"newsletter": "<newsletter-slug>"
}]
}
```
Response contains `id` and `updated_at` — capture both.
**Step 2 — publish and send:**
```
PUT /admin/posts/<id>/?newsletter=<newsletter-slug>&email_segment=all
Body: {
"posts": [{
"updated_at": "<from step 1 response>",
"status": "published"
}]
}
```
The `newsletter` query parameter must be on both requests. `email_segment=all` sends to all subscribers; `status:free` or `status:-free` target free-only or paid-only.
Do NOT set `email_only: true` — that suppresses the public blog post and sends email only. Weekly reviews should appear on both the blog and in email.
### Body transformation
The markdown on disk starts with an H1 that duplicates the Ghost `title`. Strip it before converting to HTML and sending, otherwise Ghost renders the title twice:
```bash ```bash
python3 <skill-dir>/publish_to_ghost.py <subject-directory>/<article.md> title=$(basename "$file" .md)
body=$(tail -n +2 "$file" | sed '/./,$!d') # drop first line, then leading blanks
html=$(echo "$body" | pandoc -f markdown -t html)
``` ```
The newsletter slug (`default-newsletter`) is a constant at the top of the script. There is one newsletter for the whole publication. If you ever split into per-subject or per-section-frame newsletters, change `NEWSLETTER_SLUG` in the script — or promote it back to a CLI argument and add a per-subject mapping here. ### Validation
Pipeline invariants the script enforces:
- The filename stem becomes the Ghost `title` field. The first H1 of the markdown is the same string, duplicated for local readability, and is stripped before HTML conversion so Ghost doesn't render the title twice.
- Draft-then-publish is required to trigger newsletter send. A single POST with `status: "published"` silently publishes without emailing.
- `email_only: true` is deliberately NOT set — weekly reviews appear on both the blog and in email.
The script exits non-zero with the HTTP body on any API failure.
Before publishing, the publisher should verify that the first H1 of the file matches `basename "$file" .md`. This catches rename/edit drift — cases where the filename was changed but the H1 wasn't, or vice versa. If the check fails, refuse to publish and surface the mismatch.

View file

@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""
Publish a weekly review article to Ghost via the Admin API.
Usage:
publish_to_ghost.py <article.md>
Reads the Ghost Admin API key from `pass show fulfillment/api-key`.
Title is derived from the filename stem. The first H1 of the markdown
is stripped before conversion (Ghost renders the title field itself).
The newsletter slug is hardcoded below change NEWSLETTER_SLUG if the
publication is ever split into multiple newsletters.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
ADMIN_BASE = "https://blog.the-fulfillment.org/ghost/api/admin"
API_VERSION = "v5.0"
NEWSLETTER_SLUG = "default-newsletter"
def die(msg: str, code: int = 1) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def mint_jwt(key_id: str, secret_hex: str) -> str:
"""Mint an HS256 JWT for the Ghost Admin API (5-minute expiry)."""
header = b64url(
json.dumps(
{"alg": "HS256", "typ": "JWT", "kid": key_id}, separators=(",", ":")
).encode()
)
now = int(time.time())
payload = b64url(
json.dumps(
{"iat": now, "exp": now + 300, "aud": "/admin/"}, separators=(",", ":")
).encode()
)
signing_input = f"{header}.{payload}".encode()
sig = b64url(
hmac.new(bytes.fromhex(secret_hex), signing_input, hashlib.sha256).digest()
)
return f"{header}.{payload}.{sig}"
def get_api_key() -> tuple[str, str]:
"""Read `pass show fulfillment/api-key`, split on colon."""
try:
out = subprocess.check_output(
["pass", "show", "fulfillment/api-key"], text=True
).strip()
except FileNotFoundError:
die("`pass` not found on PATH")
except subprocess.CalledProcessError as e:
die(f"`pass show fulfillment/api-key` failed: {e}")
if ":" not in out:
die("API key from pass is not in the expected id:secret form")
key_id, secret_hex = out.split(":", 1)
return key_id, secret_hex
def markdown_to_html(md: str) -> str:
"""Convert markdown body to HTML via pandoc."""
try:
result = subprocess.run(
["pandoc", "-f", "markdown", "-t", "html"],
input=md,
text=True,
capture_output=True,
check=True,
)
except FileNotFoundError:
die("`pandoc` not found on PATH")
except subprocess.CalledProcessError as e:
die(f"pandoc failed: {e.stderr}")
return result.stdout
def strip_leading_h1(md: str) -> str:
"""Drop the first non-blank line if it's an H1, then any leading blanks."""
lines = md.splitlines()
for i, line in enumerate(lines):
if line.strip() == "":
continue
if line.startswith("# "):
lines = lines[i + 1 :]
break
# drop leading blanks
while lines and lines[0].strip() == "":
lines.pop(0)
return "\n".join(lines)
def ghost_request(method: str, url: str, jwt: str, body: dict | None) -> dict:
headers = {
"Authorization": f"Ghost {jwt}",
"Accept-Version": API_VERSION,
"Content-Type": "application/json",
}
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
die(f"{method} {url} → HTTP {e.code}\n{detail}")
def main() -> None:
if len(sys.argv) != 2:
die("usage: publish_to_ghost.py <article.md>", code=2)
article_path = Path(sys.argv[1])
if not article_path.is_file():
die(f"not a file: {article_path}")
title = article_path.stem
raw = article_path.read_text(encoding="utf-8")
body_md = strip_leading_h1(raw)
html = markdown_to_html(body_md)
key_id, secret_hex = get_api_key()
jwt = mint_jwt(key_id, secret_hex)
# Step 1: create draft
draft = ghost_request(
"POST",
f"{ADMIN_BASE}/posts/?source=html",
jwt,
{"posts": [{"title": title, "html": html, "status": "draft"}]},
)
post = draft["posts"][0]
post_id = post["id"]
updated_at = post["updated_at"]
print(f"draft created: id={post_id} updated_at={updated_at}")
# Step 2: publish + send newsletter
# Mint a fresh JWT in case step 1 took a while (5-min budget is plenty,
# but free).
jwt = mint_jwt(key_id, secret_hex)
published = ghost_request(
"PUT",
f"{ADMIN_BASE}/posts/{post_id}/"
f"?newsletter={NEWSLETTER_SLUG}&email_segment=all",
jwt,
{"posts": [{"updated_at": updated_at, "status": "published"}]},
)
p = published["posts"][0]
email_status = (p.get("email") or {}).get("status", "none")
print(f"published: {p.get('url')} (email: {email_status})")
if __name__ == "__main__":
main()