Skill: flyingtimes/twitter-command (version 0.1.0)

--- name: twitter-cli description: Use twitter-cli for ALL Twitter/X operations — reading tweets, posting, replying, quoting, liking, retweeting, following, searching, user lookups. Invoke whenever user requests any Twitter interaction. author: jackwener version: "2.0.0" tags: - twitter - x - social-media - terminal - cli --- # twitter-cli — Twitter/X CLI Tool **Binary:** `twitter` **Credentials:** browser cookies (auto-extracted) or env vars ## Setup ```bash # Install (requires Python 3.8+) uv tool install twitter-cli # Or: pipx install twitter-cli # Upgrade to latest (recommended to avoid API errors) uv tool upgrade twitter-cli # Or: pipx upgrade twitter-cli ``` ## Authentication **IMPORTANT FOR AGENTS**: Before executing ANY twitter-cli command, you MUST first check if credentials exist. If not, you MUST proactively guide the user through the authentication process. Do NOT assume credentials are configured. **CRITICAL**: Write operations (posting tweets, replying, quoting) REQUIRE full browser cookies. Only providing `auth_token` + `ct0` via env vars may result in **226 error** ("looks like automated behavior"). For best results, use browser cookie extraction. ### Step 0: Check if already authenticated ```bash twitter status --yaml >/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED" ``` If `AUTH_OK`, skip to [Command Reference](#command-reference). If `AUTH_NEEDED`, proceed to guide the user: ### Step 1: Guide user to authenticate **Method A: Browser cookie extraction (recommended)** Ensure user is logged into x.com in one of: Arc, Chrome, Edge, Firefox, Brave. twitter-cli auto-extracts cookies. All Chrome profiles are scanned automatically. To specify a profile: `TWITTER_CHROME_PROFILE="Profile 2" twitter feed`. ```bash twitter whoami ``` **Method B: Environment variables** ```bash export TWITTER_AUTH_TOKEN="<auth_token from browser>" export TWITTER_CT0="<ct0 from browser>" twitter whoami ``` **Method C: Full cookie string (for cloud/remote agents)** Tell the user: > 我需要你的 Twitter 登录凭证。请按以下步骤获取： > > 1. 用 Chrome/Edge/Firefox 打开 https://x.com（确保已登录） > 2. 按 `F12` 打开开发者工具 → **Network** 标签 > 3. 在页面上刷新，点击任意 `x.com` 请求 > 4. 找到 **Request Headers** → **Cookie:** 这一行，右键 → 复制值 > 5. 把完整 Cookie 字符串发给我 > > ⚠️ Cookie 包含登录信息，请不要分享给其他人。 Then extract and set env vars: ```bash FULL_COOKIE="<user's cookie string>" export TWITTER_AUTH_TOKEN=$(echo "$FULL_COOKIE" | grep -oE 'auth_token=[a-f0-9]+' | cut -d= -f2) export TWITTER_CT0=$(echo "$FULL_COOKIE" | grep -oE 'ct0=[a-f0-9]+' | cut -d= -f2) twitter whoami ``` ### Step 2: Handle common auth issues | Symptom | Agent action | |---------|-------------| | `No Twitter cookies found` | Guide user to login to x.com in browser, or set env vars | | Read works, write returns 226 | Full cookies missing — use browser cookie extraction instead of env vars | | `Cookie expired (401/403)` | Ask user to re-login to x.com and retry | | User changed password | All old cookies invalidated — re-extract | ## Output Format ### Default: Rich table (human-readable) ```bash twitter feed # Pretty table output ``` ### YAML / JSON: structured output Non-TTY stdout defaults to YAML automatically. Use `OUTPUT=yaml|json|rich|auto` to override. ```bash twitter feed --yaml twitter feed --json | jq '.[0].text' ``` All machine-readable output uses the envelope documented in [SCHEMA.md](./SCHEMA.md). Tweet and user payloads now live under `.data`. ### Full text: `--full-text` flag (rich tables only) Use `--full-text` when the user wants complete post bodies in terminal tables. It affects rich table list views such as `feed`, `bookmarks`, `search`, `user-posts`, `likes`, `list`, and reply tables in `tweet`. It does **not** change `--json`, `--yaml`, or `-c` compact output. ```bash twitter feed --full-text twitter search "AI agent" --full-text twitter user-posts elonmusk --max 20 --full-text twitter tweet 1234567890 --full-text ``` ### Compact: `-c` flag (minimal tokens for LLM) ```bash twitter -c feed --max 10 # Minimal fields, great for LLM context twitter -c search "AI" --max 20 # ~80% fewer tokens than --json ``` **Compact fields (per tweet):** `id`, `author` (@handle), `text` (truncated 140 chars), `likes`, `rts`, `time` (short format) ## Command Reference ### Read Operations ```bash twitter status # Quick auth check twitter status --yaml # Structured auth status twitter whoami # Current authenticated user twitter whoami --yaml # YAML output twitter whoami --json # JSON output twitter user elonmusk # User profile twitter user elonmusk --json # JSON output twitter feed # Home timeline (For You) twitter feed -t following # Following timeline twitter feed --max 50 # Limit count twitter feed --full-text # Show full post body in table twitter feed --filter # Enable ranking filter twitter feed --yaml > tweets.yaml # Export as YAML twitter feed --input tweets.json # Read from local JSON file twitter bookmarks # Bookmarked tweets twitter bookmarks --full-text # Full text in bookmarks table twitter bookmarks --max 30 --yaml twitter search "keyword" # Search tweets twitter search "AI agent" -t Latest --max 50 twitter search "AI agent" --full-text # Full text in search results twitter search "topic" -o results.json # Save to file twitter tweet 1234567890 # Tweet detail + replies twitter tweet 1234567890 --full-text # Full text in reply table twitter tweet https://x.com/user/status/12345 # Accepts URL twitter list 1539453138322673664 # List timeline twitter list 1539453138322673664 --full-text twitter user-posts elonmusk --max 20 # User's tweets twitter user-posts elonmusk --full-text twitter likes elonmusk --max 30 # User's likes (own only, see note) twitter likes elonmusk --full-text twitter followers elonmusk --max 50 # Followers twitter following elonmusk --max 50 # Following ``` ### Write Operations ```bash twitter post "Hello from twitter-cli!" # Post tweet twitter reply 1234567890 "Great tweet!" # Reply (standalone) twitter post "reply text" --reply-to 1234567890 # Reply (via post) twitter quote 1234567890 "Interesting take" # Quote-tweet twitter delete 1234567890 # Delete tweet twitter like 1234567890 # Like twitter unlike 1234567890 # Unlike twitter retweet 1234567890 # Retweet twitter unretweet 1234567890 # Unretweet twitter bookmark 1234567890 # Bookmark twitter unbookmark 1234567890 # Unbookmark twitter follow elonmusk # Follow user twitter unfollow elonmusk # Unfollow user ``` ## Agent Workflows ### Post and verify ```bash twitter post "My tweet text" 2>/dev/null # Output includes tweet URL: 🔗 https://x.com/i/status/<id> ``` ### Reply to someone's latest tweet ```bash TWEET_ID=$(twitter user-posts targetuser --max 1 --json | jq -r '.data[0].id') twitter reply "$TWEET_ID" "Nice post!" ``` ### Create a thread ```bash # Post first tweet, capture output for tweet ID twitter post "Thread 1/3: First point" # Note the tweet ID from output, then: twitter reply <first_tweet_id> "2/3: Second point" twitter reply <second_tweet_id> "3/3: Final point" ``` ### Quote-tweet with commentary ```bash TWEET_ID=$(twitter search "interesting topic" --max 1 --json | jq -r '.data[0].id') twitter quote "$TWEET_ID" "This is a great insight!" ``` ### Like all search results ```bash twitter search "interesting topic" --max 5 --json | jq -r '.data[].id' | while read id; do twitter like "$id" done ``` ### Get user info then follow ```bash twitter user targethandle --json | jq '.data | {username, followers, bio}' twitter follow targethandle ``` ### Find most popular tweets from a user ```bash twitter user-posts elonmusk --max 20 --json | jq '.data | sort_by(.metrics.likes) | reverse | .[:3] | .[] | {id, text: .text[:80], likes: .metrics.likes}' ``` ### Check follower relationship ```bash MY_NAME=$(twitter whoami --json | jq -r '.data.user.username') twitter followers "$MY_NAME" --max 200 --json | jq -r '.data[].username' | grep -q "targetuser" && echo "Yes" || echo "No" ``` ### Daily reading workflow ```bash # Compact mode for token-efficient LLM context twitter -c feed -t following --max 30 twitter -c bookmarks --max 20 # Rich table with complete post bodies twitter feed -t following --max 20 --full-text twitter search "AI agent" --max 20 --full-text # Full JSON for analysis twitter feed -t following --max 30 -o following.json twitter bookmarks --max 20 -o bookmarks.json ``` ### Search with jq filtering ```bash # Tweets with > 100 likes twitter search "AI safety" --max 20 --json | jq '[.data[] | select(.metrics.likes > 100)]' # Extract just text and author twitter search "rust lang" --max 10 --json | jq '.data[] | {author: .author.screenName, text: .text[:100]}' # Most engaged tweets twitter search "topic" --max 20 --json | jq '.data | sort_by(.metrics.likes) | reverse | .[:5] | .[].id' ``` ## Ranking Filter Filtering is opt-in. Enable with `--filter`: ```bash twitter feed --filter twitter bookmarks --filter ``` ## Error Reference | Error | Cause | Fix | |-------|-------|-----| | `No Twitter cookies found` | Not authenticated | Login to x.com in browser, or set env vars | | HTTP 226 | Automated detection | Use browser cookie extraction (not env vars) | | HTTP 401/403 | Cookie expired | Re-login to x.com and retry | | HTTP 404 | QueryId rotation | Retry (auto-fallback built in) | | HTTP 429 | Rate limited | Wait 15+ minutes, then retry | | Error 187 | Duplicate tweet | Change text content | | Error 186 | Tweet too long | Keep under 280 chars | ## Limitations - **Text only** — no media/image upload - **No DMs** — no direct messaging - **No notifications** — can't read notifications - **No polls** — can't create polls - **Single account** — one set of credentials at a time - **Likes are private** — Twitter/X made all likes private since June 2024. `twitter likes` only works for your own account ## Safety Notes - Write operations have built-in random delays (1.5–4s) to avoid rate limits. - TLS fingerprint and User-Agent are automatically matched to the Chrome version used. - Do not ask users to share raw cookie values in chat logs. - Prefer local browser cookie extraction over manual secret copy/paste. - Agent should treat cookie values as secrets (do not echo to stdout unnecessarily).

path: .github/ISSUE_TEMPLATE/bug_report.yml
content: name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
  - type: input
    id: version
    attributes:
      label: Version
      description: "Run `twitter --version` or `pip show twitter-cli | grep Version`"
      placeholder: "e.g. 0.6.0"
    validations:
      required: true
  - type: dropdown
    id: os
    attributes:
      label: Operating System
      options:
        - macOS
        - Linux
        - Windows
        - Other
    validations:
      required: true
  - type: dropdown
    id: browser
    attributes:
      label: Browser (for cookie extraction)
      options:
        - Arc
        - Chrome
        - Edge
        - Firefox
        - Brave
        - Other / N/A (using env vars)
    validations:
      required: true
  - type: dropdown
    id: access_method
    attributes:
      label: Access Method
      description: How are you accessing the machine where twitter-cli runs?
      options:
        - Local terminal
        - SSH
        - Docker / container
        - Other
    validations:
      required: true
  - type: textarea
    id: description
    attributes:
      label: What happened?
      description: Describe the bug clearly.
    validations:
      required: true
  - type: textarea
    id: expected
    attributes:
      label: Expected behavior
      description: What did you expect to happen?
    validations:
      required: true
  - type: textarea
    id: reproduce
    attributes:
      label: Steps to reproduce
      description: "Commands or steps to reproduce the issue. Use `twitter -v <command>` for debug output."
      render: bash
  - type: textarea
    id: diagnostics
    attributes:
      label: Diagnostics
      description: "Paste the output of `twitter doctor` here. This helps us diagnose cookie and auth issues quickly."
      render: text
  - type: textarea
    id: logs
    attributes:
      label: Error output / logs
      description: Paste any error messages or verbose output here.
      render: text


path: .github/ISSUE_TEMPLATE/feature_request.yml
content: name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
  - type: input
    id: version
    attributes:
      label: Current version
      description: "Run `twitter --version` or `pip show twitter-cli | grep Version`"
      placeholder: "e.g. 0.6.0"
    validations:
      required: false
  - type: textarea
    id: description
    attributes:
      label: Describe the feature
      description: What would you like to see added or changed?
    validations:
      required: true
  - type: textarea
    id: use_case
    attributes:
      label: Use case
      description: Why do you need this feature? How would you use it?


path: .github/workflows/ci.yml
content: name: CI

on:
  push:
    branches: ["**"]
  pull_request:
  workflow_call:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.8", "3.10", "3.12"]
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup uv
        uses: astral-sh/setup-uv@v6

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: uv sync --extra dev

      - name: Lint
        run: uv run ruff check .

      - name: Test
        run: uv run pytest -q


path: .github/workflows/publish-clawhub.yml
content: name: Publish to ClawHub

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      CLAWHUB_SLUG: twitter-cli
      CLAWHUB_NAME: twitter-cli

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install ClawHub CLI
        run: npm install -g clawhub@0.7.0

      - name: Publish to ClawHub
        env:
          CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
        run: |
          if [ -z "${CLAWHUB_TOKEN:-}" ]; then
            echo "Missing required [REDACTED_SECRET]"
            exit 1
          fi

          if [ "${GITHUB_REF_TYPE:-}" = "tag" ] && [ -n "${GITHUB_REF_NAME:-}" ]; then
            VERSION="${GITHUB_REF_NAME#v}"
          else
            VERSION="$(sed -n 's/^version = \"\([^\"]*\)\"/\1/p' pyproject.toml | head -n 1)"
          fi
          if [ -z "${VERSION:-}" ]; then
            echo "Unable to resolve version"
            exit 1
          fi
          echo "Publishing ${CLAWHUB_SLUG}@${VERSION}"

          if clawhub --no-input inspect "${CLAWHUB_SLUG}" --version "${VERSION}" >/dev/null 2>&1; then
            echo "Version ${VERSION} already exists on ClawHub, skipping publish."
            exit 0
          fi

          clawhub --no-input login --token "${CLAWHUB_TOKEN}" --no-browser
          export VERSION

          # Workaround: clawhub@0.7.0 publish doesn't send acceptLicenseTerms,
          # which the server now requires. Use a Node.js script to publish with
          # the corrected payload.
          # Note: NODE_TLS_REJECT_UNAUTHORIZED=0 is needed because api.clawhub.io
          # has an expired SSL certificate. Remove once ClawHub renews their cert.
          NODE_TLS_REJECT_UNAUTHORIZED=0 node - <<'PUBLISH_SCRIPT'
          const { resolve } = require("path");
          const { readFileSync, statSync, readdirSync } = require("fs");
          const folder = resolve(".");
          const slug = process.env.CLAWHUB_SLUG;
          const version = process.env.VERSION;

          // Collect text files (same logic as clawhub CLI)
          function walk(dir, base = "") {
            let files = [];
            for (const entry of readdirSync(dir, { withFileTypes: true })) {
              const rel = base ? base + "/" + entry.name : entry.name;
              if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
              if (entry.isDirectory()) { files.push(...walk(dir + "/" + entry.name, rel)); }
              else { files.push({ rel, abs: dir + "/" + entry.name }); }
            }
            return files;
          }
          const files = walk(folder).filter(f => {
            const ext = f.rel.split(".").pop().toLowerCase();
            return ["md","txt","py","toml","yml","yaml","json","cfg","ini","sh","bat"].includes(ext);
          });

          const payload = JSON.stringify({
            slug,
            displayName: slug,
            version,
            changelog: "Release " + version,
            tags: ["latest"],
            acceptLicenseTerms: true,
          });

          const form = new FormData();
          form.set("payload", payload);
          for (const file of files) {
            const bytes = readFileSync(file.abs);
            const blob = new Blob([bytes], { type: "text/plain" });
            form.append("files", blob, file.rel);
          }

          // Read registry and token from clawhub config
          const os = require("os");
          const configPath = resolve(os.homedir(), ".config", "clawhub", "config.json");
          let token = "";
          try {
            const cfg = JSON.parse(readFileSync(configPath, "utf8"));
            [REDACTED_SECRET] || "";
          } catch {}
          if (!token) { console.error("No token found"); process.exit(1); }

          const registry = "https://clawhub.ai";
          fetch(registry + "/api/v1/skills", {
            method: "POST",
            headers: { Authorization: "Bearer " + token },
            body: form,
          }).then(async (r) => {
            const text = await r.text();
            if (!r.ok) {
              console.error("Publish failed:", r.status, text);
              process.exit(1);
            }
            console.log("✔ Published", slug + "@" + version);
          }).catch((e) => {
            console.error(e);
            process.exit(1);
          });
          PUBLISH_SCRIPT


path: .github/workflows/publish.yml
content: name: Publish to PyPI

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

jobs:
  verify:
    uses: ./.github/workflows/ci.yml

  publish:
    needs: verify
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      id-token: write

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Setup uv
        uses: astral-sh/setup-uv@v6

      - name: Build package
        run: uv build

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1


path: README.md
content: # twitter-cli

[![CI](https://github.com/jackwener/twitter-cli/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jackwener/twitter-cli/actions/workflows/ci.yml)
[![PyPI version](https://badge.fury.io/py/twitter-cli.svg)](https://pypi.org/project/twitter-cli/)
[![Python](https://img.shields.io/badge/python-%3E%3D3.8-blue.svg)](https://pypi.org/project/twitter-cli/)

A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles without API keys.

## More Tools

- [xiaohongshu-cli](https://github.com/jackwener/xiaohongshu-cli) — Xiaohongshu (小红书) CLI for notes and account workflows
- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili CLI for videos, users, search, and feeds
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord CLI for local-first sync, search, and export
- [tg-cli](https://github.com/jackwener/tg-cli) — Telegram CLI for local-first sync, search, and export

[English](#english) | [中文](#中文)

## English

### Features

**Read:**
- Timeline: fetch `for-you` and `following` feeds
- Bookmarks: list saved tweets from your account
- Search: find tweets by keyword with Top/Latest/Photos/Videos tabs
- Tweet detail: view a tweet and its replies
- List timeline: fetch tweets from a Twitter List
- User lookup: fetch user profile, tweets, likes, followers, and following
- `--full-text`: disable tweet text truncation in rich table output
- Structured output: export any data as YAML or JSON for scripting and AI agent integration
- Optional scoring filter: rank tweets by engagement weights
- Structured output contract: [SCHEMA.md](./SCHEMA.md)

> **AI Agent Tip:** Prefer `--yaml` for structured output unless a strict JSON parser is required. Non-TTY stdout defaults to YAML automatically. Use `--max` to limit results.

**Write:**
- Post: create new tweets and replies
- Delete: remove your own tweets
- Like / Unlike: manage tweet likes
- Retweet / Unretweet: manage retweets
- Bookmark: bookmark/unbookmark (`favorite/unfavorite` kept as compatibility aliases)
- Write commands also support explicit `--json` / `--yaml` output now

**Auth & Anti-Detection:**
- Cookie auth: use browser cookies or environment variables
- Full cookie forwarding: extracts ALL browser cookies for richer browser context
- TLS fingerprint impersonation: `curl_cffi` with dynamic Chrome version matching
- `x-client-transaction-id` header generation
- Request timing jitter to avoid pattern detection
- Write operation delays (1.5–4s random) to mitigate rate limits
- Proxy support via `TWITTER_PROXY` environment variable

### Installation

```bash
# Recommended: uv tool (fast, isolated)
uv tool install twitter-cli

# Alternative: pipx
pipx install twitter-cli
```

Upgrade to the latest version:

```bash
uv tool upgrade twitter-cli
# Or: pipx upgrade twitter-cli
```

> **Tip:** Upgrade regularly to avoid unexpected errors from outdated API handling.

Install from source:

```bash
git clone [REDACTED_SECRET]:jackwener/twitter-cli.git
cd twitter-cli
uv sync
```

### Quick Start

```bash
# Fetch home timeline (For You)
twitter feed

# Fetch Following timeline
twitter feed -t following

# Enable ranking filter explicitly
twitter feed --filter
```

### Usage

```bash
# Feed
twitter feed --max 50
twitter feed --full-text
twitter feed --json > tweets.json
twitter feed --input tweets.json

# Bookmarks
twitter bookmarks
twitter bookmarks --full-text
twitter bookmarks --max 30 --yaml

# Search
twitter search "Claude Code"
twitter search "AI agent" -t Latest --max 50
twitter search "AI agent" --full-text
twitter search "机器学习" --yaml
twitter search "topic" -o results.json         # Save to file
twitter search "trending" --filter              # Apply ranking filter

# Tweet detail (view tweet + replies)
twitter tweet 1234567890
twitter tweet 1234567890 --full-text
twitter tweet https://x.com/user/status/1234567890

# List timeline
twitter list 1539453138322673664
twitter list 1539453138322673664 --full-text

# User
twitter user elonmusk
twitter user-posts elonmusk --max 20
twitter user-posts elonmusk --full-text
twitter user-posts elonmusk -o tweets.json
twitter likes elonmusk --max 30          # ⚠️ own likes only (private since Jun 2024)
twitter likes elonmusk --full-text
twitter likes elonmusk -o likes.json
twitter followers elonmusk --max 50
twitter following elonmusk --max 50

# Write operations
twitter post "Hello from twitter-cli!"
twitter post "reply text" --reply-to 1234567890
twitter post "Hello from twitter-cli!" --json
twitter delete 1234567890
twitter like 1234567890
twitter like 1234567890 --yaml
twitter unlike 1234567890
twitter retweet 1234567890
twitter unretweet 1234567890
twitter bookmark 1234567890
twitter unbookmark 1234567890
twitter follow elonmusk --json
```

### Authentication

twitter-cli uses this auth priority:

1. **Environment variables**: `TWITTER_AUTH_TOKEN` + `TWITTER_CT0`
2. **Browser cookies** (recommended): auto-extract from Arc/Chrome/Edge/Firefox/Brave

Browser extraction is recommended — it forwards ALL Twitter cookies (not just `auth_token` + `ct0`) and aligns request headers with your local runtime, which is closer to normal browser traffic than minimal cookie auth.

**Chrome multi-profile**: All Chrome profiles are scanned automatically. To specify a profile:

```bash
TWITTER_CHROME_PROFILE="Profile 2" twitter feed
```

After loading cookies, the CLI performs lightweight verification. Commands that require account access fail fast on clear auth errors (`401/403`).

### Proxy Support

Set `TWITTER_PROXY` to route all requests through a proxy:

```bash
# HTTP proxy
export TWITTER_PROXY=http://127.0.0.1:7890

# SOCKS5 proxy
export TWITTER_PROXY=socks5://127.0.0.1:1080
```

Using a proxy can help reduce IP-based rate limiting risks.

### Configuration

Create `config.yaml` in your working directory:

```yaml
fetch:
  count: 50

filter:
  mode: "topN"          # "topN" | "score" | "all"
  topN: 20
  minScore: 50
  lang: []
  excludeRetweets: false
  weights:
    likes: 1.0
    retweets: 3.0
    replies: 2.0
    bookmarks: 5.0
    views_log: 0.5

rateLimit:
  requestDelay: 2.5     # base delay between requests (randomized ×0.7–1.5)
  maxRetries: 3          # retry count on rate limit (429)
  retryBaseDelay: 5.0    # base delay for exponential backoff
  maxCount: 200          # hard cap on fetched items
```

Fetch behavior:

- `fetch.count` is the default item count for read commands when `--max` is omitted
- Rich table output truncates long tweet text by default; use `--full-text` to show full body text in list views

Filter behavior:

- Default behavior: no ranking filter unless `--filter` is passed
- With `--filter`: tweets are scored/sorted using `config.filter`

Scoring formula:

```text
score = likes_w * likes
      + retweets_w * retweets
      + replies_w * replies
      + bookmarks_w * bookmarks
      + views_log_w * log10(max(views, 1))
```

Mode behavior:

- `mode: "topN"` keeps the highest `topN` tweets by score
- `mode: "score"` keeps tweets where `score >= minScore`
- `mode: "all"` returns all tweets after sorting by score

### Best Practices (Avoiding Bans)

- **Use a proxy** — set `TWITTER_PROXY` to avoid direct IP exposure
- **Keep request volumes low** — use `--max 20` instead of `--max 500`
- **Don't run too frequently** — each startup fetches x.com to initialize anti-detection headers
- **Use browser cookie extraction** — provides full cookie fingerprint
- **Avoid datacenter IPs** — residential proxies are much safer

### Output Modes

- Use the default rich table for interactive reading
- Use `--full-text` when reading long posts in terminal tables
- Use `--yaml` or `--json` for scripts and agent pipelines
- Use `-c` / `--compact` when token efficiency matters more than completeness

### Troubleshooting

- `No Twitter cookies found`
  - Ensure you are logged in to `x.com` in a supported browser (Arc/Chrome/Edge/Firefox/Brave).
  - Or set `TWITTER_AUTH_TOKEN` and `TWITTER_CT0` manually.
  - Run with `-v` to see browser extraction diagnostics.

- `Cookie expired or invalid (HTTP 401/403)`
  - Re-login to `x.com` and retry.

- `Unable to get key for cookie decryption` (macOS Keychain)
  - **SSH sessions**: Keychain is locked by default over SSH. Run:
    ```bash
    security unlock-keychain ~/Library/Keychains/login.keychain-db
    ```
  - **Local terminal**: Open **Keychain Access** → search for **"\<Browser\> Safe Storage"** → **Access Control** → add your Terminal app → **Save Changes**.
  - Or click **"Always Allow"** when the Keychain authorization popup appears.

- `Twitter API error 404`
  - This can happen when upstream GraphQL query IDs rotate.
  - Retry the command; the client attempts a live queryId fallback.

- `Invalid tweet JSON file`
  - Regenerate input using `twitter feed --json > tweets.json`.

**Diagnostics command**: Run `twitter doctor` to output a full diagnostic report (version, OS, browser detection, Keychain status, cookie extraction results). Paste this output into bug reports.

Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`.

### Development

```bash
# Install dev dependencies
uv sync --extra dev

# Lint + tests
uv run ruff check .
uv run pytest -q
```

Current CI validates the project on Python 3.8, 3.10, and 3.12.

### Project Structure

```text
twitter_cli/
├── __init__.py
├── cli.py
├── client.py
├── graphql.py       # GraphQL query IDs, URL building, JS bundle scanning
├── parser.py        # Tweet, User, Media parsing logic
├── auth.py
├── config.py
├── constants.py
├── exceptions.py
├── filter.py
├── formatter.py
├── output.py
├── serialization.py
└── models.py
```

### Use as AI Agent Skill

twitter-cli ships with a [`SKILL.md`](./SKILL.md) so AI agents can execute common X/Twitter workflows.

#### Claude Code / Antigravity

```bash
# Clone into your project's skills directory
mkdir -p .agents/skills
git clone [REDACTED_SECRET]:jackwener/twitter-cli.git .agents/skills/twitter-cli

# Or copy SKILL.md only
curl -o .agents/skills/twitter-cli/SKILL.md \
  https://raw.githubusercontent.com/jackwener/twitter-cli/main/SKILL.md
```

#### OpenClaw / ClawHub

Install from ClawHub:

```bash
clawhub install twitter-cli
```

After installation, OpenClaw can call `twitter-cli` commands directly.

## 中文

### 功能概览

**读取:**
- 时间线读取：支持 `for-you` 和 `following`
- 收藏读取：查看账号书签推文
- 搜索：按关键词搜索推文，支持 Top/Latest/Photos/Videos
- 推文详情：查看推文及其回复
- 列表时间线：获取 Twitter List 的推文
- 用户查询：查看用户资料、推文、点赞、粉丝和关注
- `--full-text`：在 rich table 输出里关闭推文正文截断
- 结构化输出：支持 YAML 和 JSON，便于脚本处理和 AI agent 集成

> **AI Agent 提示：** 需要结构化输出时优先使用 `--yaml`，除非下游必须是 JSON。stdout 不是 TTY 时默认输出 YAML。用 `--max` 控制返回数量。

**写入:**
- 发推：发布新推文和回复
- 删除：删除自己的推文
- 点赞 / 取消点赞
- 转推 / 取消转推
- 书签 / 取消书签：bookmark/unbookmark（保留 `favorite/unfavorite` 兼容别名）
- 写操作现在也显式支持 `--json` / `--yaml`

**认证与反风控:**
- Cookie 认证：支持环境变量和浏览器自动提取
- 完整 Cookie 转发：提取浏览器中所有 Twitter Cookie，保留更多浏览器上下文
- TLS 指纹伪装：`curl_cffi` 动态匹配 Chrome 版本
- `x-client-transaction-id` 请求头生成
- 请求时序随机化（jitter）
- 写操作随机延迟（1.5–4 秒），降低频率风控
- 代理支持：`TWITTER_PROXY` 环境变量

### 安装

```bash
# 推荐：uv tool
uv tool install twitter-cli
```

升级到最新版本：

```bash
uv tool upgrade twitter-cli
# 或：pipx upgrade twitter-cli
```

> **提示：** 建议定期升级，避免因版本过旧导致的 API 调用异常。

### 使用指南

```bash
# 时间线
twitter feed
twitter feed -t following
twitter feed --filter
twitter feed --full-text

# 收藏
twitter bookmarks
twitter bookmarks --full-text

# 搜索
twitter search "Claude Code"
twitter search "AI agent" -t Latest --max 50
twitter search "AI agent" --full-text
twitter search "topic" -o results.json         # 保存到文件
twitter search "trending" --filter              # 启用排序筛选

# 推文详情
twitter tweet 1234567890
twitter tweet 1234567890 --full-text

# 列表时间线
twitter list 1539453138322673664
twitter list 1539453138322673664 --full-text

# 用户
twitter user elonmusk
twitter user-posts elonmusk --max 20
twitter user-posts elonmusk --full-text
twitter user-posts elonmusk -o tweets.json
twitter likes elonmusk --max 30           # ⚠️ 仅可查看自己的点赞（2024年6月起平台已私密化）
twitter likes elonmusk --full-text
twitter likes elonmusk -o likes.json
twitter followers elonmusk
twitter following elonmusk

# 写操作
twitter post "你好，世界！"
twitter post "回复内容" --reply-to 1234567890
twitter post "你好，世界！" --json
twitter delete 1234567890
twitter like 1234567890
twitter like 1234567890 --yaml
twitter unlike 1234567890
twitter retweet 1234567890
twitter unretweet 1234567890
twitter bookmark 1234567890
twitter unbookmark 1234567890
twitter follow elonmusk --json
```

### 认证说明

认证优先级：

1. **环境变量**：`TWITTER_AUTH_TOKEN` + `TWITTER_CT0`
2. **浏览器提取**（推荐）：Arc/Chrome/Edge/Firefox/Brave 全量 Cookie 提取

推荐使用浏览器提取方式，会转发所有 Twitter Cookie，并按本机运行环境生成语言和平台请求头；它比仅发送 `auth_token` + `ct0` 更接近普通浏览器流量，但不等于完整浏览器自动化。

**Chrome 多 Profile 支持**：会自动遍历所有 Chrome profile。也可以通过环境变量指定：

```bash
TWITTER_CHROME_PROFILE="Profile 2" twitter feed
```

### 代理支持

设置 `TWITTER_PROXY` 环境变量即可：

```bash
export TWITTER_PROXY=http://127.0.0.1:7890
# 或 SOCKS5
export TWITTER_PROXY=socks5://127.0.0.1:1080
```

使用代理可以降低 IP 维度的风控风险。

### 筛选算法

未传 `--max` 时，所有读取命令默认使用 `config.yaml` 里的 `fetch.count`。

rich table 输出默认会截断较长正文；如果需要在列表视图中查看完整正文，可加 `--full-text`。

只有在传入 `--filter` 时才会启用筛选评分；默认不筛选。

评分公式：

```text
score = likes_w * likes
      + retweets_w * retweets
      + replies_w * replies
      + bookmarks_w * bookmarks
      + views_log_w * log10(max(views, 1))
```

模式说明：

- `mode: "topN"`：按分数排序后保留前 `topN` 条
- `mode: "score"`：仅保留 `score >= minScore` 的推文
- `mode: "all"`：按分数排序后全部保留

### 常见问题

- 报错 `No Twitter cookies found`：请先登录 `x.com`，并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一，或手动设置环境变量。
- 如需查看浏览器提取细节，可加 `-v` 打开诊断日志。
- 报错 `Cookie expired or invalid`：Cookie 过期，重新登录后重试。
- 报错 `Unable to get key for cookie decryption`（macOS Keychain 问题）：
  - **SSH 远程登录**：Keychain 默认锁定，需手动解锁：
    ```bash
    security unlock-keychain ~/Library/Keychains/login.keychain-db
    ```
  - **本地终端**：打开 **钥匙串访问** → 搜索 **"\<浏览器\> Safe Storage"** → **访问控制** → 添加你的终端 app → **保存更改**。
  - 或在弹出 Keychain 授权时点击 **"始终允许"**。
- 报错 `Twitter API error 404`：通常是 queryId 轮换，重试即可。

**诊断命令**：运行 `twitter doctor` 可输出完整诊断报告（版本、OS、浏览器检测、Keychain 状态、cookie 提取结果），方便提交 bug report。

- 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。

### 使用建议（防封号）

- **使用代理** — 设置 `TWITTER_PROXY`，避免裸 IP 直连
- **控制请求量** — 用 `--max 20` 而不是 `--max 500`
- **避免频繁启动** — 每次启动都会访问 x.com 初始化反检测请求头
- **使用浏览器 Cookie 提取** — 提供完整 Cookie 指纹
- **避免数据中心 IP** — 住宅代理更安全
- Cookie 仅在本地使用，不会被本工具上传

### 输出模式建议

- 默认 rich table 适合终端交互式浏览
- 需要在表格里看完整正文时，使用 `--full-text`
- 需要脚本消费时，优先使用 `--yaml` 或 `--json`
- 需要节省 token 时，使用 `-c` / `--compact`

### 作为 AI Agent Skill 使用

twitter-cli 提供了 [`SKILL.md`](./SKILL.md)，可让 AI Agent 更稳定地调用本工具。

#### Claude Code / Antigravity

```bash
# 克隆到项目 skills 目录
mkdir -p .agents/skills
git clone [REDACTED_SECRET]:jackwener/twitter-cli.git .agents/skills/twitter-cli

# 或仅下载 SKILL.md
curl -o .agents/skills/twitter-cli/SKILL.md \
  https://raw.githubusercontent.com/jackwener/twitter-cli/main/SKILL.md
```

#### OpenClaw / ClawHub

通过 ClawHub 安装：

```bash
clawhub install twitter-cli
```

### 更多工具

- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili 视频、用户、搜索与动态 CLI
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord 本地优先同步、检索与导出 CLI
- [tg-cli](https://github.com/jackwener/tg-cli) — Telegram 本地优先同步、检索与导出 CLI
- [xiaohongshu-cli](https://github.com/jackwener/xiaohongshu-cli) — 小红书笔记与账号工作流 CLI


path: SCHEMA.md
content: # Structured Output Schema

`twitter-cli` uses a shared agent-friendly envelope for machine-readable output.

## Success

```yaml
ok: true
schema_version: "1"
data: ...
```

## Error

```yaml
ok: false
schema_version: "1"
error:
  code: api_error
  message: User @foo not found
```

## Notes

- `--yaml` and `--json` both use this envelope
- non-TTY stdout defaults to YAML
- tweet and user lists are returned under `data`
- `status` returns `data.authenticated` plus `data.user`
- `whoami` returns `data.user`
- write commands also support explicit `--json` / `--yaml`

## Error Codes

Common structured error codes:

- `not_authenticated`
- `not_found`
- `invalid_input`
- `rate_limited`
- `api_error`


path: config.yaml
content: fetch:
  count: 50

filter:
  mode: "topN"
  topN: 20
  minScore: 50
  lang: []
  excludeRetweets: false
  weights:
    likes: 1.0
    retweets: 3.0
    replies: 2.0
    bookmarks: 5.0
    views_log: 0.5

rateLimit:
  requestDelay: 1.5    # seconds between paginated requests
  maxRetries: 3        # retry count on 429 / rate-limit errors
  retryBaseDelay: 5.0  # base delay for exponential backoff (seconds)
  maxCount: 200        # hard cap for single fetch


path: pyproject.toml
content: [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "twitter-cli"
version = "0.6.5"
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.8"
authors = [{ name = "jackwener", email = "[REDACTED_SECRET]" }]
keywords = ["twitter", "x", "cli", "feed", "timeline"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Environment :: Console",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: Apache Software License",
    "Programming Language :: Python :: 3",
    "Topic :: Utilities",
]
dependencies = [
    "browser-cookie3>=0.19",
    "click>=8.0",
    "rich>=13.0",
    "PyYAML>=6.0",
    "curl_cffi>=0.7",
    "xclienttransaction>=1.0.1",
    "beautifulsoup4>=4.12",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "ruff>=0.8",
    "mypy>=1.14,<1.15",
    "types-PyYAML>=6.0.12",
]

[project.urls]
Homepage = "https://github.com/jackwener/twitter-cli"
Repository = "https://github.com/jackwener/twitter-cli"
Issues = "https://github.com/jackwener/twitter-cli/issues"

[project.scripts]
twitter = "twitter_cli.cli:cli"

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-m 'not smoke'"
markers = [
    "smoke: real-API integration tests (run with: pytest -m smoke)",
]

[tool.ruff]
line-length = 100

[tool.mypy]
python_version = "3.8"
ignore_missing_imports = true
check_untyped_defs = true
warn_unused_ignores = true
no_implicit_optional = true

[tool.hatch.build.targets.wheel]
packages = ["twitter_cli"]


path: tests/conftest.py
content: from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import pytest

from twitter_cli.models import Author, Metrics, Tweet

os.environ.setdefault("OUTPUT", "rich")


@pytest.fixture()
def tweet_factory():
    def _make_tweet(tweet_id: str = "1", **overrides: Any) -> Tweet:
        metrics = overrides.pop(
            "metrics",
            Metrics(likes=10, retweets=2, replies=1, quotes=0, views=120, bookmarks=3),
        )
        author = overrides.pop(
            "author",
            Author(id="u1", name="Alice", screen_name="alice", verified=False),
        )
        return Tweet(
            id=tweet_id,
            text=overrides.pop("text", "hello"),
            author=author,
            metrics=metrics,
            created_at=overrides.pop("created_at", "2025-01-01"),
            media=overrides.pop("media", []),
            urls=overrides.pop("urls", []),
            is_retweet=overrides.pop("is_retweet", False),
            lang=overrides.pop("lang", "en"),
            retweeted_by=overrides.pop("retweeted_by", None),
            quoted_tweet=overrides.pop("quoted_tweet", None),
            score=overrides.pop("score", 0.0),
        )

    return _make_tweet


@pytest.fixture()
def fixture_loader():
    fixture_dir = Path(__file__).parent / "fixtures"

    def _load(name: str) -> Any:
        return json.loads((fixture_dir / name).read_text(encoding="utf-8"))

    return _load


path: tests/fixtures/followers_page.json
content: {
  "data": {
    "user": {
      "result": {
        "timeline": {
          "timeline": {
            "instructions": [
              {
                "type": "TimelineAddEntries",
                "entries": [
                  {
                    "entryId": "user-1",
                    "content": {
                      "entryType": "TimelineTimelineItem",
                      "itemContent": {
                        "user_results": {
                          "result": {
                            "rest_id": "f1",
                            "is_blue_verified": true,
                            "legacy": {
                              "name": "Follower One",
                              "screen_name": "follower1",
                              "description": "first follower",
                              "location": "Earth",
                              "followers_count": 123,
                              "friends_count": 45,
                              "statuses_count": 67,
                              "favourites_count": 89,
                              "profile_image_url_https": "https://img/f1.jpg",
                              "created_at": "Sat Mar 08 10:00:00 +0000 2026"
                            }
                          }
                        }
                      }
                    }
                  },
                  {
                    "entryId": "cursor-bottom",
                    "content": {
                      "entryType": "TimelineTimelineCursor",
                      "cursorType": "Bottom",
                      "value": "followers-cursor"
                    }
                  }
                ]
              }
            ]
          }
        }
      }
    }
  }
}


path: tests/fixtures/home_timeline.json
content: {
  "data": {
    "home": {
      "home_timeline_urt": {
        "instructions": [
          {
            "type": "TimelineAddEntries",
            "entries": [
              {
                "entryId": "tweet-1",
                "content": {
                  "entryType": "TimelineTimelineItem",
                  "itemContent": {
                    "tweet_results": {
                      "result": {
                        "__typename": "Tweet",
                        "rest_id": "1",
                        "core": {
                          "user_results": {
                            "result": {
                              "rest_id": "u1",
                              "core": {
                                "name": "Alice",
                                "screen_name": "alice"
                              },
                              "legacy": {
                                "name": "Alice",
                                "screen_name": "alice",
                                "verified": false,
                                "profile_image_url_https": "https://img/alice.jpg"
                              }
                            }
                          }
                        },
                        "legacy": {
                          "full_text": "Hello\nworld",
                          "created_at": "Sat Mar 08 12:00:00 +0000 2026",
                          "favorite_count": 10,
                          "retweet_count": 2,
                          "reply_count": 1,
                          "quote_count": 0,
                          "bookmark_count": 3,
                          "lang": "en",
                          "entities": {
                            "urls": [
                              {
                                "expanded_url": "https://example.com/post"
                              }
                            ]
                          },
                          "extended_entities": {
                            "media": [
                              {
                                "type": "photo",
                                "media_url_https": "https://pbs.twimg.com/1.jpg",
                                "original_info": {
                                  "width": 1200,
                                  "height": 800
                                }
                              }
                            ]
                          }
                        },
                        "views": {
                          "count": "1234"
                        }
                      }
                    }
                  }
                }
              },
              {
                "entryId": "tweet-2",
                "content": {
                  "entryType": "TimelineTimelineItem",
                  "itemContent": {
                    "tweet_results": {
                      "result": {
                        "__typename": "Tweet",
                        "rest_id": "2",
                        "core": {
                          "user_results": {
                            "result": {
                              "rest_id": "u2",
                              "core": {
                                "name": "Bob",
                                "screen_name": "bob"
                              },
                              "legacy": {
                                "name": "Bob",
                                "screen_name": "bob",
                                "verified": true,
                                "profile_image_url_https": "https://img/bob.jpg"
                              }
                            }
                          }
                        },
                        "legacy": {
                          "full_text": "RT wrapper",
                          "created_at": "Sat Mar 08 12:01:00 +0000 2026",
                          "favorite_count": 5,
                          "retweet_count": 1,
                          "reply_count": 0,
                          "quote_count": 0,
                          "bookmark_count": 0,
                          "lang": "zh",
                          "entities": {
                            "urls": []
                          },
                          "retweeted_status_result": {
                            "result": {
                              "__typename": "Tweet",
                              "rest_id": "20",
                              "core": {
                                "user_results": {
                                  "result": {
                                    "rest_id": "u20",
                                    "core": {
                                      "name": "Carol",
                                      "screen_name": "carol"
                                    },
                                    "legacy": {
                                      "name": "Carol",
                                      "screen_name": "carol",
                                      "verified": false,
                                      "profile_image_url_https": "https://img/carol.jpg"
                                    }
                                  }
                                }
                              },
                              "quoted_status_result": {
                                "result": {
                                  "__typename": "Tweet",
                                  "rest_id": "30",
                                  "core": {
                                    "user_results": {
                                      "result": {
                                        "rest_id": "u30",
                                        "core": {
                                          "name": "Dan",
                                          "screen_name": "dan"
                                        },
                                        "legacy": {
                                          "name": "Dan",
                                          "screen_name": "dan",
                                          "verified": false,
                                          "profile_image_url_https": "https://img/dan.jpg"
                                        }
                                      }
                                    }
                                  },
                                  "legacy": {
                                    "full_text": "quoted text",
                                    "created_at": "Sat Mar 08 11:58:00 +0000 2026",
                                    "favorite_count": 1,
                                    "retweet_count": 0,
                                    "reply_count": 0,
                                    "quote_count": 0,
                                    "bookmark_count": 0,
                                    "lang": "en",
                                    "entities": {
                                      "urls": []
                                    }
                                  }
                                }
                              },
                              "legacy": {
                                "full_text": "original retweeted post",
                                "created_at": "Sat Mar 08 11:59:00 +0000 2026",
                                "favorite_count": 50,
                                "retweet_count": 7,
                                "reply_count": 2,
                                "quote_count": 1,
                                "bookmark_count": 4,
                                "lang": "en",
                                "entities": {
                                  "urls": []
                                }
                              },
                              "views": {
                                "count": "999"
                              }
                            }
                          }
                        },
                        "views": {
                          "count": "10"
                        }
                      }
                    }
                  }
                }
              },
              {
                "entryId": "cursor-bottom",
                "content": {
                  "entryType": "TimelineTimelineCursor",
                  "cursorType": "Bottom",
                  "value": "cursor-bottom-1"
                }
              }
            ]
          }
        ]
      }
    }
  }
}


path: tests/fixtures/list_timeline.json
content: {
  "data": {
    "list": {
      "tweets_timeline": {
        "timeline": {
          "instructions": [
            {
              "type": "TimelineAddEntries",
              "entries": [
                {
                  "entryId": "list-tweet-1",
                  "content": {
                    "entryType": "TimelineTimelineItem",
                    "itemContent": {
                      "tweet_results": {
                        "result": {
                          "__typename": "TweetWithVisibilityResults",
                          "tweet": {
                            "__typename": "Tweet",
                            "rest_id": "700",
                            "core": {
                              "user_results": {
                                "result": {
                                  "rest_id": "u700",
                                  "core": {
                                    "name": "Lister",
                                    "screen_name": "lister"
                                  },
                                  "legacy": {
                                    "name": "Lister",
                                    "screen_name": "lister",
                                    "verified": true,
                                    "profile_image_url_https": "https://img/lister.jpg"
                                  }
                                }
                              }
                            },
                            "legacy": {
                              "full_text": "list timeline tweet",
                              "created_at": "Sat Mar 08 13:10:00 +0000 2026",
                              "favorite_count": 8,
                              "retweet_count": 2,
                              "reply_count": 1,
                              "quote_count": 0,
                              "bookmark_count": 1,
                              "lang": "zh",
                              "entities": {
                                "urls": []
                              }
                            },
                            "views": {
                              "count": "321"
                            }
                          }
                        }
                      }
                    }
                  }
                },
                {
                  "entryId": "cursor-bottom",
                  "content": {
                    "entryType": "TimelineTimelineCursor",
                    "cursorType": "Bottom",
                    "value": "list-cursor"
                  }
                }
              ]
            }
          ]
        }
      }
    }
  }
}


path: tests/fixtures/search_timeline.json
content: {
  "data": {
    "search_by_raw_query": {
      "search_timeline": {
        "timeline": {
          "instructions": [
            {
              "type": "TimelineAddEntries",
              "entries": [
                {
                  "entryId": "search-module-1",
                  "content": {
                    "entryType": "TimelineTimelineModule",
                    "items": [
                      {
                        "item": {
                          "itemContent": {
                            "tweet_results": {
                              "result": {
                                "__typename": "TweetWithVisibilityResults",
                                "tweet": {
                                  "__typename": "Tweet",
                                  "rest_id": "500",
                                  "core": {
                                    "user_results": {
                                      "result": {
                                        "rest_id": "u500",
                                        "core": {
                                          "name": "Searcher",
                                          "screen_name": "searcher"
                                        },
                                        "legacy": {
                                          "name": "Searcher",
                                          "screen_name": "searcher",
                                          "verified": false,
                                          "profile_image_url_https": "https://img/searcher.jpg"
                                        }
                                      }
                                    }
                                  },
                                  "legacy": {
                                    "full_text": "search result with video",
                                    "created_at": "Sat Mar 08 13:00:00 +0000 2026",
                                    "favorite_count": 12,
                                    "retweet_count": 4,
                                    "reply_count": 1,
                                    "quote_count": 0,
                                    "bookmark_count": 2,
                                    "lang": "en",
                                    "entities": {
                                      "urls": []
                                    },
                                    "extended_entities": {
                                      "media": [
                                        {
                                          "type": "video",
                                          "media_url_https": "https://pbs.twimg.com/thumb.jpg",
                                          "original_info": {
                                            "width": 1280,
                                            "height": 720
                                          },
                                          "video_info": {
                                            "variants": [
                                              {
                                                "content_type": "video/mp4",
                                                "bitrate": 832000,
                                                "url": "https://video-low.mp4"
                                              },
                                              {
                                                "content_type": "video/mp4",
                                                "bitrate": 2176000,
                                                "url": "https://video-high.mp4"
                                              }
                                            ]
                                          }
                                        }
                                      ]
                                    }
                                  },
                                  "views": {
                                    "count": "4321"
                                  }
                                }
                              }
                            }
                          }
                        }
                      }
                    ]
                  }
                },
                {
                  "entryId": "cursor-bottom",
                  "content": {
                    "entryType": "TimelineTimelineCursor",
                    "cursorType": "Bottom",
                    "value": "search-cursor"
                  }
                }
              ]
            }
          ]
        }
      }
    }
  }
}


path: tests/fixtures/tweet_detail.json
content: {
  "data": {
    "threaded_conversation_with_injections_v2": {
      "instructions": [
        {
          "type": "TimelineAddEntries",
          "entries": [
            {
              "entryId": "conversation-thread",
              "content": {
                "items": [
                  {
                    "item": {
                      "itemContent": {
                        "tweet_results": {
                          "result": {
                            "__typename": "Tweet",
                            "rest_id": "100",
                            "core": {
                              "user_results": {
                                "result": {
                                  "rest_id": "ua",
                                  "core": {
                                    "name": "Author A",
                                    "screen_name": "authora"
                                  },
                                  "legacy": {
                                    "name": "Author A",
                                    "screen_name": "authora",
                                    "verified": false,
                                    "profile_image_url_https": "https://img/a.jpg"
                                  }
                                }
                              }
                            },
                            "legacy": {
                              "full_text": "root tweet",
                              "created_at": "Sat Mar 08 12:10:00 +0000 2026",
                              "favorite_count": 100,
                              "retweet_count": 10,
                              "reply_count": 2,
                              "quote_count": 1,
                              "bookmark_count": 8,
                              "lang": "en",
                              "entities": {
                                "urls": []
                              }
                            },
                            "views": {
                              "count": "8000"
                            }
                          }
                        }
                      }
                    }
                  },
                  {
                    "item": {
                      "itemContent": {
                        "tweet_results": {
                          "result": {
                            "__typename": "Tweet",
                            "rest_id": "101",
                            "core": {
                              "user_results": {
                                "result": {
                                  "rest_id": "ub",
                                  "core": {
                                    "name": "Author B",
                                    "screen_name": "authorb"
                                  },
                                  "legacy": {
                                    "name": "Author B",
                                    "screen_name": "authorb",
                                    "verified": false,
                                    "profile_image_url_https": "https://img/b.jpg"
                                  }
                                }
                              }
                            },
                            "legacy": {
                              "full_text": "reply tweet",
                              "created_at": "Sat Mar 08 12:12:00 +0000 2026",
                              "favorite_count": 5,
                              "retweet_count": 1,
                              "reply_count": 0,
                              "quote_count": 0,
                              "bookmark_count": 0,
                              "lang": "en",
                              "entities": {
                                "urls": []
                              }
                            },
                            "views": {
                              "count": "100"
                            }
                          }
                        }
                      }
                    }
                  }
                ]
              }
            },
            {
              "entryId": "cursor-bottom",
              "content": {
                "entryType": "TimelineTimelineCursor",
                "cursorType": "Bottom",
                "value": "conversation-cursor"
              }
            }
          ]
        }
      ]
    }
  }
}


path: tests/test_auth.py
content: from __future__ import annotations

import json
import os
import sys
from types import SimpleNamespace

import pytest

from twitter_cli import auth


def test_get_cookies_prefers_env(monkeypatch) -> None:
    monkeypatch.setattr(auth, "load_from_env", lambda: {"auth_token": "env-token", "ct0": "env-csrf"})
    monkeypatch.setattr(auth, "extract_from_browser", lambda: pytest.fail("should not extract from browser"))
    seen = []
    monkeypatch.setattr(
        auth,
        "verify_cookies",
        lambda auth_token, ct0, cookie_string=None: seen.append((auth_token, ct0, cookie_string)) or {},
    )

    cookies = auth.get_cookies()

    assert cookies == {"auth_token": "env-token", "ct0": "env-csrf"}
    assert seen == [("env-token", "env-csrf", None)]


def test_get_cookies_reextracts_after_verify_failure(monkeypatch) -> None:
    monkeypatch.setattr(auth, "load_from_env", lambda: None)
    extracted = iter(
        [
            ({"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"}, []),
            ({"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"}, []),
        ]
    )
    monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))

    calls = []

    def _verify(auth_token, ct0, cookie_string=None):
        calls.append((auth_token, ct0, cookie_string))
        if auth_token == "stale-token":
            raise RuntimeError("expired")
        return {}

    monkeypatch.setattr(auth, "verify_cookies", _verify)

    cookies = auth.get_cookies()

    assert cookies["auth_token"] == "fresh-token"
    assert calls == [
        ("stale-token", "stale-csrf", "stale=1"),
        ("fresh-token", "fresh-csrf", "fresh=1"),
    ]


def test_load_from_env_logs_incomplete_env(monkeypatch, caplog) -> None:
    monkeypatch.setenv("TWITTER_AUTH_TOKEN", "token")
    monkeypatch.delenv("TWITTER_CT0", raising=False)

    with caplog.at_level("DEBUG"):
        cookies = auth.load_from_env()

    assert cookies is None
    assert "Environment cookies incomplete" in caplog.text


def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
    class Cookie:
        def __init__(self, domain: str, name: str, value: str) -> None:
            self.domain = domain
            self.name = name
            self.value = value

    jar = [Cookie(".x.com", "auth_token", "token")]

    with caplog.at_level("DEBUG"):
        cookies = auth._extract_cookies_from_jar(jar, source="test-jar")

    assert cookies is None
    assert "test-jar" in caplog.text
    assert "ct0=False" in caplog.text


def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None:
    monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, []))
    monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, []))

    with caplog.at_level("WARNING"):
        cookies, diagnostics = auth.extract_from_browser()

    assert cookies is None
    assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text


def test_extract_in_process_supports_arc(monkeypatch) -> None:
    class Cookie:
        def __init__(self, domain: str, name: str, value: str) -> None:
            self.domain = domain
            self.name = name
            self.value = value

    fake_module = SimpleNamespace(
        arc=lambda: [Cookie(".x.com", "auth_token", "token"), Cookie(".x.com", "ct0", "csrf")],
        chrome=lambda: pytest.fail("chrome should not be used when arc succeeds"),
        edge=lambda: pytest.fail("edge should not be used when arc succeeds"),
        firefox=lambda: pytest.fail("firefox should not be used when arc succeeds"),
        brave=lambda: pytest.fail("brave should not be used when arc succeeds"),
    )
    monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)

    cookies, diagnostics = auth._extract_in_process()

    assert cookies is not None
    assert cookies["auth_token"] == "token"
    assert cookies["ct0"] == "csrf"


def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None:
    class Completed:
        def __init__(self, stdout: str, stderr: str = "") -> None:
            self.stdout = stdout
            self.stderr = stderr

    seen = {}

    def _run(cmd, capture_output=True, text=True, timeout=15):
        script = cmd[-1]
        seen["script"] = script
        return Completed(json.dumps({"error": "No Twitter cookies found", "attempts": []}))

    monkeypatch.setattr(auth.subprocess, "run", _run)

    cookies, diagnostics = auth._extract_via_subprocess()

    assert cookies is None
    assert '("arc", browser_cookie3.arc)' in seen["script"]


def test_extract_via_subprocess_retries_uv_when_current_env_has_no_output(monkeypatch) -> None:
    class Completed:
        def __init__(self, stdout: str, stderr: str = "") -> None:
            self.stdout = stdout
            self.stderr = stderr

    calls = []

    def _run(cmd, capture_output=True, text=True, timeout=15):
        calls.append(cmd)
        if cmd[0] == sys.executable:
            return Completed("", "")
        return Completed(json.dumps({"auth_token": "token", "ct0": "csrf", "browser": "arc"}))

    monkeypatch.setattr(auth.subprocess, "run", _run)

    cookies, diagnostics = auth._extract_via_subprocess()

    assert cookies == {"auth_token": "token", "ct0": "csrf"}
    assert len(calls) == 2
    assert calls[1][:5] == ["uv", "run", "--with", "browser-cookie3", "python"]


def test_verify_cookies_logs_attempt_summary_on_non_auth_failures(monkeypatch, caplog) -> None:
    class Response:
        def __init__(self, status_code: int, payload=None) -> None:
            self.status_code = status_code
            self._payload = payload or {}

        def json(self):
            return self._payload

    class Session:
        def __init__(self) -> None:
            self.calls = 0

        def get(self, url, headers=None, timeout=5):
            self.calls += 1
            if self.calls == 1:
                return Response(404)
            raise Exception("network")

    monkeypatch.setattr("twitter_cli.client._get_cffi_session", lambda: Session())

    with caplog.at_level("INFO"):
        result = auth.verify_cookies("token", "csrf")

    assert result == {}
    assert "verify_credentials.json=404" in caplog.text
    assert "settings.json=Exception" in caplog.text


def test_iter_chrome_cookie_files_default_first(monkeypatch, tmp_path) -> None:
    """Default profile should be yielded first, then Profile N sorted."""
    # Create the correct platform-specific directory structure
    if sys.platform == "darwin":
        chrome_dir = tmp_path / "Library" / "Application Support" / "Google" / "Chrome"
    elif sys.platform == "win32":
        chrome_dir = tmp_path / "Google" / "Chrome" / "User Data"
    else:
        chrome_dir = tmp_path / ".config" / "Google" / "Chrome"

    (chrome_dir / "Default").mkdir(parents=True)
    (chrome_dir / "Default" / "Cookies").touch()
    (chrome_dir / "Profile 2").mkdir()
    (chrome_dir / "Profile 2" / "Cookies").touch()
    (chrome_dir / "Profile 1").mkdir()
    (chrome_dir / "Profile 1" / "Cookies").touch()

    monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)
    monkeypatch.setenv("HOME", str(tmp_path))
    if sys.platform == "win32":
        monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))

    paths = auth._iter_chrome_cookie_files("chrome")

    basenames = [os.path.basename(os.path.dirname(p)) for p in paths]
    assert basenames[0] == "Default"
    assert "Profile 1" in basenames
    assert "Profile 2" in basenames
    # Profile 1 should come before Profile 2
    assert basenames.index("Profile 1") < basenames.index("Profile 2")


def test_iter_chrome_cookie_files_env_override(monkeypatch, tmp_path) -> None:
    """TWITTER_CHROME_PROFILE should restrict to that single profile."""
    if sys.platform == "darwin":
        chrome_dir = tmp_path / "Library" / "Application Support" / "Google" / "Chrome"
    else:
        chrome_dir = tmp_path / ".config" / "Google" / "Chrome"

    (chrome_dir / "Default").mkdir(parents=True)
    (chrome_dir / "Default" / "Cookies").touch()
    (chrome_dir / "Profile 5").mkdir()
    (chrome_dir / "Profile 5" / "Cookies").touch()

    monkeypatch.setenv("HOME", str(tmp_path))
    monkeypatch.setenv("TWITTER_CHROME_PROFILE", "Profile 5")

    paths = auth._iter_chrome_cookie_files("chrome")

    assert len(paths) == 1
    assert "Profile 5" in paths[0]


def test_iter_chrome_cookie_files_edge_linux_uses_microsoft_edge_path(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(auth.sys, "platform", "linux")
    edge_dir = tmp_path / ".config" / "microsoft-edge"
    (edge_dir / "Default").mkdir(parents=True)
    (edge_dir / "Default" / "Cookies").touch()

    monkeypatch.setenv("HOME", str(tmp_path))
    monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)

    paths = auth._iter_chrome_cookie_files("edge")

    assert len(paths) == 1
    assert paths[0].endswith(".config/microsoft-edge/Default/Cookies")


def test_iter_chrome_cookie_files_edge_windows_uses_user_data(monkeypatch, tmp_path) -> None:
    monkeypatch.setattr(auth.sys, "platform", "win32")
    monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
    monkeypatch.delenv("TWITTER_CHROME_PROFILE", raising=False)

    edge_dir = tmp_path / "Microsoft" / "Edge" / "User Data" / "Default"
    edge_dir.mkdir(parents=True)
    (edge_dir / "Cookies").touch()

    paths = auth._iter_chrome_cookie_files("edge")

    assert len(paths) == 1
    assert "Microsoft/Edge/User Data/Default/Cookies".replace("/", os.sep) in paths[0]


def test_extract_in_process_tries_multiple_profiles(monkeypatch, tmp_path) -> None:
    """When Default has no Twitter cookies but Profile 1 does, it should find them."""

    class Cookie:
        def __init__(self, domain: str, name: str, value: str) -> None:
            self.domain = domain
            self.name = name
            self.value = value

    default_cookies_path = str(tmp_path / "Default" / "Cookies")
    profile1_cookies_path = str(tmp_path / "Profile 1" / "Cookies")
    os.makedirs(os.path.dirname(default_cookies_path), exist_ok=True)
    os.makedirs(os.path.dirname(profile1_cookies_path), exist_ok=True)
    open(default_cookies_path, "w").close()
    open(profile1_cookies_path, "w").close()

    # Mock _iter_chrome_cookie_files to return our tmp paths
    def mock_iter(browser_name):
        if browser_name == "arc":
            return [default_cookies_path, profile1_cookies_path]
        return []

    monkeypatch.setattr(auth, "_iter_chrome_cookie_files", mock_iter)

    # Arc: Default returns empty jar, Profile 1 returns valid cookies
    def mock_arc(cookie_file=None):
        if cookie_file == profile1_cookies_path:
            return [
                Cookie(".x.com", "auth_token", "tok123"),
                Cookie(".x.com", "ct0", "csrf456"),
            ]
        return []  # Default — no cookies

    fake_module = SimpleNamespace(
        arc=mock_arc,
        chrome=lambda cookie_file=None: [],
        edge=lambda cookie_file=None: [],
        firefox=lambda: [],
        brave=lambda cookie_file=None: [],
    )
    monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)

    cookies, diagnostics = auth._extract_in_process()

    assert cookies is not None
    assert cookies["auth_token"] == "tok123"
    assert cookies["ct0"] == "csrf456"


def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None:
    """_diagnose_keychain_issues should detect Keychain-related error strings."""
    monkeypatch.setattr("sys.platform", "darwin")
    monkeypatch.delenv("SSH_CLIENT", raising=False)
    monkeypatch.delenv("SSH_TTY", raising=False)
    monkeypatch.delenv("SSH_CONNECTION", raising=False)

    diagnostics = ["arc[Default]: Unable to get key for cookie decryption"]
    hint = auth._diagnose_keychain_issues(diagnostics)

    assert hint is not None
    assert "Keychain" in hint


def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None:
    """When SSH env vars are set, hint should suggest unlock-keychain."""
    monkeypatch.setattr("sys.platform", "darwin")
    monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")

    diagnostics = ["arc: Unable to get key for cookie decryption"]
    hint = auth._diagnose_keychain_issues(diagnostics)

    assert hint is not None
    assert "SSH session detected" in hint
    assert "security unlock-keychain" in hint


def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None:
    """Should return None when diagnostics don't mention Keychain."""
    diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"]
    hint = auth._diagnose_keychain_issues(diagnostics)

    assert hint is None


def test_get_cookies_includes_keychain_hint_in_error(monkeypatch) -> None:
    """When extraction fails with Keychain errors, error msg should contain the hint."""
    monkeypatch.setattr("sys.platform", "darwin")
    monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
    monkeypatch.setattr(auth, "load_from_env", lambda: None)
    monkeypatch.setattr(
        auth,
        "extract_from_browser",
        lambda: (None, ["arc: Unable to get key for cookie decryption"]),
    )

    with pytest.raises(RuntimeError) as exc_info:
        auth.get_cookies()

    msg = str(exc_info.value)
    assert "security unlock-keychain" in msg
    assert "twitter doctor" in msg


def test_extract_in_process_returns_diagnostics_on_failure(monkeypatch) -> None:
    """_extract_in_process should return diagnostics containing error strings."""
    from types import SimpleNamespace

    class BrowserError(Exception):
        pass

    fake_module = SimpleNamespace(
        arc=lambda: (_ for _ in ()).throw(BrowserError("Unable to get key for cookie decryption")),
        chrome=lambda: [],
        edge=lambda: (_ for _ in ()).throw(BrowserError("Edge not found")),
        firefox=lambda: (_ for _ in ()).throw(BrowserError("Firefox not found")),
        brave=lambda: (_ for _ in ()).throw(BrowserError("Brave not found")),
    )
    monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)

    cookies, diagnostics = auth._extract_in_process()

    assert cookies is None
    assert any("cookie decryption" in d for d in diagnostics)


path: tests/test_config.py
content: from __future__ import annotations

from pathlib import Path

from twitter_cli.config import DEFAULT_CONFIG, load_config


def test_load_config_supports_block_list_yaml(tmp_path: Path) -> None:
    config_file = tmp_path / "config.yaml"
    config_file.write_text(
        "\n".join(
            [
                "fetch:",
                "  count: 25",
                "filter:",
                "  mode: score",
                "  lang:",
                "    - en",
                "    - zh",
            ]
        ),
        encoding="utf-8",
    )

    config = load_config(str(config_file))
    assert config["fetch"]["count"] == 25
    assert config["filter"]["mode"] == "score"
    assert config["filter"]["lang"] == ["en", "zh"]


def test_load_config_invalid_yaml_falls_back_to_defaults(tmp_path: Path) -> None:
    config_file = tmp_path / "config.yaml"
    config_file.write_text("fetch: [", encoding="utf-8")

    config = load_config(str(config_file))
    assert config["fetch"]["count"] == DEFAULT_CONFIG["fetch"]["count"]
    assert config["filter"]["mode"] == DEFAULT_CONFIG["filter"]["mode"]


def test_load_config_does_not_mutate_defaults(tmp_path: Path) -> None:
    config = load_config(str(tmp_path / "missing-config.yaml"))
    config["filter"]["weights"]["likes"] = 999
    assert DEFAULT_CONFIG["filter"]["weights"]["likes"] == 1.0


path: tests/test_config_normalization.py
content: from __future__ import annotations

from pathlib import Path

from twitter_cli.config import load_config


def test_filter_normalization_for_invalid_values(tmp_path: Path) -> None:
    config_file = tmp_path / "config.yaml"
    config_file.write_text(
        "\n".join(
            [
                "fetch:",
                "  count: -5",
                "filter:",
                "  mode: unknown",
                "  topN: -1",
                "  minScore: abc",
                "  lang: zh",
                "  weights:",
                "    likes: bad",
                "    retweets: 4",
            ]
        ),
        encoding="utf-8",
    )

    config = load_config(str(config_file))
    assert config["fetch"]["count"] == 1
    assert config["filter"]["mode"] == "topN"
    assert config["filter"]["topN"] == 1
    assert config["filter"]["minScore"] == 50.0
    assert config["filter"]["lang"] == []
    assert config["filter"]["weights"]["likes"] == 1.0
    assert config["filter"]["weights"]["retweets"] == 4.0
    # rateLimit should get defaults since it wasn't in the yaml
    assert config["rateLimit"]["requestDelay"] == 2.5
    assert config["rateLimit"]["maxRetries"] == 3
    assert config["rateLimit"]["retryBaseDelay"] == 5.0
    assert config["rateLimit"]["maxCount"] == 200


def test_rate_limit_normalization(tmp_path: Path) -> None:
    config_file = tmp_path / "config.yaml"
    config_file.write_text(
        "\n".join(
            [
                "rateLimit:",
                "  requestDelay: -2",
                "  maxRetries: bad",
                "  retryBaseDelay: 0.1",
                "  maxCount: 0",
            ]
        ),
        encoding="utf-8",
    )

    config = load_config(str(config_file))
    assert config["rateLimit"]["requestDelay"] == 0.0  # clamped to >= 0
    assert config["rateLimit"]["maxRetries"] == 3  # fallback to default
    assert config["rateLimit"]["retryBaseDelay"] == 1.0  # clamped to >= 1.0
    assert config["rateLimit"]["maxCount"] == 1  # clamped to >= 1


path: tests/test_filter.py
content: from __future__ import annotations

from twitter_cli.filter import filter_tweets


def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None:
    tweet = tweet_factory("1", score=0.0)
    output = filter_tweets([tweet], {"mode": "all", "weights": {}})

    assert tweet.score == 0.0
    assert output[0].score > 0.0
    assert output[0] is not tweet


def test_filter_tweets_applies_language_and_retweet_filters(tweet_factory) -> None:
    tweets = [
        tweet_factory("1", lang="en", is_retweet=False),
        tweet_factory("2", lang="zh", is_retweet=False),
        tweet_factory("3", lang="en", is_retweet=True),
    ]
    output = filter_tweets(
        tweets,
        {
            "mode": "all",
            "lang": ["en"],
            "excludeRetweets": True,
            "weights": {},
        },
    )

    assert [tweet.id for tweet in output] == ["1"]


path: tests/test_serialization.py
content: from __future__ import annotations

from twitter_cli.serialization import tweet_from_dict, tweet_to_dict, tweets_from_json, tweets_to_json


def test_tweet_roundtrip_dict(tweet_factory) -> None:
    tweet = tweet_factory("42")
    payload = tweet_to_dict(tweet)
    restored = tweet_from_dict(payload)

    assert restored.id == tweet.id
    assert restored.author.screen_name == tweet.author.screen_name
    assert restored.metrics.likes == tweet.metrics.likes


def test_tweets_json_roundtrip(tweet_factory) -> None:
    tweets = [tweet_factory("1"), tweet_factory("2", lang="zh")]
    raw = tweets_to_json(tweets)
    restored = tweets_from_json(raw)

    assert [tweet.id for tweet in restored] == ["1", "2"]
    assert restored[1].lang == "zh"


def test_compact_serialization(tweet_factory) -> None:
    from twitter_cli.serialization import tweet_to_compact_dict, tweets_to_compact_json
    import json

    tweet = tweet_factory(
        "42",
        created_at="Sat Mar 07 05:51:02 +0000 2026",
        text="A" * 200,
    )
    compact = tweet_to_compact_dict(tweet)

    assert compact["id"] == "42"
    assert compact["author"] == "@alice"
    assert compact["time"] == "Mar 07 05:51"
    assert len(compact["text"]) <= 140
    assert compact["text"].endswith("...")
    assert compact["likes"] == 10
    assert compact["rts"] == 2
    # Should only have 6 keys
    assert set(compact.keys()) == {"id", "author", "text", "likes", "rts", "time"}

    # Test batch serialization
    raw = tweets_to_compact_json([tweet])
    parsed = json.loads(raw)
    assert len(parsed) == 1
    assert parsed[0]["author"] == "@alice"



path: twitter_cli/__init__.py
content: """twitter-cli: A CLI for Twitter/X."""

try:
    from importlib.metadata import version

    __version__ = version("twitter-cli")
except Exception:
    __version__ = "0.0.0"


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