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/__init__.py +3 -0
- typefully_cli/__main__.py +5 -0
- typefully_cli/auth.py +57 -0
- typefully_cli/batch.py +126 -0
- typefully_cli/cli.py +876 -0
- typefully_cli/client.py +267 -0
- typefully_cli/config.py +104 -0
- typefully_cli/console.py +56 -0
- typefully_cli/exceptions.py +80 -0
- typefully_cli/media.py +70 -0
- typefully_cli/output.py +188 -0
- typefully_cli-0.1.0.dist-info/METADATA +263 -0
- typefully_cli-0.1.0.dist-info/RECORD +16 -0
- typefully_cli-0.1.0.dist-info/WHEEL +4 -0
- typefully_cli-0.1.0.dist-info/entry_points.txt +2 -0
- typefully_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|