A Python CLI wrapping Substack's unofficial private API. This document covers the internal architecture, auth model, and how to extend the CLI. For install and usage, see the root README.
# Install deps
pip install -r requirements.txt
# Run tests
pip install -r requirements-dev.txt
pytest -q
# Install as a system binary (optional)
ln -s "$(pwd)/substack" /usr/local/bin/substack| Module | Owns | Imports from |
|---|---|---|
models.py |
Permissive TypedDicts + extract_list() / extract_pagination_meta() |
nothing |
config.py |
~/.config/substack-cli/config.json load/save + config subapp |
nothing |
auth.py |
Credential resolution (env → config), headers, redaction, hints | config.py |
client.py |
SubstackClient (HTTP, retries, throttle, errors), emit_error(), output() |
auth.py, models.py |
read.py |
All GET/read commands (archive, posts, feed, comments, search, stats, analytics, categories/sections, category leaderboard) | client.py, app.py (registers commands) |
publish.py |
Draft CRUD, publish/schedule lifecycle, image upload, MD→ProseMirror | client.py, app.py (registers commands) |
manage.py |
Comments, reactions, subscribers, recommendations, tags, pub settings | client.py, app.py (registers commands) |
notes.py |
Substack Notes CRUD (create/list/get/delete) + note bodyJson builder | client.py, app.py, publish.py (reuses _parse_inline) |
app.py |
Root Typer app, subapp wiring, config test command, main() error wrapper |
all (composition root) |
app.py ──registers──> config.py
└-> read.py -> client.py -> auth.py -> config.py
└-> publish.py -> client.py
└-> manage.py -> client.py
└-> notes.py -> client.py, publish.py (_parse_inline)
Entry point (substack script):
substack -> imports config, read, publish, manage, notes (registers commands)
-> imports app.main() -> calls app()
Circular import avoidance: The entry point script (substack) imports
the command modules BEFORE calling main(). app.py itself does NOT
import the command modules at module level to avoid circular dependencies
(config.py → app.py → config.py). Tests that exercise the CLI must import
the command modules explicitly:
from substack_cli import config, read, publish, manage, notes # ensure registration
from substack_cli.app import app- Cookies, not tokens:
connect.sid(primary),substack.sid(legacy),substack.lli(optional) - Resolution order: env var → config file → AuthError
SUBSTACK_COOKIES_STRINGenv var →~/.config/substack-cli/config.jsoncookies_stringkeySUBSTACK_PUBLICATION_URLenv var → configpublication_urlkey
- Write gate:
SUBSTACK_ENABLE_WRITE=trueenv var ORenable_write: trueconfig key --yesflag: required fordrafts delete/publish,comments delete,subscribers remove,recommendations remove,tags delete,notes create(publishes immediately, no edit/undo),notes delete
- Default: compact JSON to stdout
--pretty: Rich Panel/Table to stdout (for human consumption)- Errors:
{"error": true, "message": "...", "status_code": ...}JSON to stderr, exit 1 — ALWAYS, even under--pretty
- Host "P" (publication subdomain): default —
https://{subdomain}.substack.com - Host "A" (substack.com bare):
whoami,categories,comments delete --host A
-
Write the test: Create
tests/test_<module>_<operation>.py. Userespxto mock the HTTP response. Follow the existing test patterns (seetest_read_archive.pyfor a simple example,test_publish_drafts_crud.pyfor a complex one). -
Implement the function: Add the function to the appropriate module (
read.pyfor GET-only,publish.pyfor draft/publish ops,manage.pyfor comments/subscribers/tags). Use the existingSubstackClient.get/post/put/deletemethods — they handle retries, rate limiting, and error redaction automatically. -
Register the CLI command: Add a
@app.command(...)or@<subapp>.command(...)function in the same module. Follow the pattern: resolve auth → check write gate → create client → call function → output result → handle errors. -
Run tests:
pytest -qfromskills/substack/scripts/. All tests must pass. -
Update docs: Add the command to
SKILL.md(Claude-facing) andreferences/substack-api.md(API reference).
- No real network calls: all HTTP mocked via
respx - No real config file:
isolated_configfixture redirectsCONFIG_PATHto tmp - No real env vars:
isolated_configclearsSUBSTACK_*env vars - No real sleep:
no_sleepfixture patchestime.sleepfor retry/throttle tests - 234 test cases across 22 test files (including integration/e2e tests)
See references/substack-api.md § Deferred. Highlights:
- Substack Notes editing — create/list/get/delete are implemented (
notessubapp); the API has no edit endpoint (notes publish immediately, no draft/undo), so "edit" means delete-then-recreate - Full Markdown→ProseMirror (lists, images, footnotes, paywall markers, embeds)
- Messaging/Chat/DMs, Stripe/pledges, cross-publication discovery
- OAuth / programmatic login flows