@tuanhv/sub-cli
v0.5.0
Published
Command-line tool that manages posts on Substack and reads other people's newsletters
Readme
sub-cli
A command-line tool that manages posts on your Substack publication and reads other people's newsletters. Ships with an agent skill that teaches AI agents when and how to use it safely.
Requires Node.js 24 or later. No runtime dependencies.
Installation
npm install -g @tuanhv/sub-cliOr run it without a global install:
npx @tuanhv/sub-cli <command> ...For hacking on the tool itself, see Development below.
Profile setup
Authoring commands act on a profile: a named pair of one publication URL and one cookie.
Get your cookie:
- Log in to substack.com in a browser.
- Open DevTools (
Cmd+Option+I) → Application → Cookies →https://substack.com. - Copy the Value of the cookie named exactly
substack.sid(it starts withs%3A). Don't confuse it withsubstack.lli, which will not work.
Register a profile (the terminal prompts for the cookie without echoing):
sub-cli profile add mypub https://mypub.substack.comTip: you can pipe it instead —
pbpaste | sub-cli profile add mypub ....Check it works:
sub-cli profile check mypub # exit 0 = alive, exit 3 = expired sub-cli profile use mypub # make it the default
Profiles live in $XDG_CONFIG_HOME/sub-cli/config.json
(~/.config/sub-cli/config.json), written owner-readable only. Cookies
expire after one to two weeks; refresh with sub-cli profile login <name>.
The environment variables SUBSTACK_PUBLICATION_URL and SUBSTACK_COOKIE
override any stored profile when both are set.
Command surface
General:
sub-cli help [command] # top-level help, or one command group's help
sub-cli --version
sub-cli update # self-update to the latest npm releaseAfter a real command the CLI may print a one-line "update available" notice
on stderr, checked at most once a day and cached under the config directory.
Set SUB_CLI_NO_UPDATE_CHECK=1 to silence it. sub-cli update
installs the latest release when the CLI lives in an npm install; otherwise
it prints the exact command to run.
Reading — no cookie, works on any public publication:
sub-cli feed scan <publication> [--limit n] [--all] [--json] [--no-retry]
sub-cli feed crawl <url> [--out dir] [--overwrite] [--no-retry]
sub-cli feed crawl-all <publication> [--limit n] [--all] [--out dir] [--overwrite] [--no-retry]Writing — uses a profile:
sub-cli post create <file> [--dry-run] [--title t] [--subtitle s]
[--section name] [--cover url] [--audience a] [--slug slug]
sub-cli post list [--state draft|scheduled|published] [--limit n] [--json] [--no-retry]
sub-cli post update <id> [--file path] [--section name] [--subtitle s] [--cover url] [--slug slug] [--dry-run]
sub-cli post schedule <file> <time> [--audience a]
sub-cli post unschedule <id>
sub-cli post delete <id> --yes [--force-published]
sub-cli section list [--json]
sub-cli section add <name> <description>
sub-cli section remove <name-or-id> --yes
sub-cli section set <section-name> <id...> [--no-retry]post update and section set change drafts and scheduled posts only. They
read the post's state first and refuse a published post, because on a
published post those fields are staged and never go live (ADR-0006); a
published post is changed with post revise (below). post update <id> --file post.md replaces the title and body from the
file through the same conversion as post create, uploading local images the
same way. Subtitle, cover, section, and slug change only when the front matter
or a flag names them; the front matter audience is ignored with a warning.
Publishing — irreversible, guarded twice per ADR-0004:
sub-cli post publish <file> --profile p --yes [--no-send] [--audience a]
sub-cli post publish --id <n> --profile p --yes [--no-send] [--audience a]Revising a published post (public at once, guarded the same way):
sub-cli post revise <id> [file] --profile p --yes [--title t] [--subtitle s]
[--section name] [--cover url] [--slug slug --change-url] [--dry-run]post revise changes a published post in place and republishes it with
send:false: the change goes live, the publish date stays, and no email is
sent again (there is no way to re-send it). It refuses drafts and scheduled
posts with a pointer to post update, and follows the same field rule: title
and body from the file, everything else only when named; audience is ignored
with a warning. Before writing it:
- refuses when the post has pending changes (edits saved in Substack's web editor but never pushed, in the title, subtitle, body, or section) that the revision would not overwrite, since republishing would push them too;
- saves the live version as Markdown to
~/.config/sub-cli/backups/<publication>/<id>-<timestamp>.mdand prints the path; the backup is itself a revise file, sopost revise <id> <backup>restores the title, subtitle, slug, cover, and body text (not the section, and not embeds the Markdown conversion cannot carry); - refuses a slug change unless
--change-urlis passed: there is no redirect, and the old URL keeps serving a frozen copy that later revisions never reach. A slug equal to the current one is a no-op.
After the republish it reads the post back and fails if a field it sent did
not go live. --dry-run needs --profile but not --yes, reads the post
once, and prints the requests, the backup path, and the pending-changes check
without writing anything.
Commands that talk to the API retry on rate limits with a paced backoff
ladder, then exit 4. Pass --no-retry to make a single attempt instead;
the reading commands, post list, section set, and profile check
accept it.
Post files
A post file is Markdown with front matter carrying all metadata:
---
title: My post title # required here or via --title
subtitle: Optional subtitle
section: Essays # must already exist on the publication
cover: https://.../img.png # optional, http(s) only
audience: everyone # everyone | only_paid | only_free | founding
slug: my-post-slug # lowercase words separated by hyphens
---
Body in Markdown. Local images referenced from the body are uploaded
automatically when the post is sent.Local image paths resolve against the directory of the Markdown file. Every
command that sends a post (post create, post schedule, post publish)
uploads them and rewrites the references to the hosted URLs before the draft
exists; a missing file stops the command naming it, so no post is ever
created with a broken image. Images already referenced by an http(s) URL are
left untouched, and --dry-run uploads nothing.
post create and post schedule honour every field above. post publish
honours title, subtitle, and audience, and warns that it is dropping
slug, section, and cover: prepare those with post create, then publish
the draft with --id. A command that cannot finish what the file asks for
removes the draft it had just created rather than leaving a half-made post
behind.
Headings shift down one level on import (# becomes an H2); a sixth-level
heading is rejected rather than flattened, as are tables and other constructs
Substack's schema has no node for.
Exit codes
| Code | Meaning | Recovery |
|---|---|---|
| 0 | success | — |
| 1 | general failure | read stderr |
| 2 | usage error | fix the command line |
| 3 | authentication failed | refresh the cookie: post-less profile login |
| 4 | rate limited | wait a few minutes, retry |
Agent skill
The repository ships skills/sub-cli/SKILL.md, a skill file that teaches
AI coding agents the CLI's judgement calls: which operations are safe to run
unattended, which need human confirmation (publishing, revising or deleting
published posts), what each exit code calls for, and the habit of passing --profile
explicitly and reading --json.
Install it by hand by copying the directory into your agent's skill folder:
- Claude Code:
~/.claude/skills/sub-cli/SKILL.md - Any agent that reads markdown skills: point it at the file.
The skill assumes CLI 0.4.0 or later and is versioned independently of the package.
Development
npm run build # tsc -> dist/
npm run typecheck # tsc --noEmit
npm test # unit tests (node:test), no network needed
npm run test:integration # real-API suite; needs SUBSTACK_COOKIE +
# SUBSTACK_PUBLICATION_URL (see .env.example);
# never runs under npm test or CIReleasing
The package was renamed from substackctl to @tuanhv/sub-cli at 0.4.0
(ADR-0005); the old name was unpublished from npm. 0.4.0 itself was
bootstrapped with a manual, 2FA-protected npm publish --access public.
Later releases go through CI with trusted publishing (OIDC): in the
package's npmjs.com Settings, Trusted publishing points at GitHub Actions
for hoangvantuan/substack-cli with workflow filename release.yml. After
that one-time setup, every release is just git tag vX.Y.Z &&
git push origin vX.Y.Z; the workflow publishes with a short-lived OIDC
credential and a provenance attestation, no npm token secret involved.
Once it works, set Publishing access to "Require two-factor authentication
and disallow tokens".
Documentation
docs/api-observations.md— maintainer notes on the undocumented Substack API endpoints this tool exercises.docs/adr/— architecture decision records.CONTEXT.md— the project's domain vocabulary.
