@acedatacloud/skills 2026.703.8 → 2026.703.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.703.8",
3
+ "version": "2026.703.9",
4
4
  "description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: substack
3
+ description: Read and publish on Substack (substack.com) with the user's own login cookies (BYOC) — show who they are and their primary publication, list their published posts with stats, and publish a new post / newsletter. Use when the user mentions Substack, "my Substack", their newsletter, or publishing a post to Substack.
4
+ when_to_use: |
5
+ Trigger for anything on the user's Substack account driven by their own login
6
+ cookie: show who they are and their primary publication, list their published
7
+ posts, or publish a new post / newsletter. This acts as the user's real
8
+ account, so writes are gated behind an explicit confirmation and never email
9
+ subscribers unless asked.
10
+ connections: [substack]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ # substack — read & publish on Substack via your own cookies
19
+
20
+ Drives the user's **real** Substack account through the same internal web API
21
+ the site uses (Substack has no official public write API), authenticated by the
22
+ login cookie they captured with the ACE extension. No browser, no third-party
23
+ deps — just `urllib`. The publish flow mirrors the community `python-substack`
24
+ library: **create draft → prepublish → publish**.
25
+
26
+ The connector injects the cookie jar as an env var:
27
+
28
+ - `SUBSTACK_COOKIES` — a JSON array of cookies (`substack.sid`, `substack.lli`,
29
+ …). **Secret — never echo or print it.** Substack authenticates writes with
30
+ the session cookie alone (no CSRF token header needed).
31
+
32
+ ## CLI
33
+
34
+ The skill ships [`scripts/substack.py`](scripts/substack.py) — self-contained, stdlib only.
35
+
36
+ ```sh
37
+ SUB=$SKILL_DIR/scripts/substack.py
38
+ python3 $SUB whoami # who is logged in + primary publication
39
+ python3 $SUB articles --limit 20 # my published posts + stats
40
+ ```
41
+
42
+ ## Verify the connection first
43
+
44
+ ```sh
45
+ python3 $SUB whoami
46
+ # → {"user_id": ..., "name": "...", "handle": "...", "publication": "...", "publication_url": "https://<sub>.substack.com"}
47
+ ```
48
+
49
+ On an auth error the cookie is expired — have the user reconnect at
50
+ <https://auth.acedata.cloud/user/connections>. Do **not** loop-retry.
51
+
52
+ ## Publishing — GATED (dry-run unless trailing `--confirm`)
53
+
54
+ `publish` writes to the user's real account. Content is **Markdown**, converted
55
+ to Substack's ProseMirror blocks: `#`..`######`→heading, ```` ``` ````→code
56
+ block, `>`→blockquote, `-`/`*`/`1.`→lists, `---`→horizontal rule, everything else
57
+ →paragraphs. Inline **bold**, *italic*, `code`, ~~strike~~, and `[links](url)`
58
+ render as real Substack formatting; bare `https://…` URLs are auto-linked.
59
+
60
+ Without a trailing `--confirm` it dry-runs. `--confirm` is honored **only as the
61
+ last argument**. Always show the dry-run, get an explicit "yes", then re-run.
62
+
63
+ ```sh
64
+ # dry-run (shows the plan, writes nothing)
65
+ python3 $SUB publish --title "Title" --content-file post.md
66
+
67
+ # private draft (visible only in the user's dashboard)
68
+ python3 $SUB publish --title "Title" --content-file post.md --draft-only --confirm
69
+
70
+ # go LIVE on the web (does NOT email subscribers)
71
+ python3 $SUB publish --title "Title" --content-file post.md --confirm
72
+
73
+ # go LIVE and email subscribers (use only when the user explicitly asks)
74
+ python3 $SUB publish --title "Title" --content-file post.md --send-email --confirm
75
+ ```
76
+
77
+ - `--draft-only` stops at a draft — **default to this** unless the user asked to
78
+ go live. Returns an `edit_url` to review in the Substack editor.
79
+ - Publishing **does NOT email subscribers** unless `--send-email` is passed.
80
+ Emailing a newsletter is irreversible and hits every subscriber's inbox —
81
+ never add `--send-email` without an explicit request.
82
+ - `--subtitle` sets the post subtitle; `--audience` is one of
83
+ `everyone` (default, free/public), `only_paid`, `founding`, `only_free`.
84
+
85
+ ## Gotchas
86
+
87
+ - **This is the user's real Substack account.** Confirm before any publish, and
88
+ keep `--draft-only` as the safe default.
89
+ - **`--send-email` blasts the whole subscriber list** and cannot be undone — opt
90
+ in only on explicit request.
91
+ - Substack sits behind Cloudflare; an occasional 403/429 on a read is transient —
92
+ the CLI auto-retries reads once. A *persistent* 401/403 means the cookie is
93
+ genuinely expired (reconnect); the CLI never retries a write.
94
+ - **Never print `SUBSTACK_COOKIES`** — it is full account access.
95
+ - **ToS**: acts only on the user's own account with their own captured cookie.
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ substack — read & publish on Substack (substack.com) with the user's own login
4
+ cookies (BYOC). Standard-library only (urllib), no third-party deps.
5
+
6
+ Substack has no official public write API, so this drives the same internal
7
+ endpoints the website uses, authenticated by the session cookies
8
+ (``substack.sid`` / ``substack.lli`` on ``.substack.com``). The publish flow
9
+ mirrors the community ``python-substack`` library: create draft → prepublish →
10
+ publish. Draft content is a ProseMirror ``doc`` (JSON string in ``draft_body``).
11
+
12
+ The connector injects the cookie jar as a JSON env var ``SUBSTACK_COOKIES``.
13
+
14
+ Read commands run directly. ``publish`` is GATED by a trailing ``--confirm``
15
+ (honored only as the last arg). ``--draft-only`` stops at a private draft.
16
+ Publishing does NOT email subscribers unless ``--send-email`` is passed.
17
+
18
+ Examples:
19
+ python3 substack.py whoami
20
+ python3 substack.py articles --limit 20
21
+ python3 substack.py publish --title T --content-file a.md --draft-only --confirm
22
+ python3 substack.py publish --title T --content-file a.md --confirm
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+ import time
33
+ import urllib.error
34
+ import urllib.parse
35
+ import urllib.request
36
+
37
+ UA = (
38
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
39
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
40
+ )
41
+ PLATFORM = "substack"
42
+ ROOT = "https://substack.com/api/v1"
43
+
44
+ _RAW = sys.argv[1:]
45
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
46
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
47
+
48
+
49
+ def out(obj) -> None:
50
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
51
+
52
+
53
+ def die(msg: str, code: int = 1) -> None:
54
+ out({"error": msg})
55
+ sys.exit(code)
56
+
57
+
58
+ # ── Cookie jar ──────────────────────────────────────────────────────
59
+
60
+ def load_cookies() -> list:
61
+ env = f"{PLATFORM.upper()}_COOKIES"
62
+ raw = os.environ.get(env)
63
+ if not raw:
64
+ die(f"{env} is not set — connect Substack at "
65
+ f"https://auth.acedata.cloud/user/connections, then retry.")
66
+ try:
67
+ jar = json.loads(raw)
68
+ except json.JSONDecodeError as e:
69
+ die(f"{env} is not valid JSON: {e}")
70
+ if not isinstance(jar, list):
71
+ die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
72
+ return jar
73
+
74
+
75
+ def _domain_matches(host: str, domain: str) -> bool:
76
+ d = domain.lstrip(".").lower()
77
+ h = host.lower()
78
+ return not d or h == d or h.endswith("." + d)
79
+
80
+
81
+ def cookie_header(jar: list, url: str) -> str:
82
+ host = urllib.parse.urlsplit(url).hostname or ""
83
+ host_in_scope = any(
84
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
85
+ )
86
+ parts = []
87
+ for c in jar:
88
+ name, value = c.get("name"), c.get("value")
89
+ if not name or value is None:
90
+ continue
91
+ domain = c.get("domain")
92
+ if domain:
93
+ if not _domain_matches(host, str(domain)):
94
+ continue
95
+ elif not host_in_scope:
96
+ continue
97
+ parts.append(f"{name}={value}")
98
+ return "; ".join(parts)
99
+
100
+
101
+ # ── HTTP ────────────────────────────────────────────────────────────
102
+
103
+ def request(method, url, jar, *, body=None):
104
+ host = urllib.parse.urlsplit(url)
105
+ origin = f"{host.scheme}://{host.hostname}"
106
+ hdrs = {
107
+ "User-Agent": UA,
108
+ "Accept": "application/json, text/plain, */*",
109
+ "Origin": origin,
110
+ "Referer": origin + "/",
111
+ "X-Requested-With": "XMLHttpRequest",
112
+ }
113
+ data = None
114
+ if body is not None:
115
+ data = json.dumps(body).encode("utf-8")
116
+ hdrs["Content-Type"] = "application/json"
117
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
118
+ # Unredirected → the cookie is not re-sent if the API 30x-redirects to a
119
+ # different host (e.g. a login page), so the jar never leaks off-site.
120
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
121
+ try:
122
+ with urllib.request.urlopen(req, timeout=30) as resp:
123
+ return resp.status, resp.read().decode("utf-8", "replace")
124
+ except urllib.error.HTTPError as e:
125
+ return e.code, e.read().decode("utf-8", "replace")
126
+ except urllib.error.URLError as e:
127
+ die(f"network error reaching {url}: {e.reason}")
128
+
129
+
130
+ def api(method, url, jar, *, body=None, _retried=False):
131
+ status, text = request(method, url, jar, body=body)
132
+ # Substack sits behind Cloudflare, which intermittently 403s/429s an
133
+ # otherwise valid session; one retry after a short pause clears the
134
+ # transient block. Only retry idempotent GETs — never replay a write.
135
+ if status in (403, 429) and method == "GET" and not _retried:
136
+ time.sleep(1.5)
137
+ return api(method, url, jar, body=body, _retried=True)
138
+ if status in (401, 403):
139
+ die(f"auth failed ({status}) on {url} — cookie likely expired. "
140
+ f"Reconnect at https://auth.acedata.cloud/user/connections.")
141
+ try:
142
+ d = json.loads(text) if text.strip() else {}
143
+ except json.JSONDecodeError:
144
+ die(f"non-JSON response ({status}) from {url}: {text[:300]}")
145
+ if status >= 400:
146
+ msg = d.get("error") if isinstance(d, dict) else None
147
+ die(f"Substack API error ({status}) on {url}: {msg or text[:200]}")
148
+ return d
149
+
150
+
151
+ # ── identity / publication ──────────────────────────────────────────
152
+
153
+ def _publication_web(pub: dict) -> str:
154
+ custom = pub.get("custom_domain")
155
+ if custom and not pub.get("custom_domain_optional"):
156
+ return f"https://{custom}"
157
+ return f"https://{pub.get('subdomain')}.substack.com"
158
+
159
+
160
+ def resolve(jar) -> dict:
161
+ """Return {user_id, name, handle, publication_url(web), publication_api, publication_name}."""
162
+ prof = api("GET", f"{ROOT}/user/profile/self", jar)
163
+ if not isinstance(prof, dict) or not prof.get("id"):
164
+ die(f"could not read Substack profile (cookie expired?): {str(prof)[:200]}")
165
+ pub = None
166
+ for pu in prof.get("publicationUsers") or []:
167
+ if pu.get("is_primary") and pu.get("publication"):
168
+ pub = pu["publication"]
169
+ break
170
+ if pub is None:
171
+ for pu in prof.get("publicationUsers") or []:
172
+ if pu.get("publication"):
173
+ pub = pu["publication"]
174
+ break
175
+ if pub is None:
176
+ die("no Substack publication on this account — create one at "
177
+ "https://substack.com, then reconnect.")
178
+ web = _publication_web(pub)
179
+ return {
180
+ "user_id": prof.get("id"),
181
+ "name": prof.get("name"),
182
+ "handle": prof.get("handle"),
183
+ "publication_url": web,
184
+ "publication_api": web + "/api/v1",
185
+ "publication_name": pub.get("name"),
186
+ }
187
+
188
+
189
+ def cmd_whoami(jar, _args):
190
+ me = resolve(jar)
191
+ out({
192
+ "user_id": me["user_id"],
193
+ "name": me["name"],
194
+ "handle": me["handle"],
195
+ "publication": me["publication_name"],
196
+ "publication_url": me["publication_url"],
197
+ })
198
+
199
+
200
+ def cmd_articles(jar, args):
201
+ me = resolve(jar)
202
+ d = api("GET", f"{me['publication_api']}/post_management/published"
203
+ f"?offset=0&limit={args.limit}&order_by=post_date&order_direction=desc", jar)
204
+ posts = d.get("posts") if isinstance(d, dict) else d
205
+ posts = posts or []
206
+ web = me["publication_url"]
207
+ out({"count": len(posts), "articles": [{
208
+ "id": p.get("id"),
209
+ "title": p.get("title"),
210
+ "url": f"{web}/p/{p.get('slug')}" if p.get("slug") else None,
211
+ "audience": p.get("audience"),
212
+ "post_date": p.get("post_date"),
213
+ "reactions": p.get("reaction_count") or (p.get("reactions") or {}).get("❤"),
214
+ "comments": p.get("comment_count"),
215
+ } for p in posts]})
216
+
217
+
218
+ # ── markdown → ProseMirror doc ──────────────────────────────────────
219
+
220
+ # Inline: 1/2 link, 3 bold+italic, 4 bold, 5 code, 6 italic, 7 strike, 8 bare url
221
+ _INLINE = re.compile(
222
+ r"\[([^\]]+)\]\((https?://[^)\s]+)\)"
223
+ r"|\*\*\*([^*]+)\*\*\*"
224
+ r"|\*\*([^*]+)\*\*"
225
+ r"|`([^`]+)`"
226
+ r"|(?<![\w*])\*([^*\n]+)\*(?![\w*])"
227
+ r"|~~([^~]+)~~"
228
+ r"|(https?://[^\s<>()\[\]]+)"
229
+ )
230
+
231
+
232
+ def _text_node(text, marks=None):
233
+ n = {"type": "text", "text": text}
234
+ if marks:
235
+ n["marks"] = marks
236
+ return n
237
+
238
+
239
+ def _inline_nodes(text: str) -> list:
240
+ nodes: list = []
241
+ pos = 0
242
+ for m in _INLINE.finditer(text):
243
+ if m.start() > pos:
244
+ nodes.append(_text_node(text[pos:m.start()]))
245
+ if m.group(1) is not None: # link
246
+ nodes.append(_text_node(m.group(1),
247
+ [{"type": "link", "attrs": {"href": m.group(2)}}]))
248
+ elif m.group(3) is not None: # bold+italic
249
+ nodes.append(_text_node(m.group(3), [{"type": "strong"}, {"type": "em"}]))
250
+ elif m.group(4) is not None: # bold
251
+ nodes.append(_text_node(m.group(4), [{"type": "strong"}]))
252
+ elif m.group(5) is not None: # code
253
+ nodes.append(_text_node(m.group(5), [{"type": "code"}]))
254
+ elif m.group(6) is not None: # italic
255
+ nodes.append(_text_node(m.group(6), [{"type": "em"}]))
256
+ elif m.group(7) is not None: # strike
257
+ nodes.append(_text_node(m.group(7), [{"type": "strikethrough"}]))
258
+ elif m.group(8) is not None: # bare url
259
+ url = m.group(8)
260
+ nodes.append(_text_node(url, [{"type": "link", "attrs": {"href": url}}]))
261
+ pos = m.end()
262
+ if pos < len(text):
263
+ nodes.append(_text_node(text[pos:]))
264
+ return nodes or [_text_node("")]
265
+
266
+
267
+ def _para(text: str) -> dict:
268
+ return {"type": "paragraph", "content": _inline_nodes(text)}
269
+
270
+
271
+ def markdown_to_doc(md: str) -> dict:
272
+ """Convert Markdown → a Substack ProseMirror ``doc`` node.
273
+
274
+ Supports: ATX headings (#..######), fenced code (```), blockquotes (>),
275
+ bullet/ordered lists, horizontal rules (---/***/___), and paragraphs with
276
+ inline bold/italic/code/strike/links.
277
+ """
278
+ lines = md.replace("\r\n", "\n").replace("\r", "\n").split("\n")
279
+ content: list = []
280
+ i, n = 0, len(lines)
281
+ while i < n:
282
+ line = lines[i]
283
+ stripped = line.strip()
284
+
285
+ if not stripped:
286
+ i += 1
287
+ continue
288
+
289
+ # fenced code block
290
+ if stripped.startswith("```"):
291
+ lang = stripped[3:].strip()
292
+ buf = []
293
+ i += 1
294
+ while i < n and not lines[i].strip().startswith("```"):
295
+ buf.append(lines[i])
296
+ i += 1
297
+ i += 1 # closing fence
298
+ node = {"type": "code_block",
299
+ "content": [{"type": "text", "text": "\n".join(buf)}] if buf else []}
300
+ if lang:
301
+ node["attrs"] = {"language": lang}
302
+ content.append(node)
303
+ continue
304
+
305
+ # heading
306
+ mh = re.match(r"^(#{1,6})\s+(.*)$", stripped)
307
+ if mh:
308
+ content.append({"type": "heading",
309
+ "attrs": {"level": len(mh.group(1))},
310
+ "content": _inline_nodes(mh.group(2).strip())})
311
+ i += 1
312
+ continue
313
+
314
+ # horizontal rule
315
+ if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
316
+ content.append({"type": "horizontal_rule"})
317
+ i += 1
318
+ continue
319
+
320
+ # blockquote (consecutive > lines)
321
+ if stripped.startswith(">"):
322
+ buf = []
323
+ while i < n and lines[i].strip().startswith(">"):
324
+ buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
325
+ i += 1
326
+ content.append({"type": "blockquote",
327
+ "content": [_para(" ".join(x.strip() for x in buf if x.strip()))]})
328
+ continue
329
+
330
+ # bullet / ordered list (consecutive matching items)
331
+ mli = re.match(r"^\s*([-*+]|\d+\.)\s+(.*)$", line)
332
+ if mli:
333
+ ordered = bool(re.match(r"^\s*\d+\.", line))
334
+ items = []
335
+ while i < n:
336
+ m2 = re.match(r"^\s*([-*+]|\d+\.)\s+(.*)$", lines[i])
337
+ if not m2:
338
+ break
339
+ is_ord = bool(re.match(r"^\s*\d+\.", lines[i]))
340
+ if is_ord != ordered:
341
+ break
342
+ items.append({"type": "list_item", "content": [_para(m2.group(2).strip())]})
343
+ i += 1
344
+ content.append({"type": "ordered_list" if ordered else "bullet_list",
345
+ "content": items})
346
+ continue
347
+
348
+ # paragraph (gather until blank / block start)
349
+ buf = [stripped]
350
+ i += 1
351
+ while i < n:
352
+ nxt = lines[i].strip()
353
+ if (not nxt or nxt.startswith(("#", ">", "```"))
354
+ or re.match(r"^(-{3,}|\*{3,}|_{3,})$", nxt)
355
+ or re.match(r"^\s*([-*+]|\d+\.)\s+", lines[i])):
356
+ break
357
+ buf.append(nxt)
358
+ i += 1
359
+ content.append(_para(" ".join(buf)))
360
+
361
+ if not content:
362
+ content = [_para("")]
363
+ return {"type": "doc", "content": content}
364
+
365
+
366
+ # ── publish (GATED) ─────────────────────────────────────────────────
367
+
368
+ def _read_content(args) -> str:
369
+ if args.content_file:
370
+ try:
371
+ with open(args.content_file, encoding="utf-8") as f:
372
+ return f.read()
373
+ except OSError as e:
374
+ die(f"cannot read --content-file: {e}")
375
+ if args.content:
376
+ return args.content
377
+ die("provide --content or --content-file")
378
+
379
+
380
+ def cmd_publish(jar, args):
381
+ me = resolve(jar)
382
+ md = _read_content(args)
383
+ doc = markdown_to_doc(md)
384
+ audience = args.audience
385
+ pub_api = me["publication_api"]
386
+
387
+ plan = {
388
+ "action": "publish",
389
+ "dry_run": not CONFIRM,
390
+ "draft_only": args.draft_only,
391
+ "send_email": args.send_email,
392
+ "title": args.title,
393
+ "subtitle": args.subtitle,
394
+ "audience": audience,
395
+ "publication": me["publication_name"],
396
+ "publication_url": me["publication_url"],
397
+ "blocks": len(doc["content"]),
398
+ }
399
+ if not CONFIRM:
400
+ plan["note"] = ("DRY-RUN — re-run with --confirm as the LAST argument to "
401
+ "create the draft. Add --draft-only to stop at a private "
402
+ "draft; omit it to go live. --send-email emails subscribers.")
403
+ out(plan)
404
+ return
405
+
406
+ body = {
407
+ "draft_title": args.title,
408
+ "draft_subtitle": args.subtitle or "",
409
+ "draft_body": json.dumps(doc),
410
+ "draft_bylines": [{"id": int(me["user_id"]), "is_guest": False}],
411
+ "audience": audience,
412
+ "write_comment_permissions": audience,
413
+ "section_chosen": False,
414
+ }
415
+ draft = api("POST", f"{pub_api}/drafts", jar, body=body)
416
+ draft_id = draft.get("id") if isinstance(draft, dict) else None
417
+ if not draft_id:
418
+ die(f"draft create returned no id: {str(draft)[:200]}")
419
+
420
+ if args.draft_only:
421
+ out({**plan, "status": "draft_created", "draft_id": draft_id,
422
+ "edit_url": f"{me['publication_url']}/publish/post/{draft_id}"})
423
+ return
424
+
425
+ # prepublish validation, then publish
426
+ api("GET", f"{pub_api}/drafts/{draft_id}/prepublish", jar)
427
+ pub = api("POST", f"{pub_api}/drafts/{draft_id}/publish", jar,
428
+ body={"send": bool(args.send_email), "share_automatically": False})
429
+ slug = (pub or {}).get("slug") if isinstance(pub, dict) else None
430
+ out({**plan, "status": "published", "draft_id": draft_id,
431
+ "post_id": (pub or {}).get("id") if isinstance(pub, dict) else None,
432
+ "url": f"{me['publication_url']}/p/{slug}" if slug else me["publication_url"],
433
+ "emailed_subscribers": bool(args.send_email)})
434
+
435
+
436
+ # ── CLI ─────────────────────────────────────────────────────────────
437
+
438
+ def main():
439
+ p = argparse.ArgumentParser(description="Substack BYOC CLI")
440
+ sub = p.add_subparsers(dest="cmd", required=True)
441
+
442
+ sub.add_parser("whoami", help="who is logged in + primary publication")
443
+
444
+ pa = sub.add_parser("articles", help="my published posts + stats")
445
+ pa.add_argument("--limit", type=int, default=20)
446
+
447
+ pp = sub.add_parser("publish", help="publish a post (GATED by --confirm)")
448
+ pp.add_argument("--title", required=True)
449
+ pp.add_argument("--subtitle", default="")
450
+ pp.add_argument("--content", help="inline markdown body")
451
+ pp.add_argument("--content-file", help="path to a markdown file")
452
+ pp.add_argument("--audience", default="everyone",
453
+ choices=["everyone", "only_paid", "founding", "only_free"])
454
+ pp.add_argument("--draft-only", action="store_true",
455
+ help="stop at a private draft (recommended default)")
456
+ pp.add_argument("--send-email", action="store_true",
457
+ help="also email subscribers (off by default)")
458
+
459
+ args = p.parse_args(ARGV)
460
+ jar = load_cookies()
461
+ {"whoami": cmd_whoami, "articles": cmd_articles, "publish": cmd_publish}[args.cmd](jar, args)
462
+
463
+
464
+ if __name__ == "__main__":
465
+ main()