mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
3 Commits
codex-port
...
sid/xitter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c9e7fee2 | ||
|
|
f77811a8a2 | ||
|
|
1ad8713b2b |
22
skills/xitter/README.md
Normal file
22
skills/xitter/README.md
Normal 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
225
skills/xitter/SKILL.md
Normal 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.
|
||||
111
skills/xitter/scripts/refresh-oauth2.py
Normal file
111
skills/xitter/scripts/refresh-oauth2.py
Normal 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())
|
||||
232
skills/xitter/scripts/x-oauth2-setup.py
Normal file
232
skills/xitter/scripts/x-oauth2-setup.py
Normal 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()
|
||||
27
skills/xitter/x-cli/pyproject.toml
Normal file
27
skills/xitter/x-cli/pyproject.toml
Normal 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"]
|
||||
1
skills/xitter/x-cli/src/x_cli/__init__.py
Normal file
1
skills/xitter/x-cli/src/x_cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""x-cli: CLI for X/Twitter API v2."""
|
||||
218
skills/xitter/x-cli/src/x_cli/api.py
Normal file
218
skills/xitter/x-cli/src/x_cli/api.py
Normal 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}")
|
||||
210
skills/xitter/x-cli/src/x_cli/auth.py
Normal file
210
skills/xitter/x-cli/src/x_cli/auth.py
Normal 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}"
|
||||
271
skills/xitter/x-cli/src/x_cli/cli.py
Normal file
271
skills/xitter/x-cli/src/x_cli/cli.py
Normal 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()
|
||||
348
skills/xitter/x-cli/src/x_cli/formatters.py
Normal file
348
skills/xitter/x-cli/src/x_cli/formatters.py
Normal 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)
|
||||
21
skills/xitter/x-cli/src/x_cli/utils.py
Normal file
21
skills/xitter/x-cli/src/x_cli/utils.py
Normal 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
252
skills/xitter/x-cli/uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user