fix(twitter): support both twitter-cli and bird CLI, never uninstall user tools
CRITICAL FIX: update.md was telling agents to uninstall bird CLI, which broke users who had working bird installations. Now: - twitter.py: prefers twitter-cli, falls back to bird/birdx if installed - update.md: removed "clean up deprecated tools" step entirely - Added explicit rule: "Never uninstall any existing tools the user already has" - Tests cover twitter-cli primary + bird fallback + preference order 78 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Twitter/X — check if twitter-cli (public-clis/twitter-cli) is available."""
|
"""Twitter/X — check if twitter-cli or bird CLI is available."""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -9,7 +9,7 @@ from .base import Channel
|
|||||||
class TwitterChannel(Channel):
|
class TwitterChannel(Channel):
|
||||||
name = "twitter"
|
name = "twitter"
|
||||||
description = "Twitter/X 推文"
|
description = "Twitter/X 推文"
|
||||||
backends = ["twitter-cli"]
|
backends = ["twitter-cli", "bird CLI (legacy)"]
|
||||||
tier = 1
|
tier = 1
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
def can_handle(self, url: str) -> bool:
|
||||||
@@ -18,24 +18,32 @@ class TwitterChannel(Channel):
|
|||||||
return "x.com" in d or "twitter.com" in d
|
return "x.com" in d or "twitter.com" in d
|
||||||
|
|
||||||
def check(self, config=None):
|
def check(self, config=None):
|
||||||
|
# Prefer twitter-cli, fallback to bird/birdx
|
||||||
twitter = shutil.which("twitter")
|
twitter = shutil.which("twitter")
|
||||||
if not twitter:
|
bird = shutil.which("bird") or shutil.which("birdx")
|
||||||
|
|
||||||
|
if twitter:
|
||||||
|
return self._check_twitter_cli(twitter)
|
||||||
|
elif bird:
|
||||||
|
return self._check_bird(bird)
|
||||||
|
else:
|
||||||
return "warn", (
|
return "warn", (
|
||||||
"twitter-cli 未安装。安装方式:\n"
|
"Twitter CLI 未安装。安装方式:\n"
|
||||||
" pipx install twitter-cli\n"
|
" pipx install twitter-cli\n"
|
||||||
"或:\n"
|
"或:\n"
|
||||||
" uv tool install twitter-cli"
|
" uv tool install twitter-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_twitter_cli(self, binary: str):
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[twitter, "status"], capture_output=True,
|
[binary, "status"], capture_output=True,
|
||||||
encoding="utf-8", errors="replace", timeout=10
|
encoding="utf-8", errors="replace", timeout=10
|
||||||
)
|
)
|
||||||
output = (r.stdout or "") + (r.stderr or "")
|
output = (r.stdout or "") + (r.stderr or "")
|
||||||
if r.returncode == 0 and "ok: true" in output:
|
if r.returncode == 0 and "ok: true" in output:
|
||||||
return "ok", (
|
return "ok", (
|
||||||
"完整可用(搜索、读推文、时间线、长文/Article、"
|
"twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、"
|
||||||
"用户查询、Thread)"
|
"用户查询、Thread)"
|
||||||
)
|
)
|
||||||
if "not_authenticated" in output:
|
if "not_authenticated" in output:
|
||||||
@@ -51,3 +59,24 @@ class TwitterChannel(Channel):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return "warn", "twitter-cli 已安装但连接失败"
|
return "warn", "twitter-cli 已安装但连接失败"
|
||||||
|
|
||||||
|
def _check_bird(self, binary: str):
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[binary, "check"], capture_output=True,
|
||||||
|
encoding="utf-8", errors="replace", timeout=10
|
||||||
|
)
|
||||||
|
output = (r.stdout or "") + (r.stderr or "")
|
||||||
|
if r.returncode == 0:
|
||||||
|
return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)"
|
||||||
|
if "Missing credentials" in output or "missing" in output.lower():
|
||||||
|
return "warn", (
|
||||||
|
"bird CLI 已安装但未配置认证。设置环境变量:\n"
|
||||||
|
" export AUTH_TOKEN=\"xxx\"\n"
|
||||||
|
" export CT0=\"yyy\""
|
||||||
|
)
|
||||||
|
return "warn", (
|
||||||
|
"bird CLI 已安装但认证检查失败。"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return "warn", "bird CLI 已安装但连接失败"
|
||||||
|
|||||||
+2
-8
@@ -71,15 +71,9 @@ which yt-dlp >/dev/null 2>&1 || pip install yt-dlp 2>/dev/null
|
|||||||
which gh >/dev/null 2>&1 || echo "GitHub CLI not installed — see https://cli.github.com"
|
which gh >/dev/null 2>&1 || echo "GitHub CLI not installed — see https://cli.github.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Clean up deprecated tools
|
### Step 4: Coexistence (DO NOT uninstall old tools)
|
||||||
|
|
||||||
```bash
|
**IMPORTANT: Never uninstall any existing tools the user already has installed.** Old tools (bird, browser_cookie3, miku_ai, etc.) may still be working on the user's machine even if their upstream repos are archived or deleted. Agent Reach supports both old and new tools as fallback. Only install new tools alongside existing ones.
|
||||||
# Remove bird CLI (repo deleted, replaced by twitter-cli)
|
|
||||||
which bird >/dev/null 2>&1 && npm uninstall -g @steipete/bird 2>/dev/null && echo "✅ Removed deprecated bird CLI"
|
|
||||||
|
|
||||||
# Note: browser_cookie3 is still supported as fallback, no need to remove
|
|
||||||
# Note: miku_ai is no longer needed (WeChat now uses Exa), but no harm keeping it
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Verify
|
### Step 5: Verify
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,25 @@ def _cp(stdout="", stderr="", returncode=0):
|
|||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# --- twitter-cli tests ---
|
||||||
|
|
||||||
def test_check_twitter_cli_found_and_auth_ok():
|
def test_check_twitter_cli_found_and_auth_ok():
|
||||||
"""twitter-cli found + twitter status ok → ok."""
|
"""twitter-cli found + twitter status ok → ok."""
|
||||||
channel = TwitterChannel()
|
channel = TwitterChannel()
|
||||||
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch(
|
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||||
"subprocess.run",
|
"subprocess.run",
|
||||||
return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0),
|
return_value=_cp(stdout="ok: true\nusername: testuser\n", returncode=0),
|
||||||
):
|
):
|
||||||
status, message = channel.check()
|
status, message = channel.check()
|
||||||
assert status == "ok"
|
assert status == "ok"
|
||||||
|
assert "twitter-cli" in message
|
||||||
assert "完整可用" in message
|
assert "完整可用" in message
|
||||||
|
|
||||||
|
|
||||||
def test_check_twitter_cli_found_auth_missing():
|
def test_check_twitter_cli_found_auth_missing():
|
||||||
"""twitter-cli found + not_authenticated → warn about auth."""
|
"""twitter-cli found + not_authenticated → warn about auth."""
|
||||||
channel = TwitterChannel()
|
channel = TwitterChannel()
|
||||||
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch(
|
with patch("shutil.which", side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None), patch(
|
||||||
"subprocess.run",
|
"subprocess.run",
|
||||||
return_value=_cp(
|
return_value=_cp(
|
||||||
stderr="ok: false\nerror:\n code: not_authenticated\n",
|
stderr="ok: false\nerror:\n code: not_authenticated\n",
|
||||||
@@ -40,8 +43,44 @@ def test_check_twitter_cli_found_auth_missing():
|
|||||||
assert "未认证" in message
|
assert "未认证" in message
|
||||||
|
|
||||||
|
|
||||||
def test_check_twitter_cli_not_found():
|
# --- bird CLI fallback tests ---
|
||||||
"""twitter-cli not found → warn with install hint."""
|
|
||||||
|
def test_check_bird_fallback_auth_ok():
|
||||||
|
"""No twitter-cli, but bird found + bird check ok → ok."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stdout="Authenticated as @user\n", returncode=0),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "ok"
|
||||||
|
assert "bird" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_bird_fallback_auth_missing():
|
||||||
|
"""No twitter-cli, bird found but Missing credentials → warn."""
|
||||||
|
channel = TwitterChannel()
|
||||||
|
def which_side_effect(name):
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
|
"subprocess.run",
|
||||||
|
return_value=_cp(stderr="Missing credentials\n", returncode=1),
|
||||||
|
):
|
||||||
|
status, message = channel.check()
|
||||||
|
assert status == "warn"
|
||||||
|
assert "未配置认证" in message
|
||||||
|
|
||||||
|
|
||||||
|
# --- neither installed ---
|
||||||
|
|
||||||
|
def test_check_nothing_installed():
|
||||||
|
"""Neither twitter-cli nor bird → warn with install hint."""
|
||||||
channel = TwitterChannel()
|
channel = TwitterChannel()
|
||||||
with patch("shutil.which", return_value=None):
|
with patch("shutil.which", return_value=None):
|
||||||
status, message = channel.check()
|
status, message = channel.check()
|
||||||
@@ -49,24 +88,21 @@ def test_check_twitter_cli_not_found():
|
|||||||
assert "twitter-cli" in message
|
assert "twitter-cli" in message
|
||||||
|
|
||||||
|
|
||||||
def test_check_twitter_cli_generic_failure():
|
# --- twitter-cli preferred over bird ---
|
||||||
"""twitter status returns 1 without not_authenticated → generic warn."""
|
|
||||||
|
def test_twitter_cli_preferred_over_bird():
|
||||||
|
"""When both are installed, twitter-cli is used."""
|
||||||
channel = TwitterChannel()
|
channel = TwitterChannel()
|
||||||
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch(
|
def which_side_effect(name):
|
||||||
|
if name == "twitter":
|
||||||
|
return "/usr/local/bin/twitter"
|
||||||
|
if name == "bird":
|
||||||
|
return "/usr/local/bin/bird"
|
||||||
|
return None
|
||||||
|
with patch("shutil.which", side_effect=which_side_effect), patch(
|
||||||
"subprocess.run",
|
"subprocess.run",
|
||||||
return_value=_cp(stderr="some error\n", returncode=1),
|
return_value=_cp(stdout="ok: true\n", returncode=0),
|
||||||
):
|
):
|
||||||
status, message = channel.check()
|
status, message = channel.check()
|
||||||
assert status == "warn"
|
assert status == "ok"
|
||||||
assert "认证检查失败" in message
|
assert "twitter-cli" in message
|
||||||
|
|
||||||
|
|
||||||
def test_check_twitter_cli_exception():
|
|
||||||
"""twitter status throws exception → warn."""
|
|
||||||
channel = TwitterChannel()
|
|
||||||
with patch("shutil.which", return_value="/usr/local/bin/twitter"), patch(
|
|
||||||
"subprocess.run", side_effect=Exception("timeout"),
|
|
||||||
):
|
|
||||||
status, message = channel.check()
|
|
||||||
assert status == "warn"
|
|
||||||
assert "连接失败" in message
|
|
||||||
|
|||||||
Reference in New Issue
Block a user