typefully-cli 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
typefully_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Typefully CLI -- manage drafts, threads, and publishing from the terminal."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
typefully_cli/cli.py CHANGED
@@ -10,6 +10,7 @@ import os
10
10
  import sys
11
11
  from functools import wraps
12
12
  from typing import Any
13
+ from urllib.parse import urlsplit
13
14
 
14
15
  import click
15
16
 
@@ -152,6 +153,136 @@ POST_PLATFORMS = {"x", "linkedin", "threads", "bluesky", "mastodon", "substack"}
152
153
  LINK_PREVIEW_PLATFORMS = {"linkedin", "threads", "substack"}
153
154
 
154
155
 
156
+ def _quote_platform(url: str, platforms: list[str]) -> str:
157
+ """Route quotes using Typefully's official agent CLI host/path rules."""
158
+ try:
159
+ parsed = urlsplit(url)
160
+ host = (parsed.hostname or "").lower()
161
+ except ValueError:
162
+ raise TypefullyError("Quote URL must be a full HTTP(S) post URL", code="invalid_input")
163
+ if not host or parsed.scheme not in {"http", "https"}:
164
+ raise TypefullyError("Quote URL must be a full HTTP(S) post URL", code="invalid_input")
165
+ known = None
166
+ for platform, domains in {
167
+ "x": ("x.com", "twitter.com"),
168
+ "threads": ("threads.net", "threads.com"),
169
+ "bluesky": ("bsky.app",),
170
+ "substack": ("substack.com",),
171
+ }.items():
172
+ if any(host == domain or host.endswith("." + domain) for domain in domains):
173
+ known = platform
174
+ break
175
+ if not known and "substack" in platforms and (
176
+ "/note/c-" in parsed.path or "/p/" in parsed.path
177
+ ):
178
+ known = "substack"
179
+ if known:
180
+ if known not in platforms:
181
+ raise TypefullyError(
182
+ f"Quote URL requires {known} in --platform", code="invalid_input"
183
+ )
184
+ return known
185
+ candidates = [p for p in platforms if p in POST_PLATFORMS and p != "linkedin"]
186
+ if "mastodon" in candidates:
187
+ return "mastodon"
188
+ if len(candidates) == 1:
189
+ return candidates[0]
190
+ raise TypefullyError(
191
+ "Quote URL has no unambiguous target; select X, Threads, Bluesky, Mastodon, or Substack",
192
+ code="invalid_input",
193
+ )
194
+
195
+
196
+ def _platform_field_options(f):
197
+ """Optional platform fields, omitted unless explicitly supplied."""
198
+ options = [
199
+ click.option("--reply-settings", type=click.Choice([
200
+ "following", "mentionedUsers", "subscribers", "verified", "null",
201
+ ]), default=None, help="X reply audience; null restores everyone"),
202
+ click.option("--subscribers-only/--no-subscribers-only", default=None),
203
+ click.option("--share-with-followers/--no-share-with-followers", default=None,
204
+ help="Also share an X community post to followers"),
205
+ click.option("--linkedin-first-comment", default=None,
206
+ help="LinkedIn first comment (max 1250 characters); null clears"),
207
+ click.option("--linkedin-reshare-target", default=None,
208
+ help="LinkedIn post URL or URN (max 2048 characters); null clears"),
209
+ ]
210
+ for option in options:
211
+ f = option(f)
212
+ return f
213
+
214
+
215
+ def _validate_platform_fields(platforms, fields):
216
+ x_fields = ("reply_settings", "subscribers_only", "share_with_followers")
217
+ li_fields = ("linkedin_first_comment", "linkedin_reshare_target")
218
+ for platform, names in (("x", x_fields), ("linkedin", li_fields)):
219
+ if any(fields.get(name) is not None for name in names) and platform not in platforms:
220
+ raise TypefullyError(
221
+ f"{platform}-only options require {platform} in --platform", code="invalid_input"
222
+ )
223
+ reply = fields.get("reply_settings")
224
+ if reply is not None and reply not in {
225
+ "following", "mentionedUsers", "subscribers", "verified", "null",
226
+ }:
227
+ raise TypefullyError("Invalid X reply settings", code="invalid_input")
228
+ for name, limit in (("linkedin_first_comment", 1250), ("linkedin_reshare_target", 2048)):
229
+ value = fields.get(name)
230
+ if value is not None and len(value) > limit:
231
+ raise TypefullyError(
232
+ f"--{name.replace('_', '-')} must be at most {limit} characters",
233
+ code="invalid_input",
234
+ )
235
+
236
+
237
+ def _apply_platform_fields(platforms, fields):
238
+ """Apply explicit fields only; settings merge rather than overwrite."""
239
+ _validate_platform_fields(platforms, fields)
240
+ x = platforms.get("x", {})
241
+ for name in ("reply_settings", "subscribers_only"):
242
+ value = fields.get(name)
243
+ if value is not None:
244
+ for post in x.get("posts", []):
245
+ post[name] = None if name == "reply_settings" and value == "null" else value
246
+ share = fields.get("share_with_followers")
247
+ if share is not None:
248
+ settings = x.setdefault("settings", {})
249
+ if not settings.get("community_id"):
250
+ raise TypefullyError(
251
+ "--share-with-followers requires an X community (use --community)",
252
+ code="invalid_input",
253
+ )
254
+ settings["share_with_followers"] = share
255
+ linkedin = platforms.get("linkedin", {})
256
+ first_comment = fields.get("linkedin_first_comment")
257
+ if first_comment is not None:
258
+ linkedin.setdefault("settings", {})["first_comment"] = (
259
+ None if first_comment == "null" else first_comment
260
+ )
261
+ reshare = fields.get("linkedin_reshare_target")
262
+ if reshare is not None:
263
+ for post in linkedin.get("posts", [])[:1]:
264
+ post["linkedin_reshare_target"] = None if reshare == "null" else reshare
265
+
266
+
267
+ def _editable_posts(name, source_posts):
268
+ """Copy writable fields, retaining anchors and translating response-only names."""
269
+ allowed = {"text", "media_ids", "hide_link_preview"}
270
+ if name == "x":
271
+ allowed |= {"quote_post_url", "paid_partnership", "made_with_ai",
272
+ "subscribers_only", "reply_settings"}
273
+ elif name == "linkedin":
274
+ allowed.add("linkedin_reshare_target")
275
+ else:
276
+ allowed.add("quote_post_url")
277
+ posts = []
278
+ for source in source_posts:
279
+ post = {k: v for k, v in source.items() if k in allowed}
280
+ if name == "linkedin" and "linkedin_reshare_urn" in source:
281
+ post["linkedin_reshare_target"] = source["linkedin_reshare_urn"]
282
+ posts.append(post)
283
+ return posts
284
+
285
+
155
286
  def _resolve_platforms(
156
287
  client: TypefullyClient,
157
288
  social_set_id: int,
@@ -474,7 +605,10 @@ def recent(limit, since, until_date, output_format, api_key, account, use_text,
474
605
  @click.option("--share/--no-share", default=False)
475
606
  @click.option("--reply-to", default=None, help="URL of tweet to reply to")
476
607
  @click.option("--scratchpad", default=None, help="Internal notes (not published)")
477
- @click.option("--qrt", "--quote-post-url", "qrt", default=None, help="Quote-post URL (X only)")
608
+ @click.option(
609
+ "--qrt", "--quote-post-url", "qrt", default=None,
610
+ help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
611
+ )
478
612
  @click.option("--threadify/--no-threadify", default=False)
479
613
  @click.option("--auto-retweet/--no-auto-retweet", default=None)
480
614
  @click.option("--auto-plug/--no-auto-plug", default=None)
@@ -491,6 +625,7 @@ def recent(limit, since, until_date, output_format, api_key, account, use_text,
491
625
  @click.option("--paid-partnership", is_flag=True, help="Add X paid-partnership disclosure")
492
626
  @click.option("--made-with-ai", is_flag=True, help="Add X AI disclosure")
493
627
  @click.option("--hide-link-preview", is_flag=True, help="Hide link preview on LinkedIn, Threads, Substack")
628
+ @_platform_field_options
494
629
  @shared_options
495
630
  @error_handler
496
631
  def draft(
@@ -498,9 +633,18 @@ def draft(
498
633
  qrt, threadify, auto_retweet, auto_plug,
499
634
  linkedin, threads, bluesky, mastodon, all_platforms, community, platform, substack,
500
635
  content_markdown, cover_media_id, paid_partnership, made_with_ai, hide_link_preview,
636
+ reply_settings, subscribers_only, share_with_followers,
637
+ linkedin_first_comment, linkedin_reshare_target,
501
638
  api_key, account, use_text, use_json, quiet, debug,
502
639
  ):
503
640
  """Create a draft post."""
641
+ platform_fields = dict(
642
+ reply_settings=reply_settings, subscribers_only=subscribers_only,
643
+ share_with_followers=share_with_followers,
644
+ linkedin_first_comment=linkedin_first_comment,
645
+ linkedin_reshare_target=linkedin_reshare_target,
646
+ )
647
+
504
648
  if input_file:
505
649
  with open(input_file, encoding="utf-8") as fh:
506
650
  text = fh.read().strip()
@@ -538,7 +682,7 @@ def draft(
538
682
  quote_post_url=qrt, community=community, platforms=target_platforms,
539
683
  content_markdown=content_markdown, cover_media_id=cover_media_id,
540
684
  paid_partnership=paid_partnership, made_with_ai=made_with_ai,
541
- hide_link_preview=hide_link_preview,
685
+ hide_link_preview=hide_link_preview, **platform_fields,
542
686
  )
543
687
 
544
688
  console.status("Creating draft...")
@@ -558,7 +702,10 @@ def draft(
558
702
  @click.option("--share/--no-share", default=False)
559
703
  @click.option("--reply-to", default=None)
560
704
  @click.option("--scratchpad", default=None)
561
- @click.option("--qrt", "--quote-post-url", "qrt", default=None, help="Quote-post URL (X only)")
705
+ @click.option(
706
+ "--qrt", "--quote-post-url", "qrt", default=None,
707
+ help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
708
+ )
562
709
  @click.option("--auto-retweet/--no-auto-retweet", default=None)
563
710
  @click.option("--auto-plug/--no-auto-plug", default=None)
564
711
  @click.option("--linkedin", is_flag=True)
@@ -572,6 +719,7 @@ def draft(
572
719
  @click.option("--paid-partnership", is_flag=True, help="Add X paid-partnership disclosure")
573
720
  @click.option("--made-with-ai", is_flag=True, help="Add X AI disclosure")
574
721
  @click.option("--hide-link-preview", is_flag=True, help="Hide link preview on LinkedIn, Threads, Substack")
722
+ @_platform_field_options
575
723
  @shared_options
576
724
  @error_handler
577
725
  def thread(
@@ -579,9 +727,18 @@ def thread(
579
727
  qrt, auto_retweet, auto_plug,
580
728
  linkedin, threads, bluesky, mastodon, all_platforms, community, platform, substack,
581
729
  paid_partnership, made_with_ai, hide_link_preview,
730
+ reply_settings, subscribers_only, share_with_followers,
731
+ linkedin_first_comment, linkedin_reshare_target,
582
732
  api_key, account, use_text, use_json, quiet, debug,
583
733
  ):
584
734
  """Create a multi-post thread. Provide 2+ posts as separate arguments."""
735
+ platform_fields = dict(
736
+ reply_settings=reply_settings, subscribers_only=subscribers_only,
737
+ share_with_followers=share_with_followers,
738
+ linkedin_first_comment=linkedin_first_comment,
739
+ linkedin_reshare_target=linkedin_reshare_target,
740
+ )
741
+
585
742
  if len(posts) < 2:
586
743
  Console(quiet).error_json({
587
744
  "code": "invalid_input",
@@ -617,7 +774,7 @@ def thread(
617
774
  auto_retweet=auto_retweet, auto_plug=auto_plug,
618
775
  quote_post_url=qrt, community=community, platforms=target_platforms,
619
776
  paid_partnership=paid_partnership, made_with_ai=made_with_ai,
620
- hide_link_preview=hide_link_preview,
777
+ hide_link_preview=hide_link_preview, **platform_fields,
621
778
  )
622
779
 
623
780
  console.status(f"Creating {len(posts_list)}-post thread...")
@@ -702,19 +859,33 @@ def open_draft(draft_id, api_key, account, use_text, use_json, quiet, debug):
702
859
  @click.option("--paid-partnership", is_flag=True)
703
860
  @click.option("--made-with-ai", is_flag=True)
704
861
  @click.option("--hide-link-preview", is_flag=True)
705
- @click.option("--qrt", "--quote-post-url", "qrt", default=None, help="Quote-post URL (X only)")
862
+ @click.option(
863
+ "--qrt", "--quote-post-url", "qrt", default=None,
864
+ help="Quote URL for X, Threads, Bluesky, Mastodon, or Substack",
865
+ )
866
+ @click.option("--community", default=None, help="X community ID")
706
867
  @click.option("--force-overwrite-comments", is_flag=True)
707
868
  @click.option("--exclude-comment-markers", is_flag=True)
869
+ @_platform_field_options
708
870
  @shared_options
709
871
  @error_handler
710
872
  def update(
711
873
  draft_id, text, input_file, schedule, plan, tags, title, share_flag, scratchpad,
712
874
  auto_retweet, auto_plug, media, append_posts, platform, content_markdown, cover_media_id,
713
875
  paid_partnership, made_with_ai, hide_link_preview, force_overwrite_comments,
714
- exclude_comment_markers, qrt,
876
+ exclude_comment_markers, qrt, community,
877
+ reply_settings, subscribers_only, share_with_followers,
878
+ linkedin_first_comment, linkedin_reshare_target,
715
879
  api_key, account, use_text, use_json, quiet, debug,
716
880
  ):
717
881
  """Update an existing draft. Text supports === for thread post splitting."""
882
+ platform_fields = dict(
883
+ reply_settings=reply_settings, subscribers_only=subscribers_only,
884
+ share_with_followers=share_with_followers,
885
+ linkedin_first_comment=linkedin_first_comment,
886
+ linkedin_reshare_target=linkedin_reshare_target,
887
+ )
888
+
718
889
  client, console, cfg = _get_client_and_console(api_key, quiet, debug=debug)
719
890
  use_text = _resolve_text_mode(use_text, use_json, cfg)
720
891
  if input_file:
@@ -735,6 +906,9 @@ def update(
735
906
 
736
907
  if content_markdown is not None or cover_media_id is not None:
737
908
  target_platforms = _resolve_platforms(client, ssid, platform)
909
+ _validate_platform_fields(target_platforms, platform_fields)
910
+ if community is not None:
911
+ raise TypefullyError("--community requires X posts", code="invalid_input")
738
912
  if target_platforms != ["x_article"]:
739
913
  raise TypefullyError(
740
914
  "X Article updates require --platform x_article", code="invalid_input"
@@ -749,14 +923,39 @@ def update(
749
923
  if append_posts:
750
924
  # Fetch existing draft to get current posts
751
925
  existing = client.get_draft(ssid, draft_id)
752
- existing_posts = []
753
- platforms = existing.get("platforms", {})
754
- if isinstance(platforms, dict) and "x" in platforms:
755
- existing_posts = platforms["x"].get("posts", [])
926
+ target_platforms = _resolve_platforms(client, ssid, platform)
756
927
  new_parts = [p.strip() for p in text.split("===") if p.strip()]
757
- new_posts = [{"text": p} for p in new_parts]
758
- all_posts = existing_posts + new_posts
759
- payload["platforms"] = {"x": {"enabled": True, "posts": all_posts}}
928
+ additions = _build_draft_payload(
929
+ posts_text=new_parts, media=media, platforms=target_platforms,
930
+ quote_post_url=qrt,
931
+ paid_partnership=paid_partnership, made_with_ai=made_with_ai,
932
+ hide_link_preview=hide_link_preview,
933
+ )["platforms"]
934
+ for name, config in additions.items():
935
+ old = existing.get("platforms", {}).get(name, {})
936
+ old_posts = _editable_posts(name, old.get("posts", []))
937
+ if name == "substack" and old_posts:
938
+ raise TypefullyError(
939
+ "Substack Notes supports one post per draft; cannot append",
940
+ code="invalid_input",
941
+ )
942
+ if old.get("settings"):
943
+ config["settings"] = dict(old["settings"])
944
+ if name == "x" and community is not None:
945
+ config.setdefault("settings", {})["community_id"] = community
946
+ # Apply per-post options only to newly appended posts.
947
+ selected_fields = {
948
+ key: value for key, value in platform_fields.items()
949
+ if (name == "x" and key in {
950
+ "reply_settings", "subscribers_only", "share_with_followers"
951
+ }) or (name == "linkedin" and key.startswith("linkedin_"))
952
+ }
953
+ _apply_platform_fields({name: config}, selected_fields)
954
+ config["posts"] = old_posts + config["posts"]
955
+ _validate_platform_fields(target_platforms, platform_fields)
956
+ if community is not None and "x" not in target_platforms:
957
+ raise TypefullyError("--community requires X", code="invalid_input")
958
+ payload["platforms"] = additions
760
959
  else:
761
960
  parts = [p.strip() for p in text.split("===") if p.strip()]
762
961
  target_platforms = _resolve_platforms(client, ssid, platform)
@@ -764,34 +963,60 @@ def update(
764
963
  posts_text=parts, media=media, platforms=target_platforms,
765
964
  quote_post_url=qrt,
766
965
  paid_partnership=paid_partnership, made_with_ai=made_with_ai,
767
- hide_link_preview=hide_link_preview,
966
+ hide_link_preview=hide_link_preview, community=community, **platform_fields,
768
967
  )["platforms"]
769
- elif media:
770
- payload["platforms"] = {
771
- "x": {"enabled": True, "posts": [{"media_ids": [m.strip() for m in media.split(",")]}]}
772
- }
773
- elif qrt or paid_partnership or made_with_ai or hide_link_preview:
968
+ elif media or qrt or paid_partnership or made_with_ai or hide_link_preview or (
969
+ community is not None or any(value is not None for value in platform_fields.values())
970
+ ):
774
971
  existing = client.get_draft(ssid, draft_id)
775
- requested = _resolve_platforms(client, ssid, platform) if platform else None
776
972
  existing_platforms = existing.get("platforms", {})
777
- target_platforms = requested or [
973
+ target_platforms = _resolve_platforms(client, ssid, platform) if platform else [
778
974
  name for name, config in existing_platforms.items()
779
975
  if name in POST_PLATFORMS and isinstance(config, dict) and config.get("enabled")
780
976
  ]
781
- if not target_platforms:
782
- raise TypefullyError("Draft has no editable post platforms", code="invalid_input")
977
+ if media and not platform and not (
978
+ qrt or paid_partnership or made_with_ai or hide_link_preview
979
+ or community is not None
980
+ or any(value is not None for value in platform_fields.values())
981
+ ):
982
+ # Preserve the historical default for media-only updates (X).
983
+ target_platforms = ["x"]
984
+ _validate_platform_fields(target_platforms, platform_fields)
985
+ x_options = community is not None or paid_partnership or made_with_ai or any(
986
+ platform_fields[key] is not None
987
+ for key in ("reply_settings", "subscribers_only", "share_with_followers")
988
+ )
989
+ li_options = any(platform_fields[key] is not None for key in (
990
+ "linkedin_first_comment", "linkedin_reshare_target"
991
+ ))
992
+ if x_options and "x" not in target_platforms:
993
+ raise TypefullyError("X-only options require X", code="invalid_input")
994
+ if hide_link_preview and not set(target_platforms) & LINK_PREVIEW_PLATFORMS:
995
+ raise TypefullyError("Link-preview hiding requires a supported platform",
996
+ code="invalid_input")
997
+ quote_platform = _quote_platform(qrt, target_platforms) if qrt else None
783
998
  payload["platforms"] = {}
784
999
  for name in target_platforms:
785
- source_posts = existing_platforms.get(name, {}).get("posts", [])
786
- posts = [{k: v for k, v in post.items() if k in {
787
- "text", "media_ids", "quote_post_url", "paid_partnership", "made_with_ai", "hide_link_preview"
788
- }} for post in source_posts]
789
- if not posts:
1000
+ if not (
1001
+ name == quote_platform or (name == "x" and x_options)
1002
+ or (name == "linkedin" and li_options)
1003
+ or (name in LINK_PREVIEW_PLATFORMS and hide_link_preview)
1004
+ or (media and (not qrt or name == quote_platform))
1005
+ ):
790
1006
  continue
1007
+ source = existing_platforms.get(name, {})
1008
+ posts = _editable_posts(name, source.get("posts", []))
1009
+ if not posts:
1010
+ raise TypefullyError(
1011
+ f"Cannot update {name}: draft has no existing posts", code="invalid_input"
1012
+ )
1013
+ if name == quote_platform:
1014
+ for post in posts if name == "x" else posts[:1]:
1015
+ post["quote_post_url"] = qrt
1016
+ if media and (not qrt or name == quote_platform):
1017
+ posts[0]["media_ids"] = [m.strip() for m in media.split(",") if m.strip()]
791
1018
  if name == "x":
792
1019
  for post in posts:
793
- if qrt:
794
- post["quote_post_url"] = qrt
795
1020
  if paid_partnership:
796
1021
  post["paid_partnership"] = True
797
1022
  if made_with_ai:
@@ -799,7 +1024,15 @@ def update(
799
1024
  if name in LINK_PREVIEW_PLATFORMS and hide_link_preview:
800
1025
  for post in posts:
801
1026
  post["hide_link_preview"] = True
802
- payload["platforms"][name] = {"enabled": True, "posts": posts}
1027
+ config = {"enabled": True, "posts": posts}
1028
+ if source.get("settings"):
1029
+ config["settings"] = dict(source["settings"])
1030
+ if name == "x" and community is not None:
1031
+ config.setdefault("settings", {})["community_id"] = community
1032
+ payload["platforms"][name] = config
1033
+ _apply_platform_fields(payload["platforms"], platform_fields)
1034
+ if not payload["platforms"]:
1035
+ raise TypefullyError("Draft has no editable post platforms", code="invalid_input")
803
1036
 
804
1037
  if share_flag is True:
805
1038
  payload["share"] = True
@@ -883,11 +1116,11 @@ def delete(ids, api_key, account, use_text, use_json, quiet, debug):
883
1116
 
884
1117
 
885
1118
  @cli.command()
886
- @click.option("--status", "draft_status", default=None, help="Filter: draft|published|scheduled|error")
1119
+ @click.option("--status", "draft_status", default=None, help="Filter: draft|planned|scheduled|publishing|published|error")
887
1120
  @click.option("-n", "--limit", default=10, type=int)
888
- @click.option("--offset", default=0, type=int)
1121
+ @click.option("--offset", default=0, type=click.IntRange(min=0))
889
1122
  @click.option("--order", default="-updated_at")
890
- @click.option("--tag", default=None, help="Filter by tag slug")
1123
+ @click.option("--tag", multiple=True, help="Filter by tag slug (repeat for multiple tags)")
891
1124
  @click.option("--content-filter", default=None, help="Filter: original|repost")
892
1125
  @shared_options
893
1126
  @error_handler
@@ -906,7 +1139,7 @@ def drafts(draft_status, limit, offset, order, tag, content_filter, api_key, acc
906
1139
  if draft_status:
907
1140
  params["status"] = draft_status
908
1141
  if tag:
909
- params["tag"] = tag
1142
+ params["tag"] = tag[0] if len(tag) == 1 else list(tag)
910
1143
  if content_filter:
911
1144
  params["content_filter"] = content_filter
912
1145
 
@@ -919,7 +1152,7 @@ def drafts(draft_status, limit, offset, order, tag, content_filter, api_key, acc
919
1152
 
920
1153
  @cli.command()
921
1154
  @click.option("-n", "--limit", default=50, type=int)
922
- @click.option("--offset", default=0, type=int)
1155
+ @click.option("--offset", default=0, type=click.IntRange(min=0))
923
1156
  @shared_options
924
1157
  @error_handler
925
1158
  def tags(limit, offset, api_key, account, use_text, use_json, quiet, debug):
@@ -965,10 +1198,14 @@ def tag_create(name, api_key, account, use_text, use_json, quiet, debug):
965
1198
  @click.option("--no-wait", is_flag=True, help="Return immediately after S3 upload, skip processing poll")
966
1199
  @click.option("--timeout", "poll_timeout", default=60, type=int,
967
1200
  help="Media processing poll timeout in seconds")
1201
+ @click.option("--alt-text", default=None, help="Media alt text (max 1000 characters)")
968
1202
  @shared_options
969
1203
  @error_handler
970
- def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_json, quiet, debug):
1204
+ def upload(file_path, no_wait, poll_timeout, alt_text, api_key, account, use_text, use_json, quiet, debug):
971
1205
  """Upload a media file and return its media ID."""
1206
+ if alt_text is not None and len(alt_text) > 1000:
1207
+ raise TypefullyError("--alt-text must be at most 1000 characters", code="invalid_input")
1208
+ upload_options = {"alt_text": alt_text} if alt_text is not None else {}
972
1209
  client, console, cfg = _get_client_and_console(api_key, quiet, debug=debug)
973
1210
  use_text = _resolve_text_mode(use_text, use_json, cfg)
974
1211
  with client:
@@ -977,7 +1214,7 @@ def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_jso
977
1214
  from typefully_cli.media import _sanitize_filename
978
1215
  file_name = _sanitize_filename(os.path.basename(file_path))
979
1216
  console.status(f"Requesting upload URL for {file_name}...")
980
- upload_response = client.request_upload(ssid, file_name)
1217
+ upload_response = client.request_upload(ssid, file_name, **upload_options)
981
1218
  media_id = upload_response.get("media_id", "")
982
1219
  upload_url = upload_response.get("upload_url", "")
983
1220
  console.status(f"Uploading {file_name}...")
@@ -985,7 +1222,7 @@ def upload(file_path, no_wait, poll_timeout, api_key, account, use_text, use_jso
985
1222
  data = {"media_id": media_id, "status": "uploading"}
986
1223
  _output(data, use_text, fmt.print_upload_result)
987
1224
  else:
988
- data = upload_media(client, ssid, file_path, console, timeout=poll_timeout)
1225
+ data = upload_media(client, ssid, file_path, console, timeout=poll_timeout, **upload_options)
989
1226
  _output(data, use_text, fmt.print_upload_result)
990
1227
 
991
1228
 
@@ -1106,7 +1343,7 @@ def batch(file_path, schedule, tags, share, dry_run, output_file, api_key, accou
1106
1343
  @click.option("--end-date", default="", help="End date YYYY-MM-DD")
1107
1344
  @click.option("--include-replies", is_flag=True, help="Include reply posts")
1108
1345
  @click.option("-n", "--limit", default=50, type=int, help="Number of posts (max: 50)")
1109
- @click.option("--offset", default=0, type=int)
1346
+ @click.option("--offset", default=0, type=click.IntRange(min=0))
1110
1347
  @shared_options
1111
1348
  @error_handler
1112
1349
  def analytics(platform, start_date, end_date, include_replies, limit, offset,
@@ -1236,7 +1473,7 @@ def comments():
1236
1473
  @click.option("--platform", default=None)
1237
1474
  @click.option("--status", "comment_status", default="unresolved")
1238
1475
  @click.option("-n", "--limit", default=10, type=int)
1239
- @click.option("--offset", default=0, type=int)
1476
+ @click.option("--offset", default=0, type=click.IntRange(min=0))
1240
1477
  @shared_options
1241
1478
  @error_handler
1242
1479
  def comments_list(draft_id, platform, comment_status, limit, offset,
@@ -1452,9 +1689,15 @@ def _build_draft_payload(
1452
1689
  paid_partnership: bool = False,
1453
1690
  made_with_ai: bool = False,
1454
1691
  hide_link_preview: bool = False,
1692
+ reply_settings: str | None = None,
1693
+ subscribers_only: bool | None = None,
1694
+ share_with_followers: bool | None = None,
1695
+ linkedin_first_comment: str | None = None,
1696
+ linkedin_reshare_target: str | None = None,
1455
1697
  ) -> dict:
1456
1698
  """Build a draft creation payload from CLI args."""
1457
1699
  target_platforms = platforms or ["x"]
1700
+ quote_platform = _quote_platform(quote_post_url, target_platforms) if quote_post_url else None
1458
1701
  if "x_article" in target_platforms:
1459
1702
  if len(target_platforms) != 1 or not content_markdown or posts_text:
1460
1703
  raise TypefullyError(
@@ -1478,7 +1721,7 @@ def _build_draft_payload(
1478
1721
  "Substack Notes supports one post per draft; threads are not supported",
1479
1722
  code="invalid_input",
1480
1723
  )
1481
- if (quote_post_url or paid_partnership or made_with_ai or reply_to or community) and "x" not in target_platforms:
1724
+ if (paid_partnership or made_with_ai or reply_to or community) and "x" not in target_platforms:
1482
1725
  raise TypefullyError("X-only options require x in --platform", code="invalid_input")
1483
1726
  if hide_link_preview and not (set(target_platforms) & LINK_PREVIEW_PLATFORMS):
1484
1727
  raise TypefullyError(
@@ -1492,10 +1735,12 @@ def _build_draft_payload(
1492
1735
  platform_payload = {}
1493
1736
  for name in target_platforms:
1494
1737
  posts = [dict(post) for post in base_posts]
1738
+ if name == quote_platform:
1739
+ # Keep legacy X thread behavior; new quote platforms quote the first post.
1740
+ for post in posts if name == "x" else posts[:1]:
1741
+ post["quote_post_url"] = quote_post_url
1495
1742
  if name == "x":
1496
1743
  for post in posts:
1497
- if quote_post_url:
1498
- post["quote_post_url"] = quote_post_url
1499
1744
  if paid_partnership:
1500
1745
  post["paid_partnership"] = True
1501
1746
  if made_with_ai:
@@ -1512,6 +1757,12 @@ def _build_draft_payload(
1512
1757
  config["settings"]["community_id"] = community
1513
1758
  platform_payload[name] = config
1514
1759
 
1760
+ _apply_platform_fields(platform_payload, dict(
1761
+ reply_settings=reply_settings, subscribers_only=subscribers_only,
1762
+ share_with_followers=share_with_followers,
1763
+ linkedin_first_comment=linkedin_first_comment,
1764
+ linkedin_reshare_target=linkedin_reshare_target,
1765
+ ))
1515
1766
  payload: dict[str, Any] = {"platforms": platform_payload}
1516
1767
 
1517
1768
  if threadify:
@@ -1546,3 +1797,9 @@ def _build_draft_payload(
1546
1797
  payload["draft_title"] = title
1547
1798
 
1548
1799
  return payload
1800
+
1801
+
1802
+ # Register advanced, explicit request-input and media metadata commands.
1803
+ from typefully_cli.payloads import register_commands # noqa: E402
1804
+
1805
+ register_commands(cli)
typefully_cli/client.py CHANGED
@@ -64,8 +64,19 @@ class TypefullyClient:
64
64
  def get_social_sets(self) -> list[dict]:
65
65
  if self._social_sets_cache is not None:
66
66
  return self._social_sets_cache
67
- data = self._request("GET", "/social-sets", params={"limit": 50})
68
- self._social_sets_cache = data.get("results", []) if data else []
67
+ results = []
68
+ offset = 0
69
+ while True:
70
+ params = {"limit": 50}
71
+ if offset:
72
+ params["offset"] = offset
73
+ data = self._request("GET", "/social-sets", params=params) or {}
74
+ page = data.get("results", [])
75
+ results.extend(page)
76
+ if len(page) < 50:
77
+ break
78
+ offset += len(page)
79
+ self._social_sets_cache = results
69
80
  return self._social_sets_cache
70
81
 
71
82
  def resolve_account(self, name_or_id: str | None, config_default: str = "") -> int:
@@ -208,12 +219,15 @@ class TypefullyClient:
208
219
 
209
220
  # --- Media ---
210
221
 
211
- def request_upload(self, social_set_id: int, file_name: str) -> dict:
222
+ def request_upload(self, social_set_id: int, file_name: str, alt_text: str | None = None) -> dict:
223
+ payload = {"file_name": file_name}
224
+ if alt_text is not None:
225
+ payload["alt_text"] = alt_text
212
226
  return (
213
227
  self._request(
214
228
  "POST",
215
229
  f"/social-sets/{social_set_id}/media/upload",
216
- json={"file_name": file_name},
230
+ json=payload,
217
231
  )
218
232
  or {}
219
233
  )
@@ -229,6 +243,13 @@ class TypefullyClient:
229
243
  """Get current media status."""
230
244
  return self._request("GET", f"/social-sets/{social_set_id}/media/{media_id}") or {}
231
245
 
246
+ def update_media(self, social_set_id: int, media_id: str, alt_text: str | None) -> dict:
247
+ """Update alt text; None explicitly clears the stored value."""
248
+ return self._request(
249
+ "PATCH", f"/social-sets/{social_set_id}/media/{media_id}",
250
+ json={"alt_text": alt_text},
251
+ ) or {}
252
+
232
253
  # --- Recent (with pagination) ---
233
254
 
234
255
  def list_recent(
typefully_cli/media.py CHANGED
@@ -34,6 +34,7 @@ def upload_media(
34
34
  file_path: str,
35
35
  console: Console,
36
36
  timeout: int = 60,
37
+ alt_text: str | None = None,
37
38
  ) -> dict:
38
39
  """Upload a media file and poll until ready. Returns {media_id, status}."""
39
40
  if not os.path.isfile(file_path):
@@ -43,7 +44,7 @@ def upload_media(
43
44
 
44
45
  # Step 1: Get presigned upload URL
45
46
  console.status(f"Requesting upload URL for {file_name}...")
46
- upload_response = client.request_upload(social_set_id, file_name)
47
+ upload_response = client.request_upload(social_set_id, file_name, **({"alt_text": alt_text} if alt_text is not None else {}))
47
48
  media_id = upload_response.get("media_id", "")
48
49
  upload_url = upload_response.get("upload_url", "")
49
50
  if not media_id or not upload_url: