
Twitter Cli
- 525 installs
- 2.8k repo stars
- Updated May 7, 2026
- jackwener/twitter-cli
twitter-cli is a Claude Code skill that documents the Python twitter CLI binary so developers who need Twitter/X read and write operations from terminal or agent workflows can post, reply, search, and manage engagement w
About
twitter-cli is a Claude Code skill (version 0.8.0, 429 installs, rank 15 on Skills.sh) that teaches agents to run the `twitter` CLI for full Twitter/X operations from the terminal. Install via `uv tool install twitter-cli` or pipx on Python 3.8+, authenticate with browser cookies from Chrome, Firefox, Edge, Arc, or Brave, or with TWITTER_AUTH_TOKEN and TWITTER_CT0 env vars. Commands cover feed, search, bookmarks, user lookups, posting, replies, quotes, likes, retweets, follows, and threads, with rich tables, YAML/JSON envelopes, and compact `-c` output that trims tokens roughly 80% for LLM context. Image posts accept up to four files at 5 MB each (JPEG, PNG, GIF, WebP). Reach for twitter-cli when automating X from shell scripts, jq pipelines, or coding agents instead of switching to x.com.
- Post tweets, threads, and media from CLI or agent
- Search, like, retweet, and reply to mentions programmatically
- Schedule posts and run automated engagement campaigns
- Integrates with scripts for product launch announcements and growth loops
- Lightweight MCP-compatible tool with 429 installs
Twitter Cli by the numbers
- 525 all-time installs (skills.sh)
- +9 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #748 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jackwener/twitter-cli --skill twitter-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 525 |
|---|---|
| repo stars | ★ 2.8k |
| Last updated | May 7, 2026 |
| Repository | jackwener/twitter-cli ↗ |
How do you control Twitter/X from the terminal?
Post updates, reply to mentions, and run scheduled campaigns directly from the terminal or agent workflows without switching to the Twitter web app.
Who is it for?
Developers and coding agents that need scripted Twitter/X reads, posts, replies, and searches without building against the official API.
Skip if: Teams needing DMs, notifications, polls, video upload, multi-account management, or OAuth-based official Twitter API integrations.
When should I use this skill?
The user asks to post, reply, search, read a timeline, like, retweet, follow, or automate any Twitter/X action from terminal or an agent workflow.
What you get
Posted tweets and replies, exported YAML/JSON timeline and search results, follower lists, and verified auth status from shell commands.
- Shell-posted tweets and replies
- YAML/JSON timeline and search exports
- Agent-ready compact tweet summaries
By the numbers
- 429 installs on Skills.sh at rank 15
- Skill version 0.8.0 requires Python 3.8+
- Supports up to 4 images at 5 MB each per tweet
Files
twitter-cli — Twitter/X CLI Tool
Binary: twitter Credentials: browser cookies (auto-extracted) or env vars
Setup
# 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-cliAuthentication
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
twitter status --yaml >/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED"If AUTH_OK, skip to 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. To prioritize a specific browser: TWITTER_BROWSER=chrome twitter feed (supported: arc, chrome, edge, firefox, brave).
twitter whoamiMethod B: Environment variables
export TWITTER_AUTH_TOKEN="<auth_token from browser>"
export TWITTER_CT0="<ct0 from browser>"
twitter whoamiMethod 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:
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 whoamiStep 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)
twitter feed # Pretty table outputYAML / JSON: structured output
Non-TTY stdout defaults to YAML automatically. Use OUTPUT=yaml|json|rich|auto to override.
twitter feed --yaml
twitter feed --json | jq '.[0].text'All machine-readable output uses the envelope documented in 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.
twitter feed --full-text
twitter search "AI agent" --full-text
twitter user-posts elonmusk --max 20 --full-text
twitter tweet 1234567890 --full-textCompact: -c flag (minimal tokens for LLM)
twitter -c feed --max 10 # Minimal fields, great for LLM context
twitter -c search "AI" --max 20 # ~80% fewer tokens than --jsonCompact fields (per tweet): id, author (@handle), text (truncated 140 chars), likes, rts, time (short format)
Command Reference
Read Operations
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 show 2 # Open tweet #2 from last feed/search list
twitter show 2 --full-text # Full text in reply table
twitter show 2 --json # Structured output
twitter list 1539453138322673664 # List timeline
twitter list 1539453138322673664 --cursor "<next-cursor>"
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 # FollowingWrite Operations
twitter post "Hello from twitter-cli!" # Post tweet
twitter post "Hello!" --image photo.jpg # Post with image
twitter post "Gallery" -i a.png -i b.jpg # Up to 4 images
twitter reply 1234567890 "Great tweet!" # Reply (standalone)
twitter reply 1234567890 "Nice!" -i pic.png # Reply with image
twitter post "reply text" --reply-to 1234567890 # Reply (via post)
twitter quote 1234567890 "Interesting take" # Quote-tweet
twitter quote 1234567890 "Look" -i chart.png # Quote with image
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 userImage upload notes:
- Supported formats: JPEG, PNG, GIF, WebP
- Max file size: 5 MB per image
- Max 4 images per tweet
- Use
--image/-i(repeatable)
Agent Workflows
Post and verify
twitter post "My tweet text" 2>/dev/null
# Output includes tweet URL: 🔗 https://x.com/i/status/<id>Post with images
# Single image
twitter post "Check this out!" --image /path/to/photo.jpg
# Multiple images
twitter post "Photo gallery" -i img1.png -i img2.jpg -i img3.webpReply to someone's latest tweet
TWEET_ID=$(twitter user-posts targetuser --max 1 --json | jq -r '.data[0].id')
twitter reply "$TWEET_ID" "Nice post!"Create a thread
# 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
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
twitter search "interesting topic" --max 5 --json | jq -r '.data[].id' | while read id; do
twitter like "$id"
doneGet user info then follow
twitter user targethandle --json | jq '.data | {username, followers, bio}'
twitter follow targethandleFind most popular tweets from a user
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
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
# 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.jsonSearch with jq filtering
# 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:
twitter feed --filter
twitter bookmarks --filterError 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
- Images only — video/GIF animation upload not yet supported (image upload supports JPEG/PNG/GIF/WebP)
- 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 likesonly 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).
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 -v <command>` 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
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?
name: CI
on:
push:
branches: ["**"]
pull_request:
workflow_call:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "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: Type check
run: uv run mypy twitter_cli
- name: Test
run: uv run pytest -q
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
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
.env
*.json
!tests/fixtures/*.json
!config.yaml
.idea/
AGENTS.md — Agent Developer Guide for twitter-cli
This file provides context for AI agents working in this repository.
Project Overview
- Project: twitter-cli — A CLI for Twitter/X (read timelines, bookmarks, search, post, reply, etc.)
- Language: Python 3.10+
- Package Manager: uv (recommended) / pip
- Repository: https://github.com/jackwener/twitter-cli
Build, Lint, and Test Commands
# Install all dependencies (including dev)
uv sync --extra dev
# Run ruff linter
uv run ruff check .
# Run mypy type checker
uv run mypy twitter_cli
# Run all tests (excludes smoke tests by default)
uv run pytest -q
# Run a single test
uv run pytest tests/test_cli.py::test_feed_command -v
# Run tests matching pattern
uv run pytest -k "test_parse" -vCode Style
- Line length: 100 characters
- Python version: 3.10+
- Use
from __future__ import annotationsat top of all .py files - Functions/variables:
snake_case, Classes:PascalCase, Constants:UPPER_SNAKE_CASE - Private functions: prefix with
_ - Use
@dataclassfor data models (inmodels.py) - Use Click framework for CLI commands
- Custom exceptions in
exceptions.py, base:TwitterError(RuntimeError)
Project Structure
twitter_cli/
├── cli.py # Click CLI entry point
├── client.py # Twitter API client (HTTP)
├── auth.py # Cookie extraction & auth
├── graphql.py # GraphQL query IDs
├── parser.py # Tweet/User parsing
├── models.py # Dataclass models
├── formatter.py # Rich table formatting
├── serialization.py # YAML/JSON output
├── output.py # Structured output helpers
├── config.py # Config loading
├── filter.py # Tweet ranking/scoring
├── constants.py # Constants
├── exceptions.py # Custom exceptions
├── cache.py # Tweet caching
├── search.py # Search utilities
└── timeutil.py # Time utilitiesCI
- GitHub Actions: Python 3.10, 3.11, 3.12
- CI validates: ruff check + mypy + pytest
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
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 jackwener
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "twitter-cli"
version = "0.8.6"
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
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.10"
ignore_missing_imports = true
check_untyped_defs = true
warn_unused_ignores = true
no_implicit_optional = true
[tool.hatch.build.targets.wheel]
packages = ["twitter_cli"]
twitter-cli
  
A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles without API keys.
More Tools
- xiaohongshu-cli — Xiaohongshu (小红书) CLI for notes and account workflows
- bilibili-cli — Bilibili CLI for videos, users, search, and feeds
- discord-cli — Discord CLI for local-first sync, search, and export
- tg-cli — Telegram CLI for local-first sync, search, and export
English
Features
Read:
- Timeline: fetch
for-youandfollowingfeeds - 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; use
show <N>to open tweet #N from the last list output - Article: fetch a Twitter Article and export it as Markdown
- 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
AI Agent Tip: Prefer--yamlfor structured output unless a strict JSON parser is required. Non-TTY stdout defaults to YAML automatically. Use--maxto limit results.
Write:
- Post: create new tweets and replies, with optional image attachments (up to 4)
- Quote: quote-tweet with optional images
- Delete: remove your own tweets
- Like / Unlike: manage tweet likes
- Retweet / Unretweet: manage retweets
- Bookmark: bookmark/unbookmark (
favorite/unfavoritekept as compatibility aliases) - Write commands also support explicit
--json/--yamloutput 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_cffiwith dynamic Chrome version matching x-client-transaction-idheader generation- Request timing jitter to avoid pattern detection
- Write operation delays (1.5–4s random) to mitigate rate limits
- Proxy support via
TWITTER_PROXYenvironment variable
Installation
# Recommended: uv tool (fast, isolated)
uv tool install twitter-cli
# Alternative: pipx
pipx install twitter-cliUpgrade to the latest version:
uv tool upgrade twitter-cli
# Or: pipx upgrade twitter-cliTip: Upgrade regularly to avoid unexpected errors from outdated API handling.
Install from source:
git clone git@github.com:jackwener/twitter-cli.git
cd twitter-cli
uv syncQuick Start
# Fetch home timeline (For You)
twitter feed
# Fetch Following timeline
twitter feed -t following
# Enable ranking filter explicitly
twitter feed --filterUsage
# Feed
twitter feed --max 50
twitter feed --cursor "<next-cursor-from-previous-response>"
twitter feed --full-text
twitter feed --output tweets.json
twitter feed --input tweets.json
twitter feed --json # Structured stdout for scripts/agents
# 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 "python" --from elonmusk --lang en --since 2026-01-01
twitter search --from bbc --exclude retweets --has links
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
# Open tweet by index from last list output
twitter show 2 # Open tweet #2 from last feed/search
twitter show 2 --full-text # Full text in reply table
twitter show 2 --json # Structured output
# Twitter Article
twitter article 1234567890
twitter article https://x.com/user/article/1234567890 --json
twitter article 1234567890 --markdown
twitter article 1234567890 --output article.md
# List timeline
twitter list 1539453138322673664
twitter list 1539453138322673664 --cursor "<next-cursor-from-previous-response>"
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 "Hello!" --image photo.jpg # Post with image
twitter post "Gallery" -i a.png -i b.jpg -i c.webp # Up to 4 images
twitter post "reply text" --reply-to 1234567890
twitter reply 1234567890 "Nice!" -i screenshot.png # Reply with image
twitter quote 1234567890 "Look" -i chart.png # Quote with image
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 --jsonAuthentication
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:
TWITTER_CHROME_PROFILE="Profile 2" twitter feedBrowser priority: If you have multiple browsers, set TWITTER_BROWSER to try a specific browser first:
TWITTER_BROWSER=chrome twitter feed # Supported: arc, chrome, edge, firefox, braveAfter 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:
# HTTP proxy
export TWITTER_PROXY=http://127.0.0.1:7890
# SOCKS5 proxy
export TWITTER_PROXY=socks5://127.0.0.1:1080Using a proxy can help reduce IP-based rate limiting risks.
Configuration
Create config.yaml in your working directory:
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 itemsFetch behavior:
fetch.countis the default item count for read commands when--maxis omitted- Rich table output truncates long tweet text by default; use
--full-textto show full body text in list views
Filter behavior:
- Default behavior: no ranking filter unless
--filteris passed - With
--filter: tweets are scored/sorted usingconfig.filter
Scoring formula:
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 highesttopNtweets by scoremode: "score"keeps tweets wherescore >= minScoremode: "all"returns all tweets after sorting by score
Best Practices (Avoiding Bans)
- Use a proxy — set
TWITTER_PROXYto avoid direct IP exposure - Keep request volumes low — use
--max 20instead 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-textwhen reading long posts in terminal tables - Use
--yamlor--jsonfor scripts and agent pipelines - Use
-c/--compactwhen token efficiency matters more than completeness
Troubleshooting
No Twitter cookies found- Ensure you are logged in to
x.comin a supported browser (Arc/Chrome/Edge/Firefox/Brave). - Or set
TWITTER_AUTH_TOKENandTWITTER_CT0manually. - Run with
-vto see browser extraction diagnostics.
Cookie expired or invalid (HTTP 401/403)- Re-login to
x.comand retry.
Unable to get key for cookie decryption(macOS Keychain)- SSH sessions: Keychain is locked by default over SSH. Run:
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.
- Windows: no output captured by pipe/subprocess (AI agent integration)
- This is a ConPTY issue, not a twitter-cli bug. Windows Terminal's ConPTY pseudo-terminal can intercept pipe output from commands with network latency.
- Fix: Use Git Bash as your terminal shell and set
"windowsEnableConpty": falsein your terminal settings. - If disabling ConPTY with PowerShell, emoji output may fail with
UnicodeEncodeError: 'gbk'. Git Bash handles UTF-8 natively. - Standard
subprocess.run(capture_output=True)and file redirection (> file 2>&1) work correctly regardless of ConPTY.
Structured error codes commonly include not_authenticated, not_found, invalid_input, rate_limited, and api_error.
Development
# Install dev dependencies
uv sync --extra dev
# Lint + tests
uv run ruff check .
uv run pytest -qCurrent CI validates the project on Python 3.8, 3.10, and 3.12.
Project Structure
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.pyUse as AI Agent Skill
twitter-cli ships with a `SKILL.md` so AI agents can execute common X/Twitter workflows.
Skills CLI (Recommended)
npx skills add jackwener/twitter-cli| Flag | Description |
|---|---|
-g | Install globally (user-level, shared across projects) |
-a claude-code | Target a specific agent |
-y | Non-interactive mode |
Manual Install
mkdir -p .agents/skills
git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli~~OpenClaw / ClawHub~~ (Deprecated)
⚠️ ClawHub install method is deprecated and no longer supported. Use Skills CLI or Manual Install above.
中文
功能概览
读取:
- 时间线读取:支持
for-you和following - 收藏读取:查看账号书签推文
- 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos
- 推文详情:查看推文及其回复;用
show <N>可直接打开上次列表里的第 N 条推文 - 文章读取:获取 Twitter 长文,并导出为 Markdown
- 列表时间线:获取 Twitter List 的推文
- 用户查询:查看用户资料、推文、点赞、粉丝和关注
--full-text:在 rich table 输出里关闭推文正文截断- 结构化输出:支持 YAML 和 JSON,便于脚本处理和 AI agent 集成
AI Agent 提示: 需要结构化输出时优先使用--yaml,除非下游必须是 JSON。stdout 不是 TTY 时默认输出 YAML。用--max控制返回数量。
写入:
- 发推:发布新推文和回复,支持附带图片(最多 4 张,支持 JPEG/PNG/GIF/WebP)
- 引用推文:带评论的转发,也支持附带图片
- 删除:删除自己的推文
- 点赞 / 取消点赞
- 转推 / 取消转推
- 书签 / 取消书签:bookmark/unbookmark(保留
favorite/unfavorite兼容别名) - 写操作现在也显式支持
--json/--yaml
认证与反风控:
- Cookie 认证:支持环境变量和浏览器自动提取
- 完整 Cookie 转发:提取浏览器中所有 Twitter Cookie,保留更多浏览器上下文
- TLS 指纹伪装:
curl_cffi动态匹配 Chrome 版本 x-client-transaction-id请求头生成- 请求时序随机化(jitter)
- 写操作随机延迟(1.5–4 秒),降低频率风控
- 代理支持:
TWITTER_PROXY环境变量
安装
# 推荐:uv tool
uv tool install twitter-cli升级到最新版本:
uv tool upgrade twitter-cli
# 或:pipx upgrade twitter-cli提示: 建议定期升级,避免因版本过旧导致的 API 调用异常。
使用指南
# 时间线
twitter feed
twitter feed -t following
twitter feed --filter
twitter feed --full-text
twitter feed --cursor "<上一页返回的 nextCursor>"
# 收藏
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 show 2 # 打开上次 feed/search 的第 2 条
twitter show 2 --full-text # 在回复表格里显示完整正文
twitter show 2 --json # 结构化输出
# Twitter 长文
twitter article 1234567890
twitter article https://x.com/user/article/1234567890 --json
twitter article 1234567890 --markdown
twitter article 1234567890 --output article.md
# 列表时间线
twitter list 1539453138322673664
twitter list 1539453138322673664 --cursor "<上一页返回的 nextCursor>"
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 "发图" --image photo.jpg # 带图发推
twitter post "多图" -i a.png -i b.jpg -i c.webp # 最多 4 张图片
twitter post "回复内容" --reply-to 1234567890
twitter reply 1234567890 "回复" -i screenshot.png # 带图回复
twitter quote 1234567890 "评论" -i chart.png # 带图引用
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。也可以通过环境变量指定:
TWITTER_CHROME_PROFILE="Profile 2" twitter feed浏览器优先级:如果有多个浏览器,可通过 TWITTER_BROWSER 指定优先尝试的浏览器:
TWITTER_BROWSER=chrome twitter feed # 支持: arc, chrome, edge, firefox, brave代理支持
设置 TWITTER_PROXY 环境变量即可:
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 时才会启用筛选评分;默认不筛选。
评分公式:
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 默认锁定,需手动解锁:
security unlock-keychain ~/Library/Keychains/login.keychain-db- 本地终端:打开 钥匙串访问 → 搜索 "\<浏览器\> Safe Storage" → 访问控制 → 添加你的终端 app → 保存更改。
- 或在弹出 Keychain 授权时点击 "始终允许"。
- 报错
Twitter API error 404:通常是 queryId 轮换,重试即可。
- Windows 下 pipe/subprocess 无法捕获输出(AI agent 集成场景)
- 这是 ConPTY 伪终端的问题,不是 twitter-cli 的 bug。Windows Terminal 的 ConPTY 可能拦截有网络延迟的命令的管道输出。
- 解决方案:使用 Git Bash 并在终端设置中设置
"windowsEnableConpty": false。 - ConPTY 关闭后如用 PowerShell,emoji 可能会报
UnicodeEncodeError: 'gbk'。Git Bash 原生支持 UTF-8。 - 标准
subprocess.run(capture_output=True)和文件重定向 (> file 2>&1) 不受此问题影响。
- 结构化错误码通常会区分
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`,可让 AI Agent 更稳定地调用本工具。
Skills CLI(推荐)
npx skills add jackwener/twitter-cli| 参数 | 说明 |
|---|---|
-g | 全局安装(用户级别,跨项目共享) |
-a claude-code | 指定目标 Agent |
-y | 非交互模式 |
手动安装
mkdir -p .agents/skills
git clone git@github.com:jackwener/twitter-cli.git .agents/skills/twitter-cli~~OpenClaw / ClawHub~~(已过时)
⚠️ ClawHub 安装方式已过时,不再支持。请使用上方的 Skills CLI 或手动安装。
更多工具
- bilibili-cli — Bilibili 视频、用户、搜索与动态 CLI
- discord-cli — Discord 本地优先同步、检索与导出 CLI
- tg-cli — Telegram 本地优先同步、检索与导出 CLI
- xiaohongshu-cli — 小红书笔记与账号工作流 CLI
Structured Output Schema
twitter-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...
pagination:
nextCursor: "optional-cursor"Error
ok: false
schema_version: "1"
error:
code: api_error
message: User @foo not foundNotes
--yamland--jsonboth use this envelope- non-TTY stdout defaults to YAML
- tweet and user lists are returned under
data - timeline-style list commands may also return
pagination.nextCursor articlereturns a single tweet object underdatastatusreturnsdata.authenticatedplusdata.userwhoamireturnsdata.user- write commands also support explicit
--json/--yaml
Article Fields
twitter article <id> --json returns the standard tweet object plus:
data:
id: "1234567890"
articleTitle: "Article Title"
articleText: |
# Heading
Body text...Error Codes
Common structured error codes:
not_authenticatednot_foundinvalid_inputrate_limitedapi_error
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),
article_title=overrides.pop("article_title", None),
article_text=overrides.pop("article_text", None),
is_subscriber_only=overrides.pop("is_subscriber_only", False),
is_promoted=overrides.pop("is_promoted", False),
)
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
{
"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"
}
}
]
}
]
}
}
}
}
}
}
{
"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
}
}
]
}
},
"note_tweet": {
"note_tweet_results": {
"result": {
"text": "Hello\nworld\n\nThis is the full text of a long tweet that goes beyond the 280 character limit and contains additional content that would be hidden behind Show More in the Twitter UI."
}
}
},
"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"
}
}
]
}
]
}
}
}
}
{
"data": {
"list": {
"tweets_timeline": {
"timeline": {
"instructions": [
{
"type": "TimelineAddEntries",
"entries": [
{
"entryId": "list-tweet-1",
"content": {
"entryType": "TimelineTimelineItem",
"itemContent": {
"tweet_results": {
"result": {
"__typename": "TweetWithVisibilityResults",
"tweetInterstitial": {
"__typename": "TweetInterstitial",
"text": { "rtl": false, "text": "Subscribe to @lister to see this post" }
},
"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"
}
}
]
}
]
}
}
}
}
}
{
"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"
}
}
]
}
]
}
}
}
}
}
{
"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"
}
}
]
}
]
}
}
}
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_windows_hint(monkeypatch) -> None:
"""On Windows, hint should mention DPAPI and environment variable workaround."""
monkeypatch.setattr("sys.platform", "win32")
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
diagnostics = ["chrome: Unable to get key for cookie decryption"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is not None
assert "DPAPI" in hint
assert "TWITTER_AUTH_TOKEN" in hint
assert "shadowcopy" 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 -v" 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)
"""Tests for twitter_cli.cache module."""
from __future__ import annotations
import json
import time
from twitter_cli.cache import resolve_cached_tweet, save_tweet_cache
from twitter_cli.models import Author, Metrics, Tweet
def _make_tweet(tweet_id: str, text: str = "hello") -> Tweet:
return Tweet(
id=tweet_id,
text=text,
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1),
created_at="2025-01-01",
)
class TestSaveAndResolve:
"""save_tweet_cache → resolve_cached_tweet round-trip."""
def test_round_trip(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
tweets = [_make_tweet("100"), _make_tweet("200"), _make_tweet("300")]
save_tweet_cache(tweets)
assert cache_file.exists()
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id == "100"
assert size == 3
tweet_id, size = resolve_cached_tweet(3)
assert tweet_id == "300"
assert size == 3
def test_out_of_range_returns_none(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
monkeypatch.setattr("twitter_cli.cache._CACHE_DIR", tmp_path)
save_tweet_cache([_make_tweet("100")])
tweet_id, size = resolve_cached_tweet(99)
assert tweet_id is None
assert size == 1
class TestCacheExpiry:
"""TTL expiration behavior."""
def test_expired_cache_returns_none(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
# Write cache with old timestamp
payload = {
"created_at": time.time() - 7200, # 2 hours ago
"tweets": [{"index": 1, "id": "100", "author": "alice", "text": "hi"}],
}
cache_file.write_text(json.dumps(payload), encoding="utf-8")
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
class TestCacheEdgeCases:
"""Corrupted and missing cache files."""
def test_missing_file(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "does_not_exist.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
def test_corrupted_json(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
cache_file.write_text("{{invalid json", encoding="utf-8")
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
def test_wrong_structure(self, tmp_path, monkeypatch) -> None:
cache_file = tmp_path / "last_results.json"
cache_file.write_text('"just a string"', encoding="utf-8")
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
tweet_id, size = resolve_cached_tweet(1)
assert tweet_id is None
assert size == 0
from __future__ import annotations
import json
import time
from click.testing import CliRunner
import pytest
from rich.console import Console
import yaml
from twitter_cli.cli import cli
from twitter_cli.formatter import article_to_markdown, print_tweet_table
from twitter_cli.models import Author, BookmarkFolder, Metrics, Tweet, UserProfile
from twitter_cli.serialization import tweets_to_json
def test_cli_user_command_works_with_client_factory(monkeypatch) -> None:
class FakeClient:
def fetch_user(self, screen_name: str) -> UserProfile:
return UserProfile(id="1", name="Alice", screen_name=screen_name)
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["user", "alice"])
assert result.exit_code == 0
def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None:
json_path = tmp_path / "tweets.json"
json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8")
runner = CliRunner()
result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"])
assert result.exit_code == 0
assert '"id": "1"' in result.output
def test_cli_feed_input_accepts_structured_json_envelope(tmp_path, tweet_factory) -> None:
json_path = tmp_path / "tweets.json"
json_path.write_text(
(
"{\n"
' "ok": true,\n'
' "schema_version": "1",\n'
' "data": %s\n'
"}\n"
)
% tweets_to_json([tweet_factory("1")]),
encoding="utf-8",
)
runner = CliRunner()
result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"])
assert result.exit_code == 0
assert '"id": "1"' in result.output
def test_cli_feed_passes_include_promoted(monkeypatch, tweet_factory) -> None:
class FakeClient:
def fetch_home_timeline(
self,
count: int,
include_promoted: bool = False,
cursor: str | None = None,
return_cursor: bool = False,
):
assert count == 20
assert include_promoted is True
assert cursor is None
assert return_cursor is True
return [tweet_factory("1", is_promoted=True)], "cursor-next"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 20}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["feed", "--json", "--include-promoted"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"][0]["isPromoted"] is True
assert payload["pagination"]["nextCursor"] == "cursor-next"
def test_cli_feed_accepts_cursor_and_emits_pagination(monkeypatch) -> None:
class FakeClient:
def fetch_following_feed(
self,
count: int,
include_promoted: bool = False,
cursor: str | None = None,
return_cursor: bool = False,
):
assert count == 20
assert include_promoted is False
assert cursor == "cursor-prev"
assert return_cursor is True
return [], "cursor-next"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 20}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["feed", "-t", "following", "--cursor", "cursor-prev", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"] == []
assert payload["pagination"]["nextCursor"] == "cursor-next"
def test_cli_list_accepts_cursor_and_emits_pagination(monkeypatch, tweet_factory) -> None:
class FakeClient:
def fetch_list_timeline(
self,
list_id: str,
count: int,
cursor: str | None = None,
return_cursor: bool = False,
):
assert list_id == "123"
assert count == 20
assert cursor == "cursor-prev"
assert return_cursor is True
return [tweet_factory("1")], "cursor-next"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 20}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["list", "123", "--cursor", "cursor-prev", "--json"])
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"][0]["id"] == "1"
assert payload["pagination"]["nextCursor"] == "cursor-next"
def test_print_tweet_table_truncates_text_by_default(tweet_factory) -> None:
long_text = "A" * 140
console = Console(record=True, width=400)
print_tweet_table([tweet_factory("1", text=long_text)], console=console)
output = console.export_text()
assert ("A" * 117 + "...") in output
def test_print_tweet_table_full_text_shows_untruncated_text(tweet_factory) -> None:
long_text = "B" * 140
console = Console(record=True, width=400)
print_tweet_table([tweet_factory("1", text=long_text)], console=console, full_text=True)
output = console.export_text()
assert long_text in output
assert ("B" * 117 + "...") not in output
@pytest.mark.parametrize(
"args",
[
["favorites"],
["bookmarks"],
["search", "x"],
["user-posts", "alice"],
["likes", "alice"],
["list", "123"],
],
)
def test_cli_commands_wrap_client_creation_errors(monkeypatch, args) -> None:
monkeypatch.setattr(
"twitter_cli.cli._get_client",
lambda config=None, quiet=False: (_ for _ in ()).throw(RuntimeError("boom")),
)
runner = CliRunner()
result = runner.invoke(cli, args)
assert result.exit_code == 1
assert "boom" in result.output
assert type(result.exception).__name__ == "SystemExit"
def test_cli_user_error_yaml(monkeypatch) -> None:
from twitter_cli.exceptions import NotFoundError
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr(
"twitter_cli.cli._get_client",
lambda config=None, quiet=False: (_ for _ in ()).throw(NotFoundError("User not found")),
)
runner = CliRunner()
result = runner.invoke(cli, ["user", "alice", "--yaml"])
assert result.exit_code == 1
payload = yaml.safe_load(result.output)
assert payload["ok"] is False
assert payload["error"]["code"] == "not_found"
def test_cli_tweet_accepts_shared_url_with_query(monkeypatch) -> None:
class FakeClient:
def fetch_tweet_detail(self, tweet_id: str, max_count: int):
assert tweet_id == "12345"
assert max_count == 50
return []
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["tweet", "https://x.com/user/status/12345?s=20"])
assert result.exit_code == 0
def test_cli_article_accepts_article_url_and_json(monkeypatch) -> None:
class FakeClient:
def fetch_article(self, tweet_id: str) -> Tweet:
assert tweet_id == "12345"
return Tweet(
id="12345",
text="https://t.co/article",
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5),
created_at="2026-03-11",
article_title="Title",
article_text="Hello\n\n## Section",
)
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["article", "https://x.com/user/article/12345?s=20", "--json"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["id"] == "12345"
assert payload["data"]["articleTitle"] == "Title"
assert "Hello" in payload["data"]["articleText"]
def test_cli_article_markdown_output_and_save(monkeypatch, tmp_path) -> None:
article = Tweet(
id="12345",
text="https://t.co/article",
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5),
created_at="2026-03-11",
article_title="Title",
article_text="Hello\n\n## Section",
)
class FakeClient:
def fetch_article(self, tweet_id: str) -> Tweet:
assert tweet_id == "12345"
return article
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {})
output_path = tmp_path / "article.md"
runner = CliRunner()
result = runner.invoke(
cli,
["article", "12345", "--markdown", "--output", str(output_path)],
)
assert result.exit_code == 0
assert result.output == article_to_markdown(article)
assert output_path.read_text(encoding="utf-8") == article_to_markdown(article)
def test_cli_article_markdown_overrides_auto_structured_output(monkeypatch) -> None:
article = Tweet(
id="12345",
text="https://t.co/article",
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5),
created_at="2026-03-11",
article_title="Title",
article_text="Hello\n\n## Section",
)
class FakeClient:
def fetch_article(self, tweet_id: str) -> Tweet:
assert tweet_id == "12345"
return article
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {})
runner = CliRunner()
result = runner.invoke(cli, ["article", "12345", "--markdown"])
assert result.exit_code == 0
assert result.output == article_to_markdown(article)
def test_cli_article_json_output_file_uses_structured_format(monkeypatch, tmp_path) -> None:
article = Tweet(
id="12345",
text="https://t.co/article",
author=Author(id="u1", name="Alice", screen_name="alice"),
metrics=Metrics(likes=1, retweets=2, replies=3, views=4, bookmarks=5),
created_at="2026-03-11",
article_title="Title",
article_text="Hello\n\n## Section",
)
class FakeClient:
def fetch_article(self, tweet_id: str) -> Tweet:
assert tweet_id == "12345"
return article
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {})
output_path = tmp_path / "article.json"
runner = CliRunner()
result = runner.invoke(
cli,
["article", "12345", "--json", "--output", str(output_path)],
)
assert result.exit_code == 0
stdout_payload = yaml.safe_load(result.output)
assert stdout_payload["ok"] is True
saved_payload = json.loads(output_path.read_text(encoding="utf-8"))
assert saved_payload["id"] == "12345"
assert saved_payload["articleTitle"] == "Title"
def test_cli_article_rejects_compact_mode() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["-c", "article", "12345"])
assert result.exit_code == 2
assert "does not support --compact" in result.output
def test_cli_bookmark_alias_works(monkeypatch) -> None:
calls = []
class FakeClient:
def bookmark_tweet(self, tweet_id: str) -> bool:
calls.append(tweet_id)
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["bookmark", "123"])
assert result.exit_code == 0
assert calls == ["123"]
def test_cli_bookmarks_folders_inherits_parent_options(monkeypatch) -> None:
calls = []
def fake_folder_timeline(
folder_id: str,
max_count: int | None,
since: str | None,
as_json: bool,
as_yaml: bool,
output_file: str | None,
do_filter: bool,
compact: bool,
full_text: bool,
) -> None:
calls.append(
(
folder_id,
max_count,
since,
as_json,
as_yaml,
output_file,
do_filter,
compact,
full_text,
)
)
monkeypatch.setattr("twitter_cli.cli._run_bookmark_folder_timeline", fake_folder_timeline)
runner = CliRunner()
result = runner.invoke(
cli,
["bookmarks", "--json", "--full-text", "-n", "7", "-o", "root.json", "--filter", "folders", "123"],
)
assert result.exit_code == 0
assert calls == [("123", 7, None, True, False, "root.json", True, False, True)]
def test_cli_bookmarks_folders_list_inherits_parent_output_options(monkeypatch) -> None:
calls = []
def fake_list_bookmark_folders(
as_json: bool,
as_yaml: bool,
compact: bool,
output_file: str | None,
) -> None:
calls.append((as_json, as_yaml, compact, output_file))
monkeypatch.setattr("twitter_cli.cli._run_list_bookmark_folders", fake_list_bookmark_folders)
runner = CliRunner()
result = runner.invoke(cli, ["bookmarks", "--json", "-o", "folders.json", "folders"])
assert result.exit_code == 0
assert calls == [(True, False, False, "folders.json")]
def test_cli_bookmarks_folders_list_writes_output_file(monkeypatch, tmp_path) -> None:
class FakeClient:
def fetch_bookmark_folders(self) -> list[BookmarkFolder]:
return [BookmarkFolder(id="f1", name="Reading"), BookmarkFolder(id="f2", name="Research")]
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {})
output_path = tmp_path / "folders.json"
runner = CliRunner()
result = runner.invoke(
cli,
["bookmarks", "folders", "--json", "--output", str(output_path)],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["ok"] is True
assert payload["data"][0]["id"] == "f1"
saved = json.loads(output_path.read_text(encoding="utf-8"))
assert saved == [
{"id": "f1", "name": "Reading"},
{"id": "f2", "name": "Research"},
]
def test_cli_whoami_command(monkeypatch) -> None:
from twitter_cli.models import UserProfile
class FakeClient:
def fetch_me(self) -> UserProfile:
return UserProfile(id="42", name="Test User", screen_name="testuser")
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["whoami"])
assert result.exit_code == 0
result_json = runner.invoke(cli, ["whoami", "--json"])
assert result_json.exit_code == 0
payload = yaml.safe_load(runner.invoke(cli, ["whoami", "--yaml"]).output)
assert payload["ok"] is True
assert payload["data"]["user"]["username"] == "testuser"
def test_cli_whoami_auto_yaml(monkeypatch) -> None:
class FakeClient:
def fetch_me(self) -> UserProfile:
return UserProfile(id="42", name="Test User", screen_name="testuser")
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["whoami"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["schema_version"] == "1"
assert payload["data"]["user"]["username"] == "testuser"
def test_cli_status_auto_yaml(monkeypatch) -> None:
class FakeClient:
def fetch_me(self) -> UserProfile:
return UserProfile(id="42", name="Test User", screen_name="testuser")
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["schema_version"] == "1"
assert payload["data"]["authenticated"] is True
assert payload["data"]["user"]["username"] == "testuser"
def test_cli_reply_command(monkeypatch) -> None:
calls = []
class FakeClient:
def create_tweet(self, text: str, reply_to_id=None, media_ids=None) -> str:
calls.append({"text": text, "reply_to_id": reply_to_id})
return "999"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["reply", "12345", "Nice tweet!"])
assert result.exit_code == 0
assert calls[0]["reply_to_id"] == "12345"
assert calls[0]["text"] == "Nice tweet!"
def test_cli_quote_command(monkeypatch) -> None:
calls = []
class FakeClient:
def quote_tweet(self, tweet_id: str, text: str, media_ids=None) -> str:
calls.append({"tweet_id": tweet_id, "text": text})
return "888"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["quote", "12345", "Interesting!"])
assert result.exit_code == 0
assert calls[0]["tweet_id"] == "12345"
assert calls[0]["text"] == "Interesting!"
def test_cli_post_json_output(monkeypatch) -> None:
class FakeClient:
def create_tweet(self, text: str, reply_to_id=None, media_ids=None) -> str:
assert text == "hello"
assert reply_to_id is None
return "999"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["post", "hello", "--json"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "post"
assert payload["data"]["id"] == "999"
def test_cli_post_reply_to_accepts_status_url(monkeypatch) -> None:
calls = []
class FakeClient:
def create_tweet(self, text: str, reply_to_id=None, media_ids=None) -> str:
calls.append({"text": text, "reply_to_id": reply_to_id})
return "999"
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(
cli,
["post", "hello", "--reply-to", "https://x.com/alice/status/12345?s=20"],
)
assert result.exit_code == 0
assert calls == [{"text": "hello", "reply_to_id": "12345"}]
def test_cli_like_yaml_output(monkeypatch) -> None:
class FakeClient:
def like_tweet(self, tweet_id: str) -> bool:
assert tweet_id == "123"
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["like", "123", "--yaml"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "liking_tweet"
assert payload["data"]["id"] == "123"
def test_cli_follow_json_output(monkeypatch) -> None:
class FakeClient:
def resolve_user_id(self, identifier: str) -> str:
assert identifier == "alice"
return "42"
def follow_user(self, user_id: str) -> bool:
assert user_id == "42"
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["follow", "alice", "--json"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["action"] == "follow"
assert payload["data"]["userId"] == "42"
def test_cli_follow_command(monkeypatch) -> None:
actions = []
class FakeClient:
def resolve_user_id(self, identifier: str) -> str:
return "42"
def follow_user(self, user_id: str) -> bool:
actions.append(("follow", user_id))
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["follow", "alice"])
assert result.exit_code == 0
assert actions == [("follow", "42")]
def test_cli_unfollow_command(monkeypatch) -> None:
actions = []
class FakeClient:
def resolve_user_id(self, identifier: str) -> str:
return "42"
def unfollow_user(self, user_id: str) -> bool:
actions.append(("unfollow", user_id))
return True
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
runner = CliRunner()
result = runner.invoke(cli, ["unfollow", "alice"])
assert result.exit_code == 0
assert actions == [("unfollow", "42")]
def test_cli_search_advanced_options(monkeypatch) -> None:
captured = {}
class FakeClient:
def fetch_search(self, query: str, count: int, product: str):
captured["query"] = query
captured["product"] = product
return []
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, [
"search", "python",
"--from", "elonmusk",
"--lang", "en",
"--since", "2026-01-01",
"--has", "links",
"--exclude", "retweets",
"--min-likes", "100",
"-t", "Latest",
"--json",
])
assert result.exit_code == 0, f"search failed: {result.output}"
assert captured["query"] == (
"python from:elonmusk lang:en since:2026-01-01 "
"filter:links -filter:retweets min_faves:100"
)
assert captured["product"] == "Latest"
def test_cli_search_operators_only_no_query(monkeypatch) -> None:
captured = {}
class FakeClient:
def fetch_search(self, query: str, count: int, product: str):
captured["query"] = query
return []
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr(
"twitter_cli.cli.load_config",
lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}},
)
runner = CliRunner()
result = runner.invoke(cli, ["search", "--from", "bbc", "--json"])
assert result.exit_code == 0, f"search failed: {result.output}"
assert captured["query"] == "from:bbc"
def test_cli_search_empty_query_no_options() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["search"])
assert result.exit_code != 0
assert "Provide a QUERY" in result.output
def test_cli_search_invalid_date_rejected(monkeypatch) -> None:
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: None)
runner = CliRunner()
result = runner.invoke(cli, ["search", "python", "--since", "not-a-date"])
assert result.exit_code != 0
assert "--since must be in YYYY-MM-DD format" in result.output
def test_cli_search_rejects_reversed_date_range(monkeypatch) -> None:
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: None)
runner = CliRunner()
result = runner.invoke(cli, ["search", "python", "--since", "2026-03-02", "--until", "2026-03-01"])
assert result.exit_code != 0
assert "--since must be on or before --until" in result.output
def test_cli_compact_mode(tmp_path, tweet_factory) -> None:
json_path = tmp_path / "tweets.json"
json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8")
runner = CliRunner()
result = runner.invoke(cli, ["-c", "feed", "--input", str(json_path)])
assert result.exit_code == 0
# Compact output should have "author" field with @ prefix
assert '"@alice"' in result.output
# Compact output should NOT have full metrics keys
assert '"metrics"' not in result.output
def _write_cache(cache_file, tweets, created_at=None):
"""Write a test cache file."""
if created_at is None:
created_at = time.time()
entries = [
{"index": i + 1, "id": t.id, "author": t.author.screen_name, "text": t.text[:80]}
for i, t in enumerate(tweets)
]
payload = {"created_at": created_at, "tweets": entries}
cache_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_show_happy_path(monkeypatch, tmp_path, tweet_factory):
"""show <N> resolves cached index and fetches tweet detail."""
tw = tweet_factory("42", text="hello world")
cache_file = tmp_path / "last_results.json"
_write_cache(cache_file, [tweet_factory("10"), tw]) # tw is index 2
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
class FakeClient:
def fetch_tweet_detail(self, tweet_id, count):
assert tweet_id == "42"
return [tw]
monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None, quiet=False: FakeClient())
monkeypatch.setattr("twitter_cli.cli.load_config", lambda: {})
runner = CliRunner()
result = runner.invoke(cli, ["show", "2"])
assert result.exit_code == 0
def test_show_empty_cache(monkeypatch, tmp_path):
"""show fails with a helpful message when no cache exists."""
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "1"])
assert result.exit_code != 0
assert "No cached results" in result.output
def test_show_out_of_range(monkeypatch, tmp_path, tweet_factory):
"""show fails with out-of-range message when index exceeds cache size."""
cache_file = tmp_path / "last_results.json"
_write_cache(cache_file, [tweet_factory("1")])
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "99"])
assert result.exit_code != 0
assert "out of range" in result.output
assert "1" in result.output # cache has 1 tweet
def test_show_expired_cache(monkeypatch, tmp_path, tweet_factory):
"""show treats an expired cache the same as no cache."""
cache_file = tmp_path / "last_results.json"
expired_time = time.time() - 7200 # 2 hours ago
_write_cache(cache_file, [tweet_factory("1")], created_at=expired_time)
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "1"])
assert result.exit_code != 0
assert "No cached results" in result.output
def test_show_rejects_zero_index(monkeypatch, tmp_path):
"""show rejects index=0 because indices are 1-based."""
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "0"])
assert result.exit_code != 0
def test_show_rejects_negative_index(monkeypatch, tmp_path):
"""show rejects negative indices."""
cache_file = tmp_path / "last_results.json"
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "-1"])
assert result.exit_code != 0
def test_show_malformed_cache_treated_as_empty(monkeypatch, tmp_path):
"""show handles a corrupted cache file gracefully."""
cache_file = tmp_path / "last_results.json"
cache_file.write_text("not valid json{{}", encoding="utf-8")
monkeypatch.setattr("twitter_cli.cache._CACHE_FILE", cache_file)
runner = CliRunner()
result = runner.invoke(cli, ["show", "1"])
assert result.exit_code != 0
assert "No cached results" in result.output
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
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
"""twitter-cli: A CLI for Twitter/X."""
try:
from importlib.metadata import version
__version__ = version("twitter-cli")
except Exception:
__version__ = "0.0.0"
"""CLI command sub-modules for twitter-cli.
Commands are split into three groups:
- read: feed, bookmarks, search, tweet, article, show, list, favorites
- write: post, reply, quote, delete, like/unlike, retweet/unretweet, bookmark/unbookmark
- user: user, user-posts, likes, followers, following, whoami, status, follow/unfollow
"""
Related skills
How it compares
Choose twitter-cli over the official Twitter API when you want cookie-based terminal access and agent-friendly JSON/YAML without OAuth app registration.
FAQ
How do you install twitter-cli?
twitter-cli installs the Python `twitter` binary with `uv tool install twitter-cli` or `pipx install twitter-cli` on Python 3.8+. Upgrade with `uv tool upgrade twitter-cli` to avoid API errors documented in the skill.
How does twitter-cli authenticate to X?
twitter-cli reads browser cookies from Chrome, Firefox, Edge, Arc, or Brave after x.com login, or accepts TWITTER_AUTH_TOKEN and TWITTER_CT0 env vars. Write operations need full browser cookies to avoid HTTP 226 automated-behavior errors.
What output formats does twitter-cli support?
twitter-cli emits rich terminal tables, YAML, and JSON envelopes per SCHEMA.md, plus `-c` compact rows with id, author, truncated text, likes, and rts. Non-TTY stdout defaults to YAML for piping into jq.