Skill: sharbelayy/x-bookmarks (version 1.1.0)

--- name: x-bookmarks version: 1.1.0 description: > Fetch, summarize, and manage X/Twitter bookmarks via bird CLI or X API v2. Use when: (1) user says "check my bookmarks", "what did I bookmark", "bookmark digest", "summarize my bookmarks", "x bookmarks", "twitter bookmarks", (2) user wants a periodic digest of saved tweets, (3) user wants to categorize, search, or analyze their bookmarks, (4) scheduled bookmark digests via cron. Auth: bird CLI with browser cookies, OR X API v2 with OAuth 2.0 tokens. requires: env: - AUTH_TOKEN: "X/Twitter auth token (from browser cookies, for bird CLI auth)" - CT0: "X/Twitter CSRF token (from browser cookies, for bird CLI auth)" - X_API_BEARER_TOKEN: "Optional: X API v2 Bearer token (alternative to bird CLI)" bins: - bird: "bird-cli (npm i -g bird-cli) - preferred backend" files: - .env.bird: "Optional: stores AUTH_TOKEN and CT0 for bird CLI" - ~/.config/x-bookmarks/tokens.json: "OAuth 2.0 tokens for X API v2 backend" security: credentials: > This skill accesses X/Twitter bookmarks, which requires authentication. Two methods are supported: (1) bird CLI using browser cookies (AUTH_TOKEN/CT0 env vars sourced from .env.bird), or (2) X API v2 with OAuth 2.0 tokens stored locally. All credentials are stored locally on the user's machine and never transmitted to third parties. The user must explicitly provide or authorize credentials. permissions: - read: "X/Twitter bookmarks (read-only access)" - write: "Local files only (bookmark state, token storage)" --- # X Bookmarks v2 Turn X/Twitter bookmarks from a graveyard of good intentions into actionable work. **Core philosophy:** Don't just summarize — propose actions the agent can execute. ## Data Source Selection This skill supports **two backends**. Pick the first one that works: ### 1. bird CLI (preferred if available) - Fast, no API key needed, uses browser cookies - Install: `npm install -g bird-cli` - Test: `bird whoami` — if this prints a username, you're good ### 2. X API v2 (fallback) - Works without bird CLI - Requires an X Developer account + OAuth 2.0 app - Setup: see [references/auth-setup.md](references/auth-setup.md) → "X API Setup" ### Auto-detection logic ``` 1. Check if `bird` command exists → try `bird whoami` 2. If bird works → use bird CLI path 3. If not → check for X API tokens (~/.config/x-bookmarks/tokens.json) 4. If tokens exist → use X API path (auto-refresh) 5. If neither → guide user through setup (offer both options) ``` ## Fetching Bookmarks ### Via bird CLI ```bash # Latest 20 bookmarks (default) bird bookmarks --json # Specific count bird bookmarks -n 50 --json # All bookmarks (paginated) bird bookmarks --all --json # With thread context bird bookmarks --include-parent --thread-meta --json # With Chrome cookie auth bird --chrome-profile "Default" bookmarks --json # With manual tokens bird --auth-token "$AUTH_TOKEN" --ct0 "$CT0" bookmarks --json ``` If user has a `.env.bird` file or env vars `AUTH_TOKEN`/`CT0`, source them first: `source .env.bird` ### Via X API v2 ```bash # First-time setup (opens browser for OAuth) python3 scripts/x_api_auth.py --client-id "YOUR_CLIENT_ID" --client-secret "YOUR_SECRET" # Fetch bookmarks (auto-refreshes token) python3 scripts/fetch_bookmarks_api.py -n 20 # All bookmarks python3 scripts/fetch_bookmarks_api.py --all # Since a specific tweet python3 scripts/fetch_bookmarks_api.py --since-id "1234567890" # Pretty print python3 scripts/fetch_bookmarks_api.py -n 50 --pretty ``` The API script outputs the **same JSON format** as bird CLI, so all downstream workflows work identically. **Token management is automatic:** tokens are stored in `~/.config/x-bookmarks/tokens.json` and refreshed via the saved refresh_token. If refresh fails, the agent should guide the user to re-run `x_api_auth.py`. ### Environment variable override If the user already has a Bearer token (e.g., from another tool), they can skip the OAuth dance: ```bash X_API_BEARER_TOKEN="your_token" python3 scripts/fetch_bookmarks_api.py -n 20 ``` ## JSON Output Format (both backends) Each bookmark returns: ```json { "id": "tweet_id", "text": "tweet content", "createdAt": "2026-02-11T01:00:06.000Z", "replyCount": 46, "retweetCount": 60, "likeCount": 801, "bookmarkCount": 12, "viewCount": 50000, "author": { "username": "handle", "name": "Display Name" }, "media": [{ "type": "photo|video", "url": "..." }], "quotedTweet": { "id": "..." } } ``` ## Core Workflows ### 1. Action-First Digest (Primary Use Case) The key differentiator: don't just summarize, **propose actions the agent can execute**. 1. Fetch bookmarks (bird or API, auto-detected) 2. Parse and categorize by topic (auto-detect: crypto, AI, marketing, tools, personal, etc.) 3. For EACH category, propose specific actions: - **Tool/repo bookmarks** → "I can test this, set it up, or analyze the code" - **Strategy/advice bookmarks** → "Here are the actionable steps extracted — want me to implement any?" - **News/trends** → "This connects to [user's work]. Here's the angle for content" - **Content ideas** → "This would make a great tweet/video in your voice. Here's a draft" - **Questions/discussions** → "I can research this deeper and give you a summary" 4. Flag stale bookmarks (>2 weeks old) — "Use it or lose it" 5. Deliver categorized digest with actions Format output as: ``` 📂 CATEGORY (count) • Bookmark summary (@author) → 🤖 I CAN: [specific action the agent can take] ``` ### 2. Scheduled Digest (Cron) Set up a recurring bookmark check. Suggest this cron config to the user: ``` Schedule: daily or weekly Payload: "Check my X bookmarks for new saves since last check. Fetch bookmarks, compare against last digest, summarize only NEW ones. Categorize and propose actions. Deliver to me." ``` Track state by saving the most recent bookmark ID processed. Store in workspace: `memory/bookmark-state.json` → `{ "lastSeenId": "...", "lastDigestAt": "..." }` ### 3. Content Recycling When user asks for content ideas from bookmarks: 1. Fetch recent bookmarks 2. Identify high-engagement tweets (>500 likes) with frameworks, tips, or insights 3. Rewrite key ideas in the user's voice (if voice data available) 4. Suggest posting times based on the bookmark's original engagement ### 4. Pattern Detection When user has enough bookmark history: 1. Fetch all bookmarks (`--all`) 2. Cluster by topic/keywords 3. Report: "You've bookmarked N tweets about [topic]. Want me to go deeper?" 4. Suggest: research reports, content series, or tools based on patterns ### 5. Bookmark Cleanup For stale bookmarks: 1. Identify bookmarks older than a threshold (default: 30 days) 2. For each: extract the TL;DR and one actionable takeaway 3. Present: "Apply it today or clear it" 4. User can unbookmark via: `bird unbookmark <tweet-id>` (bird only) ## Error Handling | Error | Cause | Fix | |-------|-------|-----| | `bird: command not found` | bird CLI not installed | Use X API path instead, or `npm i -g bird-cli` | | "No Twitter cookies found" | Not logged into X in browser | Log into x.com in Chrome/Firefox, or use X API | | EPERM on Safari cookies | macOS permissions | Use Chrome/Firefox or X API instead | | Empty results | Cookies/token expired | Re-login or re-run `x_api_auth.py` | | Rate limit (429) | Too many API requests | Wait and retry, use `--count` to limit | | "No X API token found" | Haven't run auth setup | Run `x_api_auth.py --client-id YOUR_ID` | | Token refresh failed | Refresh token expired | Re-run `x_api_auth.py` to re-authorize | ## Tips - Start with `-n 20` for quick digests, `--all` for deep analysis - bird: Use `--include-parent` for thread context on replies - API: includes `bookmarkCount` and `viewCount` (bird may not) - Bookmark folders supported via bird `--folder-id <id>` - Both backends output identical JSON — workflows are backend-agnostic

path: README.md
content: # x-bookmarks

Turn X/Twitter bookmarks from a graveyard of good intentions into actionable work.

## How This Works

Once installed, just tell your AI agent:

> "check my bookmarks"

That's it. Your agent will:

1. **Fetch** your latest X bookmarks (auto-detects bird CLI or X API v2)
2. **Categorize** them by topic (crypto, AI, marketing, tools, etc.)
3. **Propose actions** for each one — not just summaries, but things your agent can actually do:

```
📂 AI TOOLS (3)
• @someone shared a repo for automating video edits
  → 🤖 I CAN: Clone it, test it, and set it up for you

📂 TRADING (2)  
• @trader posted a new momentum strategy with backtest data
  → 🤖 I CAN: Compare this against your current strategy and report differences
```

You can also say:
- **"bookmark digest"** — get a categorized summary of recent saves
- **"what did I bookmark this week?"** — filtered by time
- **"find patterns in my bookmarks"** — clusters topics you keep saving
- **"clean up old bookmarks"** — flags stale saves with TL;DRs

### Scheduled Digests

Set up a daily or weekly cron job and your agent will automatically check for new bookmarks, categorize them, and deliver a digest to you.

## What it does

- Fetches your X bookmarks via **bird CLI** or **X API v2** (auto-detects)
- Categorizes them by topic
- Proposes specific actions your AI agent can execute
- Supports scheduled digests via cron
- Pattern detection across bookmark history

## Quick Start

### Option 1: bird CLI (easiest)

```bash
npm install -g bird-cli
# Log into x.com in Chrome, then:
bird --chrome-profile "Default" bookmarks --json
```

### Option 2: X API v2 (no bird needed)

```bash
# One-time: create app at https://developer.x.com, then:
python3 scripts/x_api_auth.py --client-id "YOUR_CLIENT_ID"

# Fetch bookmarks
python3 scripts/fetch_bookmarks_api.py -n 20
```

Both backends output the same JSON format — all workflows work with either.

## Auto-Detection

You don't need to pick a backend. The skill automatically:

1. Tries `bird whoami` — if it works, uses bird CLI
2. If not, checks for X API tokens in `~/.config/x-bookmarks/`
3. If neither, walks you through setup (offers both options)

## Files

```
SKILL.md              — Agent instructions (the skill itself)
scripts/
  fetch_bookmarks.sh      — bird CLI wrapper
  fetch_bookmarks_api.py  — X API v2 fetcher
  x_api_auth.py           — OAuth 2.0 PKCE auth helper
references/
  auth-setup.md           — Detailed setup guide for both backends
```

## Requirements

**bird CLI path:** Node.js, npm, bird-cli, browser with X login  
**X API path:** Python 3.10+, X Developer account, OAuth 2.0 app

## Install as OpenClaw Skill

Copy this folder to your OpenClaw skills directory, or:

```bash
# If published to ClawhHub
openclaw skill install x-bookmarks
```

## License

MIT


path: references/auth-setup.md
content: # X Bookmarks — Auth Setup

Two ways to authenticate. Pick whichever works for you.

---

## Option A: bird CLI (browser cookies)

Easiest if you have bird installed (`npm i -g bird-cli`).

### A1. Chrome Cookie Extraction (Recommended)

```bash
bird --chrome-profile "Default" bookmarks --json
```

Find your Chrome profile name:
1. Open `chrome://version` in Chrome
2. Look for "Profile Path" — the last folder name is your profile (e.g., "Default", "Profile 1")

**Troubleshooting:**
- macOS may prompt for Keychain access → click "Allow"
- Must be logged into x.com in that Chrome profile
- If cookie extraction fails, close Chrome first (locked DB)

**Make it permanent** — create `~/.config/bird/config.json5`:
```json5
{
  chromeProfile: "Default"
}
```

### A2. Firefox Cookie Extraction

```bash
bird --firefox-profile "default-release" bookmarks --json
```

### A3. Brave Browser

```bash
bird --chrome-profile-dir "$HOME/Library/Application Support/BraveSoftware/Brave-Browser/Default" bookmarks --json
```

### A4. Manual Tokens

Extract from browser DevTools:
1. Open x.com → DevTools (F12) → Application → Cookies → `https://x.com`
2. Copy `auth_token` and `ct0`

```bash
bird --auth-token "YOUR_AUTH_TOKEN" --ct0 "YOUR_CT0" bookmarks --json
```

Or save to `.env.bird`:
```bash
export AUTH_TOKEN="abc123..."
export CT0="xyz789..."
```

### Verify

```bash
bird whoami
```

---

## Option B: X API v2 (no bird needed)

Use this if bird CLI isn't available or stops working.

### Step 1: Create an X Developer App

1. Go to [X Developer Console](https://developer.x.com/en/portal/petition/essential/basic-info)
2. Create a project + app
3. Under **User authentication settings**, configure:
   - **App permissions:** Read (minimum)
   - **Type of App:** Native App (public client) or Web App (confidential)
   - **Callback URL:** `http://localhost:8739/callback`
   - **Website URL:** anything (e.g., `https://example.com`)
4. Note your **Client ID** (and Client Secret if confidential app)

### Step 2: Authorize

Run the auth helper (one-time):

```bash
# Public client (no secret)
python3 scripts/x_api_auth.py --client-id "YOUR_CLIENT_ID"

# Confidential client (with secret)
python3 scripts/x_api_auth.py --client-id "YOUR_CLIENT_ID" --client-secret "YOUR_SECRET"
```

This opens your browser → you log in to X → authorize the app → tokens are saved automatically to `~/.config/x-bookmarks/tokens.json`.

### Step 3: Fetch Bookmarks

```bash
python3 scripts/fetch_bookmarks_api.py -n 20
```

Tokens auto-refresh. If they expire, re-run step 2.

### Alternative: Bearer Token Override

If you already have a valid Bearer token from another source:

```bash
X_API_BEARER_TOKEN="your_token" python3 scripts/fetch_bookmarks_api.py -n 20
```

### X API Pricing Note

As of 2025, the X API uses **pay-per-usage pricing**. Bookmark reads are low volume (a few calls/day for digests), so costs should be minimal. Check [X Developer Console](https://console.x.com) for current rates.

---

## Which Should I Use?

| | bird CLI | X API v2 |
|---|---|---|
| **Setup** | `npm i -g bird-cli` + browser login | Developer account + OAuth |
| **Auth** | Browser cookies (auto) | OAuth 2.0 tokens (auto-refresh) |
| **Cost** | Free | Pay-per-use (very cheap) |
| **Reliability** | Depends on cookie access | Official API, stable |
| **Extra data** | Thread context, folders | View count, bookmark count |
| **Unbookmark** | ✅ `bird unbookmark <id>` | ✅ via API (DELETE endpoint) |

**TL;DR:** Try bird first. If it doesn't work, use the API.


path: scripts/fetch_bookmarks.sh
content: #!/usr/bin/env bash
# Fetch X bookmarks via bird CLI and output JSON
# Usage: fetch_bookmarks.sh [count] [--all] [--since-id <id>] [extra bird flags...]
#
# Examples:
#   ./fetch_bookmarks.sh 20
#   ./fetch_bookmarks.sh --all --max-pages 5
#   ./fetch_bookmarks.sh 50 --chrome-profile "Default"
#
# Environment:
#   AUTH_TOKEN, CT0 — manual auth (or use --chrome-profile flag)
#   BOOKMARKS_STATE — path to state file for tracking last-seen ID

set -euo pipefail

COUNT="${1:-20}"
shift 2>/dev/null || true

# Build bird command
CMD=(bird bookmarks --json)

# If count is a number, add -n flag; otherwise treat as extra flag
if [[ "$COUNT" =~ ^[0-9]+$ ]]; then
  CMD+=(-n "$COUNT")
else
  CMD+=("$COUNT")
fi

# Add auth from env if available
if [[ -n "${AUTH_TOKEN:-}" ]] && [[ -n "${CT0:-}" ]]; then
  CMD+=(--auth-token "$AUTH_TOKEN" --ct0 "$CT0")
fi

# Pass through any extra flags
CMD+=("$@")

# Run and output
exec "${CMD[@]}"


path: scripts/fetch_bookmarks_api.py
content: #!/usr/bin/env python3
"""
Fetch X/Twitter bookmarks via X API v2.

Fallback for when bird CLI is unavailable. Uses OAuth 2.0 tokens
managed by x_api_auth.py.

Usage:
    python3 fetch_bookmarks_api.py [--count 20] [--all] [--since-id ID]

Output: JSON array of bookmarks matching bird CLI format for compatibility.

Environment:
    X_API_BEARER_TOKEN — override: use this Bearer token directly
    X_API_CLIENT_ID — used for token refresh if no bearer override
"""

import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from pathlib import Path

# Import auth helper (same directory)
sys.path.insert(0, str(Path(__file__).parent))
from x_api_auth import get_valid_token, load_tokens, load_config

BASE_URL = "https://api.x.com/2"
MAX_RESULTS_PER_PAGE = 100  # X API max


def get_me(token: str) -> str:
    """Get authenticated user's ID."""
    req = urllib.request.Request(f"{BASE_URL}/users/me")
    req.add_header("Authorization", f"Bearer {token}")
    with urllib.request.urlopen(req) as resp:
        data = json.loads(resp.read())
        return data["data"]["id"]


def fetch_bookmarks_page(
    token: str, user_id: str, max_results: int = 20,
    pagination_token: str = None, since_id: str = None
) -> dict:
    """Fetch a single page of bookmarks."""
    params = {
        "max_results": min(max_results, MAX_RESULTS_PER_PAGE),
        "tweet.fields": "created_at,public_metrics,entities,conversation_id,referenced_tweets",
        "user.fields": "username,name,profile_image_url,verified",
        "media.fields": "type,url,preview_image_url",
        "expansions": "author_id,attachments.media_keys,referenced_tweets.id",
    }
    if pagination_token:
        params["pagination_token"] = pagination_token
    if since_id:
        params["since_id"] = since_id

    qs = urllib.parse.urlencode(params)
    url = f"{BASE_URL}/users/{user_id}/bookmarks?{qs}"

    req = urllib.request.Request(url)
    req.add_header("Authorization", f"Bearer {token}")

    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


def normalize_tweet(tweet: dict, users: dict, media: dict) -> dict:
    """Convert X API v2 tweet to bird-CLI-compatible format."""
    author_id = tweet.get("author_id", "")
    author = users.get(author_id, {})
    metrics = tweet.get("public_metrics", {})

    # Resolve media
    media_list = []
    media_keys = tweet.get("attachments", {}).get("media_keys", [])
    for mk in media_keys:
        m = media.get(mk, {})
        if m:
            media_list.append({
                "type": m.get("type", "photo"),
                "url": m.get("url") or m.get("preview_image_url", ""),
            })

    # Resolve quoted tweet
    quoted = None
    for ref in tweet.get("referenced_tweets", []):
        if ref.get("type") == "quoted":
            quoted = {"id": ref["id"]}

    result = {
        "id": tweet["id"],
        "text": tweet.get("text", ""),
        "createdAt": tweet.get("created_at", ""),
        "replyCount": metrics.get("reply_count", 0),
        "retweetCount": metrics.get("retweet_count", 0),
        "likeCount": metrics.get("like_count", 0),
        "bookmarkCount": metrics.get("bookmark_count", 0),
        "viewCount": metrics.get("impression_count", 0),
        "author": {
            "username": author.get("username", ""),
            "name": author.get("name", ""),
        },
        "media": media_list,
    }
    if quoted:
        result["quotedTweet"] = quoted

    return result


def fetch_all_bookmarks(token: str, count: int = 20, all_pages: bool = False, since_id: str = None) -> list:
    """Fetch bookmarks with pagination support."""
    user_id = get_me(token)
    bookmarks = []
    pagination_token = None
    remaining = count if not all_pages else float("inf")

    while remaining > 0:
        page_size = min(int(remaining), MAX_RESULTS_PER_PAGE)
        try:
            response = fetch_bookmarks_page(
                token, user_id, page_size, pagination_token, since_id
            )
        except urllib.error.HTTPError as e:
            if e.code == 429:
                print("Rate limited. Try again later.", file=sys.stderr)
                break
            raise

        data = response.get("data", [])
        if not data:
            break

        # Build lookup maps from includes
        includes = response.get("includes", {})
        users = {u["id"]: u for u in includes.get("users", [])}
        media = {m["media_key"]: m for m in includes.get("media", [])}

        for tweet in data:
            bookmarks.append(normalize_tweet(tweet, users, media))

        remaining -= len(data)

        # Check for next page
        meta = response.get("meta", {})
        pagination_token = meta.get("next_token")
        if not pagination_token:
            break

    return bookmarks


def main():
    parser = argparse.ArgumentParser(description="Fetch X bookmarks via API v2")
    parser.add_argument("-n", "--count", type=int, default=20, help="Number of bookmarks")
    parser.add_argument("--all", action="store_true", help="Fetch all bookmarks")
    parser.add_argument("--since-id", help="Only return bookmarks after this tweet ID")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")

    args = parser.parse_args()

    # Get token: env override > stored token
    [REDACTED_SECRET]"X_API_BEARER_TOKEN")
    if not [REDACTED_SECRET])
    if not [REDACTED_SECRET]
            "No X API token found. Run x_api_auth.py first to authorize, "
            "or set X_API_BEARER_TOKEN env var.",
            file=sys.stderr,
        )
        sys.exit(1)

    bookmarks = fetch_all_bookmarks(
        token, count=args.count, all_pages=args.all, since_id=args.since_id
    )

    indent = 2 if args.pretty else None
    print(json.dumps(bookmarks, indent=indent, ensure_ascii=False))


if __name__ == "__main__":
    main()


path: scripts/x_api_auth.py
content: #!/usr/bin/env python3
"""
X API OAuth 2.0 PKCE Authentication Helper

One-time setup: runs a local server to handle the OAuth callback,
opens the browser for user authorization, and saves tokens to disk.

Usage:
    python3 x_api_auth.py --client-id YOUR_CLIENT_ID [--client-secret YOUR_SECRET]

Tokens are saved to ~/.config/x-bookmarks/tokens.json and auto-refreshed.
"""

import argparse
import base64
import hashlib
import http.server
import json
import os
import secrets
import sys
import threading
import urllib.parse
import urllib.request
import webbrowser
from pathlib import Path

TOKEN_DIR = Path.home() / ".config" / "x-bookmarks"
TOKEN_FILE = TOKEN_DIR / "tokens.json"
CONFIG_FILE = TOKEN_DIR / "config.json"

AUTHORIZE_URL = "https://x.com/i/oauth2/authorize"
TOKEN_URL = "https://api.x.com/2/oauth2/token"
REDIRECT_PORT = 8739
REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}/callback"
SCOPES = "tweet.read users.read bookmark.read bookmark.write offline.access"


def save_config(client_id: str, client_secret: str = ""):
    TOKEN_DIR.mkdir(parents=True, exist_ok=True)
    config = {"client_id": client_id}
    if client_secret:
        config["client_secret"] = client_secret
    CONFIG_FILE.write_text(json.dumps(config, indent=2))
    print(f"Config saved to {CONFIG_FILE}")


def load_config() -> dict:
    if not CONFIG_FILE.exists():
        return {}
    return json.loads(CONFIG_FILE.read_text())


def save_tokens(tokens: dict):
    TOKEN_DIR.mkdir(parents=True, exist_ok=True)
    TOKEN_FILE.write_text(json.dumps(tokens, indent=2))
    os.chmod(TOKEN_FILE, 0o600)
    print(f"Tokens saved to {TOKEN_FILE}")


def load_tokens() -> dict | None:
    if not TOKEN_FILE.exists():
        return None
    return json.loads(TOKEN_FILE.read_text())


def generate_pkce():
    verifier = secrets.token_urlsafe(64)[:128]
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()
    ).rstrip(b"=").decode()
    return verifier, challenge


def exchange_code(code: str, verifier: str, client_id: str, client_secret: str = "") -> dict:
    data = urllib.parse.urlencode({
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": verifier,
        "client_id": client_id,
    }).encode()

    req = urllib.request.Request(TOKEN_URL, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    if client_secret:
        creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
        req.add_header("Authorization", f"Basic {creds}")

    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


def refresh_access_token(refresh_token: str, client_id: str, client_secret: str = "") -> dict:
    data = urllib.parse.urlencode({
        "grant_type": "refresh_token",
        "refresh_token": refresh_token,
        "client_id": client_id,
    }).encode()

    req = urllib.request.Request(TOKEN_URL, data=data, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")

    if client_secret:
        creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
        req.add_header("Authorization", f"Basic {creds}")

    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


def get_valid_token() -> str | None:
    """Get a valid access token, refreshing if needed. Returns None if no tokens."""
    tokens = load_tokens()
    if not tokens:
        return None

    config = load_config()
    if not config.get("client_id"):
        return None

    # Try refreshing
    if tokens.get("refresh_token"):
        try:
            new_tokens = refresh_access_token(
                tokens["refresh_token"],
                config["client_id"],
                config.get("client_secret", ""),
            )
            new_tokens.setdefault("refresh_token", tokens["refresh_token"])
            save_tokens(new_tokens)
            return new_tokens["access_token"]
        except Exception as e:
            print(f"Token refresh failed: {e}", file=sys.stderr)
            return tokens.get("access_token")

    return tokens.get("access_token")


def authorize(client_id: str, client_secret: str = ""):
    """Run the full OAuth 2.0 PKCE authorization flow."""
    verifier, challenge = generate_pkce()
    state = secrets.token_urlsafe(32)

    auth_params = urllib.parse.urlencode({
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": REDIRECT_URI,
        "scope": SCOPES,
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })

    auth_url = f"{AUTHORIZE_URL}?{auth_params}"
    result = {"code": None, "error": None}

    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            parsed = urllib.parse.urlparse(self.path)
            params = urllib.parse.parse_qs(parsed.query)

            if parsed.path == "/callback":
                if params.get("state", [None])[0] != state:
                    result["error"] = "State mismatch"
                elif "error" in params:
                    result["error"] = params["error"][0]
                else:
                    result["code"] = params.get("code", [None])[0]

                self.send_response(200)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                msg = "✅ Authorization successful! You can close this tab." if result["code"] else f"❌ Error: {result['error']}"
                self.wfile.write(f"<html><body><h2>{msg}</h2></body></html>".encode())
            else:
                self.send_response(404)
                self.end_headers()

        def log_message(self, *args):
            pass  # Suppress logs

    server = http.server.HTTPServer(("localhost", REDIRECT_PORT), Handler)
    server.timeout = 120

    print(f"\n🔐 Opening browser for X authorization...")
    print(f"   If it doesn't open, visit:\n   {auth_url}\n")
    webbrowser.open(auth_url)

    # Wait for callback
    server.handle_request()
    server.server_close()

    if result["error"]:
        print(f"❌ Authorization failed: {result['error']}")
        sys.exit(1)

    if not result["code"]:
        print("❌ No authorization code received (timeout?)")
        sys.exit(1)

    print("🔄 Exchanging code for tokens...")
    tokens = exchange_code(result["code"], verifier, client_id, client_secret)
    save_tokens(tokens)
    save_config(client_id, client_secret)

    print(f"✅ Authenticated! Access token expires in {tokens.get('expires_in', '?')}s")
    if tokens.get("refresh_token"):
        print("   Refresh token saved — will auto-refresh.")
    else:
        print("   ⚠️  No refresh token. Add 'offline.access' scope or re-auth when expired.")


def main():
    parser = argparse.ArgumentParser(description="X API OAuth 2.0 PKCE setup")
    parser.add_argument("--client-id", required=True, help="Your X API Client ID")
    parser.add_argument("--client-secret", default="", help="Client Secret (for confidential apps)")
    parser.add_argument("--refresh", action="store_true", help="Just refresh the existing token")
    parser.add_argument("--print-token", action="store_true", help="Print current access token")

    args = parser.parse_args()

    if args.print_token:
        [REDACTED_SECRET])
        if [REDACTED_SECRET])
        else:
            print("No valid token found. Run without --print-token to authorize.", file=sys.stderr)
            sys.exit(1)
        return

    if args.refresh:
        save_config(args.client_id, args.client_secret)
        [REDACTED_SECRET])
        if [REDACTED_SECRET]"✅ Token refreshed successfully")
        else:
            print("❌ Refresh failed. Re-run without --refresh to re-authorize.")
            sys.exit(1)
        return

    authorize(args.client_id, args.client_secret)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)