Skill: minilozio/x-research-but-cheaper (version 1.0.1)

--- name: x-research description: > X/Twitter research skill powered by TwitterAPI.io. Agentic search, profile analysis, thread reading, watchlists, and sourced briefings. Use when asked to search X/Twitter, check what people are saying about a topic, monitor accounts, or research crypto/tech narratives on X. metadata: { "openclaw": { "emoji": "🐦", "requires": { "bins": ["node", "npx"] }, "primaryEnv": "TWITTERAPI_KEY", "envHint": "Get your API key from https://twitterapi.io — pay-per-use, no subscription needed" }, "clawhub": { "category": "research", "tags": ["twitter", "x", "research", "search", "social-media"], "website": "https://twitterapi.io" } } --- # X Research General-purpose X/Twitter research agent powered by [TwitterAPI.io](https://twitterapi.io). Search, filter, monitor — all from the terminal. No X Developer Portal account needed. For API details: read `references/twitterapi-io.md`. ## Why TwitterAPI.io? | | X API Official | TwitterAPI.io | |---|---|---| | **Cost** | $100+/mo (Basic plan) | ~$0.15/1k tweets (pay-per-use) | | **Setup** | Developer Portal application | Just an API key | | **Rate limit** | 10 req/15min | 200 QPS | | **Archive** | 7 days (recent) | Full archive | ## Setup 1. Get a TwitterAPI.io key from [twitterapi.io](https://twitterapi.io) 2. Set the env var: ```bash export TWITTERAPI_KEY="your-key-here" ``` Or add to your `.env` file. 3. Node.js 18+ required (for native `fetch`). No `npm install` needed — zero dependencies. ## CLI Tool All commands run from the scripts directory: ```bash cd <skill-dir>/scripts ``` ### Search ```bash npx tsx x-search.ts search "<query>" [options] ``` **Options:** - `--sort likes|retweets|impressions|recent` — sort order (default: likes) - `--since 1h|3h|12h|1d|7d` — time filter - `--min-likes N` — filter minimum likes - `--min-impressions N` — filter minimum views - `--pages N` — pages to fetch, 1-5 (default: 1) - `--limit N` — max results to display (default: 15) - `--quick` — fast mode: 1 page, max 10 results, noise filter, 1hr cache - `--from <username>` — shorthand for `from:username` in query - `--quality` — filter low-engagement tweets (≥10 likes) - `--no-replies` — exclude replies - `--save` — save results to file - `--type Top|Latest` — search mode (default: Latest; Top returns algorithmic ranking) - `--json` — raw JSON output - `--markdown` — markdown formatted output Auto-adds `-is:retweet` unless query already includes it. Cost estimate shown after each search. **Examples:** ```bash npx tsx x-search.ts search "AI agents Base chain" --sort likes --limit 10 npx tsx x-search.ts search "BNKR" --quick npx tsx x-search.ts search "from:frankdegods" --sort recent npx tsx x-search.ts search "(opus OR claude) trading" --pages 2 --save npx tsx x-search.ts search "$SOL memecoin" --min-likes 50 --since 1d ``` **Search operators** (passed through to X): | Operator | Example | Notes | |----------|---------|-------| | keyword | `bun 2.0` | Implicit AND | | `OR` | `bun OR deno` | Must be uppercase | | `-` | `-is:retweet` | Negation | | `()` | `(fast OR perf)` | Grouping | | `from:` | `from:elonmusk` | Posts by user | | `to:` | `to:elonmusk` | Replies to user | | `#` | `#buildinpublic` | Hashtag | | `$` | `$AAPL` | Cashtag | | `lang:` | `lang:en` | Language filter | | `is:retweet` | `-is:retweet` | Filter retweets | | `is:reply` | `-is:reply` | Filter replies | | `has:media` | `has:media` | Contains media | | `has:links` | `has:links` | Contains links | | `min_faves:` | `min_faves:100` | Min likes (native) | ### Profile ```bash npx tsx x-search.ts profile <username> [--count N] [--replies] [--json] ``` Fetches user info and recent tweets. Excludes replies by default. ### Single Tweet ```bash npx tsx x-search.ts tweet <tweet_id> [--json] ``` ### Thread ```bash npx tsx x-search.ts thread <tweet_id> ``` Fetches root tweet, author thread continuations, and replies in chronological order. ### Replies ```bash npx tsx x-search.ts replies <tweet_id> [--sort likes|recent] [--limit N] ``` Get replies to a specific tweet, sorted by engagement. ### Quote Tweets ```bash npx tsx x-search.ts quotes <tweet_id> [--sort likes|recent] [--limit N] ``` See who quoted a tweet and what they said. ### Mentions ```bash npx tsx x-search.ts mentions <username> [--since 1d|7d] [--sort recent|likes] [--limit N] ``` Get tweets that mention a user. Great for tracking what people are saying about someone. ### Followers / Following ```bash npx tsx x-search.ts followers <username> [--limit N] npx tsx x-search.ts following <username> [--limit N] ``` List a user's followers or who they follow. Shows name, follower count, and bio snippet. ### Search Users ```bash npx tsx x-search.ts users "<query>" [--limit N] ``` Search for user accounts by keyword. ### Trending ```bash npx tsx x-search.ts trending [--woeid N] [--count N] ``` Get trending topics. Default: worldwide (woeid=1). Common WOEIDs: US=23424977, UK=23424975, Italy=23424853. ### Community ```bash npx tsx x-search.ts community <id> # Community info npx tsx x-search.ts community tweets <id> # Community tweets ``` Research X Communities by ID. ### Watchlist ```bash npx tsx x-search.ts watchlist # Show all npx tsx x-search.ts watchlist add <user> [note] # Add account npx tsx x-search.ts watchlist remove <user> # Remove account npx tsx x-search.ts watchlist check # Check recent from all ``` Watchlist stored in `data/watchlist.json`. Use for periodic monitoring — check if key accounts posted anything notable. ### Cache ```bash npx tsx x-search.ts cache clear # Clear all cached results ``` Default 15-minute TTL. Quick mode uses 1-hour TTL. Avoids repeat API charges. ## Research Loop (Agentic) When doing deep research (not just a quick search), follow this loop: ### 1. Decompose the Question into Queries Turn the research question into 3-5 keyword queries: - **Core query**: Direct keywords for the topic - **Expert voices**: `from:` specific known experts - **Pain points**: Keywords like `(broken OR bug OR issue)` - **Positive signal**: Keywords like `(shipped OR love OR fast)` - **Links**: `has:links` for resource-rich tweets - **Crypto spam**: Add `-airdrop -giveaway -whitelist` if needed ### 2. Search and Extract Run each query via CLI. After each, assess: - Signal or noise? Adjust operators. - Key voices worth searching `from:` specifically? - Threads worth following via `thread` command? ### 3. Follow Threads When a tweet has high engagement or is a thread starter: ```bash npx tsx x-search.ts thread <tweet_id> ``` ### 4. Synthesize Group findings by theme, not by query: ``` ### [Theme/Finding Title] [1-2 sentence summary] - @username: "[key quote]" (♥ N, 👁 N) [Tweet](url) - @username2: "[another perspective]" (♥ N, 👁 N) [Tweet](url) ``` ### 5. Save Use `--save` flag or `--markdown` for research docs. ## Refinement Heuristics - **Too much noise?** Add `--no-replies`, use `--sort likes`, narrow keywords - **Too few results?** Broaden with `OR`, remove restrictive operators - **Crypto spam?** Add `-airdrop -giveaway -whitelist` to query - **Expert takes only?** Use `from:` or `--min-likes 50` - **Substance over hot takes?** Search with `has:links` ## Heartbeat Integration On heartbeat, run `watchlist check` to see if key accounts posted anything notable. Flag only if genuinely interesting/actionable. ## Cost TwitterAPI.io pay-per-use pricing: - ~$0.15 per 1,000 tweets fetched - ~$0.18 per profile lookup - Cost estimate shown after each command - Cache prevents redundant charges ## File Structure ``` skills/x-research/ ├── SKILL.md # This file ├── package.json # Zero dependencies ├── scripts/ │ ├── x-search.ts # CLI entry point │ └── lib/ │ ├── api.ts # TwitterAPI.io wrapper │ ├── cache.ts # File-based cache (15min/1hr TTL) │ └── format.ts # Terminal + markdown formatters ├── data/ │ ├── watchlist.json # Accounts to monitor │ └── cache/ # Auto-managed └── references/ └── twitterapi-io.md # API endpoint reference ``` ## Requirements - Node.js 18+ (for native `fetch`) - A TwitterAPI.io API key - No npm install needed — zero dependencies

path: README.md
content: <p align="center">
  <img src="assets/banner.svg" alt="X-Research But Cheaper" width="500" />
</p>

<p align="center">
  <a href="https://clawhub.ai/skills/x-research-but-cheaper"><img src="https://img.shields.io/badge/ClawHub-Install-orange?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHRleHQgeT0iMTgiIGZvbnQtc2l6ZT0iMTYiPvCfpp48L3RleHQ+PC9zdmc+" alt="ClawHub" /></a>
  <a href="https://github.com/minilozio/x-research-but-cheaper/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License" /></a>
  <img src="https://img.shields.io/badge/version-1.0.0-brightgreen?style=flat-square" alt="v1.0.0" />
  <img src="https://img.shields.io/badge/OpenClaw-compatible-red?style=flat-square" alt="OpenClaw" />
</p>

---

X/Twitter research agent for [OpenClaw](https://openclaw.ai) and [Claude Code](https://code.claude.com). Search, filter, monitor — all from the terminal.

Powered by [TwitterAPI.io](https://twitterapi.io/?ref=Minilozio). No X Developer Portal account needed. Pay-per-use pricing (~$0.15/1k tweets) instead of $100+/month.

## What it does

Wraps the TwitterAPI.io API into a fast CLI so your AI agent (or you) can search tweets, pull threads, monitor accounts, and get sourced research without writing curl commands.

- **Search** with engagement sorting, time filtering, noise removal
- **Quick mode** for cheap, targeted lookups
- **Profile analysis** — user info + recent tweets
- **Thread reading** — full conversation threads
- **Watchlists** for monitoring accounts
- **Cache** to avoid repeat API charges
- **Cost transparency** — every command shows what it cost

## Install

### ClawHub (recommended)
```bash
clawhub install x-research-but-cheaper
```

### OpenClaw (manual)
```bash
cd ~/.openclaw/workspace/skills
git clone https://github.com/minilozio/x-research-but-cheaper.git
```

### Claude Code
```bash
mkdir -p .claude/skills
cd .claude/skills
git clone https://github.com/minilozio/x-research-but-cheaper.git
```

## Setup

1. **API key** — Get one from [twitterapi.io](https://twitterapi.io/?ref=Minilozio)
2. **Set the env var:**
   ```bash
   export TWITTERAPI_KEY="your-key-here"
   ```
   Or save it to your `.env` file.
3. **Node.js 18+** required (for native `fetch`). **Zero npm dependencies.**

## Usage

### Natural language (just talk to Claude)
- "What are people saying about Opus 4.6?"
- "Search X for the latest on Seedance 2.0"
- "What's the sentiment around remote work this week?"
- "Check what @minilozio posted recently"
- "Research the best takes on AI coding assistants"
- "Find viral tweets about startups this week"

### CLI commands
```bash
cd skills/x-research-but-cheaper/scripts

# Search (sorted by likes, auto-filters retweets)
npx tsx x-search.ts search "Seedance 2.0" --sort likes --limit 10

# Quick pulse check
npx tsx x-search.ts search "remote work" --quick

# Profile — user info + recent tweets
npx tsx x-search.ts profile minilozio --count 5

# Read a thread
npx tsx x-search.ts thread 1234567890123456789

# Single tweet
npx tsx x-search.ts tweet 1234567890123456789

# Replies and quotes
npx tsx x-search.ts replies 1234567890123456789 --sort likes
npx tsx x-search.ts quotes 1234567890123456789

# Mentions — who's talking about someone
npx tsx x-search.ts mentions ycombinator --since 1d

# Followers / following
npx tsx x-search.ts followers minilozio --limit 20
npx tsx x-search.ts following minilozio --limit 20

# Search for users
npx tsx x-search.ts users "machine learning engineer"

# Trending topics
npx tsx x-search.ts trending
npx tsx x-search.ts trending --woeid 23424977  # US trends

# Community research
npx tsx x-search.ts community 1234567890
npx tsx x-search.ts community tweets 1234567890

# Watchlist
npx tsx x-search.ts watchlist add minilozio "AI research"
npx tsx x-search.ts watchlist check

# Save research to file
npx tsx x-search.ts search "query" --save --markdown
```

### Search options
```
--sort likes|retweets|impressions|recent    Sort order (default: likes)
--since 1h|3h|12h|1d|7d                    Time filter
--min-likes N                               Minimum likes
--min-impressions N                         Minimum views
--pages N                                   Pages to fetch, 1-5 (default: 3)
--limit N                                   Max results (default: 15)
--quick                                     Quick mode (see below)
--from <username>                           Shorthand for from:username
--quality                                   Min 10 likes filter
--no-replies                                Exclude replies
--type Top|Latest                           Search mode (default: Latest)
--save                                      Save results to file
--json                                      Raw JSON output
--markdown                                  Markdown research doc
```

**X search operators work too:** `from:user`, `OR`, `-is:reply`, `has:links`, `min_faves:N`, `lang:en`, `$TICKER`, and more.

## Quick Mode

`--quick` is designed for fast, cheap lookups when you just need a pulse check on a topic.

**What it does:**
- Forces single page (max 10 results) — reduces API cost
- Auto-appends `-is:retweet -is:reply` noise filters
- Minimum 5 likes filter (removes spam/zero-engagement)
- Uses 1-hour cache TTL instead of the default 15 minutes
- Shows cost summary after results

**Examples:**
```bash
# Quick pulse check on a topic
npx tsx x-search.ts search "cursor IDE" --quick

# Quick check what someone is saying
npx tsx x-search.ts search "agents" --from minilozio --quick

# Quick quality-only results
npx tsx x-search.ts search "startup funding" --quality --quick
```

**Why it's cheaper:**
- Prevents multi-page fetches (biggest cost saver)
- 1hr cache means repeat searches are free
- Noise filters mean fewer junk results
- Cost displayed after every search — no surprises

## `--from` Shorthand

Adds `from:username` to your query without typing the full operator syntax.

```bash
# These are equivalent:
npx tsx x-search.ts search "React from:dan_abramov"
npx tsx x-search.ts search "React" --from dan_abramov

# Works with other flags
npx tsx x-search.ts search "LLM" --from minilozio --quick --quality
```

If your query already contains `from:`, the flag won't double-add it.

## `--quality` Flag

Filters out low-engagement tweets (≥10 likes required).

```bash
npx tsx x-search.ts search "open source AI" --quality
```

## Examples

```bash
# What are the best takes on a trending topic
npx tsx x-search.ts search "Seedance 2.0" --sort likes --since 1d --type Top

# Expert opinions with engagement filter
npx tsx x-search.ts search "AI coding assistants" --min-likes 50 --quality

# What did someone post recently
npx tsx x-search.ts profile paulg --count 10

# Deep research with multiple pages saved to markdown
npx tsx x-search.ts search "startup advice" --pages 3 --save --markdown

# Quick check on breaking news
npx tsx x-search.ts search "GPT-5" --quick

# Filter to a specific expert's takes
npx tsx x-search.ts search "transformers" --from minilozio --quality
```

## Research Loop

For deep research (not just a quick search):

1. **Broad search** — `search "topic" --sort likes` to find key voices
2. **Drill into profiles** — `profile username` for context on interesting people
3. **Read threads** — `thread <id>` for detailed takes
4. **Track ongoing** — `watchlist add` key accounts, `watchlist check` periodically
5. **Refine** — Narrow with `--since`, `--min-likes`, `--from`, `--no-replies`
6. **Save** — Use `--save --markdown` for research docs

## Why TwitterAPI.io?

| | X API Official | TwitterAPI.io |
|---|---|---|
| **Cost** | $100+/mo (Basic) | ~$0.15/1k tweets |
| **Setup** | Developer Portal, application, approval | Just an API key |
| **Rate limit** | 10 req/15min | 200 QPS |
| **Archive** | 7 days (recent search) | Full archive |

## Cost

TwitterAPI.io uses **pay-per-use pricing** with prepaid credits. No subscriptions, no monthly caps.

**Per-resource costs:**
| Resource | Cost |
|----------|------|
| Tweet fetch | $0.15 / 1,000 tweets |
| Profile lookup | $0.18 / 1,000 lookups |

**Estimated costs per operation:**
| Operation | Est. cost |
|-----------|-----------|
| Quick search (1 page, ≤20 tweets) | ~$0.003 |
| Standard search (3 pages, default) | ~$0.009 |
| Deep research (5 pages) | ~$0.015 |
| Profile check (user + tweets) | ~$0.003 |
| Watchlist check (5 accounts) | ~$0.015 |
| Cached repeat (any) | free |

**How x-research saves money:**
- Cache (15min default, 1hr in quick mode) — repeat queries are free
- Quick mode prevents accidental multi-page fetches
- Watchlist checks run in parallel (faster, same cost)
- Cost displayed after every command so you know what you're spending
- `--from` targets specific users instead of broad searches

## File Structure

```
x-research/
├── SKILL.md              # Agent instructions (Claude reads this)
├── README.md
├── package.json          # Zero dependencies
├── scripts/
│   ├── x-search.ts       # CLI entry point
│   └── lib/
│       ├── api.ts        # TwitterAPI.io wrapper
│       ├── cache.ts      # File-based cache
│       └── format.ts     # Terminal + markdown formatters
├── data/
│   ├── watchlist.json    # Accounts to monitor
│   └── cache/            # Auto-managed
└── references/
    └── twitterapi-io.md  # API reference
```

## Security

**API key handling:** x-research reads your key from the `TWITTERAPI_KEY` env var. The key is never printed to stdout, but be aware:

- **AI coding agents** (Claude Code, OpenClaw, etc.) may log tool calls in session transcripts. If you're running x-research inside an agent session, your API key could appear in those logs.
- **Recommendations:**
  - Set `TWITTERAPI_KEY` as a system env var (not inline in commands)
  - Review your agent's session log settings
  - Use environment files (`.env`) and make sure they're in `.gitignore`
  - Rotate your key if you suspect exposure

## Limitations

- Read-only — never posts or interacts with X
- Search quality depends on TwitterAPI.io's index (may include promoted/irrelevant content)
- `min_likes` uses X's native `min_faves:` operator for server-side filtering (efficient, no wasted API quota)
- Thread fetching uses replies endpoint + conversation_id search (may miss some replies in very large threads)
- Zero dependencies means no fancy CLI framework — basic arg parsing only
- Requires Node.js 18+ for native `fetch`

## License

MIT


path: assets/banner.svg
content: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 130" width="700" height="130">
  <rect width="700" height="130" fill="white"/>
  <g transform="translate(115, 0)">
    <text x="0" y="85" font-size="80">🔍</text>
    <text x="100" y="55" font-family="Inter, -apple-system, system-ui, sans-serif" font-weight="800" font-size="32" fill="#09090b" letter-spacing="1">X-RESEARCH</text>
    <text x="100" y="80" font-family="Inter, -apple-system, system-ui, sans-serif" font-weight="800" font-size="32" fill="#09090b" letter-spacing="1">BUT CHEAPER</text>
    <text x="100" y="105" font-family="Inter, -apple-system, system-ui, sans-serif" font-weight="400" font-size="12" fill="#71717a" letter-spacing="3">X/TWITTER RESEARCH AGENT FOR OPENCLAW</text>
  </g>
</svg>


path: data/watchlist.json

path: package.json
content: {
  "name": "x-research-but-cheaper",
  "version": "1.0.0",
  "description": "X/Twitter research skill for OpenClaw and Claude Code — powered by TwitterAPI.io",
  "license": "MIT",
  "type": "module",
  "scripts": {
    "search": "tsx scripts/x-search.ts search",
    "profile": "tsx scripts/x-search.ts profile",
    "tweet": "tsx scripts/x-search.ts tweet",
    "thread": "tsx scripts/x-search.ts thread"
  }
}


path: references/twitterapi-io.md
content: # TwitterAPI.io — API Reference

Base URL: `https://api.twitterapi.io`
Auth: `X-API-Key: <key>` header

## Endpoints

### Search Tweets
`GET /twitter/tweet/advanced_search?query=QUERY&queryType=Latest`

Supports X search operators: `from:`, `-is:retweet`, `-is:reply`, `OR`, `has:links`, `min_faves:N`, `lang:en`, `conversation_id:`

Response: `{ tweets: [...], next_cursor: "..." }`

### User Info
`GET /twitter/user/info?userName=USERNAME`

Response: `{ data: { userName, name, description, followers, following, tweetsCount, ... } }`

### User Tweets
`GET /twitter/user/last_tweets?userName=USERNAME`

Response: `{ tweets: [...], next_cursor: "..." }`

### Tweets by ID
`GET /twitter/tweets?tweet_ids=ID1,ID2`

Response: `{ tweets: [...] }`

## Pricing
- $0.15 / 1,000 tweets
- $0.18 / 1,000 profiles
- Rate limit: 200 QPS


path: scripts/lib/api.ts
content: import { createHash } from 'crypto';

const BASE = 'https://api.twitterapi.io';

function getKey(): string {
  const k = process.env.TWITTERAPI_KEY;
  if (!k) throw new Error('TWITTERAPI_KEY not set');
  return k;
}

function headers() {
  return { 'X-API-Key': getKey(), 'Content-Type': 'application/json' };
}

export interface Tweet {
  id: string;
  url?: string;
  text: string;
  author: {
    userName: string;
    name: string;
    id?: string;
    followers: number;
    isBlueVerified?: boolean;
    profilePicture?: string;
    description?: string;
  };
  createdAt: string;
  likeCount: number;
  retweetCount: number;
  replyCount: number;
  quoteCount: number;
  viewCount: number;
  bookmarkCount: number;
  lang?: string;
  conversationId?: string;
  inReplyToId?: string;
  inReplyToUsername?: string;
  entities?: any;
  quotedTweet?: any;
  isRetweet?: boolean;
  isReply?: boolean;
}

export interface UserInfo {
  id?: string;
  userName: string;
  name: string;
  description: string;
  followers: number;
  following: number;
  tweetsCount: number;
  listedCount: number;
  favouritesCount?: number;
  mediaCount?: number;
  createdAt: string;
  profileImageUrl: string;
  verified: boolean;
  isBlueVerified: boolean;
  location?: string;
  url?: string;
  pinnedTweetIds?: string[];
  isAutomated?: boolean;
}

export interface SearchResult {
  tweets: Tweet[];
  cursor?: string;
  hasNextPage: boolean;
}

export interface UserListResult {
  users: UserInfo[];
  cursor?: string;
  hasNextPage: boolean;
}

export interface TrendItem {
  name: string;
  tweet_count?: number;
  description?: string;
}

let _costAccum = 0;
export function getCost() { return _costAccum; }
export function resetCost() { _costAccum = 0; }

async function apiFetch(path: string, params: Record<string, string>): Promise<any> {
  const url = new URL(path, BASE);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== '') url.searchParams.set(k, v);
  }
  const res = await fetch(url.toString(), { headers: headers() });
  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new Error(`API ${res.status}: ${body}`);
  }
  const data = await res.json();
  // Check for API-level errors
  if (data.status === 'error') {
    throw new Error(`API error: ${data.message || data.msg || 'unknown'}`);
  }
  return data;
}

function normalizeTweet(t: any): Tweet {
  return {
    id: t.id,
    url: t.url,
    text: t.text,
    author: t.author || {
      userName: t.user?.userName || t.user?.screen_name || '',
      name: t.user?.name || '',
      id: t.user?.id,
      followers: t.user?.followers || 0,
      isBlueVerified: t.user?.isBlueVerified,
    },
    createdAt: t.createdAt,
    likeCount: t.likeCount || 0,
    retweetCount: t.retweetCount || 0,
    replyCount: t.replyCount || 0,
    quoteCount: t.quoteCount || 0,
    viewCount: t.viewCount || 0,
    bookmarkCount: t.bookmarkCount || 0,
    lang: t.lang,
    conversationId: t.conversationId,
    inReplyToId: t.inReplyToId,
    inReplyToUsername: t.inReplyToUsername,
    entities: t.entities,
    quotedTweet: t.quoted_tweet,
    isReply: t.isReply,
    isRetweet: t.isRetweet,
  };
}

function normalizeUser(raw: any): UserInfo {
  return {
    id: raw.id,
    userName: raw.userName || raw.screen_name || '',
    name: raw.name || '',
    description: raw.description || raw.profile_bio?.description || '',
    followers: raw.followers || raw.followersCount || 0,
    following: raw.following || raw.followingCount || 0,
    tweetsCount: raw.tweetsCount || raw.statusesCount || 0,
    listedCount: raw.listedCount || 0,
    favouritesCount: raw.favouritesCount,
    mediaCount: raw.mediaCount,
    createdAt: raw.createdAt || '',
    profileImageUrl: raw.profilePicture || raw.profileImageUrl || '',
    verified: raw.verified || false,
    isBlueVerified: raw.isBlueVerified || false,
    location: raw.location,
    url: raw.url,
    pinnedTweetIds: raw.pinnedTweetIds,
    isAutomated: raw.isAutomated,
  };
}

// --- Tweet endpoints ---

export async function searchTweets(query: string, cursor?: string, queryType: string = 'Latest'): Promise<SearchResult> {
  const params: Record<string, string> = { query, queryType };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/tweet/advanced_search', params);
  const tweets: Tweet[] = (data.tweets || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return { tweets, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function getUserInfo(userName: string): Promise<UserInfo> {
  const data = await apiFetch('/twitter/user/info', { userName });
  _costAccum += 0.00018;
  const raw = data.data || data;
  return normalizeUser(raw);
}

export async function getUserTweets(userName: string, cursor?: string): Promise<SearchResult> {
  const params: Record<string, string> = { userName };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/user/last_tweets', params);
  const raw = data.tweets || data.data?.tweets || [];
  const tweets: Tweet[] = raw.map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return {
    tweets,
    cursor: data.next_cursor || data.data?.next_cursor,
    hasNextPage: !!data.has_next_page || !!data.data?.has_next_page,
  };
}

export async function getTweetsById(ids: string[]): Promise<Tweet[]> {
  const data = await apiFetch('/twitter/tweets', { tweet_ids: ids.join(',') });
  const tweets = (data.tweets || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return tweets;
}

export async function getTweetReplies(tweetId: string, cursor?: string): Promise<SearchResult> {
  const params: Record<string, string> = { tweetId };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/tweet/replies', params);
  const tweets = (data.replies || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return { tweets, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function getTweetQuotes(tweetId: string, cursor?: string): Promise<SearchResult> {
  const params: Record<string, string> = { tweetId };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/tweet/quotes', params);
  const tweets = (data.tweets || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return { tweets, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function getTweetRetweeters(tweetId: string, cursor?: string): Promise<UserListResult> {
  const params: Record<string, string> = { tweetId };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/tweet/retweeters', params);
  const users = (data.users || []).map(normalizeUser);
  _costAccum += Math.max(users.length, 1) * 0.00015;
  return { users, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

// --- User endpoints ---

export async function getUserMentions(userName: string, cursor?: string): Promise<SearchResult> {
  const params: Record<string, string> = { userName };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/user/mentions', params);
  const tweets = (data.tweets || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return { tweets, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function getUserFollowers(userName: string, cursor?: string): Promise<UserListResult> {
  const params: Record<string, string> = { userName };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/user/followers', params);
  const users = (data.followers || []).map(normalizeUser);
  _costAccum += Math.max(users.length, 1) * 0.00015;
  return { users, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function getUserFollowings(userName: string, cursor?: string): Promise<UserListResult> {
  const params: Record<string, string> = { userName };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/user/followings', params);
  const users = (data.followings || data.following || []).map(normalizeUser);
  _costAccum += Math.max(users.length, 1) * 0.00015;
  return { users, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

export async function searchUsers(query: string, cursor?: string): Promise<UserListResult> {
  const params: Record<string, string> = { query };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/user/search', params);
  const users = (data.users || []).map(normalizeUser);
  _costAccum += Math.max(users.length, 1) * 0.00018;
  return { users, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

// --- Trends ---

export async function getTrends(woeid: number = 1, count: number = 30): Promise<TrendItem[]> {
  const data = await apiFetch('/twitter/trends', { woeid: String(woeid), count: String(count) });
  _costAccum += 0.00015;
  // Normalize: API returns [{trend: {name, target, rank}}, ...]
  const raw = data.trends || [];
  return raw.map((t: any) => {
    const inner = t.trend || t;
    return { name: inner.name, tweet_count: inner.tweet_count, description: inner.description };
  });
}

// --- Community ---

export async function getCommunityInfo(communityId: string): Promise<any> {
  const data = await apiFetch('/twitter/community/info', { communityId });
  _costAccum += 0.00018;
  return data.community_info || data;
}

export async function getCommunityTweets(communityId: string, cursor?: string): Promise<SearchResult> {
  const params: Record<string, string> = { communityId };
  if (cursor) params.cursor = cursor;
  const data = await apiFetch('/twitter/community/tweets', params);
  const tweets = (data.tweets || []).map(normalizeTweet);
  _costAccum += Math.max(tweets.length, 1) * 0.00015;
  return { tweets, cursor: data.next_cursor, hasNextPage: !!data.has_next_page };
}

// --- Thread (improved: use dedicated replies endpoint) ---

export async function getThread(tweetId: string): Promise<Tweet[]> {
  // Get root tweet
  const roots = await getTweetsById([tweetId]);
  if (!roots.length) return [];
  const root = roots[0];

  // Use dedicated replies endpoint for better results
  const result = await getTweetReplies(tweetId);
  
  // Also try conversation search for thread continuations by the same author
  const convId = root.conversationId || root.id;
  let authorThreadTweets: Tweet[] = [];
  if (root.author?.userName) {
    try {
      const convResult = await searchTweets(`conversation_id:${convId} from:${root.author.userName}`);
      authorThreadTweets = convResult.tweets.filter(t => t.id !== root.id);
    } catch { /* fallback: just use replies */ }
  }

  // Merge: root + author's thread tweets + replies (deduped)
  const seen = new Set<string>([root.id]);
  const all: Tweet[] = [root];
  for (const t of [...authorThreadTweets, ...result.tweets]) {
    if (!seen.has(t.id)) { seen.add(t.id); all.push(t); }
  }
  all.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
  return all;
}


path: scripts/lib/cache.ts
content: import { createHash } from 'crypto';
import { readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = join(__dirname, '..', '..', 'data', 'cache');

function ensureDir() {
  mkdirSync(CACHE_DIR, { recursive: true });
}

function cacheKey(input: string): string {
  return createHash('md5').update(input).digest('hex');
}

export function get(key: string, ttlMs: number = 15 * 60 * 1000): any | null {
  ensureDir();
  const file = join(CACHE_DIR, cacheKey(key) + '.json');
  if (!existsSync(file)) return null;
  try {
    const stat = statSync(file);
    if (Date.now() - stat.mtimeMs > ttlMs) {
      unlinkSync(file);
      return null;
    }
    return JSON.parse(readFileSync(file, 'utf-8'));
  } catch { return null; }
}

export function set(key: string, data: any): void {
  ensureDir();
  const file = join(CACHE_DIR, cacheKey(key) + '.json');
  writeFileSync(file, JSON.stringify(data));
}

export function clear(): number {
  ensureDir();
  const files = readdirSync(CACHE_DIR).filter(f => f.endsWith('.json'));
  for (const f of files) unlinkSync(join(CACHE_DIR, f));
  return files.length;
}


path: scripts/lib/format.ts
content: import type { Tweet, UserInfo, TrendItem } from './api.js';

const C = {
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
  cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m',
  magenta: '\x1b[35m', white: '\x1b[37m', blue: '\x1b[34m',
  red: '\x1b[31m',
};

function fmtNum(n: number): string {
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
  if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
  return String(n);
}

function relTime(date: string): string {
  const ms = Date.now() - new Date(date).getTime();
  if (ms < 0) return 'just now';
  const mins = Math.floor(ms / 60000);
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  const days = Math.floor(hrs / 24);
  if (days < 30) return `${days}d ago`;
  const months = Math.floor(days / 30);
  return `${months}mo ago`;
}

function tweetUrl(t: Tweet): string {
  return t.url || `https://x.com/${t.author.userName}/status/${t.id}`;
}

export function formatTweet(t: Tweet, i?: number): string {
  const idx = i !== undefined ? `${C.dim}${i + 1}.${C.reset} ` : '';
  const user = `${C.cyan}@${t.author.userName}${C.reset}`;
  const verified = t.author.isBlueVerified ? ` ${C.blue}✓${C.reset}` : '';
  const text = t.text.length > 280 ? t.text.slice(0, 277) + '...' : t.text;
  const metrics = [
    `❤ ${fmtNum(t.likeCount)}`,
    `🔁 ${fmtNum(t.retweetCount)}`,
    `💬 ${fmtNum(t.replyCount)}`,
    `👁 ${fmtNum(t.viewCount)}`,
  ];
  if (t.quoteCount > 0) metrics.push(`📎 ${fmtNum(t.quoteCount)}`);
  if (t.bookmarkCount > 0) metrics.push(`🔖 ${fmtNum(t.bookmarkCount)}`);
  const metricsStr = `${C.yellow}${metrics.join('  ')}${C.reset}`;
  const time = `${C.dim}${relTime(t.createdAt)}${C.reset}`;
  const url = `${C.dim}${tweetUrl(t)}${C.reset}`;
  const lang = t.lang && t.lang !== 'en' ? ` ${C.dim}[${t.lang}]${C.reset}` : '';
  return `${idx}${user}${verified} ${time}${lang}\n${text}\n${metricsStr}\n${url}\n`;
}

export function formatProfile(u: UserInfo): string {
  const automated = u.isAutomated ? ` ${C.dim}[automated]${C.reset}` : '';
  return [
    `${C.bold}${C.cyan}@${u.userName}${C.reset} ${u.name}${u.isBlueVerified ? ' ✓' : ''}${automated}`,
    `${C.dim}${u.description}${C.reset}`,
    `${C.yellow}Followers: ${fmtNum(u.followers)}  Following: ${fmtNum(u.following)}  Tweets: ${fmtNum(u.tweetsCount)}${C.reset}`,
    u.mediaCount ? `${C.dim}Media: ${fmtNum(u.mediaCount)}  Likes: ${fmtNum(u.favouritesCount || 0)}${C.reset}` : '',
    u.location ? `📍 ${u.location}` : '',
    u.url ? `🔗 ${u.url}` : '',
    `${C.dim}Joined: ${u.createdAt}${C.reset}`,
  ].filter(Boolean).join('\n');
}

export function formatUserCompact(u: UserInfo, i?: number): string {
  const idx = i !== undefined ? `${C.dim}${i + 1}.${C.reset} ` : '';
  const verified = u.isBlueVerified ? ` ${C.blue}✓${C.reset}` : '';
  const desc = u.description ? ` — ${u.description.slice(0, 80)}${u.description.length > 80 ? '...' : ''}` : '';
  return `${idx}${C.cyan}@${u.userName}${C.reset}${verified} (${fmtNum(u.followers)} followers)${desc}`;
}

export function formatTrend(t: TrendItem, i: number): string {
  const count = t.tweet_count ? ` ${C.dim}(${fmtNum(t.tweet_count)} tweets)${C.reset}` : '';
  return `${C.dim}${i + 1}.${C.reset} ${C.bold}${t.name}${C.reset}${count}`;
}

export function formatCost(cost: number): string {
  return `\n${C.dim}💰 Estimated cost: $${cost.toFixed(4)}${C.reset}`;
}

// Markdown formatters
export function tweetToMarkdown(t: Tweet, i?: number): string {
  const idx = i !== undefined ? `### ${i + 1}. ` : '### ';
  const metrics = [`❤ ${fmtNum(t.likeCount)}`, `🔁 ${fmtNum(t.retweetCount)}`, `💬 ${fmtNum(t.replyCount)}`, `👁 ${fmtNum(t.viewCount)}`];
  if (t.quoteCount > 0) metrics.push(`📎 ${fmtNum(t.quoteCount)}`);
  return [
    `${idx}@${t.author.userName} — ${relTime(t.createdAt)}`,
    '',
    t.text,
    '',
    metrics.join(' | '),
    '',
    `[View tweet](${tweetUrl(t)})`,
    '',
    '---',
  ].join('\n');
}

export function profileToMarkdown(u: UserInfo): string {
  return [
    `# @${u.userName} — ${u.name}`,
    '',
    u.description,
    '',
    `| Followers | Following | Tweets | Media |`,
    `|-----------|-----------|--------|-------|`,
    `| ${fmtNum(u.followers)} | ${fmtNum(u.following)} | ${fmtNum(u.tweetsCount)} | ${fmtNum(u.mediaCount || 0)} |`,
  ].join('\n');
}

export function userToMarkdown(u: UserInfo, i?: number): string {
  const idx = i !== undefined ? `${i + 1}. ` : '- ';
  return `${idx}**@${u.userName}** (${fmtNum(u.followers)} followers) — ${(u.description || '').slice(0, 100)}`;
}


path: scripts/x-search.ts
content: #!/usr/bin/env tsx
import {
  searchTweets, getUserInfo, getUserTweets, getTweetsById, getThread, getCost, resetCost,
  getUserMentions, getUserFollowers, getUserFollowings, searchUsers,
  getTweetReplies, getTweetQuotes, getTweetRetweeters,
  getTrends, getCommunityInfo, getCommunityTweets,
  type Tweet, type UserInfo,
} from './lib/api.js';
import * as cache from './lib/cache.js';
import {
  formatTweet, formatProfile, formatCost, tweetToMarkdown, profileToMarkdown,
  formatUserCompact, formatTrend, userToMarkdown,
} from './lib/format.js';
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const WATCHLIST_PATH = join(__dirname, '..', 'data', 'watchlist.json');

// --- Arg parsing ---
const args = process.argv.slice(2);
const command = args[0];
const positional: string[] = [];
const flags: Record<string, string | boolean> = {};

for (let i = 1; i < args.length; i++) {
  const a = args[i];
  if (a.startsWith('--')) {
    const key = a.slice(2);
    if (key === 'no-replies') { flags['noReplies'] = true; continue; }
    const next = args[i + 1];
    if (next && !next.startsWith('--')) { flags[key] = next; i++; }
    else flags[key] = true;
  } else {
    positional.push(a);
  }
}

function flag(name: string, def?: string): string | undefined { const v = flags[name]; return typeof v === 'string' ? v : def; }
function bflag(name: string): boolean { return flags[name] === true; }
function nflag(name: string, def: number): number { const v = flag(name); return v ? parseInt(v, 10) : def; }

// --- Since filter ---
function sinceMs(since?: string): number | undefined {
  if (!since) return undefined;
  const map: Record<string, number> = { '1h': 3600000, '3h': 10800000, '6h': 21600000, '12h': 43200000, '1d': 86400000, '3d': 259200000, '7d': 604800000, '30d': 2592000000 };
  return map[since];
}

function filterSince(tweets: Tweet[], since?: string): Tweet[] {
  const ms = sinceMs(since);
  if (!ms) return tweets;
  const cutoff = Date.now() - ms;
  return tweets.filter(t => new Date(t.createdAt).getTime() >= cutoff);
}

// --- Sort ---
function sortTweets(tweets: Tweet[], sort: string): Tweet[] {
  const s = [...tweets];
  switch (sort) {
    case 'likes': return s.sort((a, b) => b.likeCount - a.likeCount);
    case 'retweets': return s.sort((a, b) => b.retweetCount - a.retweetCount);
    case 'impressions': return s.sort((a, b) => b.viewCount - a.viewCount);
    case 'replies': return s.sort((a, b) => b.replyCount - a.replyCount);
    case 'recent': return s.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
    default: return s.sort((a, b) => b.likeCount - a.likeCount);
  }
}

function saveOutput(name: string, content: string) {
  const fname = `${name.replace(/[^a-z0-9]/gi, '_').slice(0, 40)}-${Date.now()}.md`;
  const outDir = join(__dirname, '..', 'data');
  mkdirSync(outDir, { recursive: true });
  writeFileSync(join(outDir, fname), content);
  console.log(`\n📁 Saved to data/${fname}`);
}

// --- Commands ---
async function doSearch() {
  const query = positional[0];
  if (!query) { console.error('Usage: x-search.ts search "<query>" [options]'); process.exit(1); }

  const quick = bflag('quick');
  const pages = quick ? 1 : nflag('pages', 3);
  const limit = quick ? Math.min(nflag('limit', 10), 10) : nflag('limit', 15);
  const sort = flag('sort', 'likes')!;
  const since = flag('since');
  const minLikes = nflag('min-likes', quick ? 5 : 2);
  const minImpressions = nflag('min-impressions', 0);
  const from = flag('from');
  const noReplies = bflag('noReplies');
  const quality = bflag('quality');
  const save = bflag('save');
  const json = bflag('json');
  const markdown = bflag('markdown');
  const queryType = flag('type', 'Latest')!;  // Latest or Top
  const ttl = quick ? 3600000 : 900000;

  // Build query
  let q = query;
  if (from) q = `from:${from} ${q}`;
  if (!q.includes('is:retweet')) q += ' -is:retweet';
  if (noReplies && !q.includes('is:reply')) q += ' -is:reply';
  if (quick) q += ' -is:reply';
  // Server-side min likes filter (much more effective than client-side)
  if (minLikes > 0 && !q.includes('min_faves:')) q += ` min_faves:${minLikes}`;
  // Server-side time filter via since: operator (more reliable than client-side)
  if (since && !q.includes('since:')) {
    const ms = sinceMs(since);
    if (ms) {
      const d = new Date(Date.now() - ms);
      q += ` since:${d.toISOString().split('T')[0]}`;
    }
  }

  // Auto-increase pages when filtering for high engagement (more pages = more chances to find them)
  const effectivePages = (minLikes >= 50 && pages <= 1) ? 3 : pages;

  const cacheKey = `search:${q}:${effectivePages}:${sort}:${since}:${minLikes}:${queryType}`;
  const cached = cache.get(cacheKey, ttl);
  let allTweets: Tweet[];

  if (cached) {
    allTweets = cached;
  } else {
    allTweets = [];
    let cursor: string | undefined;
    for (let p = 0; p < effectivePages; p++) {
      const result = await searchTweets(q, cursor, queryType);
      allTweets.push(...result.tweets);
      cursor = result.cursor;
      if (!result.hasNextPage || !cursor) break;
    }
    cache.set(cacheKey, allTweets);
  }

  // Filter
  allTweets = filterSince(allTweets, since);
  if (minLikes > 0) allTweets = allTweets.filter(t => t.likeCount >= minLikes);
  if (minImpressions > 0) allTweets = allTweets.filter(t => t.viewCount >= minImpressions);
  if (quality) allTweets = allTweets.filter(t => t.likeCount >= 10);

  // Sort & limit
  allTweets = sortTweets(allTweets, sort).slice(0, limit);

  // Output
  if (json) {
    console.log(JSON.stringify(allTweets, null, 2));
  } else if (markdown) {
    const md = `# Search: ${query}\n\n${allTweets.map((t, i) => tweetToMarkdown(t, i)).join('\n')}`;
    if (save) saveOutput(`search-${query}`, md);
    console.log(md);
  } else {
    console.log(`\n🔍 Search: "${query}" (${allTweets.length} results, sorted by ${sort})\n`);
    allTweets.forEach((t, i) => console.log(formatTweet(t, i)));
    if (save) {
      const md = `# Search: ${query}\n\n${allTweets.map((t, i) => tweetToMarkdown(t, i)).join('\n')}`;
      saveOutput(`search-${query}`, md);
    }
  }
  if (!json) console.log(formatCost(getCost()));
}

async function doProfile() {
  const username = positional[0]?.replace('@', '');
  if (!username) { console.error('Usage: x-search.ts profile <username>'); process.exit(1); }

  const count = nflag('count', 20);
  const includeReplies = bflag('replies');
  const json = bflag('json');

  const cacheKey = `profile:${username}:${count}`;
  const cached = cache.get(cacheKey);

  let info: UserInfo, tweets: Tweet[];
  if (cached) {
    ({ info, tweets } = cached);
  } else {
    [info, { tweets }] = await Promise.all([getUserInfo(username), getUserTweets(username)]);
    if (!includeReplies) tweets = tweets.filter(t => !t.isReply);
    tweets = tweets.slice(0, count);
    cache.set(cacheKey, { info, tweets });
  }

  if (json) {
    console.log(JSON.stringify({ info, tweets }, null, 2));
  } else {
    console.log('\n' + formatProfile(info) + '\n');
    console.log(`📝 Recent tweets (${tweets.length}):\n`);
    tweets.forEach((t, i) => console.log(formatTweet(t, i)));
  }
  if (!json) console.log(formatCost(getCost()));
}

async function doTweet() {
  const id = positional[0];
  if (!id) { console.error('Usage: x-search.ts tweet <tweet_id>'); process.exit(1); }

  const tweets = await getTweetsById([id]);
  if (!tweets.length) { console.error('Tweet not found'); process.exit(1); }

  if (bflag('json')) {
    console.log(JSON.stringify(tweets[0], null, 2));
  } else {
    console.log('\n' + formatTweet(tweets[0]));
  }
  console.log(formatCost(getCost()));
}

async function doThread() {
  const id = positional[0];
  if (!id) { console.error('Usage: x-search.ts thread <tweet_id>'); process.exit(1); }

  const tweets = await getThread(id);
  if (!tweets.length) { console.error('Thread not found'); process.exit(1); }

  if (bflag('json')) {
    console.log(JSON.stringify(tweets, null, 2));
  } else {
    console.log(`\n🧵 Thread (${tweets.length} tweets):\n`);
    tweets.forEach((t, i) => console.log(formatTweet(t, i)));
  }
  console.log(formatCost(getCost()));
}

async function doReplies() {
  const id = positional[0];
  if (!id) { console.error('Usage: x-search.ts replies <tweet_id>'); process.exit(1); }

  const limit = nflag('limit', 20);
  const sort = flag('sort', 'likes')!;
  const result = await getTweetReplies(id);
  let tweets = sortTweets(result.tweets, sort).slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(tweets, null, 2));
  } else {
    console.log(`\n💬 Replies (${tweets.length}):\n`);
    tweets.forEach((t, i) => console.log(formatTweet(t, i)));
  }
  console.log(formatCost(getCost()));
}

async function doQuotes() {
  const id = positional[0];
  if (!id) { console.error('Usage: x-search.ts quotes <tweet_id>'); process.exit(1); }

  const limit = nflag('limit', 20);
  const sort = flag('sort', 'likes')!;
  const result = await getTweetQuotes(id);
  let tweets = sortTweets(result.tweets, sort).slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(tweets, null, 2));
  } else {
    console.log(`\n📎 Quote tweets (${tweets.length}):\n`);
    tweets.forEach((t, i) => console.log(formatTweet(t, i)));
  }
  console.log(formatCost(getCost()));
}

async function doMentions() {
  const username = positional[0]?.replace('@', '');
  if (!username) { console.error('Usage: x-search.ts mentions <username>'); process.exit(1); }

  const limit = nflag('limit', 20);
  const sort = flag('sort', 'recent')!;
  const since = flag('since');

  const cacheKey = `mentions:${username}`;
  const cached = cache.get(cacheKey);
  let tweets: Tweet[];

  if (cached) {
    tweets = cached;
  } else {
    const result = await getUserMentions(username);
    tweets = result.tweets;
    cache.set(cacheKey, tweets);
  }

  tweets = filterSince(tweets, since);
  tweets = sortTweets(tweets, sort).slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(tweets, null, 2));
  } else {
    console.log(`\n📢 Mentions of @${username} (${tweets.length}):\n`);
    tweets.forEach((t, i) => console.log(formatTweet(t, i)));
  }
  console.log(formatCost(getCost()));
}

async function doFollowers() {
  const username = positional[0]?.replace('@', '');
  if (!username) { console.error('Usage: x-search.ts followers <username>'); process.exit(1); }

  const limit = nflag('limit', 20);
  const result = await getUserFollowers(username);
  const users = result.users.slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(users, null, 2));
  } else {
    console.log(`\n👥 Followers of @${username} (${users.length}):\n`);
    users.forEach((u, i) => console.log(formatUserCompact(u, i)));
  }
  console.log(formatCost(getCost()));
}

async function doFollowing() {
  const username = positional[0]?.replace('@', '');
  if (!username) { console.error('Usage: x-search.ts following <username>'); process.exit(1); }

  const limit = nflag('limit', 20);
  const result = await getUserFollowings(username);
  const users = result.users.slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(users, null, 2));
  } else {
    console.log(`\n👤 @${username} is following (${users.length}):\n`);
    users.forEach((u, i) => console.log(formatUserCompact(u, i)));
  }
  console.log(formatCost(getCost()));
}

async function doSearchUsers() {
  const query = positional[0];
  if (!query) { console.error('Usage: x-search.ts users "<query>"'); process.exit(1); }

  const limit = nflag('limit', 20);
  const result = await searchUsers(query);
  const users = result.users.slice(0, limit);

  if (bflag('json')) {
    console.log(JSON.stringify(users, null, 2));
  } else {
    console.log(`\n🔍 User search: "${query}" (${users.length}):\n`);
    users.forEach((u, i) => console.log(formatUserCompact(u, i)));
  }
  console.log(formatCost(getCost()));
}

async function doTrending() {
  const woeid = nflag('woeid', 1);  // 1 = worldwide
  const count = nflag('count', 30);

  const cacheKey = `trends:${woeid}`;
  const cached = cache.get(cacheKey, 300000); // 5 min cache for trends
  let trends: any[];

  if (cached) {
    trends = cached;
  } else {
    trends = await getTrends(woeid, count);
    cache.set(cacheKey, trends);
  }

  if (bflag('json')) {
    console.log(JSON.stringify(trends, null, 2));
  } else {
    console.log(`\n📈 Trending${woeid === 1 ? ' (Worldwide)' : ` (woeid: ${woeid})`}:\n`);
    trends.forEach((t, i) => console.log(formatTrend(t, i)));
  }
  console.log(formatCost(getCost()));
}

async function doCommunity() {
  const sub = positional[0];
  const id = positional[1] || positional[0];

  if (sub === 'tweets' && positional[1]) {
    const communityId = positional[1];
    const limit = nflag('limit', 20);
    const sort = flag('sort', 'likes')!;
    const result = await getCommunityTweets(communityId);
    let tweets = sortTweets(result.tweets, sort).slice(0, limit);

    if (bflag('json')) {
      console.log(JSON.stringify(tweets, null, 2));
    } else {
      console.log(`\n🏘 Community tweets (${tweets.length}):\n`);
      tweets.forEach((t, i) => console.log(formatTweet(t, i)));
    }
    console.log(formatCost(getCost()));
    return;
  }

  if (id && id !== 'tweets') {
    const info = await getCommunityInfo(id);
    if (bflag('json')) {
      console.log(JSON.stringify(info, null, 2));
    } else {
      console.log(`\n🏘 Community: ${info.name || id}`);
      if (info.description) console.log(`   ${info.description}`);
      console.log(`   Members: ${info.member_count || '?'}  Moderators: ${info.moderator_count || '?'}`);
      if (info.primary_topic) console.log(`   Topic: ${info.primary_topic.name}`);
    }
    console.log(formatCost(getCost()));
    return;
  }

  console.error('Usage: x-search.ts community <id> | community tweets <id>');
}

// --- Watchlist ---
function loadWatchlist(): Array<{ username: string; note: string; addedAt: string }> {
  if (!existsSync(WATCHLIST_PATH)) return [];
  return JSON.parse(readFileSync(WATCHLIST_PATH, 'utf-8'));
}

function saveWatchlist(list: any[]) {
  mkdirSync(dirname(WATCHLIST_PATH), { recursive: true });
  writeFileSync(WATCHLIST_PATH, JSON.stringify(list, null, 2));
}

async function doWatchlist() {
  const sub = positional[0];
  const list = loadWatchlist();

  if (!sub) {
    if (!list.length) { console.log('Watchlist is empty'); return; }
    console.log('\n📋 Watchlist:\n');
    list.forEach(w => console.log(`  @${w.username} — ${w.note || ''} (added ${w.addedAt})`));
    return;
  }

  if (sub === 'add') {
    const user = positional[1]?.replace('@', '');
    const note = positional.slice(2).join(' ') || '';
    if (!user) { console.error('Usage: watchlist add <user> [note]'); process.exit(1); }
    if (list.find(w => w.username === user)) { console.log(`@${user} already in watchlist`); return; }
    list.push({ username: user, note, addedAt: new Date().toISOString().split('T')[0] });
    saveWatchlist(list);
    console.log(`✅ Added @${user} to watchlist`);
    return;
  }

  if (sub === 'remove') {
    const user = positional[1]?.replace('@', '');
    if (!user) { console.error('Usage: watchlist remove <user>'); process.exit(1); }
    const filtered = list.filter(w => w.username !== user);
    if (filtered.length === list.length) { console.log(`@${user} not in watchlist`); return; }
    saveWatchlist(filtered);
    console.log(`🗑 Removed @${user} from watchlist`);
    return;
  }

  if (sub === 'check') {
    if (!list.length) { console.log('Watchlist is empty'); return; }
    console.log(`\n🔍 Checking ${list.length} watchlist accounts...\n`);
    for (const w of list) {
      try {
        const { tweets } = await getUserTweets(w.username);
        const recent = tweets.filter(t => !t.isReply).slice(0, 3);
        console.log(`\n${'─'.repeat(50)}`);
        console.log(`@${w.username} ${w.note ? `(${w.note})` : ''}`);
        if (recent.length) {
          recent.forEach(t => console.log(formatTweet(t)));
        } else {
          console.log('  No recent tweets');
        }
      } catch (e: any) {
        console.error(`  Error fetching @${w.username}: ${e.message}`);
      }
    }
    console.log(formatCost(getCost()));
    return;
  }

  console.error('Unknown watchlist command. Use: add, remove, check');
}

async function doCacheClear() {
  const n = cache.clear();
  console.log(`🗑 Cleared ${n} cache files`);
}

// --- Main ---
async function main() {
  resetCost();
  switch (command) {
    case 'search': return doSearch();
    case 'profile': return doProfile();
    case 'tweet': return doTweet();
    case 'thread': return doThread();
    case 'replies': return doReplies();
    case 'quotes': return doQuotes();
    case 'mentions': return doMentions();
    case 'followers': return doFollowers();
    case 'following': return doFollowing();
    case 'users': return doSearchUsers();
    case 'trending': return doTrending();
    case 'community': return doCommunity();
    case 'watchlist': return doWatchlist();
    case 'cache': if (positional[0] === 'clear') return doCacheClear(); break;
    default:
      console.log(`x-search — X/Twitter research tool

Commands:
  search "<query>"       Search tweets (--sort, --since, --pages, --quick, --type Top|Latest)
  profile <username>     User profile + recent tweets
  tweet <tweet_id>       Get single tweet
  thread <tweet_id>      Get full thread
  replies <tweet_id>     Get tweet replies
  quotes <tweet_id>      Get quote tweets
  mentions <username>    Get tweets mentioning a user
  followers <username>   List followers
  following <username>   List who user follows
  users "<query>"        Search for users
  trending               Get trending topics (--woeid N)
  community <id>         Get community info
  community tweets <id>  Get community tweets
  watchlist [cmd]        Manage watchlist (add/remove/check)
  cache clear            Clear cache

Global flags: --json, --limit N, --sort likes|retweets|impressions|recent`);
  }
}

main().catch(e => { console.error('Error:', e.message); process.exit(1); });


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