Merge pull request #66 from j-sperling/fix/endpoint-model-updates

Fix stale API endpoints and model identifiers
This commit is contained in:
Matt Van Horn
2026-03-14 07:29:56 -07:00
committed by GitHub
20 changed files with 315 additions and 171 deletions
+23 -25
View File
@@ -14,12 +14,11 @@ clawhub install last30days-official
**The AI world reinvents itself every month. This skill keeps you current.** /last30days researches your topic across Reddit, X, Bluesky, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations. Whether it's Seedance 2.0 access, paper.design prompts, or the latest Nano Banana Pro techniques, you'll know what people who are paying attention already know.
**New in v2.9.5 — Bluesky, Comparative Mode, ScrapeCreators X:**
**New in v2.9.5 — Bluesky, Comparative Mode, and Config Improvements:**
- **Bluesky/AT Protocol** is now a social source. Opt-in via `BSKY_HANDLE` + `BSKY_APP_PASSWORD` (create at bsky.app/settings/app-passwords). Full pipeline: search, score, dedupe, render.
- **Comparative mode** - ask "X vs Y" (e.g., `/last30 cursor vs windsurf`) and get 3 parallel research passes with a side-by-side comparison: strengths, weaknesses, head-to-head table, and a data-driven verdict.
- **ScrapeCreators X backend** - X/Twitter search now uses ScrapeCreators as an additional backend alongside Bird cookie auth.
- **Per-project .env config** - drop a `.last30days.env` in your project root for per-project API keys.
- **Per-project .env config** - drop a `.claude/last30days.env` in your project root for per-project API keys.
- **SessionStart config check** - validates your config automatically when a Claude Code session starts.
- **Expanded test coverage** - 455+ tests across all modules.
@@ -70,32 +69,31 @@ git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last
# Add your API keys (optional if signed in to Codex)
mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'EOF'
SCRAPECREATORS_API_KEY=... # Reddit + TikTok + Instagram (one key, all three) scrapecreators.com
OPENAI_API_KEY=sk-... # optional legacy Reddit fallback if using `codex login`
XAI_API_KEY=xai-... # optional — cookie auth is default for X search
BSKY_HANDLE=you.bsky.social # optional — Bluesky search (create app password below)
BSKY_APP_PASSWORD=xxxx-xxxx-xxxx # optional — bsky.app/settings/app-passwords
SCRAPECREATORS_API_KEY=... # Reddit + TikTok + Instagram (one key, all three) - scrapecreators.com
OPENAI_API_KEY=sk-... # optional - legacy Reddit fallback if using `codex login`
AUTH_TOKEN=... # recommended for X search - copy once from x.com cookies
CT0=... # recommended for X search - copy once from x.com cookies
XAI_API_KEY=xai-... # optional - X fallback if you do not want cookie-based auth
BSKY_HANDLE=you.bsky.social # optional - Bluesky search (create app password below)
BSKY_APP_PASSWORD=xxxx-xxxx-xxxx # optional - bsky.app/settings/app-passwords
EOF
chmod 600 ~/.config/last30days/.env
```
If you're signed in to Codex (`codex login`), the skill will use your Codex credentials for the OpenAI Responses API and you can omit `OPENAI_API_KEY`. If you're not signed in, run `codex login` first.
For project-specific overrides, create `.claude/last30days.env` in the repo root. It overrides the global `~/.config/last30days/.env`.
### X Search Authentication
X search reads your existing browser cookies - no API keys or login commands needed.
X search prefers explicit env auth. This keeps local runs headless and avoids browser-cookie and macOS Keychain prompts.
**Safari (recommended on Mac):** Just be logged into x.com. No setup needed.
**Recommended setup:**
1. While logged into x.com once, open browser dev tools and copy the `auth_token` and `ct0` cookies for `x.com`.
2. Save them as `AUTH_TOKEN` and `CT0` in `~/.config/last30days/.env`, export them in your shell, or add them to `.claude/last30days.env` for a single project.
3. Re-run `/last30days`.
**Chrome:** Works, but macOS will prompt you to allow Keychain access the first time. Click "Allow" (or "Always Allow" to stop future prompts).
**Firefox:** Just be logged into x.com. No setup needed.
**Manual fallback:** If cookie auto-detection doesn't work, set these env vars (grab them from your browser's dev tools → Application → Cookies → x.com):
```bash
export AUTH_TOKEN=your_auth_token
export CT0=your_ct0_token
```
**xAI fallback:** If you do not want to provide `AUTH_TOKEN` and `CT0`, set `XAI_API_KEY` and the skill will use xAI's `x_search` backend instead.
**Verify it's working:**
```bash
@@ -930,13 +928,13 @@ This example shows /last30days discovering **emerging developer workflows** - re
## Requirements
- **OpenAI API key** - For Reddit research (uses web search via Responses API)
- **OpenAI auth** - For Reddit research (uses web search via Responses API). Use `OPENAI_API_KEY` or `codex login`.
- **Node.js 22+** - For X search (bundled Twitter GraphQL client)
- **X session** - Be logged into x.com in your browser, or set `AUTH_TOKEN`/`CT0` env vars
- **xAI API key** (optional fallback) - If the bundled search can't authenticate, falls back to xAI's Grok API
- **Bundled X auth** - Set `AUTH_TOKEN` and `CT0` for popup-free local X search
- **Alternate X backend** - Set `XAI_API_KEY` if bundled X auth is not configured
- **yt-dlp** (optional) - For YouTube search + transcript extraction. Install via `brew install yt-dlp` or `pip install yt-dlp`. When present, automatically searches YouTube and extracts video transcripts as an additional source.
At least one API key is required. X search works automatically if you're logged into x.com in your browser. YouTube search activates automatically when yt-dlp is in your PATH.
At least one auth path is required. Reddit needs OpenAI auth. X needs either `AUTH_TOKEN` plus `CT0` or `XAI_API_KEY`. YouTube search activates automatically when yt-dlp is in your PATH.
## Troubleshooting
@@ -1143,7 +1141,7 @@ Inspired by [Peter Steinberger](https://x.com/steipete)'s yt-dlp + [summarize](h
### Bundled X search (v2.1)
**X search is fully self-contained** - No external `bird` CLI or xAI API key needed. /last30days bundles a vendored subset of Bird's Twitter GraphQL client (MIT licensed, by Peter Steinberger). Just be logged into x.com in your browser and it auto-detects your session. Falls back to xAI API if bundled search can't authenticate.
**X search is fully self-contained** - No external `bird` CLI install needed. /last30days bundles a vendored subset of Bird's Twitter GraphQL client (MIT licensed, by Peter Steinberger). With Node.js 22+ plus `AUTH_TOKEN` and `CT0`, it runs locally without browser-cookie prompts. Falls back to xAI API if bundled auth is not configured.
### Everything else (v2.1)
@@ -1190,7 +1188,7 @@ Thanks to the contributors who helped shape V2:
| `api.scrapecreators.com` | Search query (Reddit + TikTok + Instagram) | SCRAPECREATORS_API_KEY |
| `api.openai.com` | Search query (legacy Reddit fallback) | OPENAI_API_KEY |
| `reddit.com` | Thread URLs for enrichment | None (public JSON) |
| Twitter GraphQL / `api.x.ai` | Search query | Browser cookies or XAI_API_KEY |
| Twitter GraphQL / `api.x.ai` | Search query | AUTH_TOKEN/CT0 or XAI_API_KEY |
| `youtube.com` (via yt-dlp) | Search query | None (public search) |
| `hn.algolia.com` | Search query | None (public API) |
| `gamma-api.polymarket.com` | Search query | None (public API) |
+11 -16
View File
@@ -9,7 +9,7 @@ User: /last30days "kanye west"
↓ ↓ (concurrent via ThreadPoolExecutor)
[REDDIT] [X/TWITTER]
↓ ↓
OpenAI Bird CLI or
OpenAI Bundled Bird or
API xAI API
↓ ↓
Parse Parse
@@ -95,11 +95,11 @@ No API key needed. This returns the actual thread data:
X search has **two backends** — the skill auto-detects which to use.
### Priority: Bird CLI (free) → xAI API (paid)
### Priority: Bundled Bird (env auth) → xAI API (paid)
```python
if bird_installed and bird_authenticated:
use Bird CLI # Free, uses your X login
if node_available and AUTH_TOKEN and CT0:
use bundled Bird # Free, popup-free, env-authenticated
elif XAI_API_KEY:
use xAI API # Paid, uses grok-4-1-fast
else:
@@ -128,20 +128,15 @@ The prompt asks grok to return JSON with:
- `engagement`: `{ likes, reposts, replies, quotes }`
- `why_relevant`, `relevance` score
**Engagement data comes from grok's x_search tool** it has direct access to X's data.
**Engagement data comes from grok's x_search tool** - it has direct access to X's data.
### Backend 2: Bird CLI (free alternative)
### Backend 2: Bundled Bird client (free alternative)
Bird is a CLI tool (`npm install -g @steipete/bird`) that uses your X login.
The repo vendors a search-only subset of Bird's Twitter GraphQL client and shells out to it with Node.js. No global `bird` install is required. The Python wrapper passes `AUTH_TOKEN` and `CT0` via env, which keeps normal local runs headless and avoids browser-cookie prompts.
**Command:**
```bash
bird search "{topic} since:{from_date}" -n 30 --json
```
**Bundled Bird returns raw X API data** - likes, reposts, replies are real engagement metrics from X's API, not estimates.
**Bird returns raw X API data** — likes, reposts, replies are real engagement metrics from X's API, not estimates.
| Metric | Bird CLI | xAI API |
| Metric | Bundled Bird | xAI API |
|---|---|---|
| Post text | Real | Real |
| Likes/reposts | Real (X API) | Real (x_search tool) |
@@ -151,7 +146,7 @@ bird search "{topic} since:{from_date}" -n 30 --json
### Depth settings
| Depth | xAI posts | Bird results | xAI timeout | Bird timeout |
| Depth | xAI posts | Bundled Bird results | xAI timeout | Bird timeout |
|---|---|---|---|---|
| `--quick` | 8-12 | 12 | 90s | 30s |
| default | 20-30 | 30 | 120s | 45s |
@@ -192,7 +187,7 @@ After both searches complete:
| `scripts/lib/openai_reddit.py` | Reddit search via OpenAI Responses API |
| `scripts/lib/reddit_enrich.py` | Fetch real engagement data from Reddit JSON API |
| `scripts/lib/xai_x.py` | X search via xAI API |
| `scripts/lib/bird_x.py` | X search via Bird CLI (free) |
| `scripts/lib/bird_x.py` | X search via bundled Bird client (free) |
| `scripts/lib/models.py` | Auto-select best available model |
| `scripts/lib/env.py` | API key loading, source detection |
| `scripts/lib/http.py` | HTTP transport with retries |
+18 -20
View File
@@ -13,7 +13,7 @@ YouTube transcripts are the second headline feature. Inspired by Peter Steinberg
**New in V2.1 — two headline features:**
- **YouTube transcripts as a 4th source.** When yt-dlp is installed, /last30days automatically searches YouTube, grabs view counts, and extracts auto-generated transcripts from the top videos. A 20-minute review contains 10x the signal of a tweet — now the skill reads it. Inspired by @steipete's yt-dlp + summarize toolchain.
- **X search is fully bundled.** No external `bird` CLI or xAI API key needed. Just Node.js 22+ and your browser cookies. Uses a vendored subset of Bird's Twitter GraphQL client (MIT licensed, originally by @steipete).
- **X search is fully bundled.** No external `bird` CLI install needed. Add `AUTH_TOKEN` and `CT0` once, and the vendored Bird client runs locally without browser-cookie prompts. `XAI_API_KEY` remains an optional fallback.
---
@@ -21,20 +21,18 @@ YouTube transcripts are the second headline feature. Inspired by Peter Steinberg
### X Search Authentication
X search reads your existing browser cookies — no API keys or login commands needed.
X search prefers explicit env auth. This keeps local runs headless and avoids browser-cookie and macOS Keychain prompts.
**Safari (recommended on Mac):** Just be logged into x.com. No setup needed.
**Recommended setup:** While logged into x.com once, open browser dev tools and copy the `auth_token` and `ct0` cookies for `x.com`.
**Chrome:** Works, but macOS will prompt you to allow Keychain access the first time. Click "Allow" (or "Always Allow" to stop future prompts).
**Firefox:** Just be logged into x.com. No setup needed.
**Manual fallback:** If cookie auto-detection doesn't work, set these env vars (grab them from your browser's dev tools → Application → Cookies → x.com):
Save them as `AUTH_TOKEN` and `CT0` in `~/.config/last30days/.env` or `.claude/last30days.env`:
```bash
export AUTH_TOKEN=your_auth_token
export CT0=your_ct0_token
AUTH_TOKEN=your_auth_token
CT0=your_ct0_token
```
**xAI fallback:** If you do not want to provide `AUTH_TOKEN` and `CT0`, set `XAI_API_KEY` and use xAI's `x_search` backend instead.
**Verify it's working:**
```bash
node ~/.claude/skills/last30days/scripts/lib/vendor/bird-search/bird-search.mjs --whoami
@@ -44,8 +42,10 @@ node ~/.claude/skills/last30days/scripts/lib/vendor/bird-search/bird-search.mjs
## README: Install block env line
```
XAI_API_KEY=xai-... # optional — cookie auth is default for X search
```bash
AUTH_TOKEN=... # recommended for X search
CT0=... # recommended for X search
XAI_API_KEY=xai-... # optional X fallback
```
---
@@ -60,15 +60,13 @@ XAI_API_KEY=xai-... # optional — cookie auth is default for X search
## GitHub issue #19 response (post AFTER publishing)
> Thanks for reporting this. Bird CLI was deprecated and the GitHub repo was deleted steipete was asked to take it down.
> Thanks for reporting this. Bird CLI was deprecated and the GitHub repo was deleted. steipete was asked to take it down.
>
> The good news: you don't need Bird anymore. v2.1 (just shipped) bundles X search directly — no external CLI, no `npm install`, no brew. Just Node.js 22+ and your browser cookies.
> The good news: you don't need Bird anymore. v2.1 (just shipped) bundles X search directly. No external CLI, no `npm install`, no brew. Just Node.js 22+ plus `AUTH_TOKEN` and `CT0`, or `XAI_API_KEY` as fallback.
>
> It also adds **YouTube as a 4th source** — when yt-dlp is installed, the skill automatically searches YouTube and extracts transcripts from the top videos. A 20-minute tutorial has 10x the signal of a tweet, and now the synthesis engine reads it.
> It also adds **YouTube as a 4th source**. When yt-dlp is installed, the skill automatically searches YouTube and extracts transcripts from the top videos. A 20-minute tutorial has 10x the signal of a tweet, and now the synthesis engine reads it.
>
> If you're on a Mac, Safari is the easiest path for X — just be logged into x.com. Chrome works too but macOS will prompt for Keychain access the first time.
>
> If cookie auto-detection doesn't work, you can set `AUTH_TOKEN` and `CT0` env vars manually (grab from browser dev tools → Application → Cookies → x.com).
> The recommended setup is to copy `auth_token` and `ct0` from x.com once and store them as `AUTH_TOKEN` and `CT0` in your env. That avoids browser-cookie and Keychain prompts during normal runs.
>
> The xAI API (`XAI_API_KEY`) also still works as a fallback.
@@ -82,7 +80,7 @@ XAI_API_KEY=xai-... # optional — cookie auth is default for X search
Two new features:
→ YouTube transcripts as a 4th source (yt-dlp)
→ X search fully bundled (no bird CLI needed)
→ X search fully bundled (no bird CLI install needed)
Research any topic across Reddit, X, YouTube & web in one command.
@@ -100,7 +98,7 @@ When yt-dlp is installed, the skill searches YouTube, grabs view counts, and ext
### Thread version (post 2)
2️⃣ X search is fully bundled
Bird CLI was deprecated. Instead of requiring an external tool, v2.1 vendors a search-only subset. Just be logged into x.com in your browser. No npm install, no API keys.
Bird CLI was deprecated. Instead of requiring an external tool, v2.1 vendors a search-only subset. Add `AUTH_TOKEN` and `CT0` once, then it runs locally with no npm install. `XAI_API_KEY` still works as fallback.
Both features inspired by @steipete's tooling.
+2 -2
View File
@@ -1621,8 +1621,8 @@ def main():
# Check available sources (accounting for Bird auto-detection)
available = env.get_available_sources(config)
# Override available if Bird or ScrapeCreators provides X
if x_source in ('bird', 'scrapecreators'):
# Override available if Bird provides X
if x_source == 'bird':
if available == 'reddit':
available = 'both' # Now have both Reddit + X
elif available == 'reddit-web':
+14 -1
View File
@@ -36,10 +36,19 @@ def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
_credentials['CT0'] = ct0
def _has_injected_credentials() -> bool:
"""Return True when both X session cookies were injected from config."""
return bool(_credentials.get('AUTH_TOKEN') and _credentials.get('CT0'))
def _subprocess_env() -> Dict[str, str]:
"""Build env dict for Node subprocesses, merging injected credentials."""
env = os.environ.copy()
env.update(_credentials)
# When repo config already provides cookies, disable browser-cookie fallback
# so vendored Bird never hits Safari/Chrome keychain during automation.
if _has_injected_credentials():
env.setdefault("BIRD_DISABLE_BROWSER_COOKIES", "1")
return env
@@ -126,6 +135,9 @@ def is_bird_authenticated() -> Optional[str]:
if not is_bird_installed():
return None
if _has_injected_credentials():
return "env AUTH_TOKEN"
try:
result = subprocess.run(
["node", str(_BIRD_SEARCH_MJS), "--whoami"],
@@ -353,6 +365,7 @@ def search_handles(
stderr=subprocess.PIPE,
text=True,
preexec_fn=preexec,
env=_subprocess_env(),
)
try:
@@ -473,4 +486,4 @@ def parse_bird_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
items.append(item)
return items
return items
+5 -14
View File
@@ -346,7 +346,7 @@ def get_web_search_source(config: Dict[str, Any]) -> Optional[str]:
def get_missing_keys(config: Dict[str, Any]) -> str:
"""Determine which sources are missing (accounting for Bird and ScrapeCreators).
"""Determine which sources are missing (accounting for Bird).
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
"""
@@ -358,8 +358,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
from . import bird_x
has_bird = bird_x.is_bird_installed() and bird_x.is_bird_authenticated()
has_sc_x = bool(config.get('SCRAPECREATORS_API_KEY'))
has_x = has_xai or has_bird or has_sc_x
has_x = has_xai or has_bird
if has_reddit and has_x and has_web:
return 'none'
@@ -438,7 +437,9 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
def get_x_source(config: Dict[str, Any]) -> Optional[str]:
"""Determine the best available X/Twitter source.
Priority: Bird (free) → xAI (paid API) → ScrapeCreators (shared key)
Priority: Bird (free) → xAI (paid API)
Keep X selection limited to documented, verified search backends.
Args:
config: Configuration dict from get_config()
@@ -446,7 +447,6 @@ def get_x_source(config: Dict[str, Any]) -> Optional[str]:
Returns:
'bird' if Bird is installed and authenticated,
'xai' if XAI_API_KEY is configured,
'scrapecreators' if SCRAPECREATORS_API_KEY is configured,
None if no X source available.
"""
# Import here to avoid circular dependency
@@ -462,10 +462,6 @@ def get_x_source(config: Dict[str, Any]) -> Optional[str]:
if config.get('XAI_API_KEY'):
return 'xai'
# Fall back to ScrapeCreators (same key as Reddit/TikTok/Instagram)
if config.get('SCRAPECREATORS_API_KEY'):
return 'scrapecreators'
return None
@@ -584,15 +580,11 @@ def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
bird_status = bird_x.get_bird_status()
xai_available = bool(config.get('XAI_API_KEY'))
sc_available = bool(config.get('SCRAPECREATORS_API_KEY'))
# Determine active source
if bird_status["authenticated"]:
source = 'bird'
elif xai_available:
source = 'xai'
elif sc_available:
source = 'scrapecreators'
else:
source = None
@@ -602,6 +594,5 @@ def get_x_source_status(config: Dict[str, Any]) -> Dict[str, Any]:
"bird_authenticated": bird_status["authenticated"],
"bird_username": bird_status["username"],
"xai_available": xai_available,
"scrapecreators_available": sc_available,
"can_install_bird": bird_status["can_install"],
}
+2 -2
View File
@@ -217,7 +217,7 @@ def search_instagram(
try:
resp = _requests.get(
f"{SCRAPECREATORS_BASE}/v1/instagram/reels/search",
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
params={"query": core_topic},
headers=_sc_headers(token),
timeout=30,
@@ -228,7 +228,7 @@ def search_instagram(
_log(f"ScrapeCreators error: {e}")
return {"items": [], "error": f"{type(e).__name__}: {e}"}
# Items are in the 'reels' array (ScrapeCreators v1 response)
# Items are in the 'reels' array (ScrapeCreators v2 response)
raw_items = data.get("reels") or data.get("items") or data.get("data") or []
# Limit to configured count
+2 -2
View File
@@ -13,8 +13,8 @@ CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
# xAI API - Agent Tools API requires grok-4 family
XAI_MODELS_URL = "https://api.x.ai/v1/models"
XAI_ALIASES = {
"latest": "grok-4-1-fast", # Required for x_search tool
"stable": "grok-4-1-fast",
"latest": "grok-4-1-fast-non-reasoning", # Explicit: bare grok-4-1-fast aliases to reasoning variant
"stable": "grok-4-1-fast-non-reasoning",
}
+1 -1
View File
@@ -1,7 +1,7 @@
"""Polymarket prediction market search via Gamma API (free, no auth required).
Uses gamma-api.polymarket.com for event/market discovery.
No API key needed - public read-only API with generous rate limits (350 req/10s).
No API key needed - public read-only API with generous rate limits (15K req/10s).
"""
import json
+9 -10
View File
@@ -143,7 +143,7 @@ Just start with "last30" and talk to me like normal.
# Shorter promo for single missing key
PROMO_SINGLE_KEY = {
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n",
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
"x": "\n💡 You can unlock X with AUTH_TOKEN/CT0 or XAI_API_KEY - just ask me how.\n",
}
# Bird auth help (for local users with vendored Bird CLI)
@@ -151,16 +151,16 @@ BIRD_AUTH_HELP = f"""
{Colors.YELLOW}Bird authentication failed.{Colors.RESET}
To fix this:
1. Log into X (twitter.com) in Safari, Chrome, or Firefox
2. Try again Bird reads your browser cookies automatically.
1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env or .claude/last30days.env
2. Or set XAI_API_KEY for the xAI fallback backend
"""
BIRD_AUTH_HELP_PLAIN = """
Bird authentication failed.
To fix this:
1. Log into X (twitter.com) in Safari, Chrome, or Firefox
2. Try again Bird reads your browser cookies automatically.
1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env or .claude/last30days.env
2. Or set XAI_API_KEY for the xAI fallback backend
"""
# Spinner frames
@@ -460,10 +460,9 @@ def show_diagnostic_banner(diag: dict):
label = f"Bird ({username})" if source == "bird" and username else source.upper()
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ X/Twitter{Colors.RESET}{label} {Colors.DIM}{Colors.RESET}")
else:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ X/Twitter{Colors.RESET} — No Bird CLI or XAI_API_KEY {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ X/Twitter{Colors.RESET} — No X auth or fallback key {Colors.DIM}{Colors.RESET}")
if diag.get("bird_installed"):
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Bird installed but not authenticated {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Log into x.com in your browser, then retry {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY {Colors.DIM}{Colors.RESET}")
else:
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Needs Node.js 22+ (Bird is bundled) {Colors.DIM}{Colors.RESET}")
@@ -507,9 +506,9 @@ def show_diagnostic_banner(diag: dict):
if has_x:
lines.append("│ ✅ X/Twitter — available │")
else:
lines.append("│ ❌ X/Twitter — No Bird CLI or XAI_API_KEY")
lines.append("│ ❌ X/Twitter — No X auth or fallback key")
if diag.get("bird_installed"):
lines.append("│ └─ Log into x.com in your browser, then retry")
lines.append("│ └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY ")
else:
lines.append("│ └─ Needs Node.js 22+ (Bird is bundled) │")
+19 -1
View File
@@ -14,6 +14,13 @@ function normalizeValue(value) {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function envFlagEnabled(name) {
const value = normalizeValue(process.env[name]);
if (!value) {
return false;
}
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}
function cookieHeader(authToken, ct0) {
return `auth_token=${authToken}; ct0=${ct0}`;
}
@@ -123,6 +130,8 @@ export async function extractCookiesFromFirefox(profile) {
export async function resolveCredentials(options) {
const warnings = [];
const cookies = buildEmpty();
const disableBrowserCookies = envFlagEnabled('BIRD_DISABLE_BROWSER_COOKIES') ||
envFlagEnabled('LAST30DAYS_DISABLE_BROWSER_COOKIES');
const cookieTimeoutMs = typeof options.cookieTimeoutMs === 'number' &&
Number.isFinite(options.cookieTimeoutMs) &&
options.cookieTimeoutMs > 0
@@ -146,6 +155,15 @@ export async function resolveCredentials(options) {
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
return { cookies, warnings };
}
if (disableBrowserCookies) {
if (!cookies.authToken) {
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup');
}
if (!cookies.ct0) {
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup');
}
return { cookies, warnings };
}
const sourcesToTry = resolveSources(options.cookieSource);
for (const source of sourcesToTry) {
const res = await readTwitterCookiesFromBrowser({
@@ -170,4 +188,4 @@ export async function resolveCredentials(options) {
}
return { cookies, warnings };
}
//# sourceMappingURL=cookies.js.map
//# sourceMappingURL=cookies.js.map
+2 -2
View File
@@ -91,11 +91,11 @@ def search_x(
# Adjust timeout based on depth (generous for API response time)
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
# Use Agent Tools API with x_search tool
# Use Agent Tools API with x_search tool (native date filtering)
payload = {
"model": model,
"tools": [
{"type": "x_search"}
{"type": "x_search", "from_date": from_date, "to_date": to_date}
],
"input": [
{
+4
View File
@@ -176,6 +176,8 @@ def search_youtube(
# filtering returns 0 for evergreen topics like "thumbnail tips".
cmd = [
"yt-dlp",
"--ignore-config",
"--no-cookies-from-browser",
f"ytsearch{count}:{core_topic}",
"--dump-json",
"--no-warnings",
@@ -295,6 +297,8 @@ def fetch_transcript(video_id: str, temp_dir: str) -> Optional[str]:
"""
cmd = [
"yt-dlp",
"--ignore-config",
"--no-cookies-from-browser",
"--write-auto-subs",
"--sub-lang", "en",
"--sub-format", "vtt",
+45
View File
@@ -11,6 +11,9 @@ from lib import bird_x
class TestExtractCoreSubject(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
def test_strips_trending_noise(self):
result = bird_x._extract_core_subject("trendiest Claude Code skills")
self.assertNotIn("trendiest", result)
@@ -28,6 +31,9 @@ class TestExtractCoreSubject(unittest.TestCase):
class TestBirdSearchRetries(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
def test_last_chance_retry_uses_strongest_token(self):
"""When shorter retry also returns 0, uses longest non-noise token."""
empty = {"items": []}
@@ -53,5 +59,44 @@ class TestBirdSearchRetries(unittest.TestCase):
self.assertEqual(run_mock.call_count, 1)
class TestBirdAuthEnvironment(unittest.TestCase):
def tearDown(self):
bird_x._credentials.clear()
def test_subprocess_env_disables_browser_cookie_fallback_when_injected(self):
bird_x.set_credentials("auth-token", "ct0-token")
env = bird_x._subprocess_env()
self.assertEqual(env["AUTH_TOKEN"], "auth-token")
self.assertEqual(env["CT0"], "ct0-token")
self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
def test_is_bird_authenticated_short_circuits_when_credentials_injected(self):
bird_x.set_credentials("auth-token", "ct0-token")
with mock.patch.object(bird_x, "is_bird_installed", return_value=True), \
mock.patch.object(bird_x.subprocess, "run") as run_mock:
result = bird_x.is_bird_authenticated()
self.assertEqual(result, "env AUTH_TOKEN")
run_mock.assert_not_called()
def test_search_handles_passes_injected_credentials_to_subprocess(self):
bird_x.set_credentials("auth-token", "ct0-token")
proc = mock.Mock()
proc.communicate.return_value = ("[]", "")
proc.returncode = 0
with mock.patch.object(bird_x.subprocess, "Popen", return_value=proc) as popen_mock:
bird_x.search_handles(["openai"], "codex vs claude code", "2026-01-01", count_per=1)
env = popen_mock.call_args.kwargs["env"]
self.assertEqual(env["AUTH_TOKEN"], "auth-token")
self.assertEqual(env["CT0"], "ct0-token")
self.assertEqual(env["BIRD_DISABLE_BROWSER_COOKIES"], "1")
if __name__ == "__main__":
unittest.main()
+2 -1
View File
@@ -140,7 +140,8 @@ class TestGetAvailableSourcesWithAuth(unittest.TestCase):
"XAI_API_KEY": None,
}
result = env.get_available_sources(config)
self.assertEqual(result, "web")
# Reddit is available via public JSON fallback even without OpenAI auth
self.assertEqual(result, "reddit")
class TestParseCodexStream(unittest.TestCase):
+25
View File
@@ -173,5 +173,30 @@ class TestFilePermissions(unittest.TestCase):
self.assertEqual(stderr.getvalue(), "")
class TestXSourceSelection(unittest.TestCase):
"""Tests for supported X backend selection."""
def test_get_x_source_ignores_scrapecreators_key(self):
config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
with patch('lib.bird_x.is_bird_installed', return_value=False):
self.assertIsNone(env.get_x_source(config))
def test_get_x_source_status_ignores_scrapecreators_key(self):
config = {'SCRAPECREATORS_API_KEY': 'sc-key'}
bird_status = {
'installed': True,
'authenticated': False,
'username': None,
'can_install': False,
}
with patch('lib.bird_x.get_bird_status', return_value=bird_status):
status = env.get_x_source_status(config)
self.assertIsNone(status['source'])
self.assertFalse(status['xai_available'])
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -84,7 +84,7 @@ class TestSelectXAIModel(unittest.TestCase):
"fake-key",
policy="latest"
)
self.assertEqual(result, "grok-4-latest")
self.assertEqual(result, "grok-4-1-fast-non-reasoning")
def test_stable_policy(self):
# Clear cache first to avoid interference
@@ -94,7 +94,7 @@ class TestSelectXAIModel(unittest.TestCase):
"fake-key",
policy="stable"
)
self.assertEqual(result, "grok-4")
self.assertEqual(result, "grok-4-1-fast-non-reasoning")
def test_pinned_policy(self):
result = models.select_xai_model(
@@ -125,10 +125,10 @@ class TestGetModels(unittest.TestCase):
"XAI_API_KEY": "xai-test",
}
mock_openai = [{"id": "gpt-5.2", "created": 1704067200}]
mock_xai = [{"id": "grok-4-latest", "created": 1704067200}]
mock_xai = [{"id": "grok-4-1-fast-non-reasoning", "created": 1704067200}]
result = models.get_models(config, mock_openai, mock_xai)
self.assertEqual(result["openai"], "gpt-5.2")
self.assertEqual(result["xai"], "grok-4-latest")
self.assertEqual(result["xai"], "grok-4-1-fast-non-reasoning")
if __name__ == "__main__":
+3 -3
View File
@@ -68,9 +68,9 @@ class TestModelFallbackOrder(unittest.TestCase):
"""Fallback list should include gpt-4o."""
self.assertIn("gpt-4o", MODEL_FALLBACK_ORDER)
def test_gpt4o_is_first(self):
"""gpt-4o should be the first fallback option."""
self.assertEqual(MODEL_FALLBACK_ORDER[0], "gpt-4o")
def test_gpt41_is_first(self):
"""gpt-4.1 should be the first fallback option."""
self.assertEqual(MODEL_FALLBACK_ORDER[0], "gpt-4.1")
if __name__ == "__main__":
+75 -67
View File
@@ -1,129 +1,133 @@
"""Tests for Truth Social source module."""
import pytest
import sys
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
from scripts.lib import truthsocial
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import truthsocial
class TestStripHtml:
class TestStripHtml(unittest.TestCase):
"""Test HTML tag stripping."""
def test_basic_paragraph(self):
assert truthsocial._strip_html("<p>Hello world</p>") == "Hello world"
self.assertEqual(truthsocial._strip_html("<p>Hello world</p>"), "Hello world")
def test_br_tags(self):
assert truthsocial._strip_html("Line 1<br>Line 2") == "Line 1\nLine 2"
assert truthsocial._strip_html("Line 1<br/>Line 2") == "Line 1\nLine 2"
assert truthsocial._strip_html("Line 1<br />Line 2") == "Line 1\nLine 2"
self.assertEqual(truthsocial._strip_html("Line 1<br>Line 2"), "Line 1\nLine 2")
self.assertEqual(truthsocial._strip_html("Line 1<br/>Line 2"), "Line 1\nLine 2")
self.assertEqual(truthsocial._strip_html("Line 1<br />Line 2"), "Line 1\nLine 2")
def test_nested_tags(self):
assert truthsocial._strip_html("<p>Hello <a href='#'>world</a></p>") == "Hello world"
self.assertEqual(truthsocial._strip_html("<p>Hello <a href='#'>world</a></p>"), "Hello world")
def test_empty_string(self):
assert truthsocial._strip_html("") == ""
self.assertEqual(truthsocial._strip_html(""), "")
def test_no_tags(self):
assert truthsocial._strip_html("plain text") == "plain text"
self.assertEqual(truthsocial._strip_html("plain text"), "plain text")
def test_entities_preserved(self):
assert truthsocial._strip_html("<p>&amp; test</p>") == "&amp; test"
self.assertEqual(truthsocial._strip_html("<p>&amp; test</p>"), "&amp; test")
class TestExtractCoreSubject:
class TestExtractCoreSubject(unittest.TestCase):
"""Test query preprocessing."""
def test_strips_question_prefix(self):
assert truthsocial._extract_core_subject("what are people saying about tariffs") == "tariffs"
self.assertEqual(truthsocial._extract_core_subject("what are people saying about tariffs"), "tariffs")
def test_strips_noise_words(self):
assert truthsocial._extract_core_subject("latest trending crypto news") == "crypto"
self.assertEqual(truthsocial._extract_core_subject("latest trending crypto news"), "crypto")
def test_preserves_core_topic(self):
assert truthsocial._extract_core_subject("tariffs") == "tariffs"
self.assertEqual(truthsocial._extract_core_subject("tariffs"), "tariffs")
def test_strips_trailing_punctuation(self):
assert truthsocial._extract_core_subject("what is bitcoin?") == "bitcoin"
self.assertEqual(truthsocial._extract_core_subject("what is bitcoin?"), "bitcoin")
class TestParseDate:
class TestParseDate(unittest.TestCase):
"""Test date parsing from Mastodon status."""
def test_iso_date(self):
assert truthsocial._parse_date({"created_at": "2026-03-09T12:00:00.000Z"}) == "2026-03-09"
self.assertEqual(truthsocial._parse_date({"created_at": "2026-03-09T12:00:00.000Z"}), "2026-03-09")
def test_missing_date(self):
assert truthsocial._parse_date({}) is None
self.assertIsNone(truthsocial._parse_date({}))
def test_short_date(self):
assert truthsocial._parse_date({"created_at": "short"}) is None
self.assertIsNone(truthsocial._parse_date({"created_at": "short"}))
def test_none_value(self):
assert truthsocial._parse_date({"created_at": None}) is None
self.assertIsNone(truthsocial._parse_date({"created_at": None}))
class TestDepthConfig:
class TestDepthConfig(unittest.TestCase):
"""Test depth configuration."""
def test_all_depths_exist(self):
assert "quick" in truthsocial.DEPTH_CONFIG
assert "default" in truthsocial.DEPTH_CONFIG
assert "deep" in truthsocial.DEPTH_CONFIG
self.assertIn("quick", truthsocial.DEPTH_CONFIG)
self.assertIn("default", truthsocial.DEPTH_CONFIG)
self.assertIn("deep", truthsocial.DEPTH_CONFIG)
def test_depth_ordering(self):
assert truthsocial.DEPTH_CONFIG["quick"] < truthsocial.DEPTH_CONFIG["default"]
assert truthsocial.DEPTH_CONFIG["default"] < truthsocial.DEPTH_CONFIG["deep"]
self.assertLess(truthsocial.DEPTH_CONFIG["quick"], truthsocial.DEPTH_CONFIG["default"])
self.assertLess(truthsocial.DEPTH_CONFIG["default"], truthsocial.DEPTH_CONFIG["deep"])
class TestSearchTruthSocial:
class TestSearchTruthSocial(unittest.TestCase):
"""Test search function auth handling."""
def test_no_config_returns_error(self):
result = truthsocial.search_truthsocial("test", "2026-02-09", "2026-03-09")
assert result["statuses"] == []
assert "not configured" in result["error"]
self.assertEqual(result["statuses"], [])
self.assertIn("not configured", result["error"])
def test_empty_token_returns_error(self):
result = truthsocial.search_truthsocial(
"test", "2026-02-09", "2026-03-09",
config={"TRUTHSOCIAL_TOKEN": ""},
)
assert result["statuses"] == []
assert "not configured" in result["error"]
self.assertEqual(result["statuses"], [])
self.assertIn("not configured", result["error"])
@patch("scripts.lib.truthsocial.http.request")
@patch("lib.truthsocial.http.request")
def test_401_returns_token_expired(self, mock_request):
from scripts.lib.http import HTTPError
from lib.http import HTTPError
mock_request.side_effect = HTTPError("Unauthorized", status_code=401)
result = truthsocial.search_truthsocial(
"test", "2026-02-09", "2026-03-09",
config={"TRUTHSOCIAL_TOKEN": "expired_token"},
)
assert result["statuses"] == []
assert "expired" in result["error"]
self.assertEqual(result["statuses"], [])
self.assertIn("expired", result["error"])
@patch("scripts.lib.truthsocial.http.request")
@patch("lib.truthsocial.http.request")
def test_403_returns_access_denied(self, mock_request):
from scripts.lib.http import HTTPError
from lib.http import HTTPError
mock_request.side_effect = HTTPError("Forbidden", status_code=403)
result = truthsocial.search_truthsocial(
"test", "2026-02-09", "2026-03-09",
config={"TRUTHSOCIAL_TOKEN": "blocked_token"},
)
assert result["statuses"] == []
assert "Cloudflare" in result["error"]
self.assertEqual(result["statuses"], [])
self.assertIn("Cloudflare", result["error"])
@patch("scripts.lib.truthsocial.http.request")
@patch("lib.truthsocial.http.request")
def test_429_returns_rate_limited(self, mock_request):
from scripts.lib.http import HTTPError
from lib.http import HTTPError
mock_request.side_effect = HTTPError("Too Many Requests", status_code=429)
result = truthsocial.search_truthsocial(
"test", "2026-02-09", "2026-03-09",
config={"TRUTHSOCIAL_TOKEN": "rate_limited_token"},
)
assert result["statuses"] == []
assert "rate limited" in result["error"]
self.assertEqual(result["statuses"], [])
self.assertIn("rate limited", result["error"])
@patch("scripts.lib.truthsocial.http.request")
@patch("lib.truthsocial.http.request")
def test_successful_search(self, mock_request):
mock_request.return_value = {
"statuses": [
@@ -142,13 +146,13 @@ class TestSearchTruthSocial:
"tariffs", "2026-02-09", "2026-03-09",
config={"TRUTHSOCIAL_TOKEN": "valid_token"},
)
assert len(result["statuses"]) == 1
self.assertEqual(len(result["statuses"]), 1)
# Verify bearer token was passed
call_args = mock_request.call_args
assert call_args[1]["headers"]["Authorization"] == "Bearer valid_token"
self.assertEqual(call_args[1]["headers"]["Authorization"], "Bearer valid_token")
class TestParseTruthSocialResponse:
class TestParseTruthSocialResponse(unittest.TestCase):
"""Test response parsing."""
def test_basic_post(self):
@@ -166,21 +170,21 @@ class TestParseTruthSocialResponse:
]
}
items = truthsocial.parse_truthsocial_response(response)
assert len(items) == 1
self.assertEqual(len(items), 1)
item = items[0]
assert item["handle"] == "testuser"
assert item["display_name"] == "Test User"
assert item["text"] == "Hello from Truth Social" # HTML stripped
assert item["url"] == "https://truthsocial.com/@testuser/456"
assert item["date"] == "2026-03-09"
assert item["engagement"]["likes"] == 100
assert item["engagement"]["reposts"] == 50
assert item["engagement"]["replies"] == 25
assert item["relevance"] > 0
self.assertEqual(item["handle"], "testuser")
self.assertEqual(item["display_name"], "Test User")
self.assertEqual(item["text"], "Hello from Truth Social")
self.assertEqual(item["url"], "https://truthsocial.com/@testuser/456")
self.assertEqual(item["date"], "2026-03-09")
self.assertEqual(item["engagement"]["likes"], 100)
self.assertEqual(item["engagement"]["reposts"], 50)
self.assertEqual(item["engagement"]["replies"], 25)
self.assertGreater(item["relevance"], 0)
def test_empty_response(self):
items = truthsocial.parse_truthsocial_response({"statuses": []})
assert items == []
self.assertEqual(items, [])
def test_missing_fields(self):
response = {
@@ -192,10 +196,10 @@ class TestParseTruthSocialResponse:
]
}
items = truthsocial.parse_truthsocial_response(response)
assert len(items) == 1
assert items[0]["handle"] == ""
assert items[0]["text"] == ""
assert items[0]["engagement"]["likes"] == 0
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["handle"], "")
self.assertEqual(items[0]["text"], "")
self.assertEqual(items[0]["engagement"]["likes"], 0)
def test_relevance_ordering(self):
response = {
@@ -206,8 +210,8 @@ class TestParseTruthSocialResponse:
]
}
items = truthsocial.parse_truthsocial_response(response)
assert items[0]["relevance"] >= items[1]["relevance"]
assert items[1]["relevance"] >= items[2]["relevance"]
self.assertGreaterEqual(items[0]["relevance"], items[1]["relevance"])
self.assertGreaterEqual(items[1]["relevance"], items[2]["relevance"])
def test_html_stripping_in_parse(self):
response = {
@@ -222,5 +226,9 @@ class TestParseTruthSocialResponse:
]
}
items = truthsocial.parse_truthsocial_response(response)
assert "<" not in items[0]["text"]
assert ">" not in items[0]["text"]
self.assertNotIn("<", items[0]["text"])
self.assertNotIn(">", items[0]["text"])
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
"""Tests for yt-dlp invocation safety flags."""
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from lib import youtube_yt
class _DummyProc:
def __init__(self):
self.pid = 12345
self.returncode = 0
def communicate(self, timeout=None):
return "", ""
def wait(self, timeout=None):
return 0
class TestYtDlpFlags(unittest.TestCase):
def test_search_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True), \
mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock:
youtube_yt.search_youtube("Claude Code", "2026-02-01", "2026-03-01")
cmd = popen_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd)
def test_transcript_fetch_ignores_global_config_and_browser_cookies(self):
proc = _DummyProc()
with tempfile.TemporaryDirectory() as temp_dir, \
mock.patch.object(youtube_yt.subprocess, "Popen", return_value=proc) as popen_mock:
youtube_yt.fetch_transcript("abc123", temp_dir)
cmd = popen_mock.call_args.args[0]
self.assertIn("--ignore-config", cmd)
self.assertIn("--no-cookies-from-browser", cmd)
if __name__ == "__main__":
unittest.main()