feat: improve cookie extraction diagnostics and add doctor command

- Add _diagnose_keychain_issues() for macOS Keychain/SSH detection
- Extraction functions now return (cookies, diagnostics) tuples
- Error messages include actionable Keychain hints (e.g. unlock-keychain)
- Add 'twitter doctor' diagnostic command for troubleshooting
- Enhance bug_report.yml with browser/access method/diagnostics fields
- Expand README troubleshooting (EN+CN) with Keychain/SSH solutions
- Add 5 new tests for Keychain diagnostics

Closes #11
This commit is contained in:
jackwener
2026-03-11 16:53:06 +08:00
parent 47be88e62d
commit 60e1e7c580
5 changed files with 366 additions and 36 deletions

View File

@@ -21,6 +21,31 @@ body:
- Other - Other
validations: validations:
required: true 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 - type: textarea
id: description id: description
attributes: attributes:
@@ -41,6 +66,12 @@ body:
label: Steps to reproduce label: Steps to reproduce
description: "Commands or steps to reproduce the issue. Use `twitter -v <command>` for debug output." description: "Commands or steps to reproduce the issue. Use `twitter -v <command>` for debug output."
render: bash render: bash
- type: textarea
id: diagnostics
attributes:
label: Diagnostics
description: "Paste the output of `twitter doctor` here. This helps us diagnose cookie and auth issues quickly."
render: text
- type: textarea - type: textarea
id: logs id: logs
attributes: attributes:

View File

@@ -241,6 +241,14 @@ Mode behavior:
- `Cookie expired or invalid (HTTP 401/403)` - `Cookie expired or invalid (HTTP 401/403)`
- Re-login to `x.com` and retry. - Re-login to `x.com` and retry.
- `Unable to get key for cookie decryption` (macOS Keychain)
- **SSH sessions**: Keychain is locked by default over SSH. Run:
```bash
security unlock-keychain ~/Library/Keychains/login.keychain-db
```
- **Local terminal**: Open **Keychain Access** → search for **"\<Browser\> Safe Storage"** → **Access Control** → add your Terminal app → **Save Changes**.
- Or click **"Always Allow"** when the Keychain authorization popup appears.
- `Twitter API error 404` - `Twitter API error 404`
- This can happen when upstream GraphQL query IDs rotate. - This can happen when upstream GraphQL query IDs rotate.
- Retry the command; the client attempts a live queryId fallback. - Retry the command; the client attempts a live queryId fallback.
@@ -248,6 +256,8 @@ Mode behavior:
- `Invalid tweet JSON file` - `Invalid tweet JSON file`
- Regenerate input using `twitter feed --json > tweets.json`. - Regenerate input using `twitter feed --json > tweets.json`.
**Diagnostics command**: Run `twitter doctor` to output a full diagnostic report (version, OS, browser detection, Keychain status, cookie extraction results). Paste this output into bug reports.
Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`. Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`.
### Development ### Development
@@ -458,7 +468,17 @@ score = likes_w * likes
- 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。 - 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。
- 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。 - 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。
- 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。 - 报错 `Cookie expired or invalid`Cookie 过期,重新登录后重试。
- 报错 `Unable to get key for cookie decryption`macOS Keychain 问题):
- **SSH 远程登录**Keychain 默认锁定,需手动解锁:
```bash
security unlock-keychain ~/Library/Keychains/login.keychain-db
```
- **本地终端**:打开 **钥匙串访问** → 搜索 **"\<浏览器\> Safe Storage"** → **访问控制** → 添加你的终端 app → **保存更改**。
- 或在弹出 Keychain 授权时点击 **"始终允许"**。
- 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。 - 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。
**诊断命令**:运行 `twitter doctor` 可输出完整诊断报告版本、OS、浏览器检测、Keychain 状态、cookie 提取结果),方便提交 bug report。
- 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。 - 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。
### 使用建议(防封号) ### 使用建议(防封号)

View File

@@ -30,8 +30,8 @@ def test_get_cookies_reextracts_after_verify_failure(monkeypatch) -> None:
monkeypatch.setattr(auth, "load_from_env", lambda: None) monkeypatch.setattr(auth, "load_from_env", lambda: None)
extracted = iter( extracted = iter(
[ [
{"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"}, ({"auth_token": "stale-token", "ct0": "stale-csrf", "cookie_string": "stale=1"}, []),
{"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"}, ({"auth_token": "fresh-token", "ct0": "fresh-csrf", "cookie_string": "fresh=1"}, []),
] ]
) )
monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted)) monkeypatch.setattr(auth, "extract_from_browser", lambda: next(extracted))
@@ -84,11 +84,11 @@ def test_extract_cookies_from_jar_logs_missing_required_cookies(caplog) -> None:
def test_extract_from_browser_logs_warning_when_all_methods_fail(monkeypatch, caplog) -> None: 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_in_process", lambda: (None, []))
monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: None) monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, []))
with caplog.at_level("WARNING"): with caplog.at_level("WARNING"):
cookies = auth.extract_from_browser() cookies, diagnostics = auth.extract_from_browser()
assert cookies is None assert cookies is None
assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text assert "Twitter cookie extraction failed in both in-process and subprocess modes" in caplog.text
@@ -110,7 +110,7 @@ def test_extract_in_process_supports_arc(monkeypatch) -> None:
) )
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module) monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies = auth._extract_in_process() cookies, diagnostics = auth._extract_in_process()
assert cookies is not None assert cookies is not None
assert cookies["auth_token"] == "token" assert cookies["auth_token"] == "token"
@@ -132,7 +132,7 @@ def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None:
monkeypatch.setattr(auth.subprocess, "run", _run) monkeypatch.setattr(auth.subprocess, "run", _run)
cookies = auth._extract_via_subprocess() cookies, diagnostics = auth._extract_via_subprocess()
assert cookies is None assert cookies is None
assert '("arc", browser_cookie3.arc)' in seen["script"] assert '("arc", browser_cookie3.arc)' in seen["script"]
@@ -154,7 +154,7 @@ def test_extract_via_subprocess_retries_uv_when_current_env_has_no_output(monkey
monkeypatch.setattr(auth.subprocess, "run", _run) monkeypatch.setattr(auth.subprocess, "run", _run)
cookies = auth._extract_via_subprocess() cookies, diagnostics = auth._extract_via_subprocess()
assert cookies == {"auth_token": "token", "ct0": "csrf"} assert cookies == {"auth_token": "token", "ct0": "csrf"}
assert len(calls) == 2 assert len(calls) == 2
@@ -285,8 +285,85 @@ def test_extract_in_process_tries_multiple_profiles(monkeypatch, tmp_path) -> No
) )
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module) monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies = auth._extract_in_process() cookies, diagnostics = auth._extract_in_process()
assert cookies is not None assert cookies is not None
assert cookies["auth_token"] == "tok123" assert cookies["auth_token"] == "tok123"
assert cookies["ct0"] == "csrf456" assert cookies["ct0"] == "csrf456"
def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None:
"""_diagnose_keychain_issues should detect Keychain-related error strings."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
diagnostics = ["arc[Default]: Unable to get key for cookie decryption"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is not None
assert "Keychain" in hint
def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None:
"""When SSH env vars are set, hint should suggest unlock-keychain."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
diagnostics = ["arc: Unable to get key for cookie decryption"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is not None
assert "SSH session detected" in hint
assert "security unlock-keychain" in hint
def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None:
"""Should return None when diagnostics don't mention Keychain."""
diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"]
hint = auth._diagnose_keychain_issues(diagnostics)
assert hint is None
def test_get_cookies_includes_keychain_hint_in_error(monkeypatch) -> None:
"""When extraction fails with Keychain errors, error msg should contain the hint."""
monkeypatch.setattr("sys.platform", "darwin")
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22")
monkeypatch.setattr(auth, "load_from_env", lambda: None)
monkeypatch.setattr(
auth,
"extract_from_browser",
lambda: (None, ["arc: Unable to get key for cookie decryption"]),
)
with pytest.raises(RuntimeError) as exc_info:
auth.get_cookies()
msg = str(exc_info.value)
assert "security unlock-keychain" in msg
assert "twitter doctor" in msg
def test_extract_in_process_returns_diagnostics_on_failure(monkeypatch) -> None:
"""_extract_in_process should return diagnostics containing error strings."""
from types import SimpleNamespace
class BrowserError(Exception):
pass
fake_module = SimpleNamespace(
arc=lambda: (_ for _ in ()).throw(BrowserError("Unable to get key for cookie decryption")),
chrome=lambda: [],
edge=lambda: (_ for _ in ()).throw(BrowserError("Edge not found")),
firefox=lambda: (_ for _ in ()).throw(BrowserError("Firefox not found")),
brave=lambda: (_ for _ in ()).throw(BrowserError("Brave not found")),
)
monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module)
cookies, diagnostics = auth._extract_in_process()
assert cookies is None
assert any("cookie decryption" in d for d in diagnostics)

View File

@@ -30,6 +30,48 @@ def _is_twitter_domain(domain: str) -> bool:
return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com") return domain in _TWITTER_DOMAINS or domain.endswith(".x.com") or domain.endswith(".twitter.com")
# ---------------------------------------------------------------------------
# Keychain / environment diagnostics
# ---------------------------------------------------------------------------
_KEYCHAIN_ERROR_KEYWORDS = (
"key for cookie decryption",
"safe storage",
"keychain",
"secretstorage",
)
def _diagnose_keychain_issues(diagnostics: List[str]) -> Optional[str]:
"""Analyse extraction diagnostics for Keychain permission issues.
Returns a user-friendly hint string, or None.
"""
lowered = " ".join(diagnostics).lower()
if not any(kw in lowered for kw in _KEYCHAIN_ERROR_KEYWORDS):
return None
is_ssh = bool(os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY") or os.environ.get("SSH_CONNECTION"))
if sys.platform == "darwin":
if is_ssh:
return (
"macOS Keychain is locked (SSH session detected).\n"
" Fix: security unlock-keychain ~/Library/Keychains/login.keychain-db\n"
" Then retry the command."
)
return (
"macOS Keychain permission denied — your terminal is not authorized to read browser cookie encryption keys.\n"
" Fix: Open Keychain Access → search for \"<Browser> Safe Storage\" → Access Control → add your Terminal app.\n"
" Or click \"Always Allow\" when the Keychain authorization popup appears."
)
# Linux: gnome-keyring / SecretStorage issues
return (
"System keyring access failed — the cookie encryption key could not be retrieved.\n"
" If running headless or via SSH, ensure your keyring daemon is unlocked."
)
def load_from_env() -> Optional[Dict[str, str]]: def load_from_env() -> Optional[Dict[str, str]]:
"""Load cookies from environment variables.""" """Load cookies from environment variables."""
auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "") auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "")
@@ -197,7 +239,7 @@ def _iter_chrome_cookie_files(browser_name: str) -> List[str]:
return paths return paths
def _extract_in_process() -> Optional[Dict[str, str]]: def _extract_in_process() -> Tuple[Optional[Dict[str, str]], List[str]]:
"""Extract cookies in the main process (required on macOS for Keychain access). """Extract cookies in the main process (required on macOS for Keychain access).
On macOS, Chrome encrypts cookies using a key stored in the system Keychain. On macOS, Chrome encrypts cookies using a key stored in the system Keychain.
@@ -205,12 +247,14 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
browser_cookie3 must run in the main process to decrypt cookies. browser_cookie3 must run in the main process to decrypt cookies.
For Chromium-based browsers, iterates all profiles to find Twitter cookies. For Chromium-based browsers, iterates all profiles to find Twitter cookies.
Returns (cookies_dict | None, diagnostics_list).
""" """
try: try:
import browser_cookie3 import browser_cookie3
except ImportError: except ImportError:
logger.debug("browser_cookie3 not installed, skipping in-process extraction") logger.debug("browser_cookie3 not installed, skipping in-process extraction")
return None return None, ["browser-cookie3 not installed"]
browsers = [ browsers = [
("arc", browser_cookie3.arc), ("arc", browser_cookie3.arc),
@@ -219,7 +263,8 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
("firefox", browser_cookie3.firefox), ("firefox", browser_cookie3.firefox),
("brave", browser_cookie3.brave), ("brave", browser_cookie3.brave),
] ]
attempts = [] attempts: List[str] = []
diagnostics: List[str] = []
for name, fn in browsers: for name, fn in browsers:
if name in _CHROMIUM_BASE_DIRS: if name in _CHROMIUM_BASE_DIRS:
@@ -232,11 +277,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
except Exception as e: except Exception as e:
logger.debug("%s in-process extraction failed: %s", name, e) logger.debug("%s in-process extraction failed: %s", name, e)
attempts.append("%s=%s" % (name, type(e).__name__)) attempts.append("%s=%s" % (name, type(e).__name__))
diagnostics.append("%s: %s" % (name, e))
continue continue
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
if cookies: if cookies:
logger.info("Found cookies in %s (in-process, default)", name) logger.info("Found cookies in %s (in-process, default)", name)
return cookies return cookies, diagnostics
attempts.append("%s=no-cookies" % name) attempts.append("%s=no-cookies" % name)
continue continue
@@ -247,11 +293,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
except Exception as e: except Exception as e:
logger.debug("%s[%s] in-process extraction failed: %s", name, profile_name, e) logger.debug("%s[%s] in-process extraction failed: %s", name, profile_name, e)
attempts.append("%s[%s]=%s" % (name, profile_name, type(e).__name__)) attempts.append("%s[%s]=%s" % (name, profile_name, type(e).__name__))
diagnostics.append("%s[%s]: %s" % (name, profile_name, e))
continue continue
cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name)) cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name))
if cookies: if cookies:
logger.info("Found cookies in %s profile '%s' (in-process)", name, profile_name) logger.info("Found cookies in %s profile '%s' (in-process)", name, profile_name)
return cookies return cookies, diagnostics
attempts.append("%s[%s]=no-cookies" % (name, profile_name)) attempts.append("%s[%s]=no-cookies" % (name, profile_name))
else: else:
# Non-Chromium (Firefox): use default behavior # Non-Chromium (Firefox): use default behavior
@@ -260,20 +307,24 @@ def _extract_in_process() -> Optional[Dict[str, str]]:
except Exception as e: except Exception as e:
logger.debug("%s in-process extraction failed: %s", name, e) logger.debug("%s in-process extraction failed: %s", name, e)
attempts.append("%s=%s" % (name, type(e).__name__)) attempts.append("%s=%s" % (name, type(e).__name__))
diagnostics.append("%s: %s" % (name, e))
continue continue
cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name)
if cookies: if cookies:
logger.info("Found cookies in %s (in-process)", name) logger.info("Found cookies in %s (in-process)", name)
return cookies return cookies, diagnostics
attempts.append("%s=no-cookies" % name) attempts.append("%s=no-cookies" % name)
if attempts: if attempts:
logger.debug("In-process extraction attempts: %s", ", ".join(attempts)) logger.debug("In-process extraction attempts: %s", ", ".join(attempts))
return None return None, diagnostics
def _extract_via_subprocess() -> Optional[Dict[str, str]]: def _extract_via_subprocess() -> Tuple[Optional[Dict[str, str]], List[str]]:
"""Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).""" """Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).
Returns (cookies_dict | None, diagnostics_list).
"""
extract_script = ''' extract_script = '''
import glob, json, os, sys import glob, json, os, sys
try: try:
@@ -351,7 +402,7 @@ for name, fn in browsers:
try: try:
jar = fn() jar = fn()
except Exception as exc: except Exception as exc:
attempts.append(f"{name}={type(exc).__name__}") attempts.append(f"{name}={type(exc).__name__}: {exc}")
continue continue
r = extract_from_jar(jar, name) r = extract_from_jar(jar, name)
if r: if r:
@@ -364,7 +415,7 @@ for name, fn in browsers:
try: try:
jar = fn(cookie_file=cf) jar = fn(cookie_file=cf)
except Exception as exc: except Exception as exc:
attempts.append(f"{name}[{pname}]={type(exc).__name__}") attempts.append(f"{name}[{pname}]={type(exc).__name__}: {exc}")
continue continue
r = extract_from_jar(jar, name, pname) r = extract_from_jar(jar, name, pname)
if r: if r:
@@ -375,7 +426,7 @@ for name, fn in browsers:
try: try:
jar = fn() jar = fn()
except Exception as exc: except Exception as exc:
attempts.append(f"{name}={type(exc).__name__}") attempts.append(f"{name}={type(exc).__name__}: {exc}")
continue continue
r = extract_from_jar(jar, name) r = extract_from_jar(jar, name)
if r: if r:
@@ -390,6 +441,8 @@ print(json.dumps({
sys.exit(1) sys.exit(1)
''' '''
diagnostics: List[str] = []
def _run_extract_command( def _run_extract_command(
cmd: list[str], cmd: list[str],
timeout: int, timeout: int,
@@ -427,6 +480,7 @@ sys.exit(1)
attempts = data.get("attempts") or [] attempts = data.get("attempts") or []
if attempts: if attempts:
logger.debug("Subprocess extraction attempts (%s): %s", label, ", ".join(str(item) for item in attempts)) logger.debug("Subprocess extraction attempts (%s): %s", label, ", ".join(str(item) for item in attempts))
diagnostics.extend(str(item) for item in attempts)
retryable = data.get("error") == "browser-cookie3 not installed" retryable = data.get("error") == "browser-cookie3 not installed"
return None, retryable return None, retryable
@@ -446,7 +500,7 @@ sys.exit(1)
) )
if data is None: if data is None:
return None return None, diagnostics
logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown")) logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown"))
# Build full cookie string from all extracted cookies # Build full cookie string from all extracted cookies
@@ -456,30 +510,36 @@ sys.exit(1)
cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items()) cookie_str = "; ".join("%s=%s" % (k, v) for k, v in all_cookies.items())
cookies["cookie_string"] = cookie_str cookies["cookie_string"] = cookie_str
logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies)) logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies))
return cookies return cookies, diagnostics
except KeyError as exc: except KeyError as exc:
logger.debug("Cookie extraction subprocess returned incomplete payload: %s", exc) logger.debug("Cookie extraction subprocess returned incomplete payload: %s", exc)
return None return None, diagnostics
def extract_from_browser() -> Optional[Dict[str, str]]: def extract_from_browser() -> Tuple[Optional[Dict[str, str]], List[str]]:
"""Auto-extract ALL Twitter cookies from local browser using browser-cookie3. """Auto-extract ALL Twitter cookies from local browser using browser-cookie3.
Strategy: Strategy:
1. Try in-process first (required on macOS for Keychain access) 1. Try in-process first (required on macOS for Keychain access)
2. Fall back to subprocess (handles SQLite lock when browser is running) 2. Fall back to subprocess (handles SQLite lock when browser is running)
Returns (cookies_dict | None, diagnostics_list).
""" """
all_diagnostics: List[str] = []
# 1. In-process (works on macOS, may fail with SQLite lock) # 1. In-process (works on macOS, may fail with SQLite lock)
cookies = _extract_in_process() cookies, diag = _extract_in_process()
all_diagnostics.extend(diag)
if cookies: if cookies:
return cookies return cookies, all_diagnostics
# 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain) # 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain)
logger.debug("In-process extraction failed, trying subprocess fallback") logger.debug("In-process extraction failed, trying subprocess fallback")
cookies = _extract_via_subprocess() cookies, diag = _extract_via_subprocess()
all_diagnostics.extend(diag)
if not cookies: if not cookies:
logger.warning("Twitter cookie extraction failed in both in-process and subprocess modes") logger.warning("Twitter cookie extraction failed in both in-process and subprocess modes")
return cookies return cookies, all_diagnostics
def get_cookies() -> Dict[str, str]: def get_cookies() -> Dict[str, str]:
@@ -488,6 +548,7 @@ def get_cookies() -> Dict[str, str]:
Raises RuntimeError if no cookies found. Raises RuntimeError if no cookies found.
""" """
cookies: Optional[Dict[str, str]] = None cookies: Optional[Dict[str, str]] = None
diagnostics: List[str] = []
# 1. Try environment variables # 1. Try environment variables
cookies = load_from_env() cookies = load_from_env()
@@ -497,14 +558,22 @@ def get_cookies() -> Dict[str, str]:
# 2. Try browser extraction (auto-detect) # 2. Try browser extraction (auto-detect)
if not cookies: if not cookies:
logger.debug("Attempting browser cookie extraction") logger.debug("Attempting browser cookie extraction")
cookies = extract_from_browser() cookies, diagnostics = extract_from_browser()
if not cookies: if not cookies:
raise RuntimeError( lines = ["No Twitter cookies found."]
"No Twitter cookies found.\n" # Add actionable Keychain hint when relevant
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n" hint = _diagnose_keychain_issues(diagnostics)
"Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)" if hint:
) lines.append("")
lines.append("Likely cause:")
lines.extend(" " + l for l in hint.splitlines())
lines.append("")
lines.append("Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables")
lines.append("Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)")
lines.append("")
lines.append("Run 'twitter doctor' for full diagnostics.")
raise RuntimeError("\n".join(lines))
# Verify only for explicit auth failures; transient endpoint issues are tolerated. # Verify only for explicit auth failures; transient endpoint issues are tolerated.
try: try:
@@ -512,7 +581,7 @@ def get_cookies() -> Dict[str, str]:
except RuntimeError: except RuntimeError:
# Auth failure — re-extract from browser and retry verification # Auth failure — re-extract from browser and retry verification
logger.info("Cookie verification failed, re-extracting from browser") logger.info("Cookie verification failed, re-extracting from browser")
fresh_cookies = extract_from_browser() fresh_cookies, _ = extract_from_browser()
if fresh_cookies: if fresh_cookies:
# Verify fresh cookies — if this also fails, let it raise # Verify fresh cookies — if this also fails, let it raise
verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string")) verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string"))

View File

@@ -28,6 +28,7 @@ Write commands:
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re import re
import sys import sys
import time import time
@@ -992,5 +993,137 @@ def unbookmark(tweet_id, as_json, as_yaml):
_write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml) _write_action("🔖", "Removing bookmark", "unbookmark_tweet", tweet_id, as_json=as_json, as_yaml=as_yaml)
@cli.command(name="doctor")
@structured_output_options
def doctor(as_json, as_yaml):
# type: (bool, bool) -> None
"""Run diagnostics for cookie extraction and authentication.
Useful for troubleshooting auth issues and for pasting into bug reports.
"""
import platform
from .auth import (
_diagnose_keychain_issues,
_extract_in_process,
_extract_via_subprocess,
extract_from_browser,
load_from_env,
verify_cookies,
)
info: Dict[str, Any] = {}
mode = _structured_mode(as_json=as_json, as_yaml=as_yaml)
# -- System info --
info["version"] = __version__
info["python"] = sys.version.split()[0]
info["platform"] = platform.platform()
info["os"] = sys.platform
# -- Environment --
is_ssh = bool(
os.environ.get("SSH_CLIENT")
or os.environ.get("SSH_TTY")
or os.environ.get("SSH_CONNECTION")
)
info["ssh_session"] = is_ssh
info["env_auth_token_set"] = bool(os.environ.get("TWITTER_AUTH_TOKEN"))
info["env_ct0_set"] = bool(os.environ.get("TWITTER_CT0"))
info["chrome_profile_override"] = os.environ.get("TWITTER_CHROME_PROFILE", "")
# -- Cookie extraction --
env_cookies = load_from_env()
info["env_cookies"] = "found" if env_cookies else "not set"
# In-process extraction
in_proc_cookies, in_proc_diag = _extract_in_process()
info["in_process"] = {
"status": "ok" if in_proc_cookies else "failed",
"diagnostics": in_proc_diag,
}
# Subprocess extraction
sub_cookies, sub_diag = _extract_via_subprocess()
info["subprocess"] = {
"status": "ok" if sub_cookies else "failed",
"diagnostics": sub_diag,
}
# Combined diagnostics
all_diag = in_proc_diag + sub_diag
cookies = in_proc_cookies or sub_cookies or env_cookies
# Keychain hint
hint = _diagnose_keychain_issues(all_diag)
if hint:
info["keychain_hint"] = hint
# Verification
if cookies:
try:
result = verify_cookies(
cookies["auth_token"],
cookies["ct0"],
cookies.get("cookie_string"),
)
info["verification"] = "ok"
info["screen_name"] = result.get("screen_name", "")
except RuntimeError as exc:
info["verification"] = "failed: %s" % exc
else:
info["verification"] = "skipped (no cookies)"
# -- Output --
if _emit_mode_payload(info, mode):
return
console.print("\n🩺 [bold]twitter-cli doctor[/bold]\n")
console.print(" Version: %s" % info["version"])
console.print(" Python: %s" % info["python"])
console.print(" Platform: %s" % info["platform"])
console.print(" SSH session: %s" % ("yes ⚠️" if is_ssh else "no"))
console.print()
console.print("[bold]Environment:[/bold]")
console.print(" TWITTER_AUTH_TOKEN: %s" % ("set ✅" if info["env_auth_token_set"] else "not set"))
console.print(" TWITTER_CT0: %s" % ("set ✅" if info["env_ct0_set"] else "not set"))
if info["chrome_profile_override"]:
console.print(" TWITTER_CHROME_PROFILE: %s" % info["chrome_profile_override"])
console.print()
console.print("[bold]Cookie Extraction:[/bold]")
in_status = info["in_process"]["status"]
console.print(
" In-process: %s" % ("[green]ok ✅[/green]" if in_status == "ok" else "[red]failed ❌[/red]")
)
for d in info["in_process"]["diagnostics"]:
console.print(" [dim]• %s[/dim]" % d)
sub_status = info["subprocess"]["status"]
console.print(
" Subprocess: %s" % ("[green]ok ✅[/green]" if sub_status == "ok" else "[red]failed ❌[/red]")
)
for d in info["subprocess"]["diagnostics"]:
console.print(" [dim]• %s[/dim]" % d)
console.print()
if hint:
console.print("[yellow]💡 Hint:[/yellow]")
for line in hint.splitlines():
console.print(" [yellow]%s[/yellow]" % line)
console.print()
v = info["verification"]
if v == "ok":
screen = info.get("screen_name", "")
console.print("[green]✅ Authentication: OK[/green]%s" % (" (@%s)" % screen if screen else ""))
elif v.startswith("failed"):
console.print("[red]❌ Authentication: %s[/red]" % v)
else:
console.print("[yellow]⚠️ Authentication: %s[/yellow]" % v)
console.print()
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()