typefully-cli 0.1.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/cli.py ADDED
@@ -0,0 +1,876 @@
1
+ """Click CLI: all 17 commands with shared per-subcommand options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import datetime
7
+ import io
8
+ import json
9
+ import os
10
+ import sys
11
+ from functools import wraps
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ from typefully_cli import __version__
17
+ from typefully_cli.auth import resolve_api_key
18
+ from typefully_cli.batch import merge_defaults, parse_batch_file
19
+ from typefully_cli.client import TypefullyClient
20
+ from typefully_cli.config import Config, config_set
21
+ from typefully_cli.console import Console, write_success
22
+ from typefully_cli.exceptions import APIError, TypefullyError
23
+ from typefully_cli.media import upload_media
24
+ from typefully_cli import output as fmt
25
+
26
+
27
+ # --- Shared option decorator ---
28
+
29
+
30
+ def shared_options(f):
31
+ """Add --api-key, --account, --text, --quiet to a subcommand."""
32
+
33
+ @click.option("--api-key", default=None, help="API key (overrides env/config)")
34
+ @click.option(
35
+ "--account", "-a", default=None, help="Account name, username, or numeric ID"
36
+ )
37
+ @click.option("--text", "use_text", is_flag=True, help="Human-readable Rich output")
38
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error stderr")
39
+ @wraps(f)
40
+ def wrapper(*args, **kwargs):
41
+ return f(*args, **kwargs)
42
+
43
+ return wrapper
44
+
45
+
46
+ def _get_client_and_console(
47
+ api_key: str | None, quiet: bool, config: Config | None = None
48
+ ) -> tuple[TypefullyClient, Console, Config]:
49
+ """Resolve auth and return client + console."""
50
+ cfg = config or Config.load()
51
+ console = Console(quiet=quiet)
52
+ key = resolve_api_key(api_key, os.environ.get("TYPEFULLY_API_KEY"), cfg)
53
+ client = TypefullyClient(api_key=key)
54
+ return client, console, cfg
55
+
56
+
57
+ def _resolve_account_id(
58
+ client: TypefullyClient,
59
+ account: str | None,
60
+ config: Config,
61
+ positional: str | None = None,
62
+ ) -> int:
63
+ """Account resolution chain: positional > --account > config default."""
64
+ value = positional or account
65
+ return client.resolve_account(value, config.default_account)
66
+
67
+
68
+ def _handle_error(err: TypefullyError, console: Console) -> None:
69
+ """Write structured error JSON to stderr and exit."""
70
+ console.error_json(err.to_dict())
71
+ if isinstance(err, TypefullyError) and err.code in ("auth_failed", "no_account", "invalid_input"):
72
+ sys.exit(1)
73
+ sys.exit(2)
74
+
75
+
76
+ def _validate_date(value: str, name: str) -> str:
77
+ """Validate a YYYY-MM-DD date string. Returns the value or raises."""
78
+ if not value:
79
+ return value
80
+ try:
81
+ datetime.date.fromisoformat(value)
82
+ except ValueError:
83
+ raise TypefullyError(
84
+ f"Invalid {name} date: '{value}'",
85
+ code="invalid_input",
86
+ hint="Expected format: YYYY-MM-DD",
87
+ )
88
+ return value
89
+
90
+
91
+ def _validate_limit(value: int, max_val: int = 50) -> int:
92
+ """Validate a pagination limit is within 1..max_val."""
93
+ if value < 1 or value > max_val:
94
+ raise TypefullyError(
95
+ f"Invalid limit: {value}",
96
+ code="invalid_input",
97
+ hint=f"Limit must be between 1 and {max_val}",
98
+ )
99
+ return value
100
+
101
+
102
+ def _output(data: Any, use_text: bool, text_fn: Any = None) -> None:
103
+ """Write JSON envelope to stdout, or call text_fn for --text mode."""
104
+ if use_text and text_fn:
105
+ text_fn(data)
106
+ else:
107
+ write_success(data)
108
+
109
+
110
+ def _write_recent_csv(posts: list[dict]) -> None:
111
+ """Write recently published posts as CSV to stdout."""
112
+ buf = io.StringIO()
113
+ writer = csv.writer(buf)
114
+ writer.writerow(["id", "published_at", "text", "x_url"])
115
+ for d in posts:
116
+ text = ""
117
+ platforms = d.get("platforms", {})
118
+ if isinstance(platforms, dict) and "x" in platforms:
119
+ pp = platforms.get("x", {}).get("posts", [])
120
+ if pp:
121
+ text = pp[0].get("text", "")
122
+ if not text:
123
+ text = d.get("preview", "")
124
+ writer.writerow([
125
+ d.get("id", ""),
126
+ d.get("published_at", "")[:19],
127
+ text[:280],
128
+ d.get("x_published_url", ""),
129
+ ])
130
+ sys.stdout.write(buf.getvalue())
131
+
132
+
133
+ def error_handler(f):
134
+ """Centralized error handler for all CLI commands.
135
+
136
+ Catches TypefullyError -> structured stderr JSON + appropriate exit code.
137
+ Catches unexpected Exception -> internal_error (no raw exception leak).
138
+ """
139
+
140
+ @wraps(f)
141
+ def wrapper(*args, **kwargs):
142
+ try:
143
+ return f(*args, **kwargs)
144
+ except SystemExit:
145
+ raise # let sys.exit() through
146
+ except TypefullyError as e:
147
+ _handle_error(e, Console(kwargs.get("quiet", False)))
148
+ except Exception:
149
+ Console(kwargs.get("quiet", False)).error_json(
150
+ {"code": "internal_error", "message": "Unexpected internal error"}
151
+ )
152
+ sys.exit(2)
153
+
154
+ return wrapper
155
+
156
+
157
+ def _handle_multi_result(
158
+ result: dict, errors: list, created: list,
159
+ total: int, use_text: bool, text_fn: Any, console: Console,
160
+ ) -> None:
161
+ """Handle the three branches for multi-item commands (delete/batch)."""
162
+ if not errors:
163
+ # All succeed
164
+ if use_text and text_fn:
165
+ text_fn(result)
166
+ else:
167
+ write_success(result)
168
+ elif created:
169
+ # Partial failure: some succeeded, some failed
170
+ if use_text and text_fn:
171
+ text_fn(result)
172
+ else:
173
+ write_success(result)
174
+ console.error_json({
175
+ "code": "partial_failure",
176
+ "status": 3,
177
+ "message": f"{len(errors)} of {total} operations failed",
178
+ "hint": "Check stdout data for details",
179
+ })
180
+ sys.exit(3)
181
+ else:
182
+ # All failed: nothing on stdout
183
+ console.error_json({
184
+ "code": "all_failed",
185
+ "status": 2,
186
+ "message": f"All {total} operations failed",
187
+ })
188
+ sys.exit(2)
189
+
190
+
191
+ # --- CLI Group ---
192
+
193
+
194
+ @click.group()
195
+ @click.version_option(__version__, prog_name="typefully")
196
+ def cli():
197
+ """Typefully CLI -- manage drafts, threads, and publishing."""
198
+
199
+
200
+ # --- Config commands ---
201
+
202
+
203
+ @cli.group()
204
+ def config():
205
+ """Manage configuration (~/.config/typefully/config.toml)."""
206
+
207
+
208
+ @config.command("set")
209
+ @click.argument("key")
210
+ @click.argument("value")
211
+ @error_handler
212
+ def config_set_cmd(key: str, value: str, **kwargs):
213
+ """Set a config value. Keys: api_key, default_account, onepassword_item."""
214
+ cfg = Config.load()
215
+ result = config_set(cfg, key, value)
216
+ write_success(result)
217
+
218
+
219
+ @config.command("show")
220
+ @error_handler
221
+ def config_show_cmd(**kwargs):
222
+ """Show current configuration (secrets redacted)."""
223
+ cfg = Config.load()
224
+ write_success(cfg.to_dict(redact=True))
225
+
226
+
227
+ @config.command("path")
228
+ @error_handler
229
+ def config_path_cmd(**kwargs):
230
+ """Print the config file path."""
231
+ cfg = Config.load()
232
+ write_success({"path": str(cfg._path)})
233
+
234
+
235
+ @config.command("init")
236
+ @error_handler
237
+ def config_init_cmd(**kwargs):
238
+ """Interactive setup wizard. Prompts for API key and default account."""
239
+ cfg = Config.load()
240
+ if cfg.api_key:
241
+ if not click.confirm("Config already exists. Overwrite?", default=False):
242
+ click.echo("Aborted.")
243
+ return
244
+
245
+ api_key = click.prompt("API key (from typefully.com/settings/api)", type=str)
246
+ # Validate by calling /me
247
+ console = Console(quiet=False)
248
+ console.status("Validating API key...")
249
+ try:
250
+ client = TypefullyClient(api_key=api_key)
251
+ with client:
252
+ user = client.me()
253
+ console.success(f"Authenticated as {user.get('name', 'unknown')}")
254
+ except TypefullyError:
255
+ console.error_json({"code": "auth_failed", "message": "Invalid API key"})
256
+ sys.exit(1)
257
+
258
+ default_account = click.prompt("Default account (name or username, optional)", default="", show_default=False)
259
+
260
+ config_set(cfg, "api_key", api_key)
261
+ if default_account:
262
+ config_set(cfg, "default_account", default_account)
263
+
264
+ write_success({"message": "Config saved", "path": str(cfg._path)})
265
+
266
+
267
+ # --- me ---
268
+
269
+
270
+ @cli.command()
271
+ @shared_options
272
+ @error_handler
273
+ def me(api_key, account, use_text, quiet):
274
+ """Show current authenticated user."""
275
+ client, console, cfg = _get_client_and_console(api_key, quiet)
276
+ with client:
277
+ data = client.me()
278
+ _output(data, use_text, fmt.print_user)
279
+
280
+
281
+ # --- accounts ---
282
+
283
+
284
+ @cli.command()
285
+ @shared_options
286
+ @error_handler
287
+ def accounts(api_key, account, use_text, quiet):
288
+ """List connected social sets."""
289
+ client, console, cfg = _get_client_and_console(api_key, quiet)
290
+ with client:
291
+ sets = client.get_social_sets()
292
+ if use_text:
293
+ fmt.print_accounts(sets)
294
+ else:
295
+ write_success(sets)
296
+
297
+
298
+ # --- account-detail ---
299
+
300
+
301
+ @cli.command("account-detail")
302
+ @click.argument("name", required=False, default=None)
303
+ @shared_options
304
+ @error_handler
305
+ def account_detail(name, api_key, account, use_text, quiet):
306
+ """Show details for an account. NAME is optional (falls back to --account or config default)."""
307
+ client, console, cfg = _get_client_and_console(api_key, quiet)
308
+ with client:
309
+ ssid = _resolve_account_id(client, account, cfg, positional=name)
310
+ data = client.get_social_set(ssid)
311
+ _output(data, use_text, fmt.print_account_detail)
312
+
313
+
314
+ # --- recent ---
315
+
316
+
317
+ @cli.command()
318
+ @click.option("-n", "--limit", default=3, type=int, help="Number of posts (default: 3, max: 50)")
319
+ @click.option("--since", default="", help="Start date YYYY-MM-DD")
320
+ @click.option("--until", "until_date", default="", help="End date YYYY-MM-DD")
321
+ @click.option("--format", "output_format", default=None, type=click.Choice(["csv"]), help="Output format")
322
+ @shared_options
323
+ @error_handler
324
+ def recent(limit, since, until_date, output_format, api_key, account, use_text, quiet):
325
+ """List recently published posts."""
326
+ _validate_limit(limit)
327
+ since = _validate_date(since, "--since")
328
+ until_date = _validate_date(until_date, "--until")
329
+ if since and until_date and since > until_date:
330
+ raise TypefullyError(
331
+ f"--since ({since}) is after --until ({until_date})",
332
+ code="invalid_input",
333
+ hint="--since must be before or equal to --until",
334
+ )
335
+ client, console, cfg = _get_client_and_console(api_key, quiet)
336
+ with client:
337
+ ssid = _resolve_account_id(client, account, cfg)
338
+ posts = client.list_recent(ssid, limit=limit, since=since, until=until_date)
339
+ if output_format == "csv":
340
+ _write_recent_csv(posts)
341
+ elif use_text:
342
+ fmt.print_recent(posts)
343
+ else:
344
+ write_success(posts)
345
+
346
+
347
+ # --- draft ---
348
+
349
+
350
+ @cli.command()
351
+ @click.argument("text")
352
+ @click.option("--schedule", default=None, help="ISO datetime, 'next', or 'now'")
353
+ @click.option("--tag", "tags", multiple=True, help="Tag name (repeatable, auto-created)")
354
+ @click.option("--title", default=None)
355
+ @click.option("--media", default=None, help="Comma-separated media IDs")
356
+ @click.option("--share/--no-share", default=False)
357
+ @click.option("--reply-to", default=None, help="URL of tweet to reply to")
358
+ @click.option("--scratchpad", default=None, help="Internal notes (not published)")
359
+ @click.option("--qrt", default=None, help="Quote-retweet URL")
360
+ @click.option("--threadify/--no-threadify", default=False)
361
+ @click.option("--auto-retweet/--no-auto-retweet", default=None)
362
+ @click.option("--auto-plug/--no-auto-plug", default=None)
363
+ @click.option("--linkedin", is_flag=True)
364
+ @click.option("--threads", is_flag=True)
365
+ @click.option("--bluesky", is_flag=True)
366
+ @click.option("--mastodon", is_flag=True)
367
+ @shared_options
368
+ @error_handler
369
+ def draft(
370
+ text, schedule, tags, title, media, share, reply_to, scratchpad,
371
+ qrt, threadify, auto_retweet, auto_plug,
372
+ linkedin, threads, bluesky, mastodon,
373
+ api_key, account, use_text, quiet,
374
+ ):
375
+ """Create a draft post."""
376
+ client, console, cfg = _get_client_and_console(api_key, quiet)
377
+ with client:
378
+ ssid = _resolve_account_id(client, account, cfg)
379
+
380
+ # QRT: append URL
381
+ if qrt:
382
+ text = f"{text}\n{qrt}"
383
+
384
+ # Tag auto-creation
385
+ if tags:
386
+ for w in client.ensure_tags(ssid, list(tags)):
387
+ console.warning(w)
388
+
389
+ payload = _build_draft_payload(
390
+ posts_text=[text], schedule=schedule, tags=list(tags),
391
+ title=title, media=media, share=share, reply_to=reply_to,
392
+ scratchpad=scratchpad, threadify=threadify,
393
+ auto_retweet=auto_retweet, auto_plug=auto_plug,
394
+ linkedin=linkedin, threads_flag=threads, bluesky=bluesky, mastodon=mastodon,
395
+ )
396
+
397
+ console.status("Creating draft...")
398
+ data = client.create_draft(ssid, payload)
399
+ _output(data, use_text, fmt.print_draft_short)
400
+
401
+
402
+ # --- thread ---
403
+
404
+
405
+ @cli.command()
406
+ @click.argument("posts", nargs=-1, required=True)
407
+ @click.option("--schedule", default=None, help="ISO datetime, 'next', or 'now'")
408
+ @click.option("--tag", "tags", multiple=True, help="Tag name (repeatable)")
409
+ @click.option("--title", default=None)
410
+ @click.option("--media", default=None, help="Comma-separated media IDs (first post)")
411
+ @click.option("--share/--no-share", default=False)
412
+ @click.option("--reply-to", default=None)
413
+ @click.option("--scratchpad", default=None)
414
+ @click.option("--qrt", default=None)
415
+ @click.option("--auto-retweet/--no-auto-retweet", default=None)
416
+ @click.option("--auto-plug/--no-auto-plug", default=None)
417
+ @click.option("--linkedin", is_flag=True)
418
+ @click.option("--threads", is_flag=True)
419
+ @click.option("--bluesky", is_flag=True)
420
+ @click.option("--mastodon", is_flag=True)
421
+ @shared_options
422
+ @error_handler
423
+ def thread(
424
+ posts, schedule, tags, title, media, share, reply_to, scratchpad,
425
+ qrt, auto_retweet, auto_plug,
426
+ linkedin, threads, bluesky, mastodon,
427
+ api_key, account, use_text, quiet,
428
+ ):
429
+ """Create a multi-post thread. Provide 2+ posts as separate arguments."""
430
+ if len(posts) < 2:
431
+ Console(quiet).error_json({
432
+ "code": "invalid_input",
433
+ "message": "Thread requires at least 2 posts",
434
+ "hint": 'Usage: typefully thread "post 1" "post 2" [...]',
435
+ })
436
+ sys.exit(1)
437
+
438
+ client, console, cfg = _get_client_and_console(api_key, quiet)
439
+ with client:
440
+ ssid = _resolve_account_id(client, account, cfg)
441
+
442
+ posts_list = list(posts)
443
+ if qrt and posts_list:
444
+ posts_list[0] = f"{posts_list[0]}\n{qrt}"
445
+
446
+ if tags:
447
+ for w in client.ensure_tags(ssid, list(tags)):
448
+ console.warning(w)
449
+
450
+ payload = _build_draft_payload(
451
+ posts_text=posts_list, schedule=schedule, tags=list(tags),
452
+ title=title, media=media, share=share, reply_to=reply_to,
453
+ scratchpad=scratchpad, threadify=False,
454
+ auto_retweet=auto_retweet, auto_plug=auto_plug,
455
+ linkedin=linkedin, threads_flag=threads, bluesky=bluesky, mastodon=mastodon,
456
+ )
457
+
458
+ console.status(f"Creating {len(posts_list)}-post thread...")
459
+ data = client.create_draft(ssid, payload)
460
+ _output(data, use_text, fmt.print_draft_short)
461
+
462
+
463
+ # --- get ---
464
+
465
+
466
+ @cli.command()
467
+ @click.argument("draft_id")
468
+ @shared_options
469
+ @error_handler
470
+ def get(draft_id, api_key, account, use_text, quiet):
471
+ """View a specific draft."""
472
+ client, console, cfg = _get_client_and_console(api_key, quiet)
473
+ with client:
474
+ ssid = _resolve_account_id(client, account, cfg)
475
+ data = client.get_draft(ssid, draft_id)
476
+ _output(data, use_text, fmt.print_draft)
477
+
478
+
479
+ # --- open ---
480
+
481
+
482
+ @cli.command("open")
483
+ @click.argument("draft_id")
484
+ @shared_options
485
+ @error_handler
486
+ def open_draft(draft_id, api_key, account, use_text, quiet):
487
+ """Open a draft in the browser."""
488
+ client, console, cfg = _get_client_and_console(api_key, quiet)
489
+ with client:
490
+ ssid = _resolve_account_id(client, account, cfg)
491
+ data = client.get_draft(ssid, draft_id)
492
+ url = data.get("private_url", "")
493
+ if not url:
494
+ url = data.get("share_url", "")
495
+ if not url:
496
+ console.error_json({
497
+ "code": "no_url",
498
+ "message": f"No URL found for draft {draft_id}",
499
+ })
500
+ sys.exit(2)
501
+ launched = click.launch(url)
502
+ if launched != 0:
503
+ # Fallback: print URL (e.g. over SSH with no browser)
504
+ write_success({"url": url, "opened": False})
505
+ else:
506
+ write_success({"url": url, "opened": True})
507
+
508
+
509
+ # --- update ---
510
+
511
+
512
+ @cli.command()
513
+ @click.argument("draft_id")
514
+ @click.argument("text", required=False, default=None)
515
+ @click.option("--schedule", default=None)
516
+ @click.option("--tag", "tags", multiple=True)
517
+ @click.option("--title", default=None)
518
+ @click.option("--share/--unshare", "share_flag", default=None)
519
+ @click.option("--scratchpad", default=None)
520
+ @click.option("--auto-retweet/--no-auto-retweet", default=None)
521
+ @click.option("--auto-plug/--no-auto-plug", default=None)
522
+ @click.option("--media", default=None)
523
+ @shared_options
524
+ @error_handler
525
+ def update(
526
+ draft_id, text, schedule, tags, title, share_flag, scratchpad,
527
+ auto_retweet, auto_plug, media,
528
+ api_key, account, use_text, quiet,
529
+ ):
530
+ """Update an existing draft. Text supports === for thread post splitting."""
531
+ client, console, cfg = _get_client_and_console(api_key, quiet)
532
+ with client:
533
+ ssid = _resolve_account_id(client, account, cfg)
534
+
535
+ if tags:
536
+ for w in client.ensure_tags(ssid, list(tags)):
537
+ console.warning(w)
538
+
539
+ payload: dict[str, Any] = {}
540
+
541
+ if text:
542
+ parts = [p.strip() for p in text.split("===") if p.strip()]
543
+ posts = [{"text": p} for p in parts]
544
+ if media:
545
+ posts[0]["media_ids"] = [m.strip() for m in media.split(",")]
546
+ payload["platforms"] = {"x": {"enabled": True, "posts": posts}}
547
+ elif media:
548
+ payload["platforms"] = {
549
+ "x": {"enabled": True, "posts": [{"media_ids": [m.strip() for m in media.split(",")]}]}
550
+ }
551
+
552
+ if share_flag is True:
553
+ payload["share"] = True
554
+ elif share_flag is False:
555
+ payload["share"] = False
556
+
557
+ if scratchpad is not None:
558
+ payload["scratchpad_text"] = scratchpad
559
+ if auto_retweet is True:
560
+ payload["auto_retweet_enabled"] = True
561
+ elif auto_retweet is False:
562
+ payload["auto_retweet_enabled"] = False
563
+ if auto_plug is True:
564
+ payload["auto_plug_enabled"] = True
565
+ elif auto_plug is False:
566
+ payload["auto_plug_enabled"] = False
567
+
568
+ if schedule == "next":
569
+ payload["publish_at"] = "next-free-slot"
570
+ elif schedule == "now":
571
+ payload["publish_at"] = "now"
572
+ elif schedule:
573
+ payload["publish_at"] = schedule
574
+
575
+ if tags:
576
+ payload["tags"] = list(tags)
577
+ if title:
578
+ payload["draft_title"] = title
579
+
580
+ console.status(f"Updating draft {draft_id}...")
581
+ data = client.update_draft(ssid, draft_id, payload)
582
+ _output(data, use_text, fmt.print_draft_short)
583
+
584
+
585
+ # --- delete ---
586
+
587
+
588
+ @cli.command()
589
+ @click.argument("ids", nargs=-1, required=True)
590
+ @shared_options
591
+ @error_handler
592
+ def delete(ids, api_key, account, use_text, quiet):
593
+ """Delete one or more drafts."""
594
+ client, console, cfg = _get_client_and_console(api_key, quiet)
595
+ with client:
596
+ ssid = _resolve_account_id(client, account, cfg)
597
+ deleted: list[str] = []
598
+ errors: list[dict] = []
599
+ for i, draft_id in enumerate(ids):
600
+ if i > 0:
601
+ client.rate_delay()
602
+ console.status(f"Deleting {draft_id}...")
603
+ try:
604
+ client.delete_draft(ssid, draft_id)
605
+ deleted.append(draft_id)
606
+ except TypefullyError as e:
607
+ msg = e.user_message if isinstance(e, APIError) else str(e)
608
+ errors.append({"id": draft_id, "message": msg})
609
+
610
+ result = {"deleted": deleted, "errors": errors}
611
+ _handle_multi_result(
612
+ result, errors, deleted, len(ids),
613
+ use_text, fmt.print_delete_result, console,
614
+ )
615
+
616
+
617
+ # --- drafts ---
618
+
619
+
620
+ @cli.command()
621
+ @click.option("--status", "draft_status", default=None, help="Filter: draft|published|scheduled|error")
622
+ @click.option("-n", "--limit", default=10, type=int)
623
+ @click.option("--offset", default=0, type=int)
624
+ @click.option("--order", default="-updated_at")
625
+ @click.option("--tag", default=None, help="Filter by tag slug")
626
+ @click.option("--content-filter", default=None, help="Filter: original|repost")
627
+ @shared_options
628
+ @error_handler
629
+ def drafts(draft_status, limit, offset, order, tag, content_filter, api_key, account, use_text, quiet):
630
+ """List drafts with optional filters."""
631
+ _validate_limit(limit)
632
+ client, console, cfg = _get_client_and_console(api_key, quiet)
633
+ with client:
634
+ ssid = _resolve_account_id(client, account, cfg)
635
+ params: dict[str, Any] = {
636
+ "limit": limit,
637
+ "offset": offset,
638
+ "order_by": order,
639
+ }
640
+ if draft_status:
641
+ params["status"] = draft_status
642
+ if tag:
643
+ params["tag"] = tag
644
+ if content_filter:
645
+ params["content_filter"] = content_filter
646
+
647
+ data = client.list_drafts(ssid, **params)
648
+ _output(data, use_text, fmt.print_drafts_list)
649
+
650
+
651
+ # --- tags ---
652
+
653
+
654
+ @cli.command()
655
+ @click.option("-n", "--limit", default=50, type=int)
656
+ @click.option("--offset", default=0, type=int)
657
+ @shared_options
658
+ @error_handler
659
+ def tags(limit, offset, api_key, account, use_text, quiet):
660
+ """List tags."""
661
+ _validate_limit(limit)
662
+ client, console, cfg = _get_client_and_console(api_key, quiet)
663
+ with client:
664
+ ssid = _resolve_account_id(client, account, cfg)
665
+ data = client.list_tags(ssid, limit=limit, offset=offset)
666
+ results = data.get("results", [])
667
+ if use_text:
668
+ fmt.print_tags(results)
669
+ else:
670
+ write_success(results)
671
+
672
+
673
+ # --- tag-create ---
674
+
675
+
676
+ @cli.command("tag-create")
677
+ @click.argument("name")
678
+ @shared_options
679
+ @error_handler
680
+ def tag_create(name, api_key, account, use_text, quiet):
681
+ """Create a tag."""
682
+ client, console, cfg = _get_client_and_console(api_key, quiet)
683
+ with client:
684
+ ssid = _resolve_account_id(client, account, cfg)
685
+ data = client.create_tag(ssid, name)
686
+ if use_text:
687
+ fmt.print_tags([data])
688
+ else:
689
+ write_success(data)
690
+
691
+
692
+ # --- upload ---
693
+
694
+
695
+ @cli.command()
696
+ @click.argument("file_path", type=click.Path(exists=True))
697
+ @shared_options
698
+ @error_handler
699
+ def upload(file_path, api_key, account, use_text, quiet):
700
+ """Upload a media file and return its media ID."""
701
+ client, console, cfg = _get_client_and_console(api_key, quiet)
702
+ with client:
703
+ ssid = _resolve_account_id(client, account, cfg)
704
+ data = upload_media(client, ssid, file_path, console)
705
+ _output(data, use_text, fmt.print_upload_result)
706
+
707
+
708
+ # --- batch ---
709
+
710
+
711
+ @cli.command()
712
+ @click.argument("file_path", type=click.Path(exists=True))
713
+ @click.option("--schedule", default=None, help="Default schedule for all drafts")
714
+ @click.option("--tag", "tags", multiple=True, help="Default tag (repeatable)")
715
+ @click.option("--share/--no-share", default=False)
716
+ @click.option("--dry-run", is_flag=True)
717
+ @click.option("--output", "output_file", default=None, type=click.Path(), help="JSON results log")
718
+ @shared_options
719
+ @error_handler
720
+ def batch(file_path, schedule, tags, share, dry_run, output_file, api_key, account, use_text, quiet):
721
+ """Batch create drafts from a text file.
722
+
723
+ File format: drafts separated by --- on its own line.
724
+ Thread posts separated by === on its own line.
725
+ Optional metadata at top of each block: schedule:, tag:, title:, scratchpad:, media:
726
+
727
+ Example:
728
+
729
+ tag: launch
730
+ schedule: next
731
+ First tweet in a thread
732
+ ===
733
+ Second tweet
734
+ ---
735
+ A standalone tweet
736
+ """
737
+ with open(file_path, encoding="utf-8") as f:
738
+ content = f.read()
739
+
740
+ entries = parse_batch_file(content)
741
+ merge_defaults(entries, default_schedule=schedule, default_tags=tags)
742
+
743
+ if dry_run:
744
+ result = {"dry_run": True, "entries": [e.to_dict() for e in entries]}
745
+ if use_text:
746
+ fmt.print_batch_dry_run([e.to_dict() for e in entries])
747
+ else:
748
+ write_success(result)
749
+ return
750
+
751
+ client, console, cfg = _get_client_and_console(api_key, quiet)
752
+ with client:
753
+ ssid = _resolve_account_id(client, account, cfg)
754
+
755
+ # Tag preflight
756
+ all_tags = set()
757
+ for e in entries:
758
+ all_tags.update(e.tags)
759
+ if all_tags:
760
+ for w in client.ensure_tags(ssid, list(all_tags)):
761
+ console.warning(w)
762
+
763
+ created: list[dict] = []
764
+ errors: list[dict] = []
765
+
766
+ for i, entry in enumerate(entries):
767
+ if i > 0:
768
+ client.rate_delay()
769
+
770
+ payload = _build_draft_payload(
771
+ posts_text=entry.posts,
772
+ schedule=entry.schedule,
773
+ tags=entry.tags,
774
+ title=entry.title,
775
+ media=",".join(entry.media_ids) if entry.media_ids else None,
776
+ share=share,
777
+ scratchpad=entry.scratchpad,
778
+ )
779
+
780
+ label = "thread" if len(entry.posts) > 1 else "single"
781
+ console.status(f"[{i+1}/{len(entries)}] Creating {label}...")
782
+
783
+ try:
784
+ data = client.create_draft(ssid, payload)
785
+ created.append({
786
+ "id": data.get("id"),
787
+ "type": label,
788
+ "posts": len(entry.posts),
789
+ "share_url": data.get("share_url", ""),
790
+ })
791
+ except TypefullyError as e:
792
+ msg = e.user_message if isinstance(e, APIError) else str(e)
793
+ errors.append({
794
+ "index": i,
795
+ "message": msg,
796
+ "preview": entry.posts[0][:50] if entry.posts else "",
797
+ })
798
+
799
+ result = {"total": len(entries), "created": created, "errors": errors}
800
+
801
+ if output_file:
802
+ with open(output_file, "w") as f:
803
+ json.dump(result, f, indent=2)
804
+ console.status(f"Results written to {output_file}")
805
+
806
+ _handle_multi_result(
807
+ result, errors, created, len(entries),
808
+ use_text, fmt.print_batch_result, console,
809
+ )
810
+
811
+
812
+ # --- Payload builder ---
813
+
814
+
815
+ def _build_draft_payload(
816
+ posts_text: list[str],
817
+ schedule: str | None = None,
818
+ tags: list[str] | None = None,
819
+ title: str | None = None,
820
+ media: str | None = None,
821
+ share: bool = False,
822
+ reply_to: str | None = None,
823
+ scratchpad: str | None = None,
824
+ threadify: bool = False,
825
+ auto_retweet: bool | None = None,
826
+ auto_plug: bool | None = None,
827
+ linkedin: bool = False,
828
+ threads_flag: bool = False,
829
+ bluesky: bool = False,
830
+ mastodon: bool = False,
831
+ ) -> dict:
832
+ """Build a draft creation payload from CLI args."""
833
+ posts = [{"text": t} for t in posts_text]
834
+
835
+ if media:
836
+ posts[0]["media_ids"] = [m.strip() for m in media.split(",")]
837
+
838
+ x_platform: dict[str, Any] = {"enabled": True, "posts": posts}
839
+ if reply_to:
840
+ x_platform["settings"] = {"reply_to_url": reply_to}
841
+
842
+ platforms: dict[str, Any] = {"x": x_platform}
843
+ for flag, name in [(linkedin, "linkedin"), (threads_flag, "threads"), (bluesky, "bluesky"), (mastodon, "mastodon")]:
844
+ if flag:
845
+ platforms[name] = {"enabled": True, "posts": posts}
846
+
847
+ payload: dict[str, Any] = {"platforms": platforms}
848
+
849
+ if threadify:
850
+ payload["threadify"] = True
851
+ if share:
852
+ payload["share"] = True
853
+ if scratchpad:
854
+ payload["scratchpad_text"] = scratchpad
855
+ if auto_retweet is True:
856
+ payload["auto_retweet_enabled"] = True
857
+ elif auto_retweet is False:
858
+ payload["auto_retweet_enabled"] = False
859
+ if auto_plug is True:
860
+ payload["auto_plug_enabled"] = True
861
+ elif auto_plug is False:
862
+ payload["auto_plug_enabled"] = False
863
+
864
+ if schedule == "next":
865
+ payload["publish_at"] = "next-free-slot"
866
+ elif schedule == "now":
867
+ payload["publish_at"] = "now"
868
+ elif schedule:
869
+ payload["publish_at"] = schedule
870
+
871
+ if tags:
872
+ payload["tags"] = tags
873
+ if title:
874
+ payload["draft_title"] = title
875
+
876
+ return payload