xr-cli 0.1.0__tar.gz

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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.db
xr_cli-0.1.0/CLAUDE.md ADDED
@@ -0,0 +1,94 @@
1
+ # xr — Development Guide
2
+
3
+ ## What this is
4
+
5
+ `xr` is a CLI tool for researching people and topics on X (Twitter) via the v2 API. Python package, pip-installable as `xr-cli`.
6
+
7
+ ## Project structure
8
+
9
+ ```
10
+ src/xr/
11
+ __init__.py # version
12
+ cli.py # click entry point, wires all commands
13
+ auth.py # credential loading (TOML, env, legacy) + bearer token
14
+ config.py # TOML config with XDG paths + env overrides
15
+ api.py # HTTP client with rate limit retry
16
+ cache.py # SQLite cache with TTL per resource type
17
+ models.py # dataclasses: Tweet, User, SearchResult, CountResult
18
+ commands/
19
+ tweet.py # single tweet fetch + URL parsing
20
+ thread.py # conversation reconstruction via search
21
+ search.py # recent tweet search with caching
22
+ user.py # profile lookup
23
+ timeline.py # user timeline with filters
24
+ mentions.py # user mentions
25
+ followers.py # followers/following lists
26
+ counts.py # tweet volume over time
27
+ formatters/
28
+ markdown.py # markdown with YAML frontmatter
29
+ json_fmt.py # JSON output
30
+ tests/
31
+ conftest.py # shared fixtures (sample API responses)
32
+ test_models.py
33
+ test_config.py
34
+ test_auth.py
35
+ test_api.py
36
+ test_cache.py
37
+ test_formatters.py
38
+ test_commands.py
39
+ test_cli.py
40
+ ```
41
+
42
+ ## Key patterns
43
+
44
+ - **Commands expose `fetch_*` functions** — business logic separated from CLI wiring. `cli.py` imports and calls them.
45
+ - **Cache-first**: every fetch function checks cache before hitting the API. Cache writes happen even with `--no-cache` (only reads are bypassed).
46
+ - **Models parse API responses** via `from_api()` classmethods. Raw JSON stored in cache, models built at read time.
47
+ - **Formatters are pure functions** — take model objects, return strings. No side effects.
48
+ - **Rate limit retry** in `api.py` — auto-waits on 429, up to 3 attempts.
49
+
50
+ ## Running tests
51
+
52
+ ```bash
53
+ python3 -m pytest tests/ -v
54
+ ```
55
+
56
+ All tests use mocks for API calls. No live API access needed for tests.
57
+
58
+ ## Auth
59
+
60
+ Three credential sources, checked in order:
61
+ 1. Environment: `XR_CONSUMER_KEY` / `XR_CONSUMER_SECRET`
62
+ 2. TOML: `~/.config/xr/credentials.toml`
63
+ 3. Legacy: `~/charlie/.env.x-api` (Charlie-specific, backward compat)
64
+
65
+ Bearer token is generated fresh each session via OAuth 2.0 client_credentials flow.
66
+
67
+ ## X API constraints
68
+
69
+ - **Basic tier**: ~15,000 reads/month, 450 requests/15min per endpoint
70
+ - **Search window**: 7 days only (recent search, not full archive)
71
+ - **max_results**: minimum 10, maximum 100 per request
72
+ - **Liked tweets endpoint**: requires User Context OAuth (not supported, app-only auth only)
73
+
74
+ ## Adding a new command
75
+
76
+ 1. Create `src/xr/commands/newcmd.py` with a `fetch_*` function
77
+ 2. Add click command in `cli.py` that calls it
78
+ 3. Add formatter function in `formatters/markdown.py` if needed
79
+ 4. Add cache methods in `cache.py` if caching a new resource type
80
+ 5. Write tests in `tests/test_commands.py`
81
+
82
+ ## Dependencies
83
+
84
+ Minimal on purpose: `click`, `requests`, and stdlib (`tomllib`, `sqlite3`, `json`). No async, no ORM, no heavy frameworks.
85
+
86
+ ## Build and publish
87
+
88
+ ```bash
89
+ pip install build twine
90
+ python3 -m build
91
+ twine upload dist/*
92
+ ```
93
+
94
+ PyPI package name: `xr-cli`
xr_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hernan Carlos Caravario
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.
xr_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: xr-cli
3
+ Version: 0.1.0
4
+ Summary: X (Twitter) research CLI — search, profile, timeline, cache
5
+ Project-URL: Repository, https://github.com/hernan-cc/xr
6
+ Author-email: Hernan Carlos Caravario <hernan@hernancc.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: api,cli,research,twitter,x
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Internet
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: click>=8.0
19
+ Requires-Dist: requests>=2.28
20
+ Description-Content-Type: text/markdown
21
+
22
+ # xr — X Research CLI
23
+
24
+ Research people and topics on X (Twitter) from the command line.
25
+
26
+ `xr` uses the X API v2 to search tweets, look up profiles, pull timelines, and track volume — all with SQLite caching so you don't burn through your API quota.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install xr-cli
32
+ ```
33
+
34
+ Or from source:
35
+
36
+ ```bash
37
+ git clone https://github.com/hernan-cc/xr.git
38
+ cd xr
39
+ pip install -e .
40
+ ```
41
+
42
+ ## Setup
43
+
44
+ You need X API credentials (Basic tier or higher). Get them at [developer.x.com](https://developer.x.com/en/portal/dashboard).
45
+
46
+ ```bash
47
+ xr auth setup
48
+ ```
49
+
50
+ This saves your Consumer Key and Secret to `~/.config/xr/credentials.toml` (mode 600).
51
+
52
+ You can also use environment variables:
53
+
54
+ ```bash
55
+ export XR_CONSUMER_KEY="your-key"
56
+ export XR_CONSUMER_SECRET="your-secret"
57
+ ```
58
+
59
+ ## Commands
60
+
61
+ ### Search tweets
62
+
63
+ ```bash
64
+ xr search "AI regulation" --top --max 20
65
+ xr search "from:elonmusk has:links" --max 50
66
+ xr search "startup funding" --lang en --no-rt --top
67
+ ```
68
+
69
+ Supports all 47 X search operators. `--top` sorts by relevancy, default is recency. Search window is 7 days (API limitation).
70
+
71
+ ### User profile
72
+
73
+ ```bash
74
+ xr user elonmusk
75
+ xr user @naval
76
+ ```
77
+
78
+ ### Timeline
79
+
80
+ ```bash
81
+ xr timeline paulg --top --no-rt --no-replies --max 20
82
+ ```
83
+
84
+ `--top` sorts by likes. `--no-rt` excludes retweets. `--no-replies` excludes replies.
85
+
86
+ ### Single tweet
87
+
88
+ ```bash
89
+ xr tweet https://x.com/user/status/1234567890
90
+ xr tweet 1234567890
91
+ ```
92
+
93
+ Accepts URLs or bare IDs.
94
+
95
+ ### Thread
96
+
97
+ ```bash
98
+ xr thread https://x.com/user/status/1234567890
99
+ xr thread 1234567890 --author-only
100
+ ```
101
+
102
+ Reconstructs the full conversation. `--author-only` filters to just the thread author's tweets.
103
+
104
+ ### Mentions
105
+
106
+ ```bash
107
+ xr mentions paulg --max 20
108
+ ```
109
+
110
+ ### Followers / Following
111
+
112
+ ```bash
113
+ xr followers naval --max 100
114
+ xr following naval --max 100
115
+ ```
116
+
117
+ ### Tweet volume
118
+
119
+ ```bash
120
+ xr counts "bitcoin" --granularity day
121
+ xr counts "AI agents" --granularity hour
122
+ ```
123
+
124
+ Shows tweet volume over time. Useful for spotting trends.
125
+
126
+ ## Global flags
127
+
128
+ | Flag | Description |
129
+ |------|-------------|
130
+ | `--pretty` | Output raw JSON instead of markdown |
131
+ | `--save` | Save output to `~/.local/share/xr/` (or `XR_SAVE_DIR`) |
132
+ | `--no-cache` | Bypass SQLite cache, force fresh API call |
133
+
134
+ ## Output
135
+
136
+ Default output is markdown with YAML frontmatter:
137
+
138
+ ```
139
+ ---
140
+ type: x-user
141
+ username: naval
142
+ user_id: "745273"
143
+ date: 2026-02-21
144
+ source: xr v0.1.0
145
+ ---
146
+
147
+ # @naval (Naval)
148
+
149
+ **Bio**: Angel investor, podcaster, ...
150
+ **Joined**: 2007-02-05
151
+ **Followers**: 2,100,000 | **Following**: 1,200
152
+ **Tweets**: 15,000 | **Likes**: 24,000
153
+ **URL**: https://x.com/naval
154
+ ```
155
+
156
+ Use `--pretty` for JSON output (useful for piping to `jq`).
157
+
158
+ ## Cache
159
+
160
+ API responses are cached in SQLite at `~/.cache/xr/cache.db` with sensible TTLs:
161
+
162
+ | Resource | TTL |
163
+ |----------|-----|
164
+ | Tweets | 7 days |
165
+ | Users | 24 hours |
166
+ | Searches | 1 hour |
167
+ | Counts | 1 hour |
168
+
169
+ Use `--no-cache` to force a fresh API call (still writes to cache).
170
+
171
+ ## Configuration
172
+
173
+ Optional config at `~/.config/xr/config.toml`:
174
+
175
+ ```toml
176
+ [output]
177
+ save_dir = "~/.local/share/xr"
178
+ default_format = "markdown"
179
+
180
+ [cache]
181
+ enabled = true
182
+ ttl_tweets = 604800
183
+ ttl_users = 86400
184
+ ttl_searches = 3600
185
+ ttl_counts = 3600
186
+ max_size_mb = 50
187
+
188
+ [search]
189
+ default_lang = ""
190
+ default_max = 20
191
+ ```
192
+
193
+ ## API tier
194
+
195
+ `xr` works with the X API v2 Basic tier ($200/month, ~15,000 reads/month). The cache helps you stay well under budget for research workflows.
196
+
197
+ Rate limits are handled automatically — on HTTP 429, `xr` waits for the reset window and retries (up to 3 times).
198
+
199
+ ## Dependencies
200
+
201
+ - `click` — CLI framework
202
+ - `requests` — HTTP client
203
+ - Python 3.11+ (uses `tomllib` from stdlib)
204
+
205
+ No heavy dependencies. Fast install.
206
+
207
+ ## License
208
+
209
+ MIT
xr_cli-0.1.0/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # xr — X Research CLI
2
+
3
+ Research people and topics on X (Twitter) from the command line.
4
+
5
+ `xr` uses the X API v2 to search tweets, look up profiles, pull timelines, and track volume — all with SQLite caching so you don't burn through your API quota.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install xr-cli
11
+ ```
12
+
13
+ Or from source:
14
+
15
+ ```bash
16
+ git clone https://github.com/hernan-cc/xr.git
17
+ cd xr
18
+ pip install -e .
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ You need X API credentials (Basic tier or higher). Get them at [developer.x.com](https://developer.x.com/en/portal/dashboard).
24
+
25
+ ```bash
26
+ xr auth setup
27
+ ```
28
+
29
+ This saves your Consumer Key and Secret to `~/.config/xr/credentials.toml` (mode 600).
30
+
31
+ You can also use environment variables:
32
+
33
+ ```bash
34
+ export XR_CONSUMER_KEY="your-key"
35
+ export XR_CONSUMER_SECRET="your-secret"
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ ### Search tweets
41
+
42
+ ```bash
43
+ xr search "AI regulation" --top --max 20
44
+ xr search "from:elonmusk has:links" --max 50
45
+ xr search "startup funding" --lang en --no-rt --top
46
+ ```
47
+
48
+ Supports all 47 X search operators. `--top` sorts by relevancy, default is recency. Search window is 7 days (API limitation).
49
+
50
+ ### User profile
51
+
52
+ ```bash
53
+ xr user elonmusk
54
+ xr user @naval
55
+ ```
56
+
57
+ ### Timeline
58
+
59
+ ```bash
60
+ xr timeline paulg --top --no-rt --no-replies --max 20
61
+ ```
62
+
63
+ `--top` sorts by likes. `--no-rt` excludes retweets. `--no-replies` excludes replies.
64
+
65
+ ### Single tweet
66
+
67
+ ```bash
68
+ xr tweet https://x.com/user/status/1234567890
69
+ xr tweet 1234567890
70
+ ```
71
+
72
+ Accepts URLs or bare IDs.
73
+
74
+ ### Thread
75
+
76
+ ```bash
77
+ xr thread https://x.com/user/status/1234567890
78
+ xr thread 1234567890 --author-only
79
+ ```
80
+
81
+ Reconstructs the full conversation. `--author-only` filters to just the thread author's tweets.
82
+
83
+ ### Mentions
84
+
85
+ ```bash
86
+ xr mentions paulg --max 20
87
+ ```
88
+
89
+ ### Followers / Following
90
+
91
+ ```bash
92
+ xr followers naval --max 100
93
+ xr following naval --max 100
94
+ ```
95
+
96
+ ### Tweet volume
97
+
98
+ ```bash
99
+ xr counts "bitcoin" --granularity day
100
+ xr counts "AI agents" --granularity hour
101
+ ```
102
+
103
+ Shows tweet volume over time. Useful for spotting trends.
104
+
105
+ ## Global flags
106
+
107
+ | Flag | Description |
108
+ |------|-------------|
109
+ | `--pretty` | Output raw JSON instead of markdown |
110
+ | `--save` | Save output to `~/.local/share/xr/` (or `XR_SAVE_DIR`) |
111
+ | `--no-cache` | Bypass SQLite cache, force fresh API call |
112
+
113
+ ## Output
114
+
115
+ Default output is markdown with YAML frontmatter:
116
+
117
+ ```
118
+ ---
119
+ type: x-user
120
+ username: naval
121
+ user_id: "745273"
122
+ date: 2026-02-21
123
+ source: xr v0.1.0
124
+ ---
125
+
126
+ # @naval (Naval)
127
+
128
+ **Bio**: Angel investor, podcaster, ...
129
+ **Joined**: 2007-02-05
130
+ **Followers**: 2,100,000 | **Following**: 1,200
131
+ **Tweets**: 15,000 | **Likes**: 24,000
132
+ **URL**: https://x.com/naval
133
+ ```
134
+
135
+ Use `--pretty` for JSON output (useful for piping to `jq`).
136
+
137
+ ## Cache
138
+
139
+ API responses are cached in SQLite at `~/.cache/xr/cache.db` with sensible TTLs:
140
+
141
+ | Resource | TTL |
142
+ |----------|-----|
143
+ | Tweets | 7 days |
144
+ | Users | 24 hours |
145
+ | Searches | 1 hour |
146
+ | Counts | 1 hour |
147
+
148
+ Use `--no-cache` to force a fresh API call (still writes to cache).
149
+
150
+ ## Configuration
151
+
152
+ Optional config at `~/.config/xr/config.toml`:
153
+
154
+ ```toml
155
+ [output]
156
+ save_dir = "~/.local/share/xr"
157
+ default_format = "markdown"
158
+
159
+ [cache]
160
+ enabled = true
161
+ ttl_tweets = 604800
162
+ ttl_users = 86400
163
+ ttl_searches = 3600
164
+ ttl_counts = 3600
165
+ max_size_mb = 50
166
+
167
+ [search]
168
+ default_lang = ""
169
+ default_max = 20
170
+ ```
171
+
172
+ ## API tier
173
+
174
+ `xr` works with the X API v2 Basic tier ($200/month, ~15,000 reads/month). The cache helps you stay well under budget for research workflows.
175
+
176
+ Rate limits are handled automatically — on HTTP 429, `xr` waits for the reset window and retries (up to 3 times).
177
+
178
+ ## Dependencies
179
+
180
+ - `click` — CLI framework
181
+ - `requests` — HTTP client
182
+ - Python 3.11+ (uses `tomllib` from stdlib)
183
+
184
+ No heavy dependencies. Fast install.
185
+
186
+ ## License
187
+
188
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "xr-cli"
7
+ version = "0.1.0"
8
+ description = "X (Twitter) research CLI — search, profile, timeline, cache"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Hernan Carlos Caravario", email = "hernan@hernancc.com" }
14
+ ]
15
+ keywords = ["twitter", "x", "research", "cli", "api"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Internet",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "requests>=2.28",
28
+ ]
29
+
30
+ [project.scripts]
31
+ xr = "xr.cli:main"
32
+
33
+ [project.urls]
34
+ Repository = "https://github.com/hernan-cc/xr"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/xr"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,2 @@
1
+ """XR — X (Twitter) Research CLI."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,55 @@
1
+ """HTTP client for X API v2 with rate limit handling."""
2
+ from __future__ import annotations
3
+ import sys
4
+ import time
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ API_BASE = "https://api.x.com/2"
10
+ MAX_RETRIES = 3
11
+
12
+ class APIError(Exception):
13
+ def __init__(self, status_code: int, message: str):
14
+ self.status_code = status_code
15
+ super().__init__(f"API error {status_code}: {message}")
16
+
17
+ class RateLimitError(APIError):
18
+ def __init__(self, reset_at: int):
19
+ self.reset_at = reset_at
20
+ super().__init__(429, f"Rate limited. Resets at {reset_at}")
21
+
22
+ class XClient:
23
+ def __init__(self, bearer_token: str):
24
+ self.bearer_token = bearer_token
25
+
26
+ def _url(self, endpoint: str) -> str:
27
+ return f"{API_BASE}/{endpoint}"
28
+
29
+ def _headers(self) -> dict[str, str]:
30
+ return {
31
+ "Authorization": f"Bearer {self.bearer_token}",
32
+ "User-Agent": "xr-cli/0.1.0",
33
+ }
34
+
35
+ def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
36
+ """Make GET request with retry on rate limit."""
37
+ url = self._url(endpoint)
38
+ for attempt in range(MAX_RETRIES):
39
+ resp = requests.get(url, headers=self._headers(), params=params, timeout=15)
40
+
41
+ if resp.ok:
42
+ return resp.json()
43
+
44
+ if resp.status_code == 429:
45
+ reset_at = int(resp.headers.get("x-rate-limit-reset", 0))
46
+ if attempt < MAX_RETRIES - 1:
47
+ wait = max(reset_at - int(time.time()), 1) + 1
48
+ print(f"Rate limited. Waiting {wait}s...", file=sys.stderr)
49
+ time.sleep(wait)
50
+ continue
51
+ raise RateLimitError(reset_at)
52
+
53
+ raise APIError(resp.status_code, resp.text)
54
+
55
+ raise APIError(0, "Max retries exceeded")
@@ -0,0 +1,77 @@
1
+ """Credential loading and bearer token generation."""
2
+ from __future__ import annotations
3
+ import base64
4
+ import os
5
+ import tomllib
6
+ from pathlib import Path
7
+
8
+ import requests
9
+
10
+ class CredentialError(Exception):
11
+ pass
12
+
13
+ def _credentials_path() -> Path:
14
+ xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
15
+ return Path(xdg) / "xr" / "credentials.toml"
16
+
17
+ def _legacy_path() -> Path:
18
+ return Path.home() / "charlie" / ".env.x-api"
19
+
20
+ def load_credentials(
21
+ path: Path | None = None,
22
+ legacy_path: Path | None = None,
23
+ ) -> tuple[str, str]:
24
+ """Load consumer key and secret. Priority: env vars > toml > legacy .env file."""
25
+ # 1. Environment variables
26
+ env_key = os.environ.get("XR_CONSUMER_KEY")
27
+ env_secret = os.environ.get("XR_CONSUMER_SECRET")
28
+ if env_key and env_secret:
29
+ return env_key, env_secret
30
+
31
+ # 2. TOML credentials file
32
+ cred_path = path or _credentials_path()
33
+ if cred_path.exists():
34
+ with open(cred_path, "rb") as f:
35
+ data = tomllib.load(f)
36
+ creds = data.get("credentials", {})
37
+ key = creds.get("consumer_key")
38
+ secret = creds.get("consumer_secret")
39
+ if key and secret:
40
+ return key, secret
41
+
42
+ # 3. Legacy .env.x-api
43
+ lp = legacy_path or _legacy_path()
44
+ if lp.exists():
45
+ kvs = {}
46
+ with open(lp) as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if line and not line.startswith("#") and "=" in line:
50
+ k, v = line.split("=", 1)
51
+ kvs[k.strip()] = v.strip()
52
+ key = kvs.get("X_API_CONSUMER_KEY")
53
+ secret = kvs.get("X_API_CONSUMER_SECRET")
54
+ if key and secret:
55
+ return key, secret
56
+
57
+ raise CredentialError(
58
+ "No X API credentials found. Run 'xr auth setup' or set "
59
+ "XR_CONSUMER_KEY and XR_CONSUMER_SECRET environment variables."
60
+ )
61
+
62
+ def get_bearer_token(consumer_key: str, consumer_secret: str) -> str:
63
+ """Generate OAuth 2.0 Bearer Token from consumer credentials."""
64
+ credentials = f"{consumer_key}:{consumer_secret}"
65
+ b64 = base64.b64encode(credentials.encode()).decode()
66
+ resp = requests.post(
67
+ "https://api.x.com/oauth2/token",
68
+ headers={
69
+ "Authorization": f"Basic {b64}",
70
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
71
+ },
72
+ data="grant_type=client_credentials",
73
+ timeout=10,
74
+ )
75
+ if not resp.ok:
76
+ raise CredentialError(f"Failed to get bearer token: {resp.status_code} {resp.text}")
77
+ return resp.json()["access_token"]