Compare commits

...

3 Commits

Author SHA1 Message Date
balyan.sid@gmail.com
02c9e7fee2 update skill.md to callout for remote machines 2026-03-13 07:34:29 +05:30
balyan.sid@gmail.com
f77811a8a2 Remove unnecessary comments from X OAuth2 setup script 2026-03-12 23:01:09 +05:30
balyan.sid@gmail.com
1ad8713b2b add xitter skill 2026-03-12 22:41:48 +05:30
12 changed files with 1938 additions and 0 deletions

22
skills/xitter/README.md Normal file
View File

@@ -0,0 +1,22 @@
# xitter
X/Twitter skill for Hermes Agent, powered by [x-cli](https://github.com/Infatoshi/x-cli).
## Credits
The bundled `x-cli/` is a patched fork of **Infatoshi's** work:
- **x-cli** (CLI tool): https://github.com/Infatoshi/x-cli
- **x-mcp** (MCP server): https://github.com/Infatoshi/x-mcp
The patch adds OAuth 2.0 PKCE support for the X Bookmarks API, adapting
the token exchange and refresh flow from x-mcp's `oauth2.ts` into x-cli's
Python `OAuth2Manager`.
## What's Changed from Upstream
- `auth.py`: Added `OAuth2Manager` class with PKCE token refresh
- `api.py`: Added `_oauth2_request()` for bookmark endpoints (`get_bookmarks`, `bookmark_tweet`, `unbookmark_tweet`)
- `cli.py`: Added `me bookmarks`, `me bookmark`, `me unbookmark` commands
Everything else (OAuth 1.0a signing, Bearer token auth, formatters, utils) is upstream as-is.

225
skills/xitter/SKILL.md Normal file
View File

@@ -0,0 +1,225 @@
---
name: xitter
description: "Post tweets, read timelines, search, bookmark, and engage on X/Twitter via the x-cli command-line tool. Use this skill whenever the user wants to interact with X/Twitter — posting, reading timelines, searching tweets, managing bookmarks, liking, retweeting, looking up users, or checking mentions. Also trigger when the user mentions 'tweet', 'X', 'Twitter', or any social media posting task targeting X."
version: 1.0.0
author: alt-glitch (x-cli upstream: Infatoshi)
platforms: [linux, macos]
metadata:
hermes:
tags: [twitter, x, social-media]
requires_toolsets: [terminal]
---
# Xitter — X/Twitter CLI Skill
Interact with X/Twitter through `x-cli`, a Python CLI that talks directly to the X API v2. Supports posting, reading, searching, engagement, and bookmarks.
> **Pay-per-use X API tier required.** The free tier does not support most endpoints and returns misleading 403 errors. You need at least the Basic tier ($200/month) from https://developer.x.com/en/portal/products
## When to Use
Any X/Twitter task:
- Posting tweets, replies, quote tweets, polls
- Reading timelines, mentions, user profiles
- Searching tweets
- Managing bookmarks (add, remove, list)
- Liking and retweeting
- Looking up followers/following
## Setup
If `x-cli` is not installed or the user hasn't configured credentials yet, walk them through this setup. Each step has direct links — give them to the user verbatim.
### Step 1: Install x-cli
Install from the bundled source in this skill directory:
```bash
uv tool install <SKILL_DIR>/x-cli/
```
Replace `<SKILL_DIR>` with the absolute path to this skill's directory.
Verify: `x-cli --help` should show the command list.
### Step 2: Create an X Developer App
Direct the user to: **https://developer.x.com/en/portal/dashboard**
1. Sign in with their X account
2. If no developer account exists, sign up (free tier exists but **pay-per-use is required** for API access — see note above)
3. Go to **Apps** in the left sidebar → **Create App**
4. Enter any app name (e.g. `hermes-xitter`)
5. After creation, three credentials appear on screen:
- **API Key** (Consumer Key) → this is `X_API_KEY`
- **API Secret** (Consumer Secret) → this is `X_API_SECRET`
- **Bearer Token** → this is `X_BEARER_TOKEN`
**Tell the user to save all three immediately. The secret won't be shown again.**
### Step 3: Enable Write Permissions
Without this, posting/liking/retweeting fails with a 403 error.
On the app's page in the developer portal:
1. Scroll to **User authentication settings** → click **Set up**
2. Set these values:
- **App permissions**: **Read and write** (NOT just Read)
- **Type of App**: **Web App, Automated App or Bot**
- **Callback URI / Redirect URL**: `http://127.0.0.1:3219/callback`
- **Website URL**: `https://example.com` (any valid URL)
3. Click **Save**
It will show an OAuth 2.0 Client Secret — save it for Step 6.
### Step 4: Generate Access Token & Secret
**This MUST be done AFTER Step 3.** If tokens existed before enabling write perms, they must be regenerated.
1. Go to the app's **Keys and Tokens** page: **https://developer.x.com/en/portal/dashboard** → click app → **Keys and tokens** tab
2. Under **Access Token and Secret** → click **Generate** (or **Regenerate**)
3. Save both:
- **Access Token** → `X_ACCESS_TOKEN`
- **Access Token Secret** → `X_ACCESS_TOKEN_SECRET`
4. **Verify** the Access Token section shows **"Read and Write"**, not just "Read"
### Step 5: Save Credentials
Append these 5 variables to `~/.hermes/.env`:
```bash
X_API_KEY=<API Key from Step 2>
X_API_SECRET=<API Secret from Step 2>
X_BEARER_TOKEN=<Bearer Token from Step 2>
X_ACCESS_TOKEN=<Access Token from Step 4>
X_ACCESS_TOKEN_SECRET=<Access Token Secret from Step 4>
```
Test with: `x-cli me mentions` — should return recent mentions (or an empty list).
### Step 6: OAuth2 PKCE Setup (for Bookmarks)
Bookmarks use a separate OAuth 2.0 flow. This step requires a browser.
**If running over SSH**: The setup script starts a local callback server on `127.0.0.1:3219`. For the browser redirect to reach the remote machine, the user must set up SSH port forwarding first:
```bash
ssh -L 3219:127.0.0.1:3219 <user>@<host>
```
Then they can open the printed URL in their local browser and the callback will tunnel through. If they're already in an SSH session, they can add the tunnel from another terminal.
**If running natively on Mac/Linux**: The script will open the browser automatically. No extra steps needed.
1. In the developer portal (**https://developer.x.com/en/portal/dashboard** → app → **Keys and tokens** tab), find **OAuth 2.0 Client ID and Client Secret**. Generate them if they don't exist yet.
2. Run the setup script:
```bash
uv run <SKILL_DIR>/scripts/x-oauth2-setup.py
```
3. It will ask for Client ID and Client Secret, open the browser (or print the URL if no browser is available) for authorization, then automatically:
- Save `X_OAUTH2_CLIENT_ID` and `X_OAUTH2_CLIENT_SECRET` to `~/.hermes/.env`
- Save tokens to `~/.config/x-cli/.oauth2-tokens.json`
Test with: `x-cli me bookmarks` — should return bookmarked tweets.
### Step 7: Token Refresh Cron
OAuth2 access tokens expire every 2 hours. Set up an hourly cron to keep them alive:
Create a hermes scheduled task:
- **Schedule**: every 1 hour
- **Command**: `uv run <SKILL_DIR>/scripts/refresh-oauth2.py`
- **Delivery**: local (silent on success)
If the refresh token itself dies (~6 months or revocation), the script exits with code 1 and prints a message. The user will need to re-run `x-oauth2-setup.py`.
## Command Reference
### Tweet Commands (`x-cli tweet <action>`)
| Command | Args | Flags | Description |
|---------|------|-------|-------------|
| `post` | `TEXT` | `--poll OPTIONS` `--poll-duration MINS` | Post a tweet (optionally with poll) |
| `get` | `ID_OR_URL` | | Fetch a tweet with metadata |
| `delete` | `ID_OR_URL` | | Delete a tweet |
| `reply` | `ID_OR_URL` `TEXT` | | Reply to a tweet (restricted — see Pitfalls) |
| `quote` | `ID_OR_URL` `TEXT` | | Quote-retweet a tweet |
| `search` | `QUERY` | `--max N` | Search recent tweets (last 7 days) |
| `metrics` | `ID_OR_URL` | | Get engagement metrics |
### User Commands (`x-cli user <action>`)
| Command | Args | Flags | Description |
|---------|------|-------|-------------|
| `get` | `USERNAME` | | Look up a user profile |
| `timeline` | `USERNAME` | `--max N` | Get a user's recent posts |
| `followers` | `USERNAME` | `--max N` | List a user's followers |
| `following` | `USERNAME` | `--max N` | List who a user follows |
### Self Commands (`x-cli me <action>`)
| Command | Args | Flags | Description |
|---------|------|-------|-------------|
| `mentions` | | `--max N` | Your recent mentions |
| `bookmarks` | | `--max N` | Your bookmarks (OAuth2) |
| `bookmark` | `ID_OR_URL` | | Bookmark a tweet (OAuth2) |
| `unbookmark` | `ID_OR_URL` | | Remove a bookmark (OAuth2) |
### Top-Level Commands
| Command | Args | Description |
|---------|------|-------------|
| `like` | `ID_OR_URL` | Like a tweet |
| `retweet` | `ID_OR_URL` | Retweet a tweet |
### Output Flags
All commands accept these flags (placed before the subcommand, e.g. `x-cli -j user get ...`):
- `-j` / `--json` — Raw JSON output (add `-v` for full response including `includes` and `meta`)
- `-p` / `--plain` — TSV format for piping
- `-md` / `--markdown` — Markdown tables/headings
- `-v` / `--verbose` — Include timestamps, metrics, metadata, pagination tokens
- Default: TSV (`-p`) — agent-friendly tab-separated output. Use `-j` when you need structured data for parsing.
### Search Query Syntax
The `search` command supports X's full query language:
- `from:username` — posts by a user
- `to:username` — replies to a user
- `#hashtag` — hashtag search
- `"exact phrase"` — exact match
- `has:media` / `has:links` / `has:images`
- `is:reply` / `-is:retweet`
- `lang:en` — language filter
- Combine with spaces (AND) or `OR`
## Auth Architecture
x-cli uses three auth methods depending on the endpoint:
| Method | Endpoints | Credentials |
|--------|-----------|-------------|
| **Bearer Token** | Public reads: `get_tweet`, `search`, `get_user`, `get_timeline`, `get_followers`, `get_following` | `X_BEARER_TOKEN` |
| **OAuth 1.0a** | Writes + authenticated reads: `post`, `delete`, `like`, `retweet`, `reply`, `quote`, `mentions`, `metrics` | `X_API_KEY`, `X_API_SECRET`, `X_ACCESS_TOKEN`, `X_ACCESS_TOKEN_SECRET` |
| **OAuth 2.0 PKCE** | Bookmarks only: `bookmarks`, `bookmark`, `unbookmark` | `X_OAUTH2_CLIENT_ID`, `X_OAUTH2_CLIENT_SECRET` + token file |
## Credential Locations
| What | Where | Written by |
|------|-------|-----------|
| API keys (7 vars) | `~/.hermes/.env` | User (Steps 2-5) + setup script (Step 6) |
| OAuth2 tokens | `~/.config/x-cli/.oauth2-tokens.json` | `x-oauth2-setup.py`, then auto-refreshed by cron |
## Pitfalls
**Pay-per-use API required**: The free tier returns 403 errors on most endpoints. The error message says "oauth1-permissions" which is misleading — the real issue is the API tier. Basic tier costs $200/month.
**403 "oauth1-permissions"**: If you're on the right tier and still get this, the Access Token was generated before write permissions were enabled. Fix: go to the app's User Authentication Settings, confirm "Read and write" is set, then **regenerate** the Access Token and Secret.
**Reply restrictions**: Since Feb 2024, X restricts programmatic replies. `x-cli tweet reply` only works if the original tweet's author @mentioned you or quoted your post. For everything else, use `x-cli tweet quote` instead.
**OAuth2 token expiry**: Access tokens last 2 hours. The hourly cron (Step 7) handles this. If the cron isn't running, `x-cli me bookmarks` will fail with a RuntimeError. The refresh token itself lasts ~6 months — if it dies, re-run `x-oauth2-setup.py`.
**Rate limits**: X API has per-endpoint rate limits. When hit, the error includes a reset timestamp. Wait until then.

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Refresh X/Twitter OAuth2 tokens. Intended to run as an hourly cron job.
Access tokens expire every 2h. This script refreshes them proactively
so bookmark operations never hit an expired token.
Exit codes:
0 — tokens refreshed or still valid
1 — refresh token is dead (user must re-run x-oauth2-setup.py)
Usage:
uv run refresh-oauth2.py
"""
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx", "python-dotenv"]
# ///
import base64
import json
import sys
import time
from pathlib import Path
import httpx
from dotenv import load_dotenv
import os
TOKEN_URL = "https://api.twitter.com/2/oauth2/token"
HERMES_ENV = Path.home() / ".hermes" / ".env"
TOKEN_FILE = Path.home() / ".config" / "x-cli" / ".oauth2-tokens.json"
EXPIRY_BUFFER_MS = 60_000 # refresh 60s before actual expiry
def main() -> int:
# Load credentials from ~/.hermes/.env
if HERMES_ENV.exists():
load_dotenv(HERMES_ENV)
client_id = os.environ.get("X_OAUTH2_CLIENT_ID", "")
client_secret = os.environ.get("X_OAUTH2_CLIENT_SECRET", "")
if not client_id or not client_secret:
print("ERROR: X_OAUTH2_CLIENT_ID and X_OAUTH2_CLIENT_SECRET not found in ~/.hermes/.env")
return 1
# Load tokens
if not TOKEN_FILE.exists():
print("ERROR: No token file at ~/.config/x-cli/.oauth2-tokens.json")
print("Run x-oauth2-setup.py first.")
return 1
tokens = json.loads(TOKEN_FILE.read_text())
now_ms = int(time.time() * 1000)
# Check if still valid (with 60s buffer)
if now_ms < (tokens["expires_at"] - EXPIRY_BUFFER_MS):
remaining_min = (tokens["expires_at"] - now_ms) / 60_000
print(f"OK: token still valid ({remaining_min:.0f}min remaining)")
return 0
# Refresh
print("Token expired or expiring soon. Refreshing...")
raw = f"{client_id}:{client_secret}"
basic_auth = f"Basic {base64.b64encode(raw.encode()).decode()}"
from urllib.parse import urlencode
body = urlencode({
"grant_type": "refresh_token",
"refresh_token": tokens["refresh_token"],
"client_id": client_id,
})
try:
resp = httpx.post(
TOKEN_URL,
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": basic_auth,
},
timeout=30.0,
)
except Exception as e:
print(f"ERROR: network request failed: {e}")
return 1
if resp.status_code != 200:
print(f"ERROR: refresh failed with status {resp.status_code}")
print(resp.text)
if resp.status_code == 401:
print("\nRefresh token is dead. Re-run x-oauth2-setup.py to get new tokens.")
TOKEN_FILE.unlink(missing_ok=True)
return 1
data = resp.json()
new_tokens = {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", tokens["refresh_token"]),
"expires_at": int(time.time() * 1000) + data.get("expires_in", 7200) * 1000,
}
TOKEN_FILE.write_text(json.dumps(new_tokens, indent=2))
print("OK: tokens refreshed successfully")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
One-time OAuth2 PKCE setup for X/Twitter bookmarks.
Run this on a machine where you have a browser and are logged into X.
Usage:
uv run x-oauth2-setup.py
It will ask for your Client ID and Client Secret, open your browser,
and save the tokens automatically.
To get Client ID + Secret:
1. Go to https://developer.x.com/en/portal/dashboard
2. Click your app -> "Keys and tokens" tab
3. Under "OAuth 2.0 Client ID and Client Secret" -> generate/copy both
4. Make sure your app has "Read and Write" permissions
5. Under "User authentication settings":
- Type: "Web App, Automated App or Bot"
- Callback URL: http://127.0.0.1:3219/callback
- Website URL: anything (e.g. https://example.com)
"""
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx"]
# ///
import base64
import hashlib
import json
import os
import secrets
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse
AUTH_URL = "https://twitter.com/i/oauth2/authorize"
TOKEN_URL = "https://api.twitter.com/2/oauth2/token"
REDIRECT_URI = "http://127.0.0.1:3219/callback"
SCOPES = "bookmark.read bookmark.write tweet.read users.read offline.access"
HERMES_ENV = Path.home() / ".hermes" / ".env"
TOKEN_FILE = Path.home() / ".config" / "x-cli" / ".oauth2-tokens.json"
# PKCE: generate verifier + challenge
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
state = secrets.token_urlsafe(16)
received_code = None
cid = None
csecret = None
def _basic_auth_header(client_id: str, client_secret: str) -> str:
"""Match x-mcp: Basic base64(client_id:client_secret)"""
raw = f"{client_id}:{client_secret}"
encoded = base64.b64encode(raw.encode()).decode()
return f"Basic {encoded}"
def _append_to_hermes_env(key: str, value: str) -> None:
"""Append a key=value to ~/.hermes/.env if not already present."""
HERMES_ENV.parent.mkdir(parents=True, exist_ok=True)
if HERMES_ENV.exists():
content = HERMES_ENV.read_text()
for line in content.splitlines():
if line.strip().startswith(f"{key}="):
# Already present — update in place
lines = content.splitlines()
new_lines = []
for l in lines:
if l.strip().startswith(f"{key}="):
new_lines.append(f"{key}={value}")
else:
new_lines.append(l)
HERMES_ENV.write_text("\n".join(new_lines) + "\n")
return
with open(HERMES_ENV, "a") as f:
f.write(f"{key}={value}\n")
class CallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
global received_code
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
if parsed.path == "/callback":
recv_state = params.get("state", [None])[0]
if recv_state != state:
self.send_response(400)
self.end_headers()
self.wfile.write(b"State mismatch! CSRF detected. Try again.")
return
if "code" in params:
received_code = params["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"""
<html><body style="font-family:monospace;text-align:center;padding:60px;background:#111;color:#0f0">
<h1>authorized. go back to your terminal.</h1>
<p>you can close this tab.</p>
</body></html>
""")
else:
error = params.get("error", ["unknown"])[0]
self.send_response(400)
self.end_headers()
self.wfile.write(f"Auth failed: {error}".encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
def main():
global cid, csecret
print("=" * 50)
print("X/Twitter OAuth2 PKCE Setup")
print("=" * 50)
print()
print("This gets you a refresh token for bookmark access.")
print("You only need to do this once.")
print()
cid = input("Client ID: ").strip()
csecret = input("Client Secret: ").strip()
if not cid or not csecret:
print(
"Both are required. Get them from https://developer.x.com/en/portal/dashboard"
)
return
# Build auth URL
params = {
"response_type": "code",
"client_id": cid,
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
url = f"{AUTH_URL}?{urlencode(params)}"
server = HTTPServer(("127.0.0.1", 3219), CallbackHandler)
server.timeout = 120
print()
print("Opening browser for authorization...")
print(f"If it doesn't open, go to:\n{url}")
print()
webbrowser.open(url)
while received_code is None:
server.handle_request()
server.server_close()
print("Got authorization code. Exchanging for tokens...")
import httpx
token_body = urlencode(
{
"grant_type": "authorization_code",
"code": received_code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
"client_id": cid,
}
)
resp = httpx.post(
TOKEN_URL,
content=token_body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": _basic_auth_header(cid, csecret),
},
timeout=30.0,
)
if resp.status_code != 200:
print(f"Token exchange failed: {resp.status_code}")
print(resp.text)
return
data = resp.json()
expires_at = int(time.time() * 1000) + data.get("expires_in", 7200) * 1000
result = {
"access_token": data["access_token"],
"refresh_token": data["refresh_token"],
"expires_at": expires_at,
}
# Save tokens to ~/.config/x-cli/.oauth2-tokens.json
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(json.dumps(result, indent=2))
print(f"\nTokens saved to {TOKEN_FILE}")
# Save client credentials to ~/.hermes/.env
_append_to_hermes_env("X_OAUTH2_CLIENT_ID", cid)
_append_to_hermes_env("X_OAUTH2_CLIENT_SECRET", csecret)
print(f"Client credentials saved to {HERMES_ENV}")
print()
print("=" * 50)
print("SUCCESS!")
print("=" * 50)
print()
print("OAuth2 is fully configured. Bookmarks are ready to use.")
print("The hourly token refresh cron will keep your tokens alive.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,27 @@
[project]
name = "x-cli"
version = "0.1.0"
description = "CLI for X/Twitter API v2"
requires-python = ">=3.11"
dependencies = [
"click>=8.1",
"httpx>=0.27",
"rich>=13.0",
"python-dotenv>=1.0",
]
[project.scripts]
x-cli = "x_cli.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/x_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.4"]

View File

@@ -0,0 +1 @@
"""x-cli: CLI for X/Twitter API v2."""

View File

@@ -0,0 +1,218 @@
"""Twitter API v2 client with OAuth 1.0a, Bearer token, and OAuth 2.0 auth."""
from __future__ import annotations
from typing import Any
import httpx
from .auth import Credentials, OAuth2Manager, generate_oauth_header
API_BASE = "https://api.x.com/2"
class XApiClient:
def __init__(self, creds: Credentials) -> None:
self.creds = creds
self._user_id: str | None = None
self._http = httpx.Client(timeout=30.0)
self._oauth2: OAuth2Manager | None = None
if creds.oauth2_client_id and creds.oauth2_client_secret:
self._oauth2 = OAuth2Manager(creds.oauth2_client_id, creds.oauth2_client_secret)
def close(self) -> None:
self._http.close()
# ---- internal ----
def _bearer_get(self, url: str) -> dict[str, Any]:
resp = self._http.get(url, headers={"Authorization": f"Bearer {self.creds.bearer_token}"})
return self._handle(resp)
def _oauth_request(self, method: str, url: str, json_body: dict | None = None) -> dict[str, Any]:
auth_header = generate_oauth_header(method, url, self.creds)
headers: dict[str, str] = {"Authorization": auth_header}
if json_body is not None:
headers["Content-Type"] = "application/json"
resp = self._http.request(method, url, headers=headers, json=json_body if json_body else None)
return self._handle(resp)
def _handle(self, resp: httpx.Response) -> dict[str, Any]:
if resp.status_code == 429:
reset = resp.headers.get("x-rate-limit-reset", "unknown")
raise RuntimeError(f"Rate limited. Resets at {reset}.")
data = resp.json()
if not resp.is_success:
errors = data.get("errors", [])
msg = "; ".join(e.get("detail") or e.get("message", "") for e in errors) or resp.text[:500]
raise RuntimeError(f"API error (HTTP {resp.status_code}): {msg}")
return data
def get_authenticated_user_id(self) -> str:
if self._user_id:
return self._user_id
data = self._oauth_request("GET", f"{API_BASE}/users/me")
self._user_id = data["data"]["id"]
return self._user_id
# ---- tweets ----
def post_tweet(
self,
text: str,
reply_to: str | None = None,
quote_tweet_id: str | None = None,
poll_options: list[str] | None = None,
poll_duration_minutes: int = 1440,
) -> dict[str, Any]:
body: dict[str, Any] = {"text": text}
if reply_to:
# NOTE: X API restricts programmatic replies (Feb 2024). Replies only
# succeed if the original author @mentioned you or quoted your post.
body["reply"] = {"in_reply_to_tweet_id": reply_to}
if quote_tweet_id:
body["quote_tweet_id"] = quote_tweet_id
if poll_options:
body["poll"] = {"options": poll_options, "duration_minutes": poll_duration_minutes}
return self._oauth_request("POST", f"{API_BASE}/tweets", body)
def delete_tweet(self, tweet_id: str) -> dict[str, Any]:
return self._oauth_request("DELETE", f"{API_BASE}/tweets/{tweet_id}")
def get_tweet(self, tweet_id: str) -> dict[str, Any]:
params = {
"tweet.fields": "created_at,public_metrics,author_id,conversation_id,in_reply_to_user_id,referenced_tweets,attachments,entities,lang,note_tweet",
"expansions": "author_id,referenced_tweets.id,attachments.media_keys",
"user.fields": "name,username,verified,profile_image_url,public_metrics",
"media.fields": "url,preview_image_url,type,width,height,alt_text",
}
qs = "&".join(f"{k}={v}" for k, v in params.items())
return self._bearer_get(f"{API_BASE}/tweets/{tweet_id}?{qs}")
def search_tweets(self, query: str, max_results: int = 10) -> dict[str, Any]:
max_results = max(10, min(max_results, 100))
params = {
"query": query,
"max_results": str(max_results),
"tweet.fields": "created_at,public_metrics,author_id,conversation_id,entities,lang,note_tweet",
"expansions": "author_id,attachments.media_keys",
"user.fields": "name,username,verified,profile_image_url",
"media.fields": "url,preview_image_url,type",
}
url = f"{API_BASE}/tweets/search/recent"
resp = self._http.get(url, params=params, headers={"Authorization": f"Bearer {self.creds.bearer_token}"})
return self._handle(resp)
def get_tweet_metrics(self, tweet_id: str) -> dict[str, Any]:
params = "tweet.fields=public_metrics,non_public_metrics,organic_metrics"
return self._oauth_request("GET", f"{API_BASE}/tweets/{tweet_id}?{params}")
# ---- users ----
def get_user(self, username: str) -> dict[str, Any]:
fields = "user.fields=created_at,description,public_metrics,verified,profile_image_url,url,location,pinned_tweet_id"
return self._bearer_get(f"{API_BASE}/users/by/username/{username}?{fields}")
def get_timeline(self, user_id: str, max_results: int = 10) -> dict[str, Any]:
max_results = max(5, min(max_results, 100))
params = {
"max_results": str(max_results),
"tweet.fields": "created_at,public_metrics,author_id,conversation_id,entities,lang,note_tweet",
"expansions": "author_id,attachments.media_keys,referenced_tweets.id",
"user.fields": "name,username,verified",
"media.fields": "url,preview_image_url,type",
}
resp = self._http.get(
f"{API_BASE}/users/{user_id}/tweets",
params=params,
headers={"Authorization": f"Bearer {self.creds.bearer_token}"},
)
return self._handle(resp)
def get_followers(self, user_id: str, max_results: int = 100) -> dict[str, Any]:
max_results = max(1, min(max_results, 1000))
params = {
"max_results": str(max_results),
"user.fields": "created_at,description,public_metrics,verified,profile_image_url",
}
resp = self._http.get(
f"{API_BASE}/users/{user_id}/followers",
params=params,
headers={"Authorization": f"Bearer {self.creds.bearer_token}"},
)
return self._handle(resp)
def get_following(self, user_id: str, max_results: int = 100) -> dict[str, Any]:
max_results = max(1, min(max_results, 1000))
params = {
"max_results": str(max_results),
"user.fields": "created_at,description,public_metrics,verified,profile_image_url",
}
resp = self._http.get(
f"{API_BASE}/users/{user_id}/following",
params=params,
headers={"Authorization": f"Bearer {self.creds.bearer_token}"},
)
return self._handle(resp)
def get_mentions(self, max_results: int = 10) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
max_results = max(5, min(max_results, 100))
params = {
"max_results": str(max_results),
"tweet.fields": "created_at,public_metrics,author_id,conversation_id,entities,note_tweet",
"expansions": "author_id",
"user.fields": "name,username,verified",
}
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{API_BASE}/users/{user_id}/mentions?{qs}"
return self._oauth_request("GET", url)
# ---- engagement ----
def like_tweet(self, tweet_id: str) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
return self._oauth_request("POST", f"{API_BASE}/users/{user_id}/likes", {"tweet_id": tweet_id})
def retweet(self, tweet_id: str) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
return self._oauth_request("POST", f"{API_BASE}/users/{user_id}/retweets", {"tweet_id": tweet_id})
# ---- bookmarks (OAuth 2.0 PKCE — required by X API for bookmark endpoints) ----
def _oauth2_request(self, method: str, url: str, json_body: dict | None = None) -> dict[str, Any]:
"""Make a request using OAuth 2.0 bearer token (for bookmarks)."""
if not self._oauth2:
raise RuntimeError(
"Bookmarks require OAuth2 credentials. Add X_OAUTH2_CLIENT_ID and "
"X_OAUTH2_CLIENT_SECRET to your .env, then run x-oauth2-setup.py "
"to get tokens."
)
token = self._oauth2.get_access_token()
headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
if json_body is not None:
headers["Content-Type"] = "application/json"
resp = self._http.request(method, url, headers=headers, json=json_body if json_body else None)
return self._handle(resp)
def get_bookmarks(self, max_results: int = 10) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
max_results = max(1, min(max_results, 100))
params = {
"max_results": str(max_results),
"tweet.fields": "created_at,public_metrics,author_id,conversation_id,entities,lang,note_tweet",
"expansions": "author_id,attachments.media_keys",
"user.fields": "name,username,verified,profile_image_url",
"media.fields": "url,preview_image_url,type",
}
qs = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{API_BASE}/users/{user_id}/bookmarks?{qs}"
return self._oauth2_request("GET", url)
def bookmark_tweet(self, tweet_id: str) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
return self._oauth2_request("POST", f"{API_BASE}/users/{user_id}/bookmarks", {"tweet_id": tweet_id})
def unbookmark_tweet(self, tweet_id: str) -> dict[str, Any]:
user_id = self.get_authenticated_user_id()
return self._oauth2_request("DELETE", f"{API_BASE}/users/{user_id}/bookmarks/{tweet_id}")

View File

@@ -0,0 +1,210 @@
"""Auth: env var loading, OAuth 1.0a header generation, and OAuth 2.0 PKCE token management."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.parse
from dataclasses import dataclass, field
from pathlib import Path
import httpx
from dotenv import load_dotenv
TOKEN_URL = "https://api.twitter.com/2/oauth2/token"
@dataclass
class Credentials:
api_key: str
api_secret: str
access_token: str
access_token_secret: str
bearer_token: str
oauth2_client_id: str = ""
oauth2_client_secret: str = ""
@dataclass
class OAuth2Tokens:
access_token: str
refresh_token: str
expires_at: int # unix ms
def is_expired(self) -> bool:
return int(time.time() * 1000) >= (self.expires_at - 60_000)
class OAuth2Manager:
"""Manages OAuth 2.0 tokens for bookmark operations. Auto-refreshes."""
def __init__(self, client_id: str, client_secret: str) -> None:
self.client_id = client_id
self.client_secret = client_secret
self._tokens: OAuth2Tokens | None = None
self._token_path = Path.home() / ".config" / "x-cli" / ".oauth2-tokens.json"
def _load_tokens(self) -> OAuth2Tokens | None:
if self._tokens and not self._tokens.is_expired():
return self._tokens
if self._token_path.exists():
data = json.loads(self._token_path.read_text())
self._tokens = OAuth2Tokens(**data)
if not self._tokens.is_expired():
return self._tokens
# expired — try refresh
return self._refresh()
return None
def _save_tokens(self, tokens: OAuth2Tokens) -> None:
self._tokens = tokens
self._token_path.parent.mkdir(parents=True, exist_ok=True)
self._token_path.write_text(json.dumps({
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"expires_at": tokens.expires_at,
}, indent=2))
def _basic_auth_header(self) -> str:
"""Match x-mcp: Basic base64(client_id:client_secret)"""
import base64 as b64
raw = f"{self.client_id}:{self.client_secret}"
return f"Basic {b64.b64encode(raw.encode()).decode()}"
def _refresh(self) -> OAuth2Tokens | None:
if not self._tokens:
return None
try:
# Match x-mcp exactly: client_id in body + Basic auth header
from urllib.parse import urlencode
body = urlencode({
"grant_type": "refresh_token",
"refresh_token": self._tokens.refresh_token,
"client_id": self.client_id,
})
resp = httpx.post(
TOKEN_URL,
content=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": self._basic_auth_header(),
},
timeout=30.0,
)
if resp.status_code != 200:
# token file is stale, nuke it
self._token_path.unlink(missing_ok=True)
self._tokens = None
return None
data = resp.json()
tokens = OAuth2Tokens(
access_token=data["access_token"],
refresh_token=data.get("refresh_token", self._tokens.refresh_token),
expires_at=int(time.time() * 1000) + data.get("expires_in", 7200) * 1000,
)
self._save_tokens(tokens)
return tokens
except Exception:
return None
def get_access_token(self) -> str:
tokens = self._load_tokens()
if not tokens:
raise RuntimeError(
"OAuth2 not set up. Run the x-oauth2-setup.py script on a machine "
"with a browser, then copy .oauth2-tokens.json to ~/.config/x-cli/"
)
return tokens.access_token
def load_credentials() -> Credentials:
"""Load credentials from env vars, with .env fallback."""
# Try ~/.config/x-cli/.env then cwd .env
config_env = Path.home() / ".config" / "x-cli" / ".env"
if config_env.exists():
load_dotenv(config_env)
load_dotenv() # cwd .env
def require(name: str) -> str:
val = os.environ.get(name)
if not val:
raise SystemExit(
f"Missing env var: {name}. "
"Set X_API_KEY, X_API_SECRET, X_ACCESS_TOKEN, X_ACCESS_TOKEN_SECRET, X_BEARER_TOKEN."
)
return val
return Credentials(
api_key=require("X_API_KEY"),
api_secret=require("X_API_SECRET"),
access_token=require("X_ACCESS_TOKEN"),
access_token_secret=require("X_ACCESS_TOKEN_SECRET"),
bearer_token=require("X_BEARER_TOKEN"),
oauth2_client_id=os.environ.get("X_OAUTH2_CLIENT_ID", ""),
oauth2_client_secret=os.environ.get("X_OAUTH2_CLIENT_SECRET", ""),
)
def _percent_encode(s: str) -> str:
return urllib.parse.quote(s, safe="")
def generate_oauth_header(
method: str,
url: str,
creds: Credentials,
params: dict[str, str] | None = None,
) -> str:
"""Generate an OAuth 1.0a Authorization header (HMAC-SHA1)."""
oauth_params = {
"oauth_consumer_key": creds.api_key,
"oauth_nonce": secrets.token_hex(16),
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": str(int(time.time())),
"oauth_token": creds.access_token,
"oauth_version": "1.0",
}
# Combine oauth params with any query/body params for signature base
all_params = {**oauth_params}
if params:
all_params.update(params)
# Also include query string params from the URL
parsed = urllib.parse.urlparse(url)
if parsed.query:
qs_params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
for k, v in qs_params.items():
all_params[k] = v[0]
# Sort and encode
sorted_params = sorted(all_params.items())
param_string = "&".join(f"{_percent_encode(k)}={_percent_encode(v)}" for k, v in sorted_params)
# Base URL (no query string)
base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
# Signature base string
base_string = f"{method.upper()}&{_percent_encode(base_url)}&{_percent_encode(param_string)}"
# Signing key
signing_key = f"{_percent_encode(creds.api_secret)}&{_percent_encode(creds.access_token_secret)}"
# HMAC-SHA1
signature = base64.b64encode(
hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest()
).decode()
oauth_params["oauth_signature"] = signature
# Build header
header_parts = ", ".join(
f'{_percent_encode(k)}="{_percent_encode(v)}"'
for k, v in sorted(oauth_params.items())
)
return f"OAuth {header_parts}"

View File

@@ -0,0 +1,271 @@
"""Click CLI for x-cli."""
from __future__ import annotations
import click
from .api import XApiClient
from .auth import load_credentials
from .formatters import format_output
from .utils import parse_tweet_id, strip_at
class State:
def __init__(self, mode: str, verbose: bool = False) -> None:
self.mode = mode
self.verbose = verbose
self._client: XApiClient | None = None
@property
def client(self) -> XApiClient:
if self._client is None:
creds = load_credentials()
self._client = XApiClient(creds)
return self._client
def output(self, data, title: str = "") -> None:
format_output(data, self.mode, title, verbose=self.verbose)
pass_state = click.make_pass_decorator(State)
@click.group()
@click.option("--json", "-j", "fmt", flag_value="json", help="JSON output")
@click.option("--plain", "-p", "fmt", flag_value="plain", help="TSV output for piping")
@click.option("--markdown", "-md", "fmt", flag_value="markdown", help="Markdown output")
@click.option("--verbose", "-v", is_flag=True, default=False, help="Verbose output (show metrics, timestamps, metadata)")
@click.pass_context
def cli(ctx, fmt, verbose):
"""x-cli: CLI for X/Twitter API v2."""
ctx.ensure_object(dict)
ctx.obj = State(fmt or "plain", verbose=verbose)
# ============================================================
# tweet
# ============================================================
@cli.group()
def tweet():
"""Tweet operations."""
@tweet.command("post")
@click.argument("text")
@click.option("--poll", default=None, help="Comma-separated poll options")
@click.option("--poll-duration", default=1440, type=int, help="Poll duration in minutes")
@pass_state
def tweet_post(state, text, poll, poll_duration):
"""Post a tweet."""
poll_options = [o.strip() for o in poll.split(",")] if poll else None
data = state.client.post_tweet(text, poll_options=poll_options, poll_duration_minutes=poll_duration)
state.output(data, "Posted")
@tweet.command("get")
@click.argument("id_or_url")
@pass_state
def tweet_get(state, id_or_url):
"""Fetch a tweet by ID or URL."""
tid = parse_tweet_id(id_or_url)
data = state.client.get_tweet(tid)
state.output(data, f"Tweet {tid}")
@tweet.command("delete")
@click.argument("id_or_url")
@pass_state
def tweet_delete(state, id_or_url):
"""Delete a tweet."""
tid = parse_tweet_id(id_or_url)
data = state.client.delete_tweet(tid)
state.output(data, "Deleted")
@tweet.command("reply")
@click.argument("id_or_url")
@click.argument("text")
@pass_state
def tweet_reply(state, id_or_url, text):
"""Reply to a tweet.
NOTE: X restricts programmatic replies. You can only reply if the original
author @mentioned you or quoted your post. Use 'tweet quote' as a workaround.
"""
tid = parse_tweet_id(id_or_url)
click.echo(
"Warning: X restricts programmatic replies. This will only succeed if "
"the original author @mentioned you or quoted your post.",
err=True,
)
data = state.client.post_tweet(text, reply_to=tid)
state.output(data, "Reply")
@tweet.command("quote")
@click.argument("id_or_url")
@click.argument("text")
@pass_state
def tweet_quote(state, id_or_url, text):
"""Quote tweet."""
tid = parse_tweet_id(id_or_url)
data = state.client.post_tweet(text, quote_tweet_id=tid)
state.output(data, "Quote")
@tweet.command("search")
@click.argument("query")
@click.option("--max", "max_results", default=10, type=int, help="Max results (10-100)")
@pass_state
def tweet_search(state, query, max_results):
"""Search recent tweets."""
data = state.client.search_tweets(query, max_results)
state.output(data, f"Search: {query}")
@tweet.command("metrics")
@click.argument("id_or_url")
@pass_state
def tweet_metrics(state, id_or_url):
"""Get tweet engagement metrics."""
tid = parse_tweet_id(id_or_url)
data = state.client.get_tweet_metrics(tid)
state.output(data, f"Metrics {tid}")
# ============================================================
# user
# ============================================================
@cli.group()
def user():
"""User operations."""
@user.command("get")
@click.argument("username")
@pass_state
def user_get(state, username):
"""Look up a user profile."""
data = state.client.get_user(strip_at(username))
state.output(data, f"@{strip_at(username)}")
@user.command("timeline")
@click.argument("username")
@click.option("--max", "max_results", default=10, type=int, help="Max results (5-100)")
@pass_state
def user_timeline(state, username, max_results):
"""Fetch a user's recent tweets."""
uname = strip_at(username)
user_data = state.client.get_user(uname)
uid = user_data["data"]["id"]
data = state.client.get_timeline(uid, max_results)
state.output(data, f"@{uname} timeline")
@user.command("followers")
@click.argument("username")
@click.option("--max", "max_results", default=100, type=int, help="Max results (1-1000)")
@pass_state
def user_followers(state, username, max_results):
"""List a user's followers."""
uname = strip_at(username)
user_data = state.client.get_user(uname)
uid = user_data["data"]["id"]
data = state.client.get_followers(uid, max_results)
state.output(data, f"@{uname} followers")
@user.command("following")
@click.argument("username")
@click.option("--max", "max_results", default=100, type=int, help="Max results (1-1000)")
@pass_state
def user_following(state, username, max_results):
"""List who a user follows."""
uname = strip_at(username)
user_data = state.client.get_user(uname)
uid = user_data["data"]["id"]
data = state.client.get_following(uid, max_results)
state.output(data, f"@{uname} following")
# ============================================================
# me
# ============================================================
@cli.group()
def me():
"""Self operations (authenticated user)."""
@me.command("mentions")
@click.option("--max", "max_results", default=10, type=int, help="Max results (5-100)")
@pass_state
def me_mentions(state, max_results):
"""Fetch your recent mentions."""
data = state.client.get_mentions(max_results)
state.output(data, "Mentions")
@me.command("bookmarks")
@click.option("--max", "max_results", default=10, type=int, help="Max results (1-100)")
@pass_state
def me_bookmarks(state, max_results):
"""Fetch your bookmarks."""
data = state.client.get_bookmarks(max_results)
state.output(data, "Bookmarks")
@me.command("bookmark")
@click.argument("id_or_url")
@pass_state
def me_bookmark(state, id_or_url):
"""Bookmark a tweet."""
tid = parse_tweet_id(id_or_url)
data = state.client.bookmark_tweet(tid)
state.output(data, "Bookmarked")
@me.command("unbookmark")
@click.argument("id_or_url")
@pass_state
def me_unbookmark(state, id_or_url):
"""Remove a bookmark."""
tid = parse_tweet_id(id_or_url)
data = state.client.unbookmark_tweet(tid)
state.output(data, "Unbookmarked")
# ============================================================
# quick actions (top-level)
# ============================================================
@cli.command("like")
@click.argument("id_or_url")
@pass_state
def like(state, id_or_url):
"""Like a tweet."""
tid = parse_tweet_id(id_or_url)
data = state.client.like_tweet(tid)
state.output(data, "Liked")
@cli.command("retweet")
@click.argument("id_or_url")
@pass_state
def retweet(state, id_or_url):
"""Retweet a tweet."""
tid = parse_tweet_id(id_or_url)
data = state.client.retweet(tid)
state.output(data, "Retweeted")
def main():
cli()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,348 @@
"""Output formatters: human (rich), JSON, TSV/plain, markdown."""
from __future__ import annotations
import json
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
# ---- JSON ----
def output_json(data: Any, verbose: bool = False) -> None:
"""Raw JSON to stdout."""
if not verbose and isinstance(data, dict):
# Strip includes/meta, just emit data
inner = data.get("data")
if inner is not None:
print(json.dumps(inner, indent=2, default=str))
return
print(json.dumps(data, indent=2, default=str))
# ---- Plain/TSV ----
def output_plain(data: Any, verbose: bool = False) -> None:
"""TSV output for piping."""
if isinstance(data, dict):
inner = data.get("data")
if inner is None:
inner = data
if isinstance(inner, list):
_plain_list(inner, verbose)
elif isinstance(inner, dict):
_plain_dict(inner, verbose)
else:
print(inner)
elif isinstance(data, list):
_plain_list(data, verbose)
else:
print(data)
def _plain_dict(d: dict, verbose: bool = False) -> None:
skip = set() if verbose else {"public_metrics", "entities", "edit_history_tweet_ids", "attachments", "referenced_tweets", "profile_image_url"}
for k, v in d.items():
if not verbose and k in skip:
continue
if isinstance(v, (dict, list)):
v = json.dumps(v, default=str)
print(f"{k}\t{v}")
def _plain_list(items: list, verbose: bool = False) -> None:
if not items:
return
if not isinstance(items[0], dict):
for item in items:
print(item)
return
# Pick columns based on verbose
all_keys = list(items[0].keys())
if verbose:
keys = all_keys
else:
# Compact: only the most useful fields
if "username" in items[0]:
keys = [k for k in ["username", "name", "description"] if k in all_keys]
else:
keys = [k for k in ["id", "author_id", "text", "created_at"] if k in all_keys]
if not keys:
keys = all_keys
print("\t".join(keys))
for item in items:
vals = []
for k in keys:
v = item.get(k, "")
if isinstance(v, (dict, list)):
v = json.dumps(v, default=str)
vals.append(str(v))
print("\t".join(vals))
# ---- Markdown ----
def output_markdown(data: Any, title: str = "", verbose: bool = False) -> None:
"""Markdown output to stdout."""
if isinstance(data, dict):
inner = data.get("data")
includes = data.get("includes", {})
meta = data.get("meta", {})
if inner is None:
inner = data
if isinstance(inner, list):
_md_list(inner, includes, title, verbose)
elif isinstance(inner, dict):
_md_single(inner, includes, title, verbose)
else:
print(str(inner))
if verbose and meta.get("next_token"):
print(f"\n*Next page: `--next-token {meta['next_token']}`*")
elif isinstance(data, list):
_md_list(data, {}, title, verbose)
else:
print(str(data))
def _md_single(item: dict, includes: dict, title: str = "", verbose: bool = False) -> None:
if "username" in item:
_md_user(item, verbose)
else:
_md_tweet(item, includes, title, verbose)
def _md_tweet(tweet: dict, includes: dict, title: str = "", verbose: bool = False) -> None:
author = _resolve_author(tweet.get("author_id"), includes)
text = tweet.get("text", "")
tweet_id = tweet.get("id", "")
note = tweet.get("note_tweet", {})
if note and note.get("text"):
text = note["text"]
if title:
print(f"## {title}\n")
print(f"**{author}**")
if verbose:
created = tweet.get("created_at", "")
if created:
print(f"*{created}*")
print(f"\n{text}\n")
if verbose:
metrics = tweet.get("public_metrics", {})
if metrics:
parts = [f"{k.replace('_count', '')}: {v}" for k, v in metrics.items()]
print(" | ".join(parts))
print()
print(f"ID: `{tweet_id}`")
def _md_user(user: dict, verbose: bool = False) -> None:
name = user.get("name", "")
username = user.get("username", "")
desc = user.get("description", "")
print(f"## {name} (@{username})\n")
if desc:
print(f"{desc}\n")
metrics = user.get("public_metrics", {})
if metrics:
parts = [f"**{k.replace('_count', '')}**: {v:,}" for k, v in metrics.items()]
print(" | ".join(parts))
print()
if verbose:
loc = user.get("location", "")
created = user.get("created_at", "")
if loc:
print(f"Location: {loc}")
if created:
print(f"Joined: {created}")
def _md_list(items: list, includes: dict, title: str = "", verbose: bool = False) -> None:
if not items:
return
if title:
print(f"## {title}\n")
if items and "username" in items[0]:
_md_user_table(items, verbose)
else:
for i, item in enumerate(items):
if i > 0:
print("\n---\n")
_md_tweet(item, includes, verbose=verbose)
def _md_user_table(users: list, verbose: bool = False) -> None:
if verbose:
print("| Username | Name | Followers | Description |")
print("|----------|------|-----------|-------------|")
for u in users:
m = u.get("public_metrics", {})
followers = f"{m.get('followers_count', 0):,}"
desc = (u.get("description", "") or "")[:60].replace("|", "/").replace("\n", " ")
print(f"| @{u.get('username', '')} | {u.get('name', '')} | {followers} | {desc} |")
else:
print("| Username | Name | Followers |")
print("|----------|------|-----------|")
for u in users:
m = u.get("public_metrics", {})
followers = f"{m.get('followers_count', 0):,}"
print(f"| @{u.get('username', '')} | {u.get('name', '')} | {followers} |")
# ---- Rich (human-readable) ----
_console = Console(stderr=True)
_stdout = Console()
def output_human(data: Any, title: str = "", verbose: bool = False) -> None:
"""Pretty-print with rich."""
if isinstance(data, dict):
inner = data.get("data")
includes = data.get("includes", {})
meta = data.get("meta", {})
if inner is None:
inner = data
if isinstance(inner, list):
_human_tweet_list(inner, includes, title, verbose)
elif isinstance(inner, dict):
_human_single(inner, includes, title, verbose)
else:
_stdout.print(inner)
if verbose and meta.get("next_token"):
_console.print(f"[dim]Next page: --next-token {meta['next_token']}[/dim]")
elif isinstance(data, list):
_human_tweet_list(data, {}, title, verbose)
else:
_stdout.print(data)
def _resolve_author(author_id: str | None, includes: dict) -> str:
if not author_id:
return "?"
users = includes.get("users", [])
for u in users:
if u.get("id") == author_id:
return f"@{u.get('username', '?')}"
return author_id
def _human_single(item: dict, includes: dict, title: str = "", verbose: bool = False) -> None:
if "username" in item:
_human_user(item, verbose)
else:
_human_tweet(item, includes, title, verbose)
def _human_tweet(tweet: dict, includes: dict, title: str = "", verbose: bool = False) -> None:
author = _resolve_author(tweet.get("author_id"), includes)
text = tweet.get("text", "")
tweet_id = tweet.get("id", "")
note = tweet.get("note_tweet", {})
if note and note.get("text"):
text = note["text"]
content = f"[bold]{author}[/bold]"
if verbose:
created = tweet.get("created_at", "")
content += f" [dim]{created}[/dim]"
content += f"\n\n{text}"
if verbose:
metrics = tweet.get("public_metrics", {})
if metrics:
parts = [f"{k.replace('_count', '').replace('_', ' ')}: {v}" for k, v in metrics.items()]
content += f"\n\n[dim]{' | '.join(parts)}[/dim]"
panel_title = title or f"Tweet {tweet_id}"
_stdout.print(Panel(content, title=panel_title, border_style="blue", expand=False))
def _human_user(user: dict, verbose: bool = False) -> None:
name = user.get("name", "")
username = user.get("username", "")
desc = user.get("description", "")
metrics = user.get("public_metrics", {})
metrics_parts = []
if metrics:
for k, v in metrics.items():
label = k.replace("_count", "").replace("_", " ")
metrics_parts.append(f"{label}: {v:,}")
content = f"[bold]{name}[/bold] @{username}"
if user.get("verified"):
content += " [blue]verified[/blue]"
if desc:
content += f"\n{desc}"
if verbose:
loc = user.get("location", "")
created = user.get("created_at", "")
if loc:
content += f"\n[dim]Location: {loc}[/dim]"
if created:
content += f"\n[dim]Joined: {created}[/dim]"
if metrics_parts:
content += f"\n\n{' | '.join(metrics_parts)}"
_stdout.print(Panel(content, title=f"@{username}", border_style="green", expand=False))
def _human_tweet_list(items: list, includes: dict, title: str = "", verbose: bool = False) -> None:
if items and "username" in items[0]:
_human_user_table(items, title, verbose)
else:
for item in items:
_human_tweet(item, includes, verbose=verbose)
def _human_user_table(users: list, title: str = "", verbose: bool = False) -> None:
table = Table(title=title or "Users", show_lines=True)
table.add_column("Username", style="bold")
table.add_column("Name")
table.add_column("Followers", justify="right")
if verbose:
table.add_column("Description", max_width=50)
for u in users:
metrics = u.get("public_metrics", {})
followers = str(metrics.get("followers_count", ""))
row = [
f"@{u.get('username', '')}",
u.get("name", ""),
followers,
]
if verbose:
row.append((u.get("description", "") or "")[:50])
table.add_row(*row)
_stdout.print(table)
# ---- Router ----
def format_output(data: Any, mode: str = "human", title: str = "", verbose: bool = False) -> None:
"""Route to the appropriate formatter."""
if mode == "json":
output_json(data, verbose)
elif mode == "plain":
output_plain(data, verbose)
elif mode == "markdown":
output_markdown(data, title, verbose)
else:
output_human(data, title, verbose)

View File

@@ -0,0 +1,21 @@
"""Utility helpers for x-cli."""
from __future__ import annotations
import re
def parse_tweet_id(input_str: str) -> str:
"""Extract a tweet ID from a URL or raw numeric string."""
match = re.search(r"(?:twitter\.com|x\.com)/\w+/status/(\d+)", input_str)
if match:
return match.group(1)
stripped = input_str.strip()
if re.fullmatch(r"\d+", stripped):
return stripped
raise ValueError(f"Invalid tweet ID or URL: {input_str}")
def strip_at(username: str) -> str:
"""Remove leading @ from a username if present."""
return username.lstrip("@")

252
skills/xitter/x-cli/uv.lock generated Normal file
View File

@@ -0,0 +1,252 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "rich"
version = "14.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "x-cli"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "python-dotenv" },
{ name = "rich" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "rich", specifier = ">=13.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "ruff", specifier = ">=0.4" },
]