Skip to content

Commit 51bc8c2

Browse files
Initial release: substack-cli 1.0.0
A command line client for Substack. Markdown in, live newsletter out. Push a markdown file to a draft, pull a live post back down with its images, rewrite a published post without emailing anyone, schedule a real server-side release, and post Notes. Python standard library only, with Pillow as the one optional extra for rendering markdown tables to images. Extracted and generalised from a private publishing harness that has run blog.atharvashah.com since July 2024. Every rule the tool enforces exists because something went wrong on a live page first: invented slugs, deleted images, tables published as pipe soup, raw HTML shown as literal text. 94 offline tests, each pinning a bug that reached a live newsletter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
0 parents  commit 51bc8c2

60 files changed

Lines changed: 5893 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
* text=auto eol=lf
2+
*.png binary
3+
*.jpg binary
4+
*.gif binary

‎.github/FUNDING.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github: [HighnessAtharva]
2+
custom: ["https://atharvashah.com", "https://blog.atharvashah.com"]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Bug report
2+
description: Something behaved differently from what the docs say
3+
labels: [bug]
4+
body:
5+
- type: markdown
6+
attributes:
7+
value: |
8+
**Never paste a session cookie here.** If you already have, sign out of all
9+
sessions from Substack's account settings, which invalidates it immediately.
10+
- type: textarea
11+
id: command
12+
attributes:
13+
label: The command you ran
14+
render: shell
15+
placeholder: substack push posts/my-post.md --template INIT
16+
validations: { required: true }
17+
- type: textarea
18+
id: output
19+
attributes:
20+
label: The full output
21+
description: Rerun with -v if you can. Redact anything that looks like a cookie.
22+
render: shell
23+
validations: { required: true }
24+
- type: textarea
25+
id: expected
26+
attributes:
27+
label: What you expected instead
28+
validations: { required: true }
29+
- type: textarea
30+
id: doctor
31+
attributes:
32+
label: Output of `substack doctor`
33+
render: shell
34+
- type: input
35+
id: version
36+
attributes:
37+
label: Version
38+
placeholder: "substack --version, plus your Python version and OS"
39+
validations: { required: true }
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Feature request
2+
description: Something the tool should be able to do
3+
labels: [enhancement]
4+
body:
5+
- type: textarea
6+
id: problem
7+
attributes:
8+
label: What are you trying to do
9+
description: Describe the workflow, not the implementation.
10+
validations: { required: true }
11+
- type: textarea
12+
id: today
13+
attributes:
14+
label: How do you do it today
15+
description: Manual steps, another tool, or not at all.
16+
- type: textarea
17+
id: shape
18+
attributes:
19+
label: What the command might look like
20+
render: shell
21+
placeholder: substack export --format json
22+
- type: checkboxes
23+
id: checks
24+
attributes:
25+
options:
26+
- label: I checked the docs and the existing issues.
27+
required: true

‎.github/PULL_REQUEST_TEMPLATE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## What this changes
2+
3+
<!-- One or two sentences. Link the issue if there is one. -->
4+
5+
## Why
6+
7+
<!-- Especially if this works around an API quirk. Those belong in docs/api-notes.md too. -->
8+
9+
## Checklist
10+
11+
- [ ] `pytest -q` passes.
12+
- [ ] `ruff check .` passes.
13+
- [ ] A test covers the change, with a comment saying what breaks without it.
14+
- [ ] Docs updated, if behavior changed.
15+
- [ ] No credentials, cookies, or live post ids from someone else's publication.

‎.github/workflows/ci.yml‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
name: py${{ matrix.python }} on ${{ matrix.os }}
12+
runs-on: ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, windows-latest, macos-latest]
17+
python: ["3.8", "3.12"]
18+
exclude:
19+
- os: macos-latest
20+
python: "3.8"
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python }}
26+
- run: python -m pip install --upgrade pip
27+
- run: pip install -e ".[dev]"
28+
- name: Tests
29+
run: pytest -q
30+
- name: The CLI runs
31+
run: |
32+
substack --version
33+
substack --help
34+
35+
lint:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
- uses: actions/setup-python@v5
40+
with:
41+
python-version: "3.12"
42+
- run: pip install ruff
43+
- run: ruff check .
44+
45+
no-credentials:
46+
name: no credentials committed
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
- name: Look for session cookies
51+
run: |
52+
if git grep -nE 's%3A[A-Za-z0-9_.%-]{30,}' -- . ':!*.yml'; then
53+
echo "A session cookie appears to be committed."
54+
exit 1
55+
fi
56+
if git ls-files | grep -qx '.substack.json'; then
57+
echo ".substack.json is tracked and holds credentials."
58+
exit 1
59+
fi

‎.gitignore‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Credentials. Never commit these.
2+
.substack.json
3+
.env
4+
5+
# Python
6+
__pycache__/
7+
*.py[cod]
8+
*.egg-info/
9+
build/
10+
dist/
11+
.eggs/
12+
.venv/
13+
venv/
14+
15+
# Tooling
16+
.pytest_cache/
17+
.ruff_cache/
18+
.coverage
19+
htmlcov/
20+
21+
# Editors
22+
.vscode/
23+
.idea/
24+
*.swp
25+
.DS_Store
26+
27+
# Local scratch
28+
scratch/
29+
posts/
30+
archive/

‎.substack.json.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"publication_url": "https://yourname.substack.com",
3+
"session_token": "<your connect.sid cookie>",
4+
"hub_session_token": "<your substack.sid cookie, only needed for scheduling>",
5+
"template": "INIT"
6+
}

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
3+
All notable changes are recorded here. This project follows
4+
[semantic versioning](https://semver.org).
5+
6+
## [1.0.0] - 2026-08-02
7+
8+
First public release. Extracted and generalised from a private publishing harness that has
9+
run a real newsletter since July 2024.
10+
11+
### Added
12+
13+
- `push`, which turns a markdown file into a Substack draft and writes the id back.
14+
- `update`, which rewrites an already published post with no email and no feed bump.
15+
- `audit`, which reports what an update would destroy before it destroys it.
16+
- `pull`, which downloads live posts as markdown with their images.
17+
- `publish`, `unpublish`, `schedule`, and `unschedule`.
18+
- `note` and `note-delete` for Substack Notes, with image attachments.
19+
- `render`, an offline markdown to ProseMirror conversion that sends nothing.
20+
- `init` and `doctor` for setup and diagnosis, with id discovery from one API call.
21+
- `list`, `get`, `set`, `delete`, `templates`, and `sitemap`.
22+
- Markdown tables rendered to PNG, because Substack's schema has no table node.
23+
- Automatic cover upload, used as both the post thumbnail and the template cover slot.
24+
- Slug enforcement, so Substack never invents a truncated public URL.
25+
- Preservation of editor-only blocks across an update, re-anchored by surrounding text.
26+
- 94 offline tests, each pinning a bug that reached a live newsletter.

‎CONTRIBUTING.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Contributing
2+
3+
Thanks for being here. This tool writes to live newsletters, so the bar is correctness
4+
first and features second.
5+
6+
## Setup
7+
8+
```bash
9+
git clone https://github.com/HighnessAtharva/substack-cli
10+
cd substack-cli
11+
pip install -e ".[dev]"
12+
pytest -q
13+
ruff check .
14+
```
15+
16+
The whole suite is offline and runs in under a second. It never touches the network and
17+
never needs credentials.
18+
19+
## Before you open a pull request
20+
21+
Run both:
22+
23+
```bash
24+
pytest -q
25+
ruff check .
26+
```
27+
28+
Add a test for anything you change. Every existing test pins a bug that reached a live
29+
newsletter, and the comment above each one says which. Keep that pattern: the test name
30+
should describe the behavior, and a comment should say what went wrong without it.
31+
32+
## Testing against a real publication
33+
34+
Use a throwaway draft. Never test on a live post.
35+
36+
```bash
37+
substack push examples/hello-world.md # creates a draft
38+
substack get <id> # inspect it
39+
substack delete <id> # clean up
40+
```
41+
42+
Do not run `publish`, `update`, or `unpublish` while testing. Those three change a public
43+
page and Substack has no undo. Their guard rails are covered by offline tests with a fake
44+
client in `tests/test_update.py`, which is the pattern to follow for anything else
45+
destructive.
46+
47+
## Style
48+
49+
The code is plain Python with no framework and no clever abstractions. Match what is
50+
already there.
51+
52+
- Line length 100, enforced by ruff.
53+
- Names spelled out. `publication_id`, not `pid`.
54+
- A comment explains **why**, especially when the reason is an API quirk. Anything you
55+
learned the hard way belongs in a comment and in [docs/api-notes.md](docs/api-notes.md).
56+
- Errors are written for a person mid-task. Say what happened, then say what to do about
57+
it. Raise `CLIError`, never call `sys.exit` outside the entry point.
58+
59+
## Good first contributions
60+
61+
- More markdown coverage: nested lists, strikethrough, task lists.
62+
- Better table rendering: column alignment from the separator row, dark mode.
63+
- Comment and subscriber endpoints, which nothing here touches yet.
64+
- Shell completions for bash, zsh, and fish.
65+
- Anything in [docs/api-notes.md](docs/api-notes.md) you can verify, correct, or extend.
66+
67+
## Reporting a bug
68+
69+
Include the command you ran, the full output with `-v`, your Python version, and your OS.
70+
71+
Never paste a session cookie into an issue. If you already did, sign out of all sessions
72+
from Substack's account settings, which invalidates it immediately.
73+
74+
## Security
75+
76+
Report anything sensitive privately. See [SECURITY.md](SECURITY.md).

0 commit comments

Comments
 (0)