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.
- xr_cli-0.1.0/.gitignore +7 -0
- xr_cli-0.1.0/CLAUDE.md +94 -0
- xr_cli-0.1.0/LICENSE +21 -0
- xr_cli-0.1.0/PKG-INFO +209 -0
- xr_cli-0.1.0/README.md +188 -0
- xr_cli-0.1.0/pyproject.toml +40 -0
- xr_cli-0.1.0/src/xr/__init__.py +2 -0
- xr_cli-0.1.0/src/xr/api.py +55 -0
- xr_cli-0.1.0/src/xr/auth.py +77 -0
- xr_cli-0.1.0/src/xr/cache.py +158 -0
- xr_cli-0.1.0/src/xr/cli.py +258 -0
- xr_cli-0.1.0/src/xr/commands/__init__.py +1 -0
- xr_cli-0.1.0/src/xr/commands/counts.py +33 -0
- xr_cli-0.1.0/src/xr/commands/followers.py +38 -0
- xr_cli-0.1.0/src/xr/commands/mentions.py +30 -0
- xr_cli-0.1.0/src/xr/commands/search.py +57 -0
- xr_cli-0.1.0/src/xr/commands/thread.py +50 -0
- xr_cli-0.1.0/src/xr/commands/timeline.py +45 -0
- xr_cli-0.1.0/src/xr/commands/tweet.py +35 -0
- xr_cli-0.1.0/src/xr/commands/user.py +19 -0
- xr_cli-0.1.0/src/xr/config.py +86 -0
- xr_cli-0.1.0/src/xr/formatters/__init__.py +1 -0
- xr_cli-0.1.0/src/xr/formatters/json_fmt.py +6 -0
- xr_cli-0.1.0/src/xr/formatters/markdown.py +99 -0
- xr_cli-0.1.0/src/xr/models.py +136 -0
- xr_cli-0.1.0/tests/__init__.py +0 -0
- xr_cli-0.1.0/tests/conftest.py +87 -0
- xr_cli-0.1.0/tests/test_api.py +34 -0
- xr_cli-0.1.0/tests/test_auth.py +34 -0
- xr_cli-0.1.0/tests/test_cache.py +43 -0
- xr_cli-0.1.0/tests/test_cli.py +20 -0
- xr_cli-0.1.0/tests/test_commands.py +47 -0
- xr_cli-0.1.0/tests/test_config.py +27 -0
- xr_cli-0.1.0/tests/test_formatters.py +59 -0
- xr_cli-0.1.0/tests/test_models.py +30 -0
xr_cli-0.1.0/.gitignore
ADDED
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,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"]
|