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.
@@ -0,0 +1,188 @@
1
+ """Output formatting: JSON envelope (default) and Rich human-readable (--text)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console as RichConsole
6
+ from rich.markup import escape as _esc
7
+ from rich.table import Table
8
+
9
+
10
+ _console = RichConsole()
11
+
12
+
13
+ def print_user(data: dict) -> None:
14
+ _console.print(f" Name: {_esc(data.get('name', '?'))}")
15
+ _console.print(f" Email: {_esc(data.get('email', '?'))}")
16
+ _console.print(f" Key: {_esc(data.get('api_key_label', '?'))}")
17
+
18
+
19
+ def print_accounts(results: list[dict]) -> None:
20
+ table = Table(title="Social Sets")
21
+ table.add_column("Username")
22
+ table.add_column("ID", style="dim")
23
+ table.add_column("Name")
24
+ table.add_column("Platforms")
25
+ for s in results:
26
+ platforms = ", ".join(s.get("platforms", {}).keys()) if isinstance(s.get("platforms"), dict) else ""
27
+ table.add_row(
28
+ _esc(s.get("username", "?")),
29
+ str(s.get("id", "?")),
30
+ _esc(s.get("name", "")),
31
+ _esc(platforms),
32
+ )
33
+ _console.print(table)
34
+
35
+
36
+ def print_account_detail(data: dict) -> None:
37
+ _console.print(f" Name: {_esc(data.get('name', '?'))}")
38
+ _console.print(f" ID: {data.get('id', '?')}")
39
+ team = data.get("team")
40
+ if team:
41
+ _console.print(f" Team: {_esc(team.get('name', '?'))} (ID: {team.get('id', '?')})")
42
+ platforms = data.get("platforms", {})
43
+ if platforms:
44
+ _console.print("\n Connected platforms:")
45
+ for name, info in platforms.items():
46
+ if not info or not isinstance(info, dict):
47
+ continue
48
+ username = info.get("username", "?")
49
+ profile = info.get("profile_url", "")
50
+ _console.print(f" \\[{_esc(name.upper())}] @{_esc(username)}")
51
+ if profile:
52
+ _console.print(f" {profile}")
53
+
54
+
55
+ def print_draft(data: dict) -> None:
56
+ _console.print(f" ID: {data.get('id', '?')}")
57
+ _console.print(f" Status: {data.get('status', '?')}")
58
+ for key in ("created_at", "updated_at", "draft_title", "scheduled_date", "published_at"):
59
+ val = data.get(key, "")
60
+ if val:
61
+ label = key.replace("_", " ").title()
62
+ _console.print(f" {label}: {_esc(str(val)[:19])}")
63
+ tags = data.get("tags", [])
64
+ if tags:
65
+ tag_str = ", ".join(
66
+ t.get("name", t.get("slug", "?")) if isinstance(t, dict) else str(t)
67
+ for t in tags
68
+ )
69
+ _console.print(f" Tags: {_esc(tag_str)}")
70
+ for url_key in ("share_url", "private_url", "x_published_url", "linkedin_published_url"):
71
+ val = data.get(url_key, "")
72
+ if val:
73
+ label = url_key.replace("_", " ").title()
74
+ _console.print(f" {label}: {val}")
75
+ scratchpad = data.get("scratchpad_text", "")
76
+ if scratchpad:
77
+ _console.print(f" Scratchpad: {_esc(scratchpad)}")
78
+ # Posts per platform
79
+ for platform, pdata in data.get("platforms", {}).items():
80
+ if not isinstance(pdata, dict) or not pdata.get("enabled"):
81
+ continue
82
+ posts = pdata.get("posts", [])
83
+ _console.print(f"\n \\[{_esc(platform.upper())}] ({len(posts)} post{'s' if len(posts) != 1 else ''})")
84
+ for i, p in enumerate(posts):
85
+ if i > 0:
86
+ _console.print(" ---")
87
+ _console.print(f" {_esc(p.get('text', '(no text)'))}")
88
+
89
+
90
+ def print_draft_short(data: dict) -> None:
91
+ _console.print(f" ID: {data.get('id', '?')}")
92
+ _console.print(f" Status: {data.get('status', '?')}")
93
+ share_url = data.get("share_url", "")
94
+ if share_url:
95
+ _console.print(f" Share: {share_url}")
96
+ private_url = data.get("private_url", "")
97
+ if private_url:
98
+ _console.print(f" Edit: {private_url}")
99
+
100
+
101
+ def print_drafts_list(data: dict) -> None:
102
+ count = data.get("count", "?")
103
+ results = data.get("results", [])
104
+ if not results:
105
+ _console.print(" (none)")
106
+ return
107
+ _console.print(f" Showing {len(results)} of {count} total\n")
108
+ for d in results:
109
+ status = d.get("status", "?")
110
+ # Get text from platforms or preview
111
+ text = ""
112
+ platforms = d.get("platforms", {})
113
+ if isinstance(platforms, dict) and "x" in platforms:
114
+ posts = platforms.get("x", {}).get("posts", [])
115
+ if posts:
116
+ text = posts[0].get("text", "")
117
+ if not text:
118
+ text = d.get("preview", "(no text)")
119
+ preview = text[:80] + "..." if len(text) > 80 else text
120
+ draft_id = d.get("id", "?")
121
+ tags = d.get("tags", [])
122
+ tag_str = ""
123
+ if tags:
124
+ tag_str = " " + " ".join(
125
+ f"#{t['name'] if isinstance(t, dict) else t}" for t in tags
126
+ )
127
+ _console.print(f" \\[{_esc(status)}] {_esc(preview)} (ID: {draft_id}){_esc(tag_str)}")
128
+
129
+
130
+ def print_recent(posts: list[dict]) -> None:
131
+ if not posts:
132
+ _console.print(" (none)")
133
+ return
134
+ for d in posts:
135
+ pub = d.get("published_at", "?")[:10]
136
+ draft_id = d.get("id", "?")
137
+ text = ""
138
+ platforms = d.get("platforms", {})
139
+ if isinstance(platforms, dict) and "x" in platforms:
140
+ pp = platforms.get("x", {}).get("posts", [])
141
+ if pp:
142
+ text = "\n ---\n".join(p.get("text", "") for p in pp)
143
+ if not text:
144
+ text = d.get("preview", "(no text)")
145
+ x_url = d.get("x_published_url", "")
146
+ _console.print(f"--- \\[{_esc(pub)}] (ID: {draft_id}) ---")
147
+ _console.print(f" {_esc(text)}")
148
+ if x_url:
149
+ _console.print(f" -> {x_url}")
150
+ _console.print()
151
+
152
+
153
+ def print_tags(results: list[dict]) -> None:
154
+ if not results:
155
+ _console.print(" (none)")
156
+ return
157
+ for t in results:
158
+ _console.print(f" {_esc(t.get('name', '?'))} (slug: {_esc(t.get('slug', '?'))})")
159
+
160
+
161
+ def print_batch_dry_run(entries: list[dict]) -> None:
162
+ for i, e in enumerate(entries):
163
+ n = len(e.get("posts", []))
164
+ label = "thread" if n > 1 else "single"
165
+ preview = _esc(e["posts"][0][:60]) if e.get("posts") else "(empty)"
166
+ sched = e.get("schedule") or "(no schedule)"
167
+ tags = ", ".join(e.get("tags", [])) or "(no tags)"
168
+ _console.print(f" \\[{i+1}] {label} ({n} post{'s' if n > 1 else ''}) | {_esc(sched)} | {_esc(tags)}")
169
+ _console.print(f" {preview}")
170
+ _console.print()
171
+
172
+
173
+ def print_batch_result(result: dict) -> None:
174
+ total = result.get("total", 0)
175
+ created = result.get("created", [])
176
+ errors = result.get("errors", [])
177
+ _console.print(f"\n Done: {len(created)} created, {len(errors)} errors (of {total} total)")
178
+
179
+
180
+ def print_delete_result(result: dict) -> None:
181
+ deleted = result.get("deleted", [])
182
+ errors = result.get("errors", [])
183
+ _console.print(f" Deleted: {len(deleted)}, Errors: {len(errors)}")
184
+
185
+
186
+ def print_upload_result(data: dict) -> None:
187
+ _console.print(f" Media ID: {data.get('media_id', '?')}")
188
+ _console.print(f" Status: {data.get('status', '?')}")
@@ -0,0 +1,263 @@
1
+ Metadata-Version: 2.4
2
+ Name: typefully-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Typefully API -- manage drafts, threads, and publishing from the terminal
5
+ Project-URL: Homepage, https://github.com/nicedevelopers/typefully-cli
6
+ Project-URL: Repository, https://github.com/nicedevelopers/typefully-cli
7
+ Project-URL: Issues, https://github.com/nicedevelopers/typefully-cli/issues
8
+ Project-URL: Changelog, https://github.com/nicedevelopers/typefully-cli/blob/main/CHANGELOG.md
9
+ Author: Dheeraj Shah
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: cli,social-media,twitter,typefully,x
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Utilities
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: click>=8.0
27
+ Requires-Dist: httpx>=0.24
28
+ Requires-Dist: rich>=13.0
29
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
32
+ Requires-Dist: pytest>=7.0; extra == 'dev'
33
+ Requires-Dist: ruff; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # typefully-cli
37
+
38
+ Manage [Typefully](https://typefully.com) drafts, threads, and publishing from your terminal. Built for AI agents. Works great for humans too.
39
+
40
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://github.com/dheeraj-shah/typefully-cli)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
42
+
43
+ ## Why
44
+
45
+ - **Automate publishing** -- schedule and publish from CI/CD pipelines, cron jobs, or scripts
46
+ - **Batch create** -- turn a text file into 50 drafts with one command
47
+ - **Agent-native** -- JSON output, structured errors, deterministic exit codes. Designed for Claude Code, LLM agents, and automation
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pipx install typefully-cli # recommended
53
+ uv tool install typefully-cli # alternative
54
+ pip install typefully-cli # fallback
55
+ ```
56
+
57
+ ## Quick Start
58
+
59
+ ```bash
60
+ # First-time setup (interactive)
61
+ typefully config init
62
+
63
+ # Or set your API key directly (get it from typefully.com/settings/api)
64
+ typefully config set api_key tf_your_key_here
65
+
66
+ # Check auth
67
+ typefully me
68
+
69
+ # Create a draft
70
+ typefully draft "Hello world" --schedule next
71
+
72
+ # Create a thread
73
+ typefully thread "First post" "Second post" --tag launch
74
+
75
+ # Batch create from file
76
+ typefully batch posts.txt --schedule next --tag campaign
77
+
78
+ # Upload media and attach it
79
+ typefully upload photo.jpg
80
+ typefully draft "Check this out" --media <media_id>
81
+ ```
82
+
83
+ ## For Humans
84
+
85
+ Add `--text` to any command for readable output instead of JSON.
86
+
87
+ ```bash
88
+ typefully drafts --text
89
+ typefully recent --text --since 2025-01-01
90
+ typefully get 12345 --text
91
+ ```
92
+
93
+ **Other handy features:**
94
+
95
+ - `typefully open 12345` -- open a draft in your browser
96
+ - `typefully recent --format csv` -- export published posts to CSV
97
+ - `typefully config init` -- guided first-time setup
98
+
99
+ **Shell completions** -- add to your shell rc file:
100
+
101
+ ```bash
102
+ # Bash
103
+ eval "$(_TYPEFULLY_COMPLETE=bash_source typefully)"
104
+
105
+ # Zsh
106
+ eval "$(_TYPEFULLY_COMPLETE=zsh_source typefully)"
107
+
108
+ # Fish
109
+ _TYPEFULLY_COMPLETE=fish_source typefully | source
110
+ ```
111
+
112
+ ## For Agents
113
+
114
+ Every command writes structured JSON. Success to stdout, errors to stderr. Parse with `jq`, pipe to other tools, or consume directly from an LLM agent.
115
+
116
+ ### Output contract
117
+
118
+ ```bash
119
+ # Success (stdout)
120
+ {"ok": true, "data": ...}
121
+
122
+ # Error (stderr)
123
+ {"ok": false, "error": {"code": "auth_failed", "message": "...", "hint": "..."}}
124
+ ```
125
+
126
+ ### Exit codes
127
+
128
+ | Code | Meaning | When |
129
+ |------|---------|------|
130
+ | `0` | Success | Everything worked |
131
+ | `1` | Input/auth error | Bad credentials, missing account, invalid input |
132
+ | `2` | API error | Upstream Typefully API failure |
133
+ | `3` | Partial failure | Some items in a batch/delete succeeded, others failed |
134
+
135
+ ### Error codes
136
+
137
+ | Code | Trigger |
138
+ |------|---------|
139
+ | `auth_failed` | No API key found in flag, env, config, or 1Password |
140
+ | `no_account` | No account specified or account not found |
141
+ | `api_error` | Non-2xx response from Typefully API |
142
+ | `invalid_input` | Bad date format, limit out of range, single-post thread |
143
+ | `batch_parse_error` | Malformed batch file |
144
+ | `media_upload_error` | Upload failed or unsupported format |
145
+
146
+ ### Partial failure (batch/delete)
147
+
148
+ When some operations succeed and some fail (exit 3):
149
+ - **stdout**: success data with `deleted`/`created` array + `errors` array
150
+ - **stderr**: `partial_failure` error envelope
151
+
152
+ When all fail (exit 2): nothing on stdout, `all_failed` on stderr.
153
+
154
+ ### Common patterns
155
+
156
+ ```bash
157
+ # Suppress status messages, keep errors
158
+ typefully accounts --quiet 2>/dev/null
159
+
160
+ # Extract draft IDs
161
+ typefully drafts | jq '.data.results[].id'
162
+
163
+ # Get error hint on failure
164
+ typefully me 2>/tmp/err.json || jq '.error.hint' /tmp/err.json
165
+
166
+ # Batch create and log results
167
+ typefully batch posts.txt --output results.json
168
+ ```
169
+
170
+ ### Flags for agents
171
+
172
+ - `--quiet` -- suppress stderr status messages (errors still shown)
173
+ - `--api-key KEY` -- pass API key directly (skips config/env lookup)
174
+ - `--account NAME` -- target a specific account (accepts name, username, or numeric ID)
175
+
176
+ ## Auth
177
+
178
+ Resolution order: `--api-key` flag > `TYPEFULLY_API_KEY` env var > config file > 1Password.
179
+
180
+ Get your API key from [Typefully Settings > API](https://typefully.com/settings/api).
181
+
182
+ ```bash
183
+ # Interactive setup
184
+ typefully config init
185
+
186
+ # Or manually
187
+ typefully config set api_key tf_your_key_here
188
+ typefully config set default_account MyBrand
189
+
190
+ # Optional: 1Password fallback (requires op CLI)
191
+ typefully config set onepassword_item "Typefully API"
192
+ ```
193
+
194
+ ## Commands
195
+
196
+ All account-scoped commands accept `--api-key`, `--account`, `--text`, `--quiet`.
197
+
198
+ | Command | Description |
199
+ |---------|-------------|
200
+ | `me` | Current user info |
201
+ | `accounts` | List connected accounts with IDs |
202
+ | `account-detail [NAME]` | Platform details for an account |
203
+ | `draft "text"` | Create a single-post draft |
204
+ | `thread "p1" "p2" ...` | Create a multi-post thread (min 2 posts) |
205
+ | `get ID` | View a draft |
206
+ | `open ID` | Open a draft in the browser |
207
+ | `update ID ["text"]` | Edit a draft (use `===` to separate thread posts) |
208
+ | `delete ID [ID ...]` | Delete one or more drafts |
209
+ | `drafts` | List drafts (`--status`, `--tag`, `--limit 1-50`, `--offset`, `--order`) |
210
+ | `recent` | Published posts (`-n 1-50`, `--since`, `--until`, `--format csv`) |
211
+ | `upload FILE` | Upload media, returns media ID |
212
+ | `tags` | List tags (`--limit 1-50`, `--offset`) |
213
+ | `tag-create "name"` | Create a tag (usually auto-created via `--tag`) |
214
+ | `batch FILE` | Batch create from text file (`--dry-run`, `--output`, `--schedule`, `--tag`) |
215
+ | `config set KEY VALUE` | Set config (api_key, default_account, onepassword_item) |
216
+ | `config init` | Interactive first-time setup |
217
+ | `config show` | Show config (secrets redacted) |
218
+ | `config path` | Print config file path |
219
+
220
+ **Draft options:** `--schedule ISO|next|now`, `--tag SLUG` (repeatable, auto-created), `--title`, `--media ID`, `--share`, `--reply-to URL`, `--scratchpad`, `--qrt URL`, `--threadify`, `--auto-retweet/--no-auto-retweet`, `--auto-plug/--no-auto-plug`, `--linkedin`, `--threads`, `--bluesky`, `--mastodon`
221
+
222
+ Run `typefully COMMAND --help` for full option details.
223
+
224
+ ## Batch File Format
225
+
226
+ Drafts separated by `---`. Thread posts separated by `===`. Optional metadata headers per block.
227
+
228
+ ```
229
+ tag: launch
230
+ schedule: next
231
+ First post of a thread
232
+ ===
233
+ Second post
234
+ ---
235
+ A standalone draft
236
+ ---
237
+ tag: announcement
238
+ schedule: 2026-04-01T10:00:00Z
239
+ Another standalone draft
240
+ ```
241
+
242
+ Supported metadata: `tag`, `schedule`, `title`, `scratchpad`, `media` (comma-separated IDs). See [docs/batch-format.md](docs/batch-format.md) for the full spec.
243
+
244
+ ## Configuration
245
+
246
+ Config file: `~/.config/typefully/config.toml`
247
+
248
+ ```toml
249
+ [auth]
250
+ api_key = "tf_..."
251
+ # onepassword_item = "Typefully API"
252
+
253
+ [defaults]
254
+ account = "MyBrand"
255
+ ```
256
+
257
+ ## Contributing
258
+
259
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
260
+
261
+ ## License
262
+
263
+ MIT
@@ -0,0 +1,16 @@
1
+ typefully_cli/__init__.py,sha256=uxVKJuwcPuay8W6wYSM3QQ6sYtFWKe1K792y6XHDLuI,104
2
+ typefully_cli/__main__.py,sha256=lAv7csx7EYimOu3qalDWwjV3N-BiGv7VoRiHGg9yhb4,92
3
+ typefully_cli/auth.py,sha256=qYsFhtTj6jZfVvcIW3MQgSMcvSaYZnho-S35t7W2FIw,1567
4
+ typefully_cli/batch.py,sha256=9NR14tSYFz2CrOl75AFE7bX1y1eMu9Ll5iq74qs3Gkk,3846
5
+ typefully_cli/cli.py,sha256=7cL4ChM3YTlwzf7A5uphU6hy8wgWbP274DWoubtoY3k,28418
6
+ typefully_cli/client.py,sha256=yC6JYEmLhnH5_yCbgbN_NblMzT1Yh7j83GVy4N7aG1k,8828
7
+ typefully_cli/config.py,sha256=GBzhwhm1zSOgTg02nbafYJ2tNhKp3G7RaZXDX5bNW_k,3305
8
+ typefully_cli/console.py,sha256=0rfo9SJGeNWX01bf5sPEDTrWfkwCXELJOsIswSbn_jk,1768
9
+ typefully_cli/exceptions.py,sha256=JeXcqF4YBsfF5zG8h7P_3MP-2kYzXDG_VF3W4SIUwtE,2161
10
+ typefully_cli/media.py,sha256=7MXWxK_tpEJ9dOwJ30qls8CbPkzn-zAxCAPpN4-VPNo,2520
11
+ typefully_cli/output.py,sha256=CnSrv5mA4veh_-_dkcPQsaf_qNhKoOKPY5LuqjVKwtE,7027
12
+ typefully_cli-0.1.0.dist-info/METADATA,sha256=NoEEBuoAdbX5lwXKwiGJGY9peb3yR0fPQsNxJSqQ5kg,8259
13
+ typefully_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ typefully_cli-0.1.0.dist-info/entry_points.txt,sha256=j8dzA0OIGtgsNYcFTxZMtSTJgeQ3GzhMDMYChmXcEj4,52
15
+ typefully_cli-0.1.0.dist-info/licenses/LICENSE,sha256=t7C9PE4W_rix38nAz5ECap62B2d04YXw3V8tMf91UaI,1069
16
+ typefully_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ typefully = typefully_cli.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dheeraj Shah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.