diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f66ab6f..d385cb0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -21,6 +21,31 @@ body: - 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: @@ -41,6 +66,12 @@ body: label: Steps to reproduce description: "Commands or steps to reproduce the issue. Use `twitter -v ` for debug output." render: bash + - type: textarea + id: diagnostics + attributes: + label: Diagnostics + description: "Paste the output of `twitter doctor` here. This helps us diagnose cookie and auth issues quickly." + render: text - type: textarea id: logs attributes: diff --git a/README.md b/README.md index b395674..0d29a84 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,14 @@ Mode behavior: - `Cookie expired or invalid (HTTP 401/403)` - Re-login to `x.com` and retry. +- `Unable to get key for cookie decryption` (macOS Keychain) + - **SSH sessions**: Keychain is locked by default over SSH. Run: + ```bash + security unlock-keychain ~/Library/Keychains/login.keychain-db + ``` + - **Local terminal**: Open **Keychain Access** → search for **"\ 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. @@ -248,6 +256,8 @@ Mode behavior: - `Invalid tweet JSON file` - Regenerate input using `twitter feed --json > tweets.json`. +**Diagnostics command**: Run `twitter doctor` to output a full diagnostic report (version, OS, browser detection, Keychain status, cookie extraction results). Paste this output into bug reports. + Structured error codes commonly include `not_authenticated`, `not_found`, `invalid_input`, `rate_limited`, and `api_error`. ### Development @@ -458,7 +468,17 @@ score = likes_w * likes - 报错 `No Twitter cookies found`:请先登录 `x.com`,并确认浏览器为 Arc/Chrome/Edge/Firefox/Brave 之一,或手动设置环境变量。 - 如需查看浏览器提取细节,可加 `-v` 打开诊断日志。 - 报错 `Cookie expired or invalid`:Cookie 过期,重新登录后重试。 +- 报错 `Unable to get key for cookie decryption`(macOS Keychain 问题): + - **SSH 远程登录**:Keychain 默认锁定,需手动解锁: + ```bash + security unlock-keychain ~/Library/Keychains/login.keychain-db + ``` + - **本地终端**:打开 **钥匙串访问** → 搜索 **"\<浏览器\> Safe Storage"** → **访问控制** → 添加你的终端 app → **保存更改**。 + - 或在弹出 Keychain 授权时点击 **"始终允许"**。 - 报错 `Twitter API error 404`:通常是 queryId 轮换,重试即可。 + +**诊断命令**:运行 `twitter doctor` 可输出完整诊断报告(版本、OS、浏览器检测、Keychain 状态、cookie 提取结果),方便提交 bug report。 + - 结构化错误码通常会区分 `not_authenticated`、`not_found`、`invalid_input`、`rate_limited`、`api_error`。 ### 使用建议(防封号) diff --git a/tests/test_auth.py b/tests/test_auth.py index f2b84bd..3e7e9bc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -30,8 +30,8 @@ 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"}, + ({"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)) @@ -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: - monkeypatch.setattr(auth, "_extract_in_process", lambda: None) - monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: None) + monkeypatch.setattr(auth, "_extract_in_process", lambda: (None, [])) + monkeypatch.setattr(auth, "_extract_via_subprocess", lambda: (None, [])) with caplog.at_level("WARNING"): - cookies = auth.extract_from_browser() + 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 @@ -110,7 +110,7 @@ def test_extract_in_process_supports_arc(monkeypatch) -> None: ) 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["auth_token"] == "token" @@ -132,7 +132,7 @@ def test_extract_via_subprocess_script_includes_arc(monkeypatch) -> None: monkeypatch.setattr(auth.subprocess, "run", _run) - cookies = auth._extract_via_subprocess() + cookies, diagnostics = auth._extract_via_subprocess() assert cookies is None 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) - cookies = auth._extract_via_subprocess() + cookies, diagnostics = auth._extract_via_subprocess() assert cookies == {"auth_token": "token", "ct0": "csrf"} 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) - cookies = auth._extract_in_process() + cookies, diagnostics = auth._extract_in_process() assert cookies is not None assert cookies["auth_token"] == "tok123" assert cookies["ct0"] == "csrf456" + + +def test_diagnose_keychain_issues_detects_decryption_error(monkeypatch) -> None: + """_diagnose_keychain_issues should detect Keychain-related error strings.""" + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + + diagnostics = ["arc[Default]: Unable to get key for cookie decryption"] + hint = auth._diagnose_keychain_issues(diagnostics) + + assert hint is not None + assert "Keychain" in hint + + +def test_diagnose_keychain_issues_ssh_hint(monkeypatch) -> None: + """When SSH env vars are set, hint should suggest unlock-keychain.""" + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22") + + diagnostics = ["arc: Unable to get key for cookie decryption"] + hint = auth._diagnose_keychain_issues(diagnostics) + + assert hint is not None + assert "SSH session detected" in hint + assert "security unlock-keychain" in hint + + +def test_diagnose_keychain_issues_returns_none_for_unrelated_errors() -> None: + """Should return None when diagnostics don't mention Keychain.""" + diagnostics = ["chrome[Default]=no-cookies", "firefox: profile not found"] + hint = auth._diagnose_keychain_issues(diagnostics) + + assert hint is None + + +def test_get_cookies_includes_keychain_hint_in_error(monkeypatch) -> None: + """When extraction fails with Keychain errors, error msg should contain the hint.""" + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22") + monkeypatch.setattr(auth, "load_from_env", lambda: None) + monkeypatch.setattr( + auth, + "extract_from_browser", + lambda: (None, ["arc: Unable to get key for cookie decryption"]), + ) + + with pytest.raises(RuntimeError) as exc_info: + auth.get_cookies() + + msg = str(exc_info.value) + assert "security unlock-keychain" in msg + assert "twitter doctor" in msg + + +def test_extract_in_process_returns_diagnostics_on_failure(monkeypatch) -> None: + """_extract_in_process should return diagnostics containing error strings.""" + from types import SimpleNamespace + + class BrowserError(Exception): + pass + + fake_module = SimpleNamespace( + arc=lambda: (_ for _ in ()).throw(BrowserError("Unable to get key for cookie decryption")), + chrome=lambda: [], + edge=lambda: (_ for _ in ()).throw(BrowserError("Edge not found")), + firefox=lambda: (_ for _ in ()).throw(BrowserError("Firefox not found")), + brave=lambda: (_ for _ in ()).throw(BrowserError("Brave not found")), + ) + monkeypatch.setitem(sys.modules, "browser_cookie3", fake_module) + + cookies, diagnostics = auth._extract_in_process() + + assert cookies is None + assert any("cookie decryption" in d for d in diagnostics) + diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 5a198d4..a5bf28c 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -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") +# --------------------------------------------------------------------------- +# 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 \" 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]]: """Load cookies from environment variables.""" auth_token = os.environ.get("TWITTER_AUTH_TOKEN", "") @@ -197,7 +239,7 @@ def _iter_chrome_cookie_files(browser_name: str) -> List[str]: 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). 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. For Chromium-based browsers, iterates all profiles to find Twitter cookies. + + Returns (cookies_dict | None, diagnostics_list). """ try: import browser_cookie3 except ImportError: logger.debug("browser_cookie3 not installed, skipping in-process extraction") - return None + return None, ["browser-cookie3 not installed"] browsers = [ ("arc", browser_cookie3.arc), @@ -219,7 +263,8 @@ def _extract_in_process() -> Optional[Dict[str, str]]: ("firefox", browser_cookie3.firefox), ("brave", browser_cookie3.brave), ] - attempts = [] + attempts: List[str] = [] + diagnostics: List[str] = [] for name, fn in browsers: if name in _CHROMIUM_BASE_DIRS: @@ -232,11 +277,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]: except Exception as e: logger.debug("%s in-process extraction failed: %s", name, e) attempts.append("%s=%s" % (name, type(e).__name__)) + diagnostics.append("%s: %s" % (name, e)) continue cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) if cookies: logger.info("Found cookies in %s (in-process, default)", name) - return cookies + return cookies, diagnostics attempts.append("%s=no-cookies" % name) continue @@ -247,11 +293,12 @@ def _extract_in_process() -> Optional[Dict[str, str]]: except Exception as 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__)) + diagnostics.append("%s[%s]: %s" % (name, profile_name, e)) continue cookies = _extract_cookies_from_jar(jar, source="%s[%s](in-process)" % (name, profile_name)) if cookies: 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)) else: # Non-Chromium (Firefox): use default behavior @@ -260,20 +307,24 @@ def _extract_in_process() -> Optional[Dict[str, str]]: except Exception as e: logger.debug("%s in-process extraction failed: %s", name, e) attempts.append("%s=%s" % (name, type(e).__name__)) + diagnostics.append("%s: %s" % (name, e)) continue cookies = _extract_cookies_from_jar(jar, source="%s(in-process)" % name) if cookies: logger.info("Found cookies in %s (in-process)", name) - return cookies + return cookies, diagnostics attempts.append("%s=no-cookies" % name) if attempts: logger.debug("In-process extraction attempts: %s", ", ".join(attempts)) - return None + return None, diagnostics -def _extract_via_subprocess() -> Optional[Dict[str, str]]: - """Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock).""" +def _extract_via_subprocess() -> Tuple[Optional[Dict[str, str]], List[str]]: + """Extract cookies via subprocess (fallback if in-process fails, e.g. SQLite lock). + + Returns (cookies_dict | None, diagnostics_list). + """ extract_script = ''' import glob, json, os, sys try: @@ -351,7 +402,7 @@ for name, fn in browsers: try: jar = fn() except Exception as exc: - attempts.append(f"{name}={type(exc).__name__}") + attempts.append(f"{name}={type(exc).__name__}: {exc}") continue r = extract_from_jar(jar, name) if r: @@ -364,7 +415,7 @@ for name, fn in browsers: try: jar = fn(cookie_file=cf) except Exception as exc: - attempts.append(f"{name}[{pname}]={type(exc).__name__}") + attempts.append(f"{name}[{pname}]={type(exc).__name__}: {exc}") continue r = extract_from_jar(jar, name, pname) if r: @@ -375,7 +426,7 @@ for name, fn in browsers: try: jar = fn() except Exception as exc: - attempts.append(f"{name}={type(exc).__name__}") + attempts.append(f"{name}={type(exc).__name__}: {exc}") continue r = extract_from_jar(jar, name) if r: @@ -390,6 +441,8 @@ print(json.dumps({ sys.exit(1) ''' + diagnostics: List[str] = [] + def _run_extract_command( cmd: list[str], timeout: int, @@ -427,6 +480,7 @@ sys.exit(1) attempts = data.get("attempts") or [] if 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" return None, retryable @@ -446,7 +500,7 @@ sys.exit(1) ) if data is None: - return None + return None, diagnostics logger.info("Found cookies in %s (subprocess)", data.get("browser", "unknown")) # 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()) cookies["cookie_string"] = cookie_str logger.info("Extracted %d total cookies for full browser fingerprint", len(all_cookies)) - return cookies + return cookies, diagnostics except KeyError as 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. Strategy: 1. Try in-process first (required on macOS for Keychain access) 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) - cookies = _extract_in_process() + cookies, diag = _extract_in_process() + all_diagnostics.extend(diag) if cookies: - return cookies + return cookies, all_diagnostics # 2. Subprocess fallback (handles SQLite lock, but fails on macOS Keychain) 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: 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]: @@ -488,6 +548,7 @@ def get_cookies() -> Dict[str, str]: Raises RuntimeError if no cookies found. """ cookies: Optional[Dict[str, str]] = None + diagnostics: List[str] = [] # 1. Try environment variables cookies = load_from_env() @@ -497,14 +558,22 @@ def get_cookies() -> Dict[str, str]: # 2. Try browser extraction (auto-detect) if not cookies: logger.debug("Attempting browser cookie extraction") - cookies = extract_from_browser() + cookies, diagnostics = extract_from_browser() if not cookies: - raise RuntimeError( - "No Twitter cookies found.\n" - "Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n" - "Option 2: Make sure you are logged into x.com in your browser (Arc/Chrome/Edge/Firefox/Brave)" - ) + lines = ["No Twitter cookies found."] + # Add actionable Keychain hint when relevant + hint = _diagnose_keychain_issues(diagnostics) + 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. try: @@ -512,7 +581,7 @@ def get_cookies() -> Dict[str, str]: except RuntimeError: # Auth failure — re-extract from browser and retry verification logger.info("Cookie verification failed, re-extracting from browser") - fresh_cookies = extract_from_browser() + fresh_cookies, _ = extract_from_browser() if fresh_cookies: # Verify fresh cookies — if this also fails, let it raise verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string")) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 25acfc7..ed10c75 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -28,6 +28,7 @@ Write commands: from __future__ import annotations import logging +import os import re import sys 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) +@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__": cli()