Skill: klemenska/twitter-x-api (version 1.0.0)

--- name: twitter description: "X/Twitter messaging management via API. Read tweets, post tweets, reply, send DMs, search, and view analytics. Use when the user wants to interact with X/Twitter: (1) posting or scheduling tweets, (2) reading timeline/mentions/DMs, (3) replying to tweets, (4) searching tweets/users/hashtags, (5) checking engagement analytics. Requires Twitter API credentials (API key, API secret, access token, access secret) or Bearer token." --- # Twitter/X API Skill Interact with X (Twitter) via API v2 for reading, posting, replying, DMs, search, and analytics. ## Setup ### Credentials Store credentials in environment variables or `~/.config/twitter/credentials.json`: ```bash export TWITTER_API_KEY="your-api-key" export TWITTER_API_SECRET="your-api-secret" export TWITTER_ACCESS_TOKEN="your-access-token" export TWITTER_ACCESS_SECRET="your-access-secret" export TWITTER_BEARER_TOKEN="your-bearer-token" # For read-only operations ``` Or create credentials file: ```bash mkdir -p ~/.config/twitter cat > ~/.config/twitter/credentials.json << 'EOF' { "api_key": "your-api-key", "api_secret": "your-api-secret", "access_token": "your-access-token", "access_secret": "your-access-secret", "bearer_token": "your-bearer-token" } EOF chmod 600 ~/.config/twitter/credentials.json ``` ### Install Dependencies ```bash pip install tweepy ``` ## Quick Reference | Task | Command | |------|---------| | Post tweet | `{baseDir}/scripts/tweet.py post "text"` | | Post with image | `{baseDir}/scripts/tweet.py post "text" --media image.png` | | Reply | `{baseDir}/scripts/tweet.py reply TWEET_ID "text"` | | Thread | `{baseDir}/scripts/tweet.py thread "tweet1" "tweet2" ...` | | Get timeline | `{baseDir}/scripts/tweet.py timeline [--count 20]` | | Get mentions | `{baseDir}/scripts/tweet.py mentions [--count 20]` | | Get DMs | `{baseDir}/scripts/tweet.py dms [--count 20]` | | Send DM | `{baseDir}/scripts/tweet.py dm USERNAME "message"` | | Search | `{baseDir}/scripts/tweet.py search "query" [--count 20]` | | User info | `{baseDir}/scripts/tweet.py user USERNAME` | | Tweet info | `{baseDir}/scripts/tweet.py show TWEET_ID` | | Analytics | `{baseDir}/scripts/tweet.py analytics TWEET_ID` | ## Scripts ### tweet.py Main script for all Twitter operations. Run with `--help` for details: ```bash {baseDir}/scripts/tweet.py --help {baseDir}/scripts/tweet.py post --help ``` ### Common Workflows **Post a simple tweet:** ```bash {baseDir}/scripts/tweet.py post "Hello, world!" ``` **Post with image:** ```bash {baseDir}/scripts/tweet.py post "Check this out!" --media photo.png {baseDir}/scripts/tweet.py post "Multiple images" --media img1.png --media img2.png ``` **Reply to a tweet:** ```bash {baseDir}/scripts/tweet.py reply 1234567890 "Great point!" ``` **Post a thread:** ```bash {baseDir}/scripts/tweet.py thread \ "First tweet in thread" \ "Second tweet" \ "Third tweet" ``` **Read your mentions:** ```bash {baseDir}/scripts/tweet.py mentions --count 50 ``` **Search for tweets:** ```bash {baseDir}/scripts/tweet.py search "openclaw agent" --count 20 {baseDir}/scripts/tweet.py search "#AI lang:en" --count 20 ``` **Get user info:** ```bash {baseDir}/scripts/tweet.py user elonmusk ``` **Send a DM:** ```bash {baseDir}/scripts/tweet.py dm username "Hello from OpenClaw!" ``` **View tweet analytics:** ```bash {baseDir}/scripts/tweet.py analytics 1234567890 ``` ## API Tiers & Limits | Tier | Cost | Read | Write | Search | |------|------|------|-------|--------| | Free | $0 | Limited | - | - | | Basic | $100/mo | 10k/mo | 1.5k/mo | 50/mo | | Pro | $5000/mo | 1M/mo | 300k/mo | 500/mo | **Free tier** can only post tweets (no read access to timeline/mentions). **Basic tier** required for reading mentions, timeline, and search. **Write-only operations** work on free tier. See `{baseDir}/references/api-limits.md` for detailed rate limits. ## Error Handling Common errors: | Error | Cause | Solution | |-------|-------|----------| | 403 Forbidden | Insufficient tier | Upgrade API tier or check endpoint access | | 429 Too Many Requests | Rate limit hit | Wait and retry; check rate limit headers | | 401 Unauthorized | Invalid credentials | Verify API keys and tokens | | 404 Not Found | Tweet/user deleted | Handle gracefully, inform user | | 422 Unprocessable | Duplicate tweet | Wait before posting same content | ## Notes - **Rate limits**: X API has strict rate limits. Scripts include retry logic. - **Media uploads**: Images must be <5MB (PNG/JPG) or <15MB (GIF). Videos <512MB. - **Character limit**: 280 characters per tweet. Threads for longer content. - **DMs**: Require OAuth 1.0a user context (not Bearer token). - **Search operators**: `{baseDir}/references/search-operators.md` for advanced queries. ## Related Files - `{baseDir}/scripts/tweet.py` - Main CLI for all operations - `{baseDir}/references/api-limits.md` - Detailed rate limits by endpoint - `{baseDir}/references/search-operators.md` - Twitter search syntax

path: references/api-limits.md
content: # Twitter API v2 Rate Limits

## Endpoint Limits by Tier

### Free Tier ($0/month)

| Endpoint | Limit | Window |
|----------|-------|--------|
| Post tweet | 50 | 24 hours |
| Delete tweet | 50 | 24 hours |
| Get user info | 25 | 15 minutes |

**Note:** No read access to timeline, mentions, or search on Free tier.

### Basic Tier ($100/month)

| Endpoint | Limit | Window |
|----------|-------|--------|
| Post tweet | 1,500 | 24 hours |
| Delete tweet | 1,500 | 24 hours |
| Get timeline | 60 | 15 minutes |
| Get mentions | 60 | 15 minutes |
| Search recent tweets | 60 | 15 minutes |
| Get user info | 60 | 15 minutes |
| Get tweet info | 60 | 15 minutes |

### Pro Tier ($5,000/month)

| Endpoint | Limit | Window |
|----------|-------|--------|
| Post tweet | 300,000 | 24 hours |
| Get timeline | 900 | 15 minutes |
| Get mentions | 900 | 15 minutes |
| Search recent tweets | 900 | 15 minutes |
| Search full archive | 300 | 15 minutes |
| Get user info | 900 | 15 minutes |

## Monthly Tweet Caps

| Tier | Posts/Month |
|------|-------------|
| Free | 1,500 |
| Basic | 3,000 |
| Pro | 300,000 |

## Rate Limit Headers

Twitter includes these headers in responses:

- `x-rate-limit-limit`: Maximum requests per window
- `x-rate-limit-remaining`: Requests remaining in window
- `x-rate-limit-reset`: Unix timestamp when limit resets

## Handling Rate Limits

```python
import time
import tweepy

def with_retry(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except tweepy.errors.TooManyRequests as e:
            reset = int(e.response.headers.get('x-rate-limit-reset', 0))
            wait = max(reset - time.time(), 60)
            print(f"Rate limited. Waiting {wait:.0f}s...")
            time.sleep(wait)
    raise Exception("Max retries exceeded")
```

## Best Practices

1. **Cache results** - Don't re-fetch the same data repeatedly
2. **Use Bearer token** for read-only operations (doesn't consume user quota)
3. **Batch operations** - Combine multiple lookups into single requests
4. **Monitor headers** - Track remaining limits proactively
5. **Implement backoff** - Exponential backoff for rate limit errors

## Error Codes

| Code | Meaning | Action |
|------|---------|--------|
| 429 | Rate limit exceeded | Wait for reset, retry |
| 403 | Forbidden (tier issue) | Check API access, upgrade tier |
| 401 | Unauthorized | Verify credentials |
| 404 | Not found | Tweet/user deleted or private |
| 422 | Unprocessable | Duplicate tweet, wait before reposting |

path: references/search-operators.md
content: # Twitter Search Operators

## Basic Operators

| Operator | Example | Description |
|----------|---------|-------------|
| keyword | openclaw | Contains "openclaw" |
| "exact phrase" | "openclaw agent" | Exact phrase match |
| -exclude | openclaw -spam | Excludes tweets with "spam" |
| OR | openclaw OR "ai agent" | Match either term |

## User Operators

| Operator | Example | Description |
|----------|---------|-------------|
| from:user | from:openclaw_ai | Tweets from @openclaw_ai |
| to:user | to:elonmusk | Replies to @elonmusk |
| @user | @openclaw_ai | Mentions @openclaw_ai |

## Content Operators

| Operator | Example | Description |
|----------|---------|-------------|
| has:media | has:media | Contains media (image/video) |
| has:images | has:images | Contains images |
| has:videos | has:videos | Contains videos |
| has:links | has:links | Contains links |
| has:mentions | has:mentions | Contains @mentions |
| has:hashtags | has:hashtags | Contains hashtags |
| url:domain | url:github.com | Links to github.com |

## Engagement Operators

| Operator | Example | Description |
|----------|---------|-------------|
| min_retweets:N | min_retweets:100 | At least 100 retweets |
| min_faves:N | min_faves:50 | At least 50 likes |
| min_replies:N | min_replies:10 | At least 10 replies |

## Language & Location

| Operator | Example | Description |
|----------|---------|-------------|
| lang:code | lang:en | English tweets |
| lang:de | lang:de | German tweets |
| place:country | place:US | Tweets from USA |
| place:city | place:London | Tweets from London |
| near:city | near:Berlin | Geo-tagged near Berlin |
| within:radius | within:10km | Within radius of location |

## Time Operators

| Operator | Example | Description |
|----------|---------|-------------|
| since:YYYY-MM-DD | since:2024-01-01 | After this date |
| until:YYYY-MM-DD | until:2024-12-31 | Before this date |

## Type Operators

| Operator | Example | Description |
|----------|---------|-------------|
| is:retweet | is:retweet | Only retweets |
| is:quote | is:quote | Only quote tweets |
| is:reply | is:reply | Only replies |
| is:verified | is:verified | From verified accounts |

## Combining Operators

Build complex queries by combining operators:

```
# AI tweets from verified users with 100+ likes
"AI agent" is:verified min_faves:100 lang:en

# Recent tweets about OpenClaw with images
openclaw has:images since:2024-01-01

# Threads (replies from same user)
from:user is:reply to:user
```

## Query Limits

- Maximum query length: 512 characters
- Maximum operators: 50 (varies by tier)
- Boolean operators: AND, OR, NOT (also `-`)

## Common Patterns

### Find viral content
```
"topic" min_retweets:1000 min_faves:5000
```

### Find questions
```
"topic" ?
```

### Find original content (no retweets)
```
"topic" -is:retweet
```

### Find threads
```
from:username is:reply to:username
```

### Find recent news
```
"topic" has:links since:2024-01-15 lang:en
```

## Notes

- Search requires Basic tier or higher
- `search_recent_tweets`: Last 7 days (Basic tier)
- `search_all_tweets`: Full archive (Pro tier)
- Operators are case-insensitive
- Parentheses for grouping: `(openclaw OR "AI agent") -spam`

path: scripts/tweet.py
content: #!/usr/bin/env python3
"""
Twitter/X API CLI for reading, posting, replying, DMs, search, and analytics.
Uses Tweepy for X API v2 access.

Usage:
    tweet.py post "text" [--media FILE]
    tweet.py reply TWEET_ID "text"
    tweet.py thread "tweet1" "tweet2" ...
    tweet.py timeline [--count N]
    tweet.py mentions [--count N]
    tweet.py dms [--count N]
    tweet.py dm USERNAME "message"
    tweet.py search "query" [--count N]
    tweet.py user USERNAME
    tweet.py show TWEET_ID
    tweet.py analytics TWEET_ID
"""

import argparse
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path

try:
    import tweepy
except ImportError:
    print("Error: tweepy not installed. Run: pip install tweepy")
    sys.exit(1)


def load_credentials():
    """Load credentials from env vars or credentials file."""
    creds = {}

    # Try environment variables first
    env_keys = {
        "TWITTER_API_KEY": "api_key",
        "TWITTER_API_SECRET": "api_secret",
        "TWITTER_ACCESS_TOKEN": "access_token",
        "TWITTER_ACCESS_SECRET": "access_secret",
        "TWITTER_BEARER_TOKEN": "bearer_token",
    }
    for env_key, cred_key in env_keys.items():
        if os.environ.get(env_key):
            creds[cred_key] = os.environ[env_key]

    # Try credentials file
    cred_file = Path.home() / ".config" / "twitter" / "credentials.json"
    if cred_file.exists():
        with open(cred_file) as f:
            file_creds = json.load(f)
            creds.update({k: v for k, v in file_creds.items() if v})

    return creds


def get_client(creds, read_only=False):
    """Get Tweepy client for API v2."""
    if read_only and creds.get("bearer_token"):
        return tweepy.Client(bearer_token=creds["bearer_token"])

    if all(k in creds for k in ["api_key", "api_secret", "access_token", "access_secret"]):
        return tweepy.Client(
            consumer_key=creds["api_key"],
            consumer_secret=creds["api_secret"],
            access_token=creds["access_token"],
            access_token_secret=creds["access_secret"],
        )

    raise ValueError("Insufficient credentials. Need either bearer_token for read-only or all four OAuth keys for full access.")


def get_api_v1(creds):
    """Get Tweepy API v1.1 object for media uploads."""
    auth = tweepy.OAuth1UserHandler(
        creds["api_key"],
        creds["api_secret"],
        creds["access_token"],
        creds["access_secret"],
    )
    return tweepy.API(auth)


def format_tweet(tweet, include_user=True):
    """Format a tweet for display."""
    lines = []
    if include_user and hasattr(tweet, "author_id") or (isinstance(tweet, dict) and "author_id" in tweet):
        # Will show user separately if available
        pass

    text = tweet.text if hasattr(tweet, "text") else tweet.get("text", "")
    created_at = tweet.created_at if hasattr(tweet, "created_at") else tweet.get("created_at")
    tweet_id = tweet.id if hasattr(tweet, "id") else tweet.get("id")

    lines.append(f"Tweet ID: {tweet_id}")
    if created_at:
        if isinstance(created_at, str):
            lines.append(f"Time: {created_at}")
        else:
            lines.append(f"Time: {created_at.strftime('%Y-%m-%d %H:%M:%S')}")
    lines.append(f"Text: {text}")
    lines.append("")

    return "\n".join(lines)


def cmd_post(args):
    """Post a new tweet."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)

    media_ids = None
    if args.media:
        api = get_api_v1(creds)
        media_ids = []
        for media_path in args.media:
            print(f"Uploading {media_path}...")
            media = api.media_upload(filename=media_path)
            media_ids.append(media.media_id)

    response = client.create_tweet(text=args.text, media_ids=media_ids if media_ids else None)
    tweet_id = response.data["id"]
    print(f"Posted tweet: https://twitter.com/user/status/{tweet_id}")
    return tweet_id


def cmd_reply(args):
    """Reply to a tweet."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)

    response = client.create_tweet(text=args.text, in_reply_to_tweet_id=args.tweet_id)
    tweet_id = response.data["id"]
    print(f"Posted reply: https://twitter.com/user/status/{tweet_id}")
    return tweet_id


def cmd_thread(args):
    """Post a thread of tweets."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)

    if len(args.tweets) < 2:
        print("Error: Thread requires at least 2 tweets")
        sys.exit(1)

    previous_id = None
    tweet_ids = []

    for i, text in enumerate(args.tweets):
        if previous_id:
            response = client.create_tweet(text=text, in_reply_to_tweet_id=previous_id)
        else:
            response = client.create_tweet(text=text)

        tweet_id = response.data["id"]
        tweet_ids.append(tweet_id)
        previous_id = tweet_id
        print(f"Tweet {i+1}/{len(args.tweets)}: https://twitter.com/user/status/{tweet_id}")

        # Small delay to avoid rate limits
        if i < len(args.tweets) - 1:
            time.sleep(0.5)

    print(f"\nThread posted: {len(tweet_ids)} tweets")
    return tweet_ids


def cmd_timeline(args):
    """Get home timeline."""
    creds = load_credentials()
    client = get_client(creds, read_only=True)

    response = client.get_home_timeline(max_results=args.count, tweet_fields=["created_at", "author_id"])

    if not response.data:
        print("No tweets in timeline")
        return

    print(f"Timeline ({len(response.data)} tweets):\n")
    for tweet in response.data:
        print(format_tweet(tweet))


def cmd_mentions(args):
    """Get mentions timeline."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)  # Requires user context

    # Get current user first
    me = client.get_me()
    if not me.data:
        print("Could not get user info")
        return

    response = client.get_users_mentions(
        id=me.data.id,
        max_results=args.count,
        tweet_fields=["created_at", "author_id"],
        expansions=["author_id"],
    )

    if not response.data:
        print("No mentions")
        return

    # Build user lookup
    users = {u.id: u.username for u in response.includes.get("users", [])} if response.includes else {}

    print(f"Mentions ({len(response.data)} tweets):\n")
    for tweet in response.data:
        username = users.get(tweet.author_id, "unknown")
        print(f"@{username}: {tweet.text}")
        print(f"  ID: {tweet.id} | Time: {tweet.created_at.strftime('%Y-%m-%d %H:%M')}")
        print()


def cmd_dms(args):
    """Get recent DMs."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)

    # Note: DM reading requires special access
    print("Note: DM reading may require elevated API access.")
    print("Attempting to fetch DMs...\n")

    try:
        # This endpoint may not be available on all tiers
        response = client.get_direct_message_events(
            max_results=args.count,
            dm_event_fields=["created_at", "sender_id"],
        )

        if not response.data:
            print("No DMs found")
            return

        print(f"Recent DMs:\n")
        for event in response.data:
            print(f"From: {event.sender_id}")
            print(f"Time: {event.created_at}")
            print(f"Message: {event.text}")
            print()
    except tweepy.errors.Forbidden:
        print("Error: DM access requires elevated API permissions (Basic tier or higher with DM access).")
    except AttributeError:
        print("Error: DM endpoint not available in this tweepy version or API tier.")


def cmd_dm(args):
    """Send a DM to a user."""
    creds = load_credentials()
    client = get_client(creds, read_only=False)

    # Get user ID from username
    user = client.get_user(username=args.username.lstrip("@"))
    if not user.data:
        print(f"Error: User @{args.username} not found")
        sys.exit(1)

    try:
        response = client.create_direct_message(participant_id=user.data.id, text=args.message)
        print(f"DM sent to @{args.username}")
    except tweepy.errors.Forbidden:
        print("Error: DM sending requires elevated API permissions.")


def cmd_search(args):
    """Search for tweets."""
    creds = load_credentials()
    client = get_client(creds, read_only=True)

    try:
        response = client.search_recent_tweets(
            query=args.query,
            max_results=args.count,
            tweet_fields=["created_at", "author_id", "public_metrics"],
            expansions=["author_id"],
        )

        if not response.data:
            print("No results found")
            return

        users = {u.id: u.username for u in response.includes.get("users", [])} if response.includes else {}

        print(f"Search results for '{args.query}' ({len(response.data)} tweets):\n")
        for tweet in response.data:
            username = users.get(tweet.author_id, "unknown")
            metrics = tweet.public_metrics if hasattr(tweet, "public_metrics") else {}
            print(f"@{username}: {tweet.text}")
            print(f"  ID: {tweet.id} | Likes: {metrics.get('like_count', '?')} | RTs: {metrics.get('retweet_count', '?')}")
            print()
    except tweepy.errors.Forbidden:
        print("Error: Search requires Basic API tier or higher.")


def cmd_user(args):
    """Get user information."""
    creds = load_credentials()
    client = get_client(creds, read_only=True)

    username = args.username.lstrip("@")
    response = client.get_user(
        username=username,
        user_fields=["created_at", "description", "public_metrics", "location", "verified"],
    )

    if not response.data:
        print(f"User @{username} not found")
        return

    user = response.data
    metrics = user.public_metrics if hasattr(user, "public_metrics") else {}

    print(f"User: @{user.username}")
    if hasattr(user, "name"):
        print(f"Name: {user.name}")
    if hasattr(user, "description") and user.description:
        print(f"Bio: {user.description}")
    if hasattr(user, "location") and user.location:
        print(f"Location: {user.location}")
    if hasattr(user, "verified"):
        print(f"Verified: {'Yes' if user.verified else 'No'}")
    if hasattr(user, "created_at"):
        print(f"Joined: {user.created_at.strftime('%Y-%m-%d')}")
    print(f"Followers: {metrics.get('followers_count', '?')}")
    print(f"Following: {metrics.get('following_count', '?')}")
    print(f"Tweets: {metrics.get('tweet_count', '?')}")


def cmd_show(args):
    """Show a specific tweet."""
    creds = load_credentials()
    client = get_client(creds, read_only=True)

    response = client.get_tweet(
        args.tweet_id,
        tweet_fields=["created_at", "author_id", "public_metrics"],
        expansions=["author_id"],
    )

    if not response.data:
        print(f"Tweet {args.tweet_id} not found")
        return

    tweet = response.data
    users = {u.id: u.username for u in response.includes.get("users", [])} if response.includes else {}
    username = users.get(tweet.author_id, "unknown")

    print(f"Tweet by @{username}")
    print(f"ID: {tweet.id}")
    print(f"Time: {tweet.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"\n{tweet.text}")

    if hasattr(tweet, "public_metrics"):
        m = tweet.public_metrics
        print(f"\n📊 Likes: {m.get('like_count', 0)} | RTs: {m.get('retweet_count', 0)} | Replies: {m.get('reply_count', 0)} | Quotes: {m.get('quote_count', 0)}")


def cmd_analytics(args):
    """Get tweet analytics (public metrics)."""
    creds = load_credentials()
    client = get_client(creds, read_only=True)

    response = client.get_tweet(
        args.tweet_id,
        tweet_fields=["public_metrics", "created_at", "author_id"],
        expansions=["author_id"],
    )

    if not response.data:
        print(f"Tweet {args.tweet_id} not found")
        return

    tweet = response.data
    users = {u.id: u.username for u in response.includes.get("users", [])} if response.includes else {}
    username = users.get(tweet.author_id, "unknown")

    print(f"📊 Analytics for tweet by @{username}")
    print(f"Tweet ID: {tweet.id}")
    print(f"Posted: {tweet.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"\nText: {tweet.text[:100]}{'...' if len(tweet.text) > 100 else ''}")

    if hasattr(tweet, "public_metrics"):
        m = tweet.public_metrics
        print(f"\n📈 Engagement:")
        print(f"  ❤️  Likes: {m.get('like_count', 0):,}")
        print(f"  🔄 Retweets: {m.get('retweet_count', 0):,}")
        print(f"  💬 Replies: {m.get('reply_count', 0):,}")
        print(f"  💭 Quotes: {m.get('quote_count', 0):,}")
        print(f"  👁️  Impressions: {m.get('impression_count', 'N/A')}")

        total = m.get('like_count', 0) + m.get('retweet_count', 0) + m.get('reply_count', 0) + m.get('quote_count', 0)
        print(f"\n  Total Engagements: {total:,}")


def main():
    parser = argparse.ArgumentParser(
        description="Twitter/X API CLI",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    # Post
    p_post = subparsers.add_parser("post", help="Post a new tweet")
    p_post.add_argument("text", help="Tweet text")
    p_post.add_argument("--media", action="append", help="Media file to attach (can be used multiple times)")

    # Reply
    p_reply = subparsers.add_parser("reply", help="Reply to a tweet")
    p_reply.add_argument("tweet_id", help="Tweet ID to reply to")
    p_reply.add_argument("text", help="Reply text")

    # Thread
    p_thread = subparsers.add_parser("thread", help="Post a thread")
    p_thread.add_argument("tweets", nargs="+", help="Tweets in order")

    # Timeline
    p_timeline = subparsers.add_parser("timeline", help="Get home timeline")
    p_timeline.add_argument("--count", type=int, default=20, help="Number of tweets (max 100)")

    # Mentions
    p_mentions = subparsers.add_parser("mentions", help="Get mentions")
    p_mentions.add_argument("--count", type=int, default=20, help="Number of tweets (max 100)")

    # DMs
    p_dms = subparsers.add_parser("dms", help="Get recent DMs")
    p_dms.add_argument("--count", type=int, default=20, help="Number of DMs")

    # Send DM
    p_dm = subparsers.add_parser("dm", help="Send a DM")
    p_dm.add_argument("username", help="Recipient username")
    p_dm.add_argument("message", help="Message text")

    # Search
    p_search = subparsers.add_parser("search", help="Search tweets")
    p_search.add_argument("query", help="Search query")
    p_search.add_argument("--count", type=int, default=20, help="Number of results (max 100)")

    # User
    p_user = subparsers.add_parser("user", help="Get user info")
    p_user.add_argument("username", help="Username (with or without @)")

    # Show
    p_show = subparsers.add_parser("show", help="Show a specific tweet")
    p_show.add_argument("tweet_id", help="Tweet ID")

    # Analytics
    p_analytics = subparsers.add_parser("analytics", help="Get tweet analytics")
    p_analytics.add_argument("tweet_id", help="Tweet ID")

    args = parser.parse_args()

    commands = {
        "post": cmd_post,
        "reply": cmd_reply,
        "thread": cmd_thread,
        "timeline": cmd_timeline,
        "mentions": cmd_mentions,
        "dms": cmd_dms,
        "dm": cmd_dm,
        "search": cmd_search,
        "user": cmd_user,
        "show": cmd_show,
        "analytics": cmd_analytics,
    }

    try:
        commands[args.command](args)
    except tweepy.errors.TooManyRequests as e:
        print(f"Rate limit exceeded. Reset at: {e.response.headers.get('x-rate-limit-reset', 'unknown')}")
        sys.exit(1)
    except tweepy.errors.Forbidden as e:
        print(f"Forbidden: {e}")
        print("This operation may require a higher API tier.")
        sys.exit(1)
    except tweepy.errors.Unauthorized:
        print("Unauthorized: Check your credentials")
        sys.exit(1)
    except tweepy.errors.NotFound:
        print("Not found: Tweet or user may have been deleted")
        sys.exit(1)
    except ValueError as e:
        print(f"Error: {e}")
        sys.exit(1)


if __name__ == "__main__":
    main()

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