Merge PR #48: feat: add Xiaohongshu source + Reddit public fallback

- Xiaohongshu search via local MCP service (opt-in, zero impact if service not running)
- Reddit public JSON fallback (works with zero API keys)
- Reddit priority: ScrapeCreators -> OpenAI -> public fallback
- Updated env.py: Reddit always available via public fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-07 16:11:35 -08:00
254 changed files with 18608 additions and 87 deletions
+21
View File
@@ -0,0 +1,21 @@
# last30days Skill
Claude Code skill for researching any topic across Reddit, X, YouTube, and web.
Python scripts with multi-source search aggregation.
## Structure
- `scripts/last30days.py` — main research engine
- `scripts/lib/` — search, enrichment, rendering modules
- `scripts/lib/vendor/bird-search/` — vendored X search client
- `SKILL.md` — skill definition (deployed to ~/.claude/skills/last30days/)
## Commands
```bash
python3 scripts/last30days.py "test query" --emit=compact # Run research
bash scripts/sync.sh # Deploy to ~/.claude, ~/.agents, ~/.codex
```
## Rules
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
- After edits: run `bash scripts/sync.sh` to deploy
- Git remotes: origin=private, upstream=public
+53 -5
View File
@@ -1,6 +1,6 @@
--- ---
name: last30days name: last30days
version: "2.9.2" version: "2.9.1"
description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, web. Become an expert and write copy-paste-ready prompts." description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, web. Become an expert and write copy-paste-ready prompts."
argument-hint: 'last30 AI video tools, last30 best project management tools' argument-hint: 'last30 AI video tools, last30 best project management tools'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
@@ -30,7 +30,7 @@ metadata:
- prompts - prompts
--- ---
# last30days v2.9.4: Research Any Topic from the Last 30 Days # last30days v2.9.1: Research Any Topic from the Last 30 Days
Research ANY topic across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, and the web. Surface what people are actually discussing, recommending, betting on, and debating right now. Research ANY topic across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, and the web. Surface what people are actually discussing, recommending, betting on, and debating right now.
@@ -123,7 +123,7 @@ If `--agent` appears in ARGUMENTS (e.g., `/last30days plaud granola --agent`):
5. **Skip** the follow-up invitation ("I'm now an expert on X...") 5. **Skip** the follow-up invitation ("I'm now an expert on X...")
6. **Output** the complete research report and stop - do not wait for further input 6. **Output** the complete research report and stop - do not wait for further input
Agent mode saves raw research data to `~/Documents/Last30Days/` automatically via `--save-dir` (handled by the script, no extra tool calls). Agent mode still saves the research briefing to `~/Documents/Last30Days/` using the same logic as interactive mode (see "Save Research to Documents" section).
Agent mode report format: Agent mode report format:
@@ -167,7 +167,7 @@ if [ -z "${SKILL_ROOT:-}" ]; then
exit 1 exit 1
fi fi
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web --save-dir=~/Documents/Last30Days # Add --x-handle=HANDLE if RESOLVED_HANDLE is set python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact --no-native-web # Add --x-handle=HANDLE if RESOLVED_HANDLE is set
``` ```
Use a **timeout of 300000** (5 minutes) on the Bash call. The script typically takes 1-3 minutes. Use a **timeout of 300000** (5 minutes) on the Bash call. The script typically takes 1-3 minutes.
@@ -498,9 +498,57 @@ For `/last30days war in Iran` (NEWS):
--- ---
## Save Research to Documents
After displaying the invitation, save the complete research briefing to `~/Documents/Last30Days/`. This happens automatically on every run.
**Generate the filename** from TOPIC:
- Lowercase, replace spaces/special chars with hyphens, remove consecutive hyphens, trim to 60 chars
- Example: "Claude Code Best Practices" → `claude-code-best-practices.md`
- If file already exists, append today's date: `{slug}-YYYY-MM-DD.md`
**End your invitation with a single `📎` footer line:**
```
📎 ~/Documents/Last30Days/{slug}.md
```
**Then immediately save using a background Bash command** (`run_in_background: true`):
```bash
mkdir -p ~/Documents/Last30Days && cat > ~/Documents/Last30Days/{slug}.md << 'RESEARCH_EOF'
# {TOPIC}
> Researched {date} | Query type: {QUERY_TYPE} | Target tool: {TARGET_TOOL or "general"}
## What I learned
{The full synthesis section you just displayed - all topics, patterns, and citations}
## Stats
{The full stats box with source counts and engagement - copy exactly as displayed}
## Follow-up suggestions
{The 2-3 specific suggestions from the invitation block}
---
*Generated by [last30days](https://github.com/mvanhorn/last30days-skill) v2.9.1*
RESEARCH_EOF
```
**CRITICAL RULES:**
1. NEVER use the `Write` tool — it displays "Wrote N lines..." which ruins the experience
2. ALWAYS use `run_in_background: true` so the Bash call is nearly invisible
3. The `📎` line is part of your text message, not a separate tool call
4. The invitation + `📎` line must be the LAST visible thing on screen
---
## WAIT FOR USER'S RESPONSE ## WAIT FOR USER'S RESPONSE
**STOP and wait** for the user to respond. Do NOT call any tools after displaying the invitation. The research script already saved raw data to `~/Documents/Last30Days/` via `--save-dir`. **STOP and wait** for the user to respond.
--- ---
+200
View File
@@ -0,0 +1,200 @@
# How Reddit & X Search Work in last30days
## Architecture Overview
```
User: /last30days "kanye west"
┌─────┴─────┐
↓ ↓ (concurrent via ThreadPoolExecutor)
[REDDIT] [X/TWITTER]
↓ ↓
OpenAI Bird CLI or
API xAI API
↓ ↓
Parse Parse
↓ ↓
Enrich ───┘
(fetch ↓
actual [MERGE]
upvotes) ↓
↓ [NORMALIZE → FILTER → SCORE → DEDUPE]
└───────────↓
[OUTPUT to SKILL.md agent]
```
Both searches run **in parallel** using Python's `ThreadPoolExecutor(max_workers=2)`.
---
## Reddit Search
### How it works
Reddit search uses the **OpenAI Responses API** with the `web_search` tool, domain-filtered to `reddit.com` only.
**API Call:**
```
POST https://api.openai.com/v1/responses
Authorization: Bearer {OPENAI_API_KEY}
```
**Payload:**
```json
{
"model": "gpt-5.2",
"tools": [{
"type": "web_search",
"filters": { "allowed_domains": ["reddit.com"] }
}],
"input": "Search Reddit for threads about {topic}..."
}
```
The prompt asks the model to:
1. Extract core subject (strip noise words like "best", "tips", "top")
2. Search 3 patterns: `"{topic} site:reddit.com"`, `"reddit {topic}"`, `"{topic} reddit"`
3. Return JSON with `title`, `url`, `subreddit`, `date`, `relevance`
4. URLs must contain `/r/` AND `/comments/` (real threads only)
**Model fallback chain:** `gpt-5.2 → gpt-5.1 → gpt-5 → gpt-4.1 → gpt-4o → gpt-4o-mini`
Triggers on HTTP 400/403 with access error keywords.
### Enrichment (the secret sauce)
After search, each thread gets **enriched** by hitting Reddit's free JSON API:
```
GET https://reddit.com/r/{sub}/comments/{id}/{slug}/.json
```
No API key needed. This returns the actual thread data:
| Data Point | Source |
|---|---|
| Upvotes (score) | Reddit JSON API |
| Comment count | Reddit JSON API |
| Upvote ratio | Reddit JSON API |
| Top 10 comments (text + score) | Reddit JSON API |
| 7 key comment insights | Extracted via heuristics |
| Actual post date | `created_utc` timestamp |
**This is why Reddit results have real engagement metrics** — the enrichment step fetches actual upvote/comment data, not AI estimates.
### Depth settings
| Depth | Threads requested | Timeout |
|---|---|---|
| `--quick` | 15-25 | 90s |
| default | 30-50 | 120s |
| `--deep` | 70-100 | 180s |
---
## X/Twitter Search
X search has **two backends** — the skill auto-detects which to use.
### Priority: Bird CLI (free) → xAI API (paid)
```python
if bird_installed and bird_authenticated:
use Bird CLI # Free, uses your X login
elif XAI_API_KEY:
use xAI API # Paid, uses grok-4-1-fast
else:
skip X entirely # No X results
```
### Backend 1: xAI API
**API Call:**
```
POST https://api.x.ai/v1/responses
Authorization: Bearer {XAI_API_KEY}
```
**Payload:**
```json
{
"model": "grok-4-1-fast",
"tools": [{ "type": "x_search" }],
"input": "Search X for posts about {topic} from {from_date} to {to_date}..."
}
```
The prompt asks grok to return JSON with:
- `text`, `url`, `author_handle`, `date`
- `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.
### Backend 2: Bird CLI (free alternative)
Bird is a CLI tool (`npm install -g @steipete/bird`) that uses your X login.
**Command:**
```bash
bird search "{topic} since:{from_date}" -n 30 --json
```
**Bird returns raw X API data** — likes, reposts, replies are real engagement metrics from X's API, not estimates.
| Metric | Bird CLI | xAI API |
|---|---|---|
| Post text | Real | Real |
| Likes/reposts | Real (X API) | Real (x_search tool) |
| Replies/quotes | Real | Real |
| Author handle | Real | Real |
| Relevance score | Default 0.7 (re-ranked by score.py) | AI-assessed 0.0-1.0 |
### Depth settings
| Depth | xAI posts | Bird results | xAI timeout | Bird timeout |
|---|---|---|---|---|
| `--quick` | 8-12 | 12 | 90s | 30s |
| default | 20-30 | 30 | 120s | 45s |
| `--deep` | 40-60 | 60 | 180s | 60s |
---
## Post-Processing (both sources)
After both searches complete:
1. **Normalize** — consistent formatting, timezone handling
2. **Date filter** — hard filter to requested date range
3. **Score** — relevance scoring (engagement-weighted)
4. **Sort** — highest scores first
5. **Deduplicate** — remove duplicate URLs
6. **Fallback** — if all items filtered out, keep top 3 by relevance
---
## Error Handling
| Layer | Strategy |
|---|---|
| HTTP requests | 3 retries with exponential backoff (1s → 2s → 3s) |
| Model access errors | Automatic fallback to next model in chain |
| Reddit enrichment | Per-item try/catch; keeps unenriched item on failure |
| X source detection | Silent fallback from Bird → xAI → skip |
| Overall pipeline | Errors stored as `reddit_error`/`x_error`, shown to user |
---
## Key Files
| File | Purpose |
|---|---|
| `scripts/last30days.py` | Main orchestrator, concurrent execution |
| `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/models.py` | Auto-select best available model |
| `scripts/lib/env.py` | API key loading, source detection |
| `scripts/lib/http.py` | HTTP transport with retries |
| `scripts/lib/score.py` | Relevance scoring |
| `scripts/lib/dedupe.py` | URL-based deduplication |
@@ -0,0 +1,281 @@
---
title: "feat: Automated v1 vs v2 test harness using claude --print"
type: feat
date: 2026-02-06
---
# feat: Automated V1 vs V2 Test Harness
## Overview
Build a bash script that swaps SKILL.md between v1 (upstream) and v2 (current), runs `claude --print "/last30days [query]"` for all 17 test queries on each version, captures output to files, then generates a comparison doc with analysis.
## How It Works
```
┌─────────────────────────────────────────────────┐
│ test-v1-vs-v2.sh │
│ │
│ 1. Save current SKILL.md as .v2 backup │
│ 2. Install v1 SKILL.md from upstream │
│ 3. Loop 17 queries → claude --print → v1/*.txt │
│ 4. Restore v2 SKILL.md │
│ 5. Loop 17 queries → claude --print → v2/*.txt │
│ 6. Generate comparison doc │
└─────────────────────────────────────────────────┘
```
Each `claude --print` call:
- Invokes the /last30days skill exactly as a user would
- Runs the Python script (real API calls to OpenAI + xAI)
- Runs WebSearch
- Applies SKILL.md presentation instructions
- Returns the full formatted output
- Exits (no interactive session)
## Implementation
### File: `scripts/test-v1-vs-v2.sh`
```bash
#!/bin/bash
set -euo pipefail
# === Config ===
SKILL_DIR="$HOME/.claude/skills/last30days"
REPO_DIR="/Users/mvanhorn/last30days-skill-private"
OUT_DIR="$REPO_DIR/docs/test-results/v1-vs-v2-$(date +%Y%m%d-%H%M%S)"
V1_DIR="$OUT_DIR/v1"
V2_DIR="$OUT_DIR/v2"
mkdir -p "$V1_DIR" "$V2_DIR"
# All 17 test queries (from README + plans)
declare -a QUERIES=(
"prompting techniques for chatgpt for legal questions"
"best clawdbot use cases"
"how to best setup clawdbot"
"prompting tips for nano banana pro for ios designs"
"top claude code skills"
"using ChatGPT to make images of dogs"
"research best practices for beautiful remotion animation videos in claude code"
"photorealistic people in nano banana pro"
"What are the best rap songs lately"
"what are people saying about DeepSeek R1"
"best practices for cursor rules files for Cursor"
"prompt advice for using suno to make killer songs in simple mode"
"how do I use Codex with Claude Code on same app to make it better"
"kanye west"
"howie.ai"
"open claw"
"nano banana pro prompting"
)
declare -a TYPES=(
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"HOW-TO"
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"GENERAL"
"PROMPTING"
"PROMPTING"
"RECOMMENDATIONS"
"NEWS"
"PROMPTING"
"PROMPTING"
"HOW-TO"
"NEWS"
"GENERAL"
"GENERAL"
"PROMPTING"
)
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | head -c 60
}
run_version() {
local version="$1"
local outdir="$2"
echo ""
echo "=========================================="
echo " Running $version${#QUERIES[@]} queries"
echo "=========================================="
for i in "${!QUERIES[@]}"; do
local query="${QUERIES[$i]}"
local type="${TYPES[$i]}"
local slug=$(slugify "$query")
local num=$((i + 1))
local outfile="$outdir/${num}-${slug}.txt"
echo ""
echo "[$version] ($num/${#QUERIES[@]}) $query [$type]"
echo "$outfile"
# Run claude --print with the skill invocation
# --no-session-persistence: don't save to session history
# Timeout after 5 minutes per query (generous for slow API calls)
if timeout 300 claude --print \
"/last30days $query" \
> "$outfile" 2>"$outdir/${num}-${slug}.stderr.txt"; then
echo " ✅ Done ($(wc -l < "$outfile") lines)"
else
echo " ❌ Failed or timed out"
echo "FAILED: timeout or error" >> "$outfile"
fi
# Brief pause between queries to avoid rate limits
sleep 2
done
}
# === Phase 1: Test V1 ===
echo "📦 Backing up current SKILL.md..."
cp "$SKILL_DIR/SKILL.md" "$SKILL_DIR/SKILL.md.v2.bak"
echo "📥 Installing V1 SKILL.md from upstream..."
cd "$REPO_DIR"
git show upstream/main:SKILL.md > "$SKILL_DIR/SKILL.md"
# Also save a copy for reference
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v1-SKILL.md"
run_version "V1" "$V1_DIR"
# === Phase 2: Test V2 ===
echo ""
echo "📥 Restoring V2 SKILL.md..."
cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
# Also save a copy for reference
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v2-SKILL.md"
run_version "V2" "$V2_DIR"
# === Phase 3: Generate summary ===
echo ""
echo "=========================================="
echo " Generating comparison summary"
echo "=========================================="
SUMMARY="$OUT_DIR/comparison-summary.md"
cat > "$SUMMARY" << 'HEADER'
# V1 vs V2 Comparison Results
Generated: $(date)
## Output Files
| # | Query | Type | V1 Lines | V2 Lines |
|---|-------|------|----------|----------|
HEADER
# Replace the date placeholder
sed -i '' "s/\$(date)/$(date)/" "$SUMMARY"
for i in "${!QUERIES[@]}"; do
local query="${QUERIES[$i]}"
local type="${TYPES[$i]}"
local slug=$(slugify "$query")
local num=$((i + 1))
local v1file="$V1_DIR/${num}-${slug}.txt"
local v2file="$V2_DIR/${num}-${slug}.txt"
local v1lines=$(wc -l < "$v1file" 2>/dev/null || echo "0")
local v2lines=$(wc -l < "$v2file" 2>/dev/null || echo "0")
echo "| $num | \`$query\` | $type | $v1lines | $v2lines |" >> "$SUMMARY"
done
cat >> "$SUMMARY" << 'FOOTER'
## Scorecard Template
For each query, score both versions on:
| Dimension | V1 | V2 | Notes |
|-----------|----|----|-------|
| Query Parsing Display (1-5) | | | |
| Source Coverage (1-5) | | | |
| Citation Quality (1-5) | | | |
| Summary Structure (1-5) | | | |
| Stats Box Format (1-5) | | | |
| Research Grounding (1-5) | | | |
## Next Step
Read each pair of output files and score them using the test plan at:
`docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md`
FOOTER
echo ""
echo "✅ All done!"
echo "📁 Results: $OUT_DIR"
echo "📊 Summary: $SUMMARY"
echo ""
echo "V1 outputs: $V1_DIR/"
echo "V2 outputs: $V2_DIR/"
echo ""
echo "To review, run:"
echo " open $OUT_DIR"
# Cleanup backup
rm -f "$SKILL_DIR/SKILL.md.v2.bak"
```
## Acceptance Criteria
- [ ] Script runs all 17 queries on v1 SKILL.md
- [ ] Script runs all 17 queries on v2 SKILL.md
- [ ] Each query output saved to a separate .txt file
- [ ] Comparison summary generated with line counts
- [ ] SKILL.md restored to v2 after testing
- [ ] Both SKILL.md versions saved in output dir for reference
- [ ] Script handles timeouts gracefully (5 min per query)
- [ ] Brief pause between queries to avoid rate limits
## Cost Estimate
- 34 total `claude --print` invocations
- Each invocation: ~1 Python script run (OpenAI + xAI API) + 2-3 WebSearches + Claude response
- Estimated: ~$0.10-0.30 per invocation for API calls
- **Total estimate: $3-10 for the full run**
## Time Estimate
- Each query: ~1-3 minutes (script + WebSearch + synthesis)
- 17 queries × 2 versions = 34 runs
- **Total: ~45-90 minutes** (could run in background)
## How to Run
```bash
cd /Users/mvanhorn/last30days-skill-private
chmod +x scripts/test-v1-vs-v2.sh
./scripts/test-v1-vs-v2.sh
```
Or run in background:
```bash
nohup ./scripts/test-v1-vs-v2.sh > test-run.log 2>&1 &
tail -f test-run.log
```
## After the Run
Once all outputs are captured, Claude can read every file pair and generate the scored comparison doc with analysis — that's the part where I score each dimension 1-5 and write the final report.
## Files
| File | Purpose |
|------|---------|
| `scripts/test-v1-vs-v2.sh` | The test harness script |
| `docs/test-results/v1-vs-v2-*/` | Output directory (timestamped) |
| `docs/test-results/v1-vs-v2-*/v1/*.txt` | V1 outputs |
| `docs/test-results/v1-vs-v2-*/v2/*.txt` | V2 outputs |
| `docs/test-results/v1-vs-v2-*/comparison-summary.md` | Auto-generated summary |
@@ -0,0 +1,352 @@
---
title: "feat: Free Reddit via MCP — Remove OpenAI Key Requirement"
type: feat
date: 2026-02-06
---
# feat: Free Reddit via MCP — Remove OpenAI Key Requirement
## Overview
Replace the OpenAI Responses API (paid) for Reddit searching with a **free, zero-config Reddit MCP server**, eliminating the need for an `OPENAI_API_KEY` to get Reddit results in the last30days skill. This work happens in a **new forked repo** to keep the current release branch clean.
## Problem Statement
Today, the last30days skill requires an OpenAI API key (`OPENAI_API_KEY`) to search Reddit. This is the most expensive dependency in the stack — OpenAI charges per-call for the Responses API with web search. Users without an OpenAI key get zero Reddit results and fall back to WebSearch-only mode, which loses the engagement metrics (upvotes, comments) that make last30days uniquely valuable.
**Goal:** Make Reddit search completely free with no API key registration required.
## Research Findings
### Option Analysis
Five approaches were evaluated. Two stand out:
| Option | Free? | Search? | Engagement? | Setup | Notes |
|--------|-------|---------|-------------|-------|-------|
| **reddit-mcp-buddy** (Node.js) | Yes (anonymous mode) | Yes | Yes | One CLI command | 371 stars, 3-tier auth, most popular |
| **mcp-server-reddit** (Python) | Yes (redditwarp) | **No** | Yes | pip install | Recommended by ClaudeLog, but browse-only |
| **.json URL trick** (curl) | Yes | Yes | Yes | Zero | ~10 req/min, no dependencies |
| Reddit OAuth (PRAW) | Free but needs registration | Yes | Yes | App registration | 100 req/min, most reliable |
| RSS feeds | Yes | Basic | **No** | Zero | Least useful, no metrics |
### Top Contender: reddit-mcp-buddy
**GitHub:** [karanb192/reddit-mcp-buddy](https://github.com/karanb192/reddit-mcp-buddy)
- 371 stars, actively maintained (last update: Jan 29, 2026)
- **Anonymous mode** — zero credentials, ~10 req/min
- **MCP tools:** `search_reddit`, `browse_subreddit`, `get_post_details`, `user_analysis`, `reddit_explain`
- **Install:** `claude mcp add --transport stdio reddit-mcp-buddy -s user -- npx -y reddit-mcp-buddy`
- Returns full engagement metrics (score, num_comments, upvote_ratio)
- TypeScript/Node.js (npx, no Python dependency)
### Strong Alternative: .json URL trick
- Append `.json` to any Reddit URL → full JSON response
- Search: `https://www.reddit.com/search.json?q=TOPIC&sort=relevance&t=month&limit=100`
- Zero dependencies, zero auth, zero setup
- Returns same data as official API (score, num_comments, created_utc, upvote_ratio)
- ~10 req/min rate limit (sufficient for skill use)
- Can be called via `curl` or Python `urllib`
### Also Considered
- **Hawstein/mcp-server-reddit** (134 stars, Python, no auth) — **No search tool**, only browse subreddits. Cannot replace OpenAI's keyword search capability.
- **Arindam200/reddit-mcp** (262 stars, PRAW) — Has search but **requires Reddit OAuth credentials**. Not truly zero-config.
- **adhikasp/mcp-reddit** (348 stars) — Hot threads only, no search. Last updated Dec 2024.
## Proposed Solution
### Approach A: MCP-First with .json Fallback (Recommended)
Add a new module `mcp_reddit.py` that:
1. **Detects** if a Reddit MCP server is configured in the user's Claude Code session
2. **If MCP available:** Calls MCP `search_reddit` tool via the skill's `allowed-tools` (the skill already allows tools — MCP tools become available when configured)
3. **If no MCP:** Falls back to Reddit's `.json` URL endpoints via `urllib` (stdlib, no dependencies)
4. **Normalize** MCP/JSON results to the existing `RedditItem` schema
5. **Enrich** with `reddit_enrich.py` (already works — fetches real thread data)
This gives users three tiers of Reddit access:
- **Tier 1 (best):** Reddit MCP installed → full search + engagement via MCP tools
- **Tier 2 (good):** No MCP, no keys → `.json` URL search + enrichment
- **Tier 3 (existing):** OpenAI key present → existing `openai_reddit.py` still works (backward compat)
### Approach B: .json-Only (Simpler)
Skip MCP entirely. Add a `reddit_json.py` module that uses `https://www.reddit.com/search.json` directly. Simpler but misses the MCP ecosystem integration.
### Approach C: MCP-Only (Cleaner)
Require MCP setup. Simpler code but adds a user setup step (`claude mcp add ...`).
**Recommendation: Approach A** — MCP-first with .json fallback gives zero-config Reddit search for everyone while rewarding users who set up MCP with a better experience.
## Technical Approach
### Architecture
```
User invokes /last30days "topic"
├─ env.py detects sources:
│ ├─ MCP reddit available? → mcp_reddit.py
│ ├─ No MCP, no key? → reddit_json.py (.json URL trick)
│ └─ OPENAI_API_KEY set? → openai_reddit.py (existing, backward compat)
├─ X search (Bird CLI / xAI — unchanged)
└─ Normalize → Score → Dedupe → Render (unchanged)
```
### New Files
#### `scripts/lib/reddit_json.py`
- Uses `urllib.request` (stdlib) to call Reddit's `.json` search endpoint
- URL: `https://www.reddit.com/search.json?q={topic}&sort=relevance&t=month&limit=100`
- Custom `User-Agent` header (required by Reddit)
- Parses response into the same format as `openai_reddit.py` output
- Handles pagination via `after` token if depth=deep (multiple requests)
- Rate limiting: 1 second delay between requests
#### `scripts/lib/mcp_reddit.py`
- Detects MCP availability (check if Reddit MCP tools exist in session)
- Formats MCP tool calls for `search_reddit` with topic + date filters
- Normalizes MCP response to match existing `RedditItem` schema
- Falls back to `reddit_json.py` if MCP unavailable
#### Modified Files
- **`scripts/lib/env.py`** — Add Reddit source detection: MCP → .json → OpenAI
- **`scripts/last30days.py`** — Route Reddit search through new priority chain
- **`scripts/lib/normalize.py`** — Add normalizer for `.json` endpoint response format
- **`SKILL.md`** — Document MCP setup as optional enhancement
### MCP Integration Design
The MCP approach has a subtle challenge: the last30days skill runs as a **Python subprocess** (`python3 last30days.py`), but MCP tools are available to **Claude's session**, not to subprocesses.
**Two paths to solve this:**
**Path 1: SKILL.md orchestration** — The SKILL.md workflow calls the MCP `search_reddit` tool directly (before or in parallel with the Python script), saves the MCP response to a temp file, and the Python script reads it:
```
SKILL.md workflow:
1. Call MCP search_reddit → save to /tmp/last30days_mcp_reddit.json
2. Call python3 last30days.py --reddit-from=/tmp/last30days_mcp_reddit.json --emit=compact
3. Python script reads pre-fetched Reddit data instead of calling OpenAI
```
**Path 2: .json only for subprocess** — The Python script uses `.json` URLs directly (no MCP needed in subprocess). MCP is a bonus for users who want Claude to also browse specific threads interactively.
**Recommendation: Path 2 for MVP, Path 1 as enhancement.** The `.json` approach is self-contained, testable, and doesn't require SKILL.md workflow changes. MCP can be layered on later.
### Source Priority Chain (updated env.py)
```python
def get_reddit_source(config: dict) -> str:
"""Returns: 'mcp', 'json', 'openai', or 'none'"""
# 1. Check for pre-fetched MCP data (from SKILL.md)
if os.path.exists(MCP_REDDIT_CACHE_PATH):
return 'mcp'
# 2. Always available — no key needed
# (reddit .json endpoints are free)
return 'json'
# 3. OpenAI key present → legacy path
# if config.get('OPENAI_API_KEY'):
# return 'openai'
```
For MVP, the `.json` path is **always available** so it becomes the default. OpenAI path remains as opt-in for users who want higher rate limits.
### Data Mapping: .json → RedditItem
Reddit `.json` search returns `data.children[].data` with:
```json
{
"title": "Post title",
"permalink": "/r/subreddit/comments/abc123/...",
"subreddit": "ClaudeAI",
"score": 42,
"num_comments": 15,
"upvote_ratio": 0.95,
"created_utc": 1738800000,
"selftext": "Post body...",
"url": "https://...",
"author": "username"
}
```
Maps cleanly to existing `RedditItem`:
| .json field | RedditItem field | Notes |
|-------------|------------------|-------|
| `title` | `title` | Direct |
| `permalink` | `url` | Prepend `https://www.reddit.com` |
| `subreddit` | `subreddit` | Direct |
| `score` | `engagement.score` | Direct |
| `num_comments` | `engagement.num_comments` | Direct |
| `upvote_ratio` | `engagement.upvote_ratio` | Direct |
| `created_utc` | `date` | Convert epoch → YYYY-MM-DD |
| `selftext` | (used for relevance) | AI relevance scoring needed |
| `author` | (not in current schema) | Ignore for now |
### Relevance Scoring Without AI
The current `openai_reddit.py` gets relevance scores **from OpenAI** (the AI judges how relevant each result is). With `.json` endpoints, we lose that AI relevance judgment.
**Options:**
1. **Keyword matching** — Score based on how many query terms appear in title + selftext. Simple but effective.
2. **TF-IDF-like** — Weight rarer query terms higher. More accurate but more code.
3. **Let Claude judge** — Pass results to Claude in SKILL.md and have Claude score relevance. Most accurate but changes the workflow.
4. **Skip relevance, rely on Reddit's sort** — Reddit's `sort=relevance` already ranks by relevance. Trust it and use position-based scoring (first result = 1.0, last = 0.5).
**Recommendation: Option 4 for MVP.** Reddit's search relevance ranking is already good. Use position-based relevance (1.0 → 0.5 linear decay over result set) combined with the existing engagement-based scoring. This requires zero external dependencies and no AI calls.
## Implementation Phases
### Phase 0: Fork Repository
- [ ] Create new repo `last30days-free-reddit` (or branch in private repo)
- [ ] `git clone /Users/mvanhorn/last30days-skill-private /Users/mvanhorn/last30days-free-reddit`
- [ ] Create feature branch: `feat/free-reddit`
- [ ] Verify all existing tests pass on the new branch
### Phase 1: reddit_json.py — Core .json Search
- [ ] Create `scripts/lib/reddit_json.py`
- `search_reddit_json(topic: str, depth: str, date_from: str, date_to: str) -> list[dict]`
- Build search URL with `q`, `sort=relevance`, `t=month`, `limit` (25/50/100 by depth)
- Custom `User-Agent: last30days-skill/2.0 (by /u/last30days-bot)`
- Parse `data.children[].data` → list of raw dicts
- Handle pagination for deep mode (follow `after` token, max 3 pages)
- Rate limit: `time.sleep(1.0)` between requests
- Error handling: 429 (rate limit), 403 (blocked), network errors → return empty + error msg
- [ ] Create `tests/test_reddit_json.py`
- Mock HTTP responses using fixture files
- Test: basic search parsing, pagination, rate limit handling, error cases
- [ ] Create `fixtures/reddit_json_search_sample.json`
- Real `.json` search response (sanitized)
### Phase 2: Normalize + Score .json Results
- [ ] Add `normalize_reddit_json()` to `scripts/lib/normalize.py`
- Convert `.json` response format → `RedditItem` schema
- `created_utc` (epoch) → `YYYY-MM-DD` string
- `permalink` → full URL
- Position-based relevance: `1.0 - (index / total * 0.5)` → range [1.0, 0.5]
- Date confidence: `"high"` (Reddit provides exact timestamps)
- [ ] Update `scripts/lib/score.py` if needed
- `.json` results already have real engagement metrics — existing scoring formula works as-is
- No penalty needed (unlike WebSearch which lacks engagement)
- [ ] Add tests for normalization + scoring of `.json` data
### Phase 3: Integration into Orchestrator
- [ ] Update `scripts/lib/env.py`
- Add `get_reddit_source(config) -> str` returning `'json'`, `'openai'`, or `'none'`
- Priority: `.json` always available (default), `'openai'` if key present and user prefers
- Add `REDDIT_SOURCE` config option: `auto` (default), `json`, `openai`
- Update `get_available_sources()` to always include Reddit (since `.json` is free)
- Update `get_missing_keys()` — Reddit no longer shows as "missing"
- [ ] Update `scripts/last30days.py`
- Add `_search_reddit_json()` function alongside existing `_search_reddit()`
- Route based on `get_reddit_source()`: json → `_search_reddit_json()`, openai → `_search_reddit()`
- Skip `reddit_enrich.py` for `.json` results (already have real engagement metrics!)
- Update progress/stats output to show source: "Reddit (free)" vs "Reddit (OpenAI)"
- [ ] Update SKILL.md promo messaging
- Remove "Add OPENAI_API_KEY for Reddit" messaging
- Instead: "Reddit search included free! Add OpenAI key for AI-enhanced relevance scoring."
- [ ] Integration tests: full pipeline with `.json` mock data
### Phase 4: Testing & Polish
- [ ] Run all existing tests — ensure backward compatibility
- [ ] Manual test: invoke `/last30days` with NO API keys → should get Reddit + WebSearch results
- [ ] Manual test: invoke with OPENAI_API_KEY → should still use OpenAI path (backward compat)
- [ ] Manual test: compare result quality — `.json` vs OpenAI for same topic
- [ ] Update README.md — document free Reddit access
- [ ] Update SPEC.md — document new source priority chain
### Phase 5 (Future): MCP Enhancement Layer
- [ ] Detect Reddit MCP in Claude's session
- [ ] SKILL.md pre-fetches via MCP `search_reddit` → saves to temp file
- [ ] Python script reads pre-fetched MCP data via `--reddit-from=` flag
- [ ] MCP results get AI-judged relevance (since Claude sees them)
- [ ] Better than `.json` position-based relevance
## Acceptance Criteria
### Functional
- [ ] `/last30days "any topic"` returns Reddit results with **zero API keys configured**
- [ ] Reddit results include real engagement metrics (score, comments, upvote_ratio)
- [ ] Results are properly scored, deduped, and rendered (same quality as OpenAI path)
- [ ] Existing OpenAI path still works when key is present
- [ ] Existing X search (Bird CLI / xAI) is unchanged
- [ ] Stats box shows correct source indicator ("Reddit (free)" or "Reddit (OpenAI)")
### Non-Functional
- [ ] No external Python packages (stdlib only — `urllib.request`, `json`, `time`)
- [ ] Respects Reddit rate limits (~10 req/min for unauthenticated)
- [ ] Graceful degradation if Reddit blocks requests (429/403 → empty results + error msg)
- [ ] All new code has unit tests with fixtures (no live API calls in tests)
### Quality Gates
- [ ] All existing tests pass (zero regressions)
- [ ] New tests cover: search parsing, normalization, scoring, error handling, pagination
- [ ] Manual smoke test passes with zero API keys
## Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Reddit blocks `.json` endpoints | Low | High | Fall back to OpenAI path; monitor for 429s |
| `.json` rate limit too restrictive | Medium | Medium | 1s delay between requests; cache results |
| Relevance quality lower without AI scoring | Medium | Medium | Reddit's `sort=relevance` is decent; can add keyword scoring later |
| Reddit changes `.json` response format | Low | Medium | Schema validation in normalize; fixture-based tests catch changes |
| Node.js MCP server dependency conflicts | N/A (Phase 5) | Low | MCP is optional enhancement, not required |
## Dependencies
- **None** — this feature uses only Python stdlib and Reddit's public `.json` endpoints
- Phase 5 (future) would add optional dependency on an MCP server
## Repository Setup
```bash
# Fork into new working repo
cp -r /Users/mvanhorn/last30days-skill-private /Users/mvanhorn/last30days-free-reddit
cd /Users/mvanhorn/last30days-free-reddit
# Create feature branch
git checkout -b feat/free-reddit
# Verify existing tests
python3 -m pytest tests/ -v
```
The new repo is disposable — once the feature is validated, changes merge back into `last30days-skill-private` via PR or cherry-pick.
## References
### Internal
- `scripts/lib/openai_reddit.py` — Current Reddit search (to be replaced/supplemented)
- `scripts/lib/env.py:get_available_sources()` — Source detection logic to update
- `scripts/lib/normalize.py` — Add `.json` normalizer alongside existing
- `scripts/lib/reddit_enrich.py` — May be skippable for `.json` results (already have engagement)
- `scripts/lib/schema.py:RedditItem` — Target schema (unchanged)
### External
- [Reddit .json search endpoint](https://www.reddit.com/search.json?q=test&sort=relevance&t=month&limit=25)
- [Simon Willison — Scraping Reddit via JSON API](https://til.simonwillison.net/reddit/scraping-reddit-json)
- [karanb192/reddit-mcp-buddy](https://github.com/karanb192/reddit-mcp-buddy) — Best MCP option for Phase 5
- [Hawstein/mcp-server-reddit](https://github.com/Hawstein/mcp-server-reddit) — ClaudeLog-recommended MCP (no search though)
- [Reddit API Rate Limits Guide](https://painonsocial.com/blog/reddit-api-rate-limits-guide)
### Research Sources
- last30days skill output — community recommendations for Reddit search tools
- GitHub search — 10+ Reddit MCP repos evaluated
- npm/PyPI registries — package availability confirmed
- ClaudeLog — [Reddit MCP reference](https://claudelog.com/claude-code-mcps/reddit-mcp/)
@@ -0,0 +1,67 @@
---
title: "release: Push V2 to public repo and launch"
type: release
date: 2026-02-07
---
# release: Push V2 to public repo and launch
## Overview
Push all V2 changes from the private development repo to the public GitHub repo, then sync the installed skill. 52 commits need to go from `origin/main` (private) to `upstream/main` (public).
## Current State
- **Private repo:** `https://github.com/mvanhorn/last30days-skill-private` - 52 commits ahead of public
- **Public repo:** `https://github.com/mvanhorn/last30days-skill` - last commit is V1 (`cc892d7`)
- **Upstream remote:** Already configured in private repo
- **Untracked files in private:** test logs, draft plans - these should NOT be pushed
## Steps
### Step 1: Clean up private repo
- [ ] Review untracked files - make sure nothing sensitive gets pushed
- [ ] Decide: commit the untracked docs/plans or leave them out of the push
### Step 2: Push to public
```bash
cd /Users/mvanhorn/last30days-skill-private
git push upstream main
```
This pushes all 52 commits from private `main` to public `main`. Since the private repo was forked from public, history is shared - this is a fast-forward push.
### Step 3: Sync installed skill
```bash
# Update the installed copy that Claude Code actually uses
cd ~/.claude/skills/last30days
git pull origin main
```
### Step 4: Verify
- [ ] Check `https://github.com/mvanhorn/last30days-skill` shows V2 README with new examples
- [ ] Check SKILL.md has the new argument-hint and timing disclaimer
- [ ] Check bird_x.py has the fixed `_extract_core_subject()` and retry logic
- [ ] Run a quick `/last30days` test to confirm installed skill works
## What Gets Published
Key files going public:
- `README.md` - V2 features, 3 new examples (Nano Banana Pro, Kanye, Vibe Motion), speed tradeoff note
- `SKILL.md` - New argument-hint, timing disclaimer, citation rules
- `scripts/lib/bird_x.py` - Fixed query construction + retry logic
- `scripts/lib/http.py` - USER_AGENT bumped to 2.0
- `.claude-plugin/plugin.json` - Marketplace support
- All Bird CLI integration code
- Phase 2 supplemental search code
- Model fallback chain
## Risks
- **Low risk:** This is a fast-forward push, no force push needed
- **Public API keys:** Already confirmed - no `.env` files or secrets in the repo
- **Untracked files:** Won't be pushed unless committed first
@@ -0,0 +1,194 @@
---
title: ClawHub Scanner Compliance for last30days-official
type: feat
date: 2026-02-15
---
# ClawHub Scanner Compliance for last30days-official
## Overview
Make the last30days skill pass ClawHub's security scanner (VirusTotal + Code Insight) so it can be published as `last30days-official`. The user's 8 other mvanhorn skills already pass - we replicate their exact pattern.
## Problem Statement
ClawHub requires skills to pass a multi-layer security scan before publication:
1. Metadata validation (frontmatter fields)
2. VirusTotal automated scanning
3. LLM-powered Code Insight analysis (checks if capabilities match documentation)
4. Credential handling review
The current last30days SKILL.md has basic `metadata.clawdbot` but is missing fields the scanner checks: `emoji`, `user-invocable`, `disable-model-invocation`, `files` declaration. There's no `## Security & Permissions` section (required pattern from passing skills). README has no security/privacy documentation.
## Proposed Solution
Follow the exact pattern from `clawdbot-skill-xai` and `clawdbot-skill-search-x` (both pass the scanner). Three files need changes.
### Fix 1: SKILL.md Frontmatter
Add missing scanner fields to the existing frontmatter:
```yaml
---
name: last30days
version: "2.1"
description: "Research a topic from the last 30 days. Also triggered by 'last30'. Sources: Reddit, X, YouTube, web."
argument-hint: 'last30 AI video tools, last30 best project management tools'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
homepage: https://github.com/mvanhorn/last30days-skill
user-invocable: true
disable-model-invocation: true
metadata:
clawdbot:
emoji: "📰"
requires:
env:
- OPENAI_API_KEY
bins:
- node
- python3
primaryEnv: OPENAI_API_KEY
files:
- "scripts/*"
homepage: https://github.com/mvanhorn/last30days-skill
tags:
- research
- reddit
- x
- youtube
- trends
- prompts
---
```
Key additions:
- `user-invocable: true` - human must trigger it
- `disable-model-invocation: true` - agent cannot self-trigger
- `emoji: "📰"` - required display field
- `files: ["scripts/*"]` - prevents false "instruction-only but has scripts" flag
### Fix 2: Security & Permissions Section in SKILL.md
Add to the bottom of SKILL.md (matches xai/search-x pattern exactly):
```markdown
## Security & Permissions
**What this skill does:**
- Sends search queries to OpenAI's Responses API (`api.openai.com`) for Reddit discovery
- Sends search queries to Twitter's GraphQL API (via browser cookie auth) or xAI's API (`api.x.ai`) for X search
- Runs `yt-dlp` locally for YouTube search and transcript extraction (no API key, public data)
- Optionally sends search queries to Brave Search API, Parallel AI API, or OpenRouter API for web search
- Fetches public Reddit thread data from `reddit.com` for engagement metrics
- Stores research findings in local SQLite database (watchlist mode only)
**What this skill does NOT do:**
- Does not post, like, or modify content on any platform
- Does not access your Reddit, X, or YouTube accounts
- Does not share API keys between providers (OpenAI key only goes to api.openai.com, etc.)
- Does not log, cache, or write API keys to output files
- Does not send data to any endpoint not listed above
- Cannot be invoked autonomously by the agent (`disable-model-invocation: true`)
**Bundled scripts:** `scripts/last30days.py` (main research engine), `scripts/lib/` (search, enrichment, rendering modules), `scripts/lib/vendor/bird-search/` (vendored X search client, MIT licensed)
Review scripts before first use to verify behavior.
```
### Fix 3: Security & Privacy Section in README.md
Add after the "How It Works" section:
```markdown
## Security & Privacy
### Data that leaves your machine
| Destination | Data Sent | API Key Required |
|------------|-----------|-----------------|
| `api.openai.com` | Search query (topic string) | 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 |
| `youtube.com` (via yt-dlp) | Search query | None (public search) |
| `api.search.brave.com` | Search query (optional) | BRAVE_API_KEY |
| `api.parallel.ai` | Search query (optional) | PARALLEL_API_KEY |
| `openrouter.ai` | Search query (optional) | OPENROUTER_API_KEY |
Your research topic is included in all outbound API requests. If you research sensitive topics, be aware that query strings are transmitted to the API providers listed above.
### Data stored locally
- API keys: `~/.config/last30days/.env` (chmod 600 recommended)
- Watchlist database: `~/.local/share/last30days/research.db` (SQLite)
- Briefings: `~/.local/share/last30days/briefs/`
### API key isolation
Each API key is transmitted only to its respective endpoint. Your OpenAI key is never sent to xAI, Brave, or any other provider. Browser cookies for X are read locally and used only for Twitter GraphQL requests.
```
### Fix 4: Update .claude-plugin Files
**plugin.json** - bump version, add youtube keyword:
```json
{
"name": "last30days",
"description": "Research any topic from the last 30 days across Reddit, X, YouTube, and the web",
"version": "2.1.0",
"author": {"name": "mvanhorn"},
"repository": "https://github.com/mvanhorn/last30days-skill",
"license": "MIT",
"keywords": ["research", "reddit", "twitter", "x", "youtube", "trends", "prompts"],
"skills": ["./"]
}
```
**marketplace.json** - add version, update description:
```json
{
"name": "last30days",
"owner": {"name": "mvanhorn", "url": "https://github.com/mvanhorn"},
"metadata": {
"description": "Research any topic from the last 30 days across Reddit, X, YouTube, and the web",
"version": "2.1.0"
},
"plugins": [{"name": "last30days", "source": "."}]
}
```
## What We DON'T Need
Based on the audit of your 8 passing skills:
- **Script-level security manifest headers** - your passing skills (xai, search-x, parallel) do NOT have these. The scanner relies on SKILL.md, not per-file headers.
- **Separate SECURITY.md file** - not needed; README section + SKILL.md section is sufficient.
- **Shell injection fixes** - already clean. All subprocess calls use list-form args, no `shell=True` anywhere.
- **Credential leak fixes** - already clean. All keys loaded from env vars, none hardcoded or logged.
## Acceptance Criteria
- [x] SKILL.md frontmatter has `user-invocable`, `disable-model-invocation`, `emoji`, `files`
- [x] SKILL.md has `## Security & Permissions` section with "does" and "does NOT do" lists
- [x] README.md has `## Security & Privacy` section with endpoint table and key isolation docs
- [x] plugin.json version bumped to 2.1.0, youtube keyword added
- [x] marketplace.json version and description updated
- [x] `python3 scripts/last30days.py --diagnose` still works after changes
- [x] Synced to all installed skill locations
- [ ] Published to ClawHub as `last30days-official` (when auth is fixed)
## Files to Modify
| File | Change |
|------|--------|
| `SKILL.md` | Add frontmatter fields + Security & Permissions section |
| `README.md` | Add Security & Privacy section |
| `.claude-plugin/plugin.json` | Bump version, add youtube keyword |
| `.claude-plugin/marketplace.json` | Add version, update description |
## References
- [13-point ClawHub checklist](https://gist.github.com/adhishthite/0db995ecfe2f23e09d0b2d418491982c)
- [ClawHub docs](https://docs.openclaw.ai/tools/clawhub)
- [ClawHub Developer Guide 2026](https://www.digitalapplied.com/blog/clawhub-skills-marketplace-developer-guide-2026)
- Your passing skills: `clawdbot-skill-xai`, `clawdbot-skill-search-x` (exact pattern replicated)
@@ -0,0 +1,76 @@
---
title: "fix: watchlist engagement null crash"
type: fix
date: 2026-02-15
---
# fix: Watchlist crashes on `engagement: null` from X posts
## Problem
When X posts return `engagement: null` (JSON null) instead of `engagement: {}` (empty object), the watchlist `_run_topic` findings parser crashes. This is because Python's `dict.get("engagement", {})` returns `None` when the key **exists** with value `None` — the default `{}` only applies when the key is **missing**.
```python
# CRASHES — .get() returns None, not {}
item.get("engagement", {}).get("likes", 0)
# AttributeError: 'NoneType' object has no attribute 'get'
```
**Reporter:** Soft launch tester, 2026-02-15
**Severity:** Medium — breaks watchlist `run-one` and `run-all` for any topic that pulls X posts with null engagement
**One-shot research unaffected** — the main `last30days.py` pipeline uses `normalize.py` which has `isinstance(eng_raw, dict)` guards
## Root Cause
Two locations use the vulnerable `dict.get("key", {})` pattern:
1. **`scripts/watchlist.py:188`** — THE REPORTED BUG
```python
"engagement_score": item.get("engagement", {}).get("likes", 0),
```
2. **`scripts/lib/normalize.py:177`** — YouTube normalizer (same pattern, latent)
```python
eng_raw = item.get("engagement", {})
```
Three other locations are already safe — they use `isinstance(eng_raw, dict)`:
- `scripts/lib/normalize.py:70` (Reddit)
- `scripts/lib/normalize.py:130` (X)
- `scripts/lib/xai_x.py:190`
## Fix
Apply the `or {}` idiom (as suggested by reporter):
### scripts/watchlist.py:188
```python
# Before
"engagement_score": item.get("engagement", {}).get("likes", 0),
# After
"engagement_score": (item.get("engagement") or {}).get("likes", 0),
```
### scripts/lib/normalize.py:177
```python
# Before
eng_raw = item.get("engagement", {})
# After
eng_raw = item.get("engagement") or {}
```
## Acceptance Criteria
- [ ] `watchlist.py run-one` handles X posts with `engagement: null` without crashing
- [ ] `watchlist.py run-one` handles X posts with `engagement: {}` (empty object)
- [ ] `watchlist.py run-one` handles X posts with no engagement key at all
- [ ] YouTube normalizer handles `engagement: null` without crashing
- [ ] Existing tests still pass
## Regarding the screenshot question
The tester asked "Did you use watchlist on open claw or Claude?" — watchlist is designed for the **open variant** (Open Claw). It works in Claude Code too since it's just Python + SQLite, but the SKILL.md routing for watchlist commands is only in `variants/open/SKILL.md`. The main `SKILL.md` is one-shot research only.
Answer to give tester: "Watchlist works in both — it's plain Python. But the skill routing that understands 'watch add topic' is in the open variant. In Claude Code you'd need to call the script directly or use the open variant SKILL.md."
@@ -0,0 +1,221 @@
---
title: Fix YouTube Display and Search Quality
type: fix
date: 2026-02-15
---
# Fix YouTube Display and Search Quality
## Overview
YouTube is the v2.1 headline feature but it's broken in two ways: results don't appear in Claude's synthesis (display bug), and search quality is worse than youtube.com (search bug). Both need fixing before launch.
## Problem Statement
**Display bug:** YouTube data exists in the script output but Claude never sees it. Reproduced on 4/5 recent test runs (Kanye, Seedance 2, Peter Steinberger, YouTube thumbnails). The skill worked once — the earlier "YouTube thumbnails" and "OpenClaw" runs showed YouTube stats — but subsequent runs silently dropped it.
**Search quality bug:** User searched "how to get on seedance 2" on youtube.com and got multiple recent results. yt-dlp returned 10 videos for the same query, but they included old irrelevant content because date filtering is broken.
## Root Cause Analysis
### Display Bug — Three compounding causes
1. **`2>&1` in SKILL.md bash command** (line 79) merges stderr progress messages into stdout. When Claude Code receives this mixed output, the YouTube section (which renders LAST after Reddit + X) can get lost in the noise or hit the 30K char Bash output limit.
2. **Background execution** (partially fixed). The old SKILL.md said "DO WEBSEARCH WHILE SCRIPT RUNS" which caused Claude to background the bash command. Backgrounded commands return truncated output via Task Output. *Already fixed in this session — SKILL.md now says FOREGROUND with 5-minute timeout.*
3. **No explicit model instruction to look for YouTube.** The SKILL.md tells Claude to synthesize but doesn't emphasize that YouTube data is in the script output and must be included.
### Search Quality Bug — `--flat-playlist` breaks date filtering
The yt-dlp command in `youtube_yt.py:110-116`:
```bash
yt-dlp ytsearch{count}:{query} --dateafter {YYYYMMDD} --flat-playlist --dump-json
```
**`--flat-playlist` causes three problems:**
1. `--dateafter` is silently ignored (no video-level metadata to filter on)
2. All items have `date: None` (upload_date not in flat-playlist JSON)
3. Old content leaks in (e.g., "the greatest youtube thumbnails of all time" returned for a 30-day query)
**`_extract_core_subject()` over-strips useful YouTube terms:**
- Strips "tips", "tutorial", "review" — but these ARE the content types people search for on YouTube
- "youtube thumbnail tips" → "youtube thumbnail" loses the intent signal
## Proposed Solution
### Phase 1: Fix Display (Critical — blocks launch)
#### 1a. Remove `2>&1` from SKILL.md bash command
**File:** `SKILL.md:79` (both `last30days-skill-private/SKILL.md` and `~/.claude/skills/last30days21/SKILL.md`)
```bash
# Before:
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1
# After:
python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact
```
This removes ~1-5KB of progress spam from the model's input and ensures clean stdout-only output.
#### 1b. Add YouTube-specific synthesis instruction to SKILL.md
After the "Read the ENTIRE output" instruction, add:
```markdown
**The script output has THREE sections: Reddit items, X items, and YouTube items (in that order).
If you see YouTube items in the output, you MUST include them in your synthesis and stats block.
YouTube items look like: `**{video_id}** (score:N) {channel} [N views, N likes]`**
```
#### 1c. Verify fix with test run
Run `/last30days21 youtube thumbnail tips` and confirm YouTube appears in stats.
### Phase 2: Fix Search Quality (High — headline feature quality)
#### 2a. Remove `--flat-playlist` flag
**File:** `scripts/lib/youtube_yt.py:110-116`
```python
# Before:
cmd = [
"yt-dlp",
f"ytsearch{count}:{core_topic}",
"--dateafter", date_filter,
"--flat-playlist",
"--dump-json",
]
# After:
cmd = [
"yt-dlp",
f"ytsearch{count}:{core_topic}",
"--dateafter", date_filter,
"--dump-json",
"--no-warnings",
"--no-download",
]
```
**Impact:** Slower (yt-dlp resolves each video page for metadata) but:
- `--dateafter` actually works — filters old content
- `upload_date` populated — items get real dates
- Engagement metrics more accurate
**Risk:** Could increase search time from ~5s to ~30-60s for 20 videos. Mitigate by reducing default count or increasing timeout.
**Alternative if too slow:** Keep `--flat-playlist` but append year to search query:
```python
# Bias toward recent content since --dateafter doesn't work with flat-playlist
search_query = f"{core_topic} {from_date[:4]}" # e.g., "youtube thumbnail 2026"
```
#### 2b. Fix `_extract_core_subject()` for YouTube-relevant terms
**File:** `scripts/lib/youtube_yt.py:67-76`
Don't strip terms that are useful YouTube content type signals:
```python
# YouTube-specific: keep 'tips', 'tutorial', 'review' etc.
# These are stripped for Reddit/X search but are valuable for YouTube
noise = {
'best', 'top', 'good', 'great', 'awesome', 'killer',
'latest', 'new', 'news', 'update', 'updates',
'trending', 'hottest', 'popular', 'viral',
'practices', 'features',
'recommendations', 'advice',
'prompt', 'prompts', 'prompting',
'methods', 'strategies', 'approaches',
}
# NOTE: 'tips', 'tricks', 'tutorial', 'guide', 'review', 'reviews'
# are intentionally KEPT — they're YouTube content types
```
#### 2c. Clean question marks and trailing punctuation
**File:** `scripts/lib/youtube_yt.py:48-80`
```python
# At the end of _extract_core_subject():
result = ' '.join(filtered) if filtered else text
return result.rstrip('?!.') # Clean trailing punctuation
```
### Phase 3: Polish (Medium — nice to have before launch)
#### 3a. Increase subprocess timeout for non-flat-playlist mode
**File:** `scripts/lib/youtube_yt.py:119-121`
```python
# Before:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
# After — resolving video pages takes longer:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
```
#### 3b. Add year hint to search query for recency bias
Even with `--dateafter` working, YouTube's search algorithm ranks by relevance not recency. Adding the year helps:
```python
# After core_topic extraction:
import datetime
current_year = datetime.datetime.now().year
search_query = f"{core_topic} {current_year}"
```
#### 3c. Reduce compact render item count for YouTube
The compact render currently shows up to 15 YouTube items. With transcripts, this is too much output. Reduce to 10:
**File:** `scripts/lib/render.py` — in `render_compact()`, add YouTube-specific limit or reduce the default.
## Acceptance Criteria
- [ ] `/last30days21 youtube thumbnail tips` shows YouTube in stats block
- [ ] YouTube items have real dates (not `None`)
- [ ] Old content (> 30 days) filtered out by `--dateafter`
- [ ] "youtube thumbnail tips" search returns videos about thumbnail tips (not generic old content)
- [ ] "How to access Seedance 2" returns recent Seedance 2 tutorials
- [ ] Script completes within 5 minutes for default depth
- [ ] No `2>&1` in SKILL.md bash command
## Files to Modify
| File | Changes |
|------|---------|
| `SKILL.md` | Remove `2>&1`, add YouTube synthesis instruction |
| `scripts/lib/youtube_yt.py` | Remove `--flat-playlist`, fix noise words, add year hint, increase timeout |
| `scripts/lib/render.py` | Optional: reduce YouTube item limit in compact mode |
## Testing
```bash
# Quick smoke test — should show YouTube items with dates
cd ~/.claude/skills/last30days21
python3 scripts/last30days.py "youtube thumbnail tips" --quick --emit=compact 2>/dev/null | grep -c "youtube.com"
# Date test — should show YYYY-MM-DD dates, not None
python3 -c "
from scripts.lib import youtube_yt
r = youtube_yt.search_youtube('youtube thumbnail tips', '2026-01-16', '2026-02-15', depth='quick')
for v in r['items'][:3]: print(v['date'], v['title'][:50])
"
# Full integration — run the skill in Claude Code and verify YouTube in stats
```
## References
- SKILL.md bash command: `scripts/last30days.py` line 79
- YouTube search: `scripts/lib/youtube_yt.py` lines 83-174
- Core subject extraction: `scripts/lib/youtube_yt.py` lines 48-80
- Compact render: `scripts/lib/render.py` lines 48-238
- Prior YouTube plan: `docs/plans/2026-02-14-feat-youtube-transcript-search-plan.md`
@@ -0,0 +1,102 @@
---
title: Fix YouTube Timeout and Reddit Resilience
type: fix
date: 2026-02-15
---
# Fix YouTube Timeout and Reddit Resilience
## Overview
YouTube search+transcript fetching exceeds the 60s future timeout on popular topics (20 videos + 5 transcripts), discarding all results. Reddit 429 rate-limiting burns through the entire time budget with aggressive retries, causing global timeouts. Both issues cause the script to return incomplete results and sometimes crash.
## Problem Statement
**YouTube timeout (blocks launch):** YouTube found 20 Seedance 2.0 videos and fetched 4/5 transcripts, but the 60s future timeout killed everything — `✗ Error: YouTube search timed out after 60s`. The data was there; the budget wasn't.
**Reddit 429 cascade (degrades reliability):** One enrichment item hitting 429 burns up to 93s (3 retries x 30s timeout + backoff) against a 45s budget. Phase 2 supplemental search then also 429s, burning another 30s. Total wasted: 75s on doomed requests, triggering the 180s global timeout.
Observed failures across 6 test runs:
- `seedance 2 access`: YouTube timed out (60s), Reddit 0 threads
- `kanye west bully`: Global timeout first run, needed --quick retry
- `Peter Steinberger`: Global timeout during enrichment
- `nano banana pro`: Reddit timed out, YouTube worked (5 videos in time)
- `kanye west bully` (retry): Reddit 0 threads, YouTube 4 videos
## Proposed Solution
### Fix 1: Bump YouTube future timeout (NOT YET DONE)
**File:** `scripts/last30days.py:40-44`
Give YouTube its own timeout, separate from the shared `future` timeout. YouTube inherently takes longer because it does search + parallel transcript fetching.
```python
# Option A: YouTube-specific timeout key
TIMEOUT_PROFILES = {
"quick": {"global": 90, "future": 30, "youtube_future": 60, ...},
"default": {"global": 180, "future": 60, "youtube_future": 90, ...},
"deep": {"global": 300, "future": 90, "youtube_future": 120, ...},
}
# Option B: Simpler — just bump default future to 90 for all sources
TIMEOUT_PROFILES = {
"quick": {"global": 90, "future": 45, ...},
"default": {"global": 180, "future": 90, ...},
"deep": {"global": 300, "future": 120, ...},
}
```
**Recommendation:** Option A (YouTube-specific key) — keeps Reddit/X futures tight while giving YouTube the breathing room it needs. Reddit/X finish in 20-40s; YouTube needs 60-90s for transcript fetching.
Then where YouTube future is collected (~line 646):
```python
youtube_timeout = timeouts.get("youtube_future", timeouts["future"])
youtube_items, youtube_error = youtube_future.result(timeout=youtube_timeout)
```
### Fix 2: Reddit 429 fail-fast (ALREADY DONE — needs commit)
**Status:** Implemented in working tree, uncommitted. Changes across 4 files:
| File | Change |
|------|--------|
| `scripts/lib/http.py` | `get_reddit_json()` accepts `timeout` and `retries` params (was hardcoded 30s/3) |
| `scripts/lib/reddit_enrich.py` | `RedditRateLimitError` exception; `fetch_thread_data()` propagates 429; `enrich_reddit_item()` defaults to 10s timeout / 1 retry |
| `scripts/lib/openai_reddit.py` | `search_subreddits()` reduced to 1 retry; breaks subreddit loop on first 429 |
| `scripts/last30days.py` | Enrichment loop catches `RedditRateLimitError`, cancels futures, bails; `rate_limited` flag skips Phase 2 Reddit |
**Impact:** 429 scenario drops from ~75s wasted to ~12s wasted.
## Acceptance Criteria
- [ ] YouTube `seedance 2 access` query completes with videos (was timing out at 60s)
- [ ] YouTube `kanye west bully` query returns 4+ videos with transcripts
- [ ] Reddit 429 detected within ~12s, remaining enrichment skipped
- [ ] Phase 2 Reddit skipped when rate-limited
- [ ] No global timeout on default depth for any of the 5 test queries
- [ ] All changes committed and synced to `~/.claude/skills/last30days21/` and `~/.claude/skills/last30days/`
## Files to Modify
| File | Status | Changes |
|------|--------|---------|
| `scripts/last30days.py` | Modify (partially done) | Add `youtube_future` to TIMEOUT_PROFILES; use it for YouTube future collection |
| `scripts/lib/http.py` | Done (uncommitted) | Parameterized `get_reddit_json()` timeout/retries |
| `scripts/lib/reddit_enrich.py` | Done (uncommitted) | `RedditRateLimitError`, fail-fast enrichment |
| `scripts/lib/openai_reddit.py` | Done (uncommitted) | 429 early-bail in `search_subreddits()` |
## Testing
```bash
# Quick smoke test — YouTube should complete within 90s
cd ~/.claude/skills/last30days21
python3 scripts/last30days.py "seedance 2 access" --emit=compact 2>&1 | grep -E "YouTube|timeout"
# Verify YouTube items in output
python3 scripts/last30days.py "kanye west bully" --quick --emit=compact 2>/dev/null | grep "youtube.com" | wc -l
# Full integration — run the skill in Claude Code
# /last30days21 seedance 2 access
# Verify YouTube appears in stats block
```
@@ -0,0 +1,255 @@
---
title: "feat: Create GitHub Release for v2.1"
type: feat
date: 2026-02-17
---
# Create GitHub Release for v2.1
## Overview
The last30days skill is at v2.1 with 2,747 stars and 317 forks, but has **zero git tags and zero GitHub Releases**. The code is already live on the public repo (`upstream/main`). All marketing copy is written. This plan creates a proper v2.1.0 GitHub Release from existing materials.
## Problem Statement
GitHub Releases provide:
- A landing page for each version with formatted release notes
- Discoverability (GitHub shows releases in the sidebar, feeds them to the Explore page)
- A stable reference point for users (`git checkout v2.1.0`)
- A "Full Changelog" diff link
- RSS feed for watchers
- New contributor callouts (community goodwill)
Currently users have no way to reference a specific version of the skill. The README says "v2.1" but there's nothing in git to anchor that.
## Proposed Solution
Create the first GitHub Release (v2.1.0) on the **public** repo using existing marketing copy. Use an annotated tag (not lightweight) for proper `git describe` support and fork propagation.
## Implementation Steps
### Phase 1: Tag and Release
#### 1. Create CHANGELOG.md
Create `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Source material already exists:
- `README.md` "What's New in V2.1" and "What's New in V2" sections
- `docs/v2.1-tweets.md` (raw verified test results, feature descriptions)
- `docs/v2.1-launch-copy.md` (feature copy, social posts)
- `docs/pr-credits.md` (contributor credits)
- Git log (`git log --oneline` for commit references)
Structure:
```markdown
# Changelog
## [2.1.0] - 2026-02-15
### Highlights
30 days of research. 30 seconds of work. Four sources. Zero stale prompts.
Three headline features...
### Added
- Open-class skill with watchlists (SQLite-backed, FTS5)
- YouTube as 4th research source via yt-dlp (search + transcript extraction)
- OpenAI Codex CLI compatibility ($last30days invocation)
- Bundled X search (vendored Bird GraphQL client, no external CLI)
- Native web search backends (Parallel AI, Brave, OpenRouter/Perplexity Sonar)
- Briefing and history modes (open variant)
- --diagnose flag for source status checking
- --store flag for SQLite accumulation
### Changed
- Smarter query construction (strips noise words, auto-retry)
- Two-phase search (Phase 2 entity-aware drill-down)
- Reddit JSON enrichment (real upvotes/comments from reddit.com/.json)
- Engagement-weighted scoring (relevance 45%, recency 25%, engagement 30%)
- Model auto-selection with 7-day cache
### Fixed
- YouTube timeout increased to 90s
- Reddit 429 rate limit fail-fast
- YouTube soft date filter (keeps evergreen content)
- Eager import crash in __init__.py (Codex compatibility)
### New Contributors
- @JosephOIbrahim - Windows Unicode fix
- @levineam - Model fallback for unverified orgs
- @jonthebeef - --days=N configurable lookback flag
### Credits
- @steipete - Bird CLI (vendored X search) and yt-dlp inspiration
- @galligan - Marketplace plugin inspiration
- @hutchins - Pushed for YouTube feature
## [1.0.0] - 2026-01-15
Initial public release. Reddit + X search via OpenAI and xAI APIs.
```
#### 2. Create annotated tag on the public repo
```bash
cd /Users/mvanhorn/last30days-skill-private
# Tag on the current HEAD (which matches upstream/main)
git tag -a v2.1.0 -m "Release v2.1.0: Watchlists, YouTube transcripts, Codex CLI, bundled X search"
# Push to public repo
git push upstream v2.1.0
```
Use annotated tag (not lightweight) because:
- Stores tagger metadata and date
- Works with `git describe`
- Propagates to forks (317 forks)
- Supports GPG signing if desired later
#### 3. Craft release notes
Assemble from existing copy. The release body should follow this structure:
```markdown
## Highlights
The AI world reinvents itself every month. This skill keeps you current.
`/last30days` researches your topic across **Reddit, X, YouTube, and the web**
from the last 30 days, finds what the community is actually upvoting, sharing,
and saying on camera, and writes you a prompt that works today.
**Three headline features in v2.1:**
1. **Open-class skill with watchlists** - Track competitors, people, topics on a schedule.
Pair with Open Claw for automated briefings. SQLite-backed with FTS5 search.
2. **YouTube transcripts as a 4th source** - When yt-dlp is installed, searches YouTube,
grabs view counts, and reads the actual transcripts. A 20-minute review has 10x the
signal of one X post.
3. **Codex CLI compatibility** - Same skill, same engine, same four sources.
Install to `~/.agents/skills/last30days` and invoke with `$last30days`.
Plus: **Bundled X search** - Vendored Bird GraphQL client. No external CLI needed.
Just Node.js 22+ and browser cookies.
## Real Results (verified 2/15)
| Topic | Reddit | X | YouTube | Web |
|-------|--------|---|---------|-----|
| Nano Banana Pro | - | 32 posts, 164 likes | 5 videos, 98K views | - |
| Seedance 2.0 | 21 threads | 33 posts | 20 videos | 5 pages |
| OpenClaw use cases | 35 threads, 1,130 upvotes | 23 posts | 20 videos, 1.57M views | - |
| YouTube thumbnails | 7 threads, 654 upvotes | 32 posts | 18 videos, 6.15M views | - |
## What's New
### Added
- Open-class skill with watchlist, briefing, and history modes
- YouTube search + transcript extraction via yt-dlp
- OpenAI Codex CLI compatibility
- Bundled Twitter/X search (vendored Bird GraphQL)
- Native web search (Parallel AI, Brave, OpenRouter)
- `--diagnose` and `--store` flags
- Conversational first-run experience (NUX)
### Changed
- Two-phase search architecture (entity-aware drill-down)
- Reddit JSON enrichment for real engagement metrics
- Smarter query construction with auto-retry
- Engagement-weighted scoring algorithm
### Fixed
- YouTube/Reddit timeout resilience
- Reddit 429 rate limit fail-fast
- Eager import crash in Codex environments
## New Contributors
- @JosephOIbrahim - Windows Unicode fix
- @levineam - Model fallback for unverified orgs
- @jonthebeef - `--days=N` configurable lookback
## Credits
- @steipete - Bird CLI and yt-dlp/summarize inspiration
- @galligan - Marketplace plugin inspiration
- @hutchins - Pushed for YouTube feature
## Install
```bash
# Claude Code
claude install-skill https://github.com/mvanhorn/last30days-skill
# Manual
git clone https://github.com/mvanhorn/last30days-skill ~/.claude/skills/last30days
```
**Full Changelog**: https://github.com/mvanhorn/last30days-skill/commits/v2.1.0
```
#### 4. Create the GitHub Release
```bash
gh release create v2.1.0 \
--repo mvanhorn/last30days-skill \
--verify-tag \
--title "v2.1.0 - Watchlists, YouTube Transcripts, Codex CLI" \
-F release-notes.md
```
No binary assets needed - this is an interpreted skill, and GitHub auto-generates source archives.
### Phase 2: Future Automation (Optional)
#### 5. Add `.github/release.yml` for auto-generated notes on future releases
```yaml
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: "Breaking Changes"
labels: [breaking-change]
- title: "Features"
labels: [enhancement, feature]
- title: "Bug Fixes"
labels: [bug, fix]
- title: "Documentation"
labels: [documentation]
- title: "Other Changes"
labels: ["*"]
```
This enables `gh release create v2.2.0 --generate-notes` for future releases with automatic PR categorization.
## Acceptance Criteria
- [x] `CHANGELOG.md` exists in repo root following Keep a Changelog format
- [x] Annotated tag `v2.1.0` exists on the public repo
- [x] GitHub Release `v2.1.0` is published at `github.com/mvanhorn/last30days-skill/releases`
- [x] Release notes include: highlights, real results table, feature list, contributor credits, install instructions
- [x] Release appears in the repo sidebar on GitHub
## Key Decisions
1. **v2.1.0 only** - Don't retroactively create v1.0.0 or v2.0.0 tags. The git history doesn't have clean boundary commits for those versions, and retroactive tags add complexity without value.
2. **Release on the public repo** (`upstream`), not the private repo. Users see `mvanhorn/last30days-skill`.
3. **CHANGELOG.md as source of truth** - The release notes are derived from CHANGELOG.md, not the other way around. Future releases update CHANGELOG.md first, then create the release.
4. **Update star count** - The existing copy says "1.5K stars" but the repo now has 2,747. Update in release notes.
## References
- Existing copy: `docs/v2.1-launch-copy.md`, `docs/v2.1-tweets.md`
- Contributors: `docs/pr-credits.md`
- README features: `README.md` "What's New" sections
- GitHub Releases docs: https://docs.github.com/en/repositories/releasing-projects-on-github
- Keep a Changelog: https://keepachangelog.com/en/1.1.0/
@@ -0,0 +1,274 @@
---
title: "feat: Last30Days.com - Automated Trending Topic Research"
type: feat
date: 2026-02-20
---
# Last30Days.com - Automated Trending Topic Research
## Overview
A website at Last30Days.com that automatically shows daily trending topics researched by the /last30days engine. The core challenge: the skill is query-driven (you give it a topic), but a trending page needs to *discover* what topics to research. This plan covers how to source trending topics, run them through the engine, and publish results.
## Problem Statement
The /last30days skill has 2,747 GitHub stars but no public-facing showcase. Users have to install the skill and run it themselves. A website that automatically shows trending topic results would:
1. **Drive installs** - people see the quality and want it for their own topics
2. **SEO surface area** - each topic page is indexable content
3. **Demonstrate capability** - "here's what /last30days found about X today" with real stats
4. **Content flywheel** - daily fresh content with zero manual curation
## Trending Topic Discovery: The Options
The skill has **no existing trending discovery mechanism** - it's entirely query-driven. Here are the available sources, ranked by practicality.
### Tier 1: Free, High Signal, Zero Friction
| Source | What It Returns | Auth | Cost | Best For |
|--------|----------------|------|------|----------|
| **Wikipedia Pageviews** | Top 100 most-viewed articles yesterday | None | Free | General public interest (news, culture, events) |
| **Hacker News** | Top 30 stories with scores | None | Free | Tech/startup topics |
| **Reddit r/all/hot + rising** | Hottest and rapidly rising posts | OAuth (free) | Free | Broad internet culture |
| **Google News RSS** | Top headlines, algorithmically curated | None | Free | Mainstream news |
| **YouTube mostPopular** | Top 50 trending videos by region | API key | Free (10K units/day) | Pop culture, entertainment |
### Tier 2: Very Cheap, High Value
| Source | What It Returns | Auth | Cost | Best For |
|--------|----------------|------|------|----------|
| **Perplexity Sonar API** | "What's trending today?" with sourced answers | API key | ~$1/month | Meta-aggregator that replaces multiple sources |
| **Google Trends (pytrends)** | Daily trending Google searches | None | Free but flaky | What people are actually searching |
| **Bird `getNews()`** | X Explore page trending topics | Browser cookies | Free | Real-time Twitter/X conversation |
### Tier 3: Paid, Skip for MVP
| Source | Cost | Why Skip |
|--------|------|----------|
| X/Twitter API | $200/month minimum | Too expensive; Bird `getNews()` is free |
| Exploding Topics | $249/month | Overkill for daily trends |
| TikTok | Gated, requires approval | Application process |
### Existing Codebase Hooks
Several pieces already exist in the codebase that could be leveraged:
1. **Bird `getNews()` is already on disk.** The vendored Bird library at `scripts/lib/vendor/bird-search/` includes `twitter-client-news.js` which fetches X's Explore page tabs (For You, Trending, News, Sports, Entertainment). Only needs a ~30-line `bird-news.mjs` wrapper to expose it. Zero API keys needed.
2. **`store.get_trending(days=7)`** at `scripts/store.py:559` ranks watchlist topics by recent finding activity. Could feed "what the skill has been researching" as a meta-signal.
3. **Scoring algorithm** at `scripts/lib/score.py` has engagement formulas for Reddit, X, and YouTube that could rank "buzz" by reweighting engagement >> relevance.
4. **Brave Trending API** exists but isn't implemented in `brave_search.py`. Could be added.
## Proposed Architecture
### Topic Discovery Pipeline (daily cron)
```
┌─────────────────────────────────────────────────┐
│ STEP 1: Fetch trending signals (parallel, free) │
├─────────────────────────────────────────────────┤
│ Wikipedia Pageviews ─┐ │
│ Hacker News Top 30 ─┤ │
│ Reddit r/all/hot ─┼─→ Raw topics + signals │
│ Google News RSS ─┤ │
│ YouTube mostPopular ─┤ │
│ Bird getNews() ─┘ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ STEP 2: Cluster + rank │
├─────────────────────────────────────────────────┤
│ Extract topic keywords from titles │
│ Cluster by semantic similarity │
│ Rank by cross-source frequency │
│ ("Pope Leo XIV" on Wikipedia + Reddit + News │
│ = high confidence trend) │
│ Output: top 15-20 topics, ranked │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ STEP 3: Run /last30days on top topics │
├─────────────────────────────────────────────────┤
│ Top 3-5 topics: full research (--emit=json) │
│ → Full "What I learned" synthesis articles │
│ Remaining 10-15: quick research (--quick) │
│ → Stats teasers (32 X posts, 5 YouTube...) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ STEP 4: Publish to website │
├─────────────────────────────────────────────────┤
│ Generate static HTML/JSON │
│ Deploy to Last30Days.com │
│ RSS feed for subscribers │
└─────────────────────────────────────────────────┘
```
### Topic Clustering Strategy
The hardest part is deduplicating across sources. "Pope Francis Dies" (Google News), "Pope_Francis" (Wikipedia #1 viewed), and a r/worldnews post are all the same topic. Approaches:
**Option A: LLM clustering (recommended for MVP)**
Feed all raw titles to an LLM and ask it to cluster into distinct topics with a representative label. ~$0.01 per day via a fast model. Simple, accurate, handles edge cases.
**Option B: TF-IDF + cosine similarity**
Extract keywords, compute pairwise similarity, agglomerative clustering. No API cost but worse on paraphrased titles.
**Option C: Embedding similarity**
Embed all titles, cluster by cosine distance. Better than TF-IDF, costs a few cents per day.
### Website Architecture
**Option A: Static site (recommended for MVP)**
- Daily cron generates JSON + static HTML
- Host on GitHub Pages, Cloudflare Pages, or Vercel
- Zero server cost, zero maintenance
- Framework: plain HTML/CSS, or minimal Astro/11ty
**Option B: Next.js with ISR**
- Incremental Static Regeneration rebuilds pages daily
- More flexibility for future features (search, filtering, user accounts)
- Hosting: Vercel free tier
**Option C: Full web app**
- Database-backed, real-time updates, user accounts
- Overkill for MVP
### Cost Estimate (daily operation)
| Item | Cost |
|------|------|
| Trending source APIs | $0 (all free tier) |
| LLM clustering (fast model) | ~$0.01/day |
| Full research on 3-5 topics | ~$0.10-0.50/day (OpenAI API for Reddit search) |
| Quick research on 10-15 topics | ~$0.05-0.20/day |
| Static hosting | $0 (GitHub/Cloudflare Pages) |
| **Total** | **~$5-20/month** |
## Implementation Phases
### Phase 1: Topic Discovery Script (MVP)
Build `scripts/discover_trending.py` that:
- Fetches Wikipedia Pageviews, HN top stories, Reddit hot, Google News RSS in parallel
- Filters out evergreen/non-topical Wikipedia pages (e.g., "Main Page", "ChatGPT" permanent traffic)
- Uses a fast LLM to cluster raw titles into 15-20 distinct topics
- Outputs ranked JSON: `[{topic, sources, confidence, category}]`
**Acceptance criteria:**
- [ ] Fetches from at least 4 free trending sources in parallel
- [ ] Clusters raw titles into deduplicated topics via LLM
- [ ] Filters Wikipedia evergreen pages (maintain a blocklist)
- [ ] Outputs ranked JSON with topic name, source count, category
- [ ] Runs in < 60 seconds
- [ ] No paid API keys required for discovery (only LLM clustering)
### Phase 2: Research Automation
Wire discovered topics into the existing /last30days engine:
- Top 3-5 topics: `python3 scripts/last30days.py "$TOPIC" --emit=json --deep`
- Remaining topics: `python3 scripts/last30days.py "$TOPIC" --emit=json --quick`
- Store all results in `~/.local/share/last30days/trending/YYYY-MM-DD/`
**Acceptance criteria:**
- [ ] Orchestration script runs discovery then research sequentially
- [ ] Full research on top N topics, quick on the rest
- [ ] Results stored as dated JSON files
- [ ] Total daily runtime < 30 minutes
- [ ] Handles timeouts/failures gracefully (skip topic, continue)
### Phase 3: Website Generation
Build a static site generator that reads the daily JSON and produces Last30Days.com:
- Homepage: today's trending topics grid (title, category, key stat, source badges)
- Topic pages: full synthesis for showcase topics, stats teaser for others
- Archive: previous days accessible by date
- RSS feed
- CTA: "Want to research your own topic? Install the skill"
**Acceptance criteria:**
- [ ] Static HTML generated from daily JSON
- [ ] Homepage shows today's 15-20 trending topics
- [ ] 3-5 showcase topic pages with full synthesis
- [ ] Remaining topics show stats teasers + install CTA
- [ ] Deploys to Last30Days.com (Cloudflare Pages or similar)
- [ ] RSS feed for daily updates
- [ ] Mobile responsive
### Phase 4: Bird Trending Integration (bonus)
Wire up the already-vendored Bird `getNews()` for X trending:
- Create `scripts/lib/vendor/bird-search/bird-news.mjs` (~30 lines)
- Add X Explore trending data as a 5th discovery source
- X trends are the fastest-moving signal and fill the "what's happening right now" gap
**Acceptance criteria:**
- [ ] `bird-news.mjs` wrapper exposes X Explore trending topics
- [ ] Integrated into discovery pipeline as an additional source
- [ ] Falls back gracefully if no X session cookies available
## Alternative Approaches Considered
### Perplexity-only approach
Just ask Perplexity Sonar "what are the top 20 trending topics today?" daily. Simpler, but:
- Single point of failure
- Less transparent (can't show "sourced from Reddit, Wikipedia, HN")
- Model may hallucinate or miss niche topics
- **Verdict:** Good fallback, not primary.
### Curated topics (manual)
Manually pick topics each day. Defeats the purpose of automation. Could supplement for "editorial picks."
### Social listening tools (Brandwatch, Sprout Social, etc.)
Expensive ($500+/month), enterprise-focused, overkill.
## Technical Considerations
- **Rate limits:** Wikipedia, HN, and RSS have no practical limits for 1 daily call. Reddit's 100 QPM is generous. YouTube's 10K units/day allows ~3,000 `mostPopular` calls.
- **Wikipedia filtering:** The top Wikipedia pages are always "Main Page", "Special:Search", etc. Need a blocklist of ~50 evergreen pages plus heuristics (skip pages under 1,000 characters, skip disambiguation pages).
- **Cron timing:** Run discovery at ~2am UTC (after Wikipedia pageviews finalize for previous day). Run research at ~3am UTC. Deploy site by ~5am UTC.
- **Cost control:** The /last30days engine uses OpenAI API for Reddit search. Full research on 5 topics * $0.05-0.10 each = ~$0.25-0.50/day. Quick research is cheaper.
- **Domain:** Last30Days.com needs to be registered (check availability).
## Dependencies & Risks
| Risk | Mitigation |
|------|------------|
| pytrends breaks (Google changes) | pytrends is a bonus source, not required. Other 4+ sources sufficient. |
| Wikipedia pageviews delayed | Fall back to Perplexity Sonar for day's topics |
| OpenAI API cost spikes | Cap at 5 full + 15 quick topics per day; use --quick for most |
| Bird cookie auth stops working | X trending is a bonus source; discovery works without it |
| Domain not available | Check availability; alternatives: last30.day, last30days.app |
## Success Metrics
- Daily automated publishing with zero manual intervention
- 15-20 trending topics surfaced daily
- 3-5 full synthesis articles per day
- Site loads in < 2 seconds (static)
- Drives measurable GitHub star growth / skill installs
## References
### Trending APIs (free)
- Wikipedia Pageviews: `wikimedia.org/api/rest_v1/metrics/pageviews/top/{project}/{access}/{year}/{month}/{day}`
- Hacker News: `hacker-news.firebaseio.com/v0/topstories.json`
- Reddit: `oauth.reddit.com/r/all/hot` (or `reddit.com/r/all/hot.json` unauthenticated)
- Google News RSS: `news.google.com/rss`
- YouTube: `googleapis.com/youtube/v3/videos?chart=mostPopular`
### Existing codebase hooks
- Bird `getNews()`: `scripts/lib/vendor/bird-search/vendor/package/dist/lib/twitter-client-news.js`
- Store trending: `scripts/store.py:559` (`get_trending()`)
- Scoring: `scripts/lib/score.py` (engagement formulas)
- Brave Trending API: not implemented, available in Brave docs
### Inspiration
- [Keep a Changelog](https://keepachangelog.com/) - clean daily update format
- [Hacker News front page](https://news.ycombinator.com/) - minimal trending UI
- [Exploding Topics](https://explodingtopics.com/) - trending topic showcase (paid, $249/mo)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
---
title: "feat: 15-Test Side-by-Side Comparison - Make CROSS the GOAT"
type: feat
status: active
date: 2026-02-25
origin: docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md
---
# 15-Test Side-by-Side Comparison - Make CROSS the GOAT
## Overview
Run all 5 canonical topics through all 3 skill versions (base, HN, CROSS) for 15 total full last30days runs. Save every result. Analyze side-by-side. Judge which version is best. Then create an improvement plan to make CROSS the definitive next release.
## Problem Statement / Motivation
The previous analysis only ran 5 tests on the CROSS branch - never comparing the same topics across all 3 versions. Without true side-by-side data, we can't judge whether CROSS is actually better or if the new features (dynamic YouTube scoring, cross-refs) introduce regressions. The user wants to see all 15 results, know which version wins, and get a concrete plan to make CROSS the GOAT before shipping.
## The 3 Versions
| Version | Git State | Sources | YouTube Relevance | Cross-Refs |
|---------|-----------|---------|-------------------|------------|
| **Base** | commit `427a4e4` | Reddit, X, YouTube, Web | Hardcoded 0.7 | No |
| **HN** | `main` / `f60a435` | Reddit, X, YouTube, **HN**, Web | Hardcoded 0.7 | No |
| **CROSS** | `feat/youtube-relevance-cross-source` / `0591f55` | Reddit, X, YouTube, HN, Web | Dynamic 0.1-1.0 | Yes (Jaccard 0.5) |
## The 5 Topics
| # | Topic | Category | Why chosen |
|---|-------|----------|------------|
| 1 | "Claude Code skills and MCP servers" | Developer tools | Core user base topic |
| 2 | "Seedance AI video generation" | Creative AI | Trending topic, cross-platform buzz |
| 3 | "M4 MacBook Pro review" | Consumer tech | Product review, mainstream |
| 4 | "best rap songs 2026" | Pop culture | Non-tech stress test |
| 5 | "React vs Svelte 2026" | Framework debate | Dev community, opinion-heavy |
## Test Matrix (15 Runs)
Run in **topic-sequential** order (all 3 versions of topic 1 back-to-back, then topic 2, etc.) to minimize temporal confounds on YouTube/X search results.
| Run | Topic | Version | Output File |
|-----|-------|---------|-------------|
| 1 | Claude Code | Base | `base-1-claude-code.json` |
| 2 | Claude Code | HN | `hn-1-claude-code.json` |
| 3 | Claude Code | CROSS | `cross-1-claude-code.json` |
| 4 | Seedance | Base | `base-2-seedance.json` |
| 5 | Seedance | HN | `hn-2-seedance.json` |
| 6 | Seedance | CROSS | `cross-2-seedance.json` |
| 7 | MacBook | Base | `base-3-macbook.json` |
| 8 | MacBook | HN | `hn-3-macbook.json` |
| 9 | MacBook | CROSS | `cross-3-macbook.json` |
| 10 | Rap songs | Base | `base-4-rap.json` |
| 11 | Rap songs | HN | `hn-4-rap.json` |
| 12 | Rap songs | CROSS | `cross-4-rap.json` |
| 13 | React/Svelte | Base | `base-5-react-svelte.json` |
| 14 | React/Svelte | HN | `hn-5-react-svelte.json` |
| 15 | React/Svelte | CROSS | `cross-5-react-svelte.json` |
**Output directory:** `/tmp/last30days-comparison/full/`
## Execution Protocol
### Pre-flight (once)
- [x] Clear model cache: `rm -f ~/.cache/last30days/model_selection.json`
- [x] Run `--diagnose` on current branch, save as `diagnose-baseline.json`
- [x] Verify all API keys active: Reddit (OPENAI_API_KEY), X (Bird cookies or XAI_API_KEY), YouTube (yt-dlp), HN (no key needed), Web (parallel AI or Brave)
- [x] Create output dir: `mkdir -p /tmp/last30days-comparison/full`
- [x] Stash any uncommitted changes: `git stash` (none needed - no uncommitted changes)
### Per-topic loop (repeat 5 times)
For each topic, run all 3 versions back-to-back:
```bash
# CRITICAL: Clean __pycache__ between EVERY git checkout to prevent stale bytecode
cleanup() {
find scripts -name '__pycache__' -exec rm -rf {} + 2>/dev/null
find scripts -name '*.pyc' -delete 2>/dev/null
}
```
- [ ] **Step 1: Base version**
- `cleanup && git checkout 427a4e4`
- `python3 scripts/last30days.py --diagnose 2>/dev/null` (verify sources match baseline)
- `python3 scripts/last30days.py "<topic>" --quick --emit=json > /tmp/last30days-comparison/full/base-{N}-{slug}.json 2>/tmp/last30days-comparison/full/base-{N}-{slug}.log`
- [ ] **Step 2: HN version**
- `cleanup && git checkout main`
- `python3 scripts/last30days.py "<topic>" --quick --emit=json > /tmp/last30days-comparison/full/hn-{N}-{slug}.json 2>/tmp/last30days-comparison/full/hn-{N}-{slug}.log`
- [ ] **Step 3: CROSS version**
- `cleanup && git checkout feat/youtube-relevance-cross-source`
- `python3 scripts/last30days.py "<topic>" --quick --emit=json > /tmp/last30days-comparison/full/cross-{N}-{slug}.json 2>/tmp/last30days-comparison/full/cross-{N}-{slug}.log`
### Post-run
- [x] Return to feature branch: `git checkout feat/youtube-relevance-cross-source`
- [x] Verify all 15 JSON files exist and are non-empty
- [x] Check for `*_error` fields in any JSON output - flag but include (zero errors)
## Analysis Dimensions
### 1. Source Coverage Table
For each of 15 runs, count items per source:
| Topic | Version | Reddit | X | YouTube | HN | Web | Total |
|-------|---------|--------|---|---------|----|----|-------|
Expected: Base has 0 HN items. HN and CROSS should have identical source counts (same search code). Any differences indicate API non-determinism.
### 2. YouTube Relevance Comparison
**Matched-item analysis:** Match videos by `video_id` across Base/CROSS runs of the same topic. For each matched video:
- Base relevance: always 0.7
- CROSS relevance: dynamic score
- Delta and direction (did dynamic scoring promote or demote this video?)
**Aggregate analysis:** Distribution stats (min/avg/max/stddev) per version per topic.
### 3. Cross-Source Links (CROSS only)
- Count of items with cross_refs per topic
- Quality assessment: are the linked items actually about the same story?
- Which source pairs link most often? (Reddit-HN? YouTube-HN? X-Reddit?)
### 4. Score Distribution and Rankings
- Mean/median score of top 10 items per version per topic
- Rank position changes: do the same items appear in different orders?
- Does HN crowd out Reddit/X items in the top 10?
### 5. "Best Version" Judging Criteria
Define BEFORE analyzing to avoid bias:
| Metric | Weight | How measured |
|--------|--------|-------------|
| Source diversity | 25% | Shannon entropy across source types in top 15 items |
| Score quality | 25% | Mean score of top 10 items |
| Relevance accuracy | 25% | YouTube: do high-relevance videos actually match the query? Manual spot-check of top 3 + bottom 3 per topic |
| Bonus features | 25% | HN value-add (unique info not in other sources) + cross-ref utility (do xrefs add value to the reader?) |
### 6. HN Value-Add Assessment
For each topic where HN returns results:
- How many HN items appear in the overall top 10?
- Do HN items provide information not available from Reddit/X/YouTube?
- Are HN comment insights (top_comments) genuinely useful?
## Deliverables
### A. Raw Results Archive
All 15 JSON files plus logs saved in `/tmp/last30days-comparison/full/`, also copied to `docs/comparison-results/` for persistence.
### B. Comparison Summary Table
A single markdown table showing all 15 runs with key metrics per cell.
### C. Version Verdict
Clear judgment: which version is best overall, and best per topic category (tech, consumer, culture).
### D. CROSS Improvement Plan
Concrete changes to make CROSS the GOAT:
**Based on the previous analysis (see origin doc), likely improvements include:**
1. **Fix cross-source linking** - Switch from char-trigram Jaccard (0.5) to hybrid similarity (max of trigram + token Jaccard) at threshold 0.40. Previous modeling showed this goes from 1 link to 24 links across 5 tests.
2. **Cross-ref rendering** - Change from cryptic `[xref: X1, HN3]` IDs to human-readable `[also on: Reddit, HN]` labels.
3. **HN search broadening** - Investigate why React/Svelte returns 0 HN items.
4. **YouTube relevance** - Already working well, may need minor threshold tuning based on 15-test data.
5. **Score normalization** - Ensure HN items don't systematically crowd out other sources in the merged ranking.
**New improvements to discover from 15-test data:**
- Regressions introduced by CROSS changes
- Edge cases where Base or HN outperforms CROSS
- Topic-specific tuning opportunities
## Acceptance Criteria
- [x] All 15 JSON result files saved and non-empty
- [x] All 15 runs use identical source routing (verified via --diagnose)
- [x] __pycache__ cleaned between every git checkout
- [x] Comparison summary table covers all 15 runs
- [x] YouTube matched-item analysis for at least 3 topics (all 5 done)
- [x] Cross-source link quality spot-check for CROSS runs
- [x] Clear version verdict with supporting data
- [x] CROSS improvement plan with specific code changes
- [x] All results appended to the analysis document for the user to review
## Technical Considerations
- **__pycache__ poisoning**: Python caches bytecode. Switching git commits without clearing `__pycache__` means old code runs even after checkout. MUST clean between every checkout.
- **API rate limiting**: 15 runs hitting Reddit, X, YouTube, HN APIs. The `--quick` mode and natural ~30-90s per run provide spacing, but monitor for 429s in logs.
- **YouTube non-determinism**: yt-dlp search results shift over time. Topic-sequential ordering minimizes this by running all 3 versions of the same topic within ~3-5 minutes.
- **X search session**: Bird CLI depends on browser cookies. If session expires mid-run, X results degrade silently. Check X item counts across runs.
- **`--quick` limitation**: Results reflect Phase 1 only (8-12 items per source). Full pipeline with `--deep` might show different patterns. Document this caveat.
## Sources & References
- Previous analysis: [docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md](docs/plans/2026-02-25-analysis-cross-source-comparison-plan.md)
- Implementation plan: [docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md](docs/plans/2026-02-25-feat-youtube-relevance-and-cross-source-linking-plan.md)
- Existing comparison harness: [scripts/test-v1-vs-v2.sh](scripts/test-v1-vs-v2.sh)
- Scoring weights: [scripts/lib/score.py](scripts/lib/score.py) - 45% relevance + 25% recency + 30% engagement
@@ -0,0 +1,201 @@
---
title: "chore: open PR triage - build or close decision for all 5 open PRs"
type: chore
status: active
date: 2026-03-02
---
# Open PR Triage
Decision record for each of the 5 open PRs on `mvanhorn/last30days-skill`.
For each: build/merge, cherry-pick, or close with explanation.
---
## PR #26 - Add HN, YouTube, and Product Hunt sources + --search flag
**Author:** wkbaran | **Size:** +3402/-49 | **Date:** 2026-02-15
### What it adds
- Hacker News source (Algolia API, no auth)
- YouTube source (YouTube Data API v3, optional key)
- Product Hunt source (GraphQL API, optional key)
- `--search=SOURCES` flag (e.g. `--search=reddit,hn,yt`)
- 58 new tests
### What's already on main
- **HN:** ✅ already merged (`scripts/lib/hackernews.py`)
- **YouTube:** ✅ already merged (`scripts/lib/youtube_yt.py`)
- **Product Hunt:** ❌ NOT on main
- **`--search` flag:** ❌ NOT on main
### Decision: CHERRY-PICK (`--search` flag only, skip Product Hunt)
The HN and YouTube modules in this PR are earlier versions than what's on
main. We should NOT take those. Product Hunt is not a priority source.
The one missing piece worth landing:
**`--search=SOURCES` flag** - lets callers (including agent mode) specify
which sources to run. Useful for `--search=hn,polymarket` or
`--search=reddit,x` focus runs.
### Plan
- Cherry-pick just `tests/test_search_flag.py` and the `--search` argument
wiring in `scripts/last30days.py`
- Skip `scripts/lib/producthunt.py` and all PH-related code entirely
- Do NOT take their `hackernews.py`, `youtube.py`, `render.py`, `normalize.py`,
`schema.py` - our versions are newer
- Update SKILL.md to document `--search` flag
- Comment on PR thanking wkbaran - his HN/YouTube work was an inspiration
for the sources we ended up building. Acknowledge that specifically.
### Acceptance Criteria
- [ ] `python3 scripts/last30days.py "AI coding tools" --search=hn,reddit` runs only HN + Reddit
- [ ] `python3 scripts/last30days.py "test" --search=x,web` skips Reddit
- [ ] `test_search_flag.py` passes (adapt to our test patterns, no PH references)
- [ ] SKILL.md updated with `--search` flag docs
- [ ] Sync via `bash scripts/sync.sh`
---
## PR #24 - Adding Codex compatibility
**Author:** el-analista | **Size:** +228/-29 | **Date:** 2026-02-11
### What it adds
- `agents/openai.yaml` Codex discovery metadata
- Portable script path resolution in SKILL.md (multi-install-path loop)
- Platform-neutral UI text (`assistant` instead of Claude-specific wording)
- `LAST30DAYS_CACHE_DIR` env override in `scripts/lib/cache.py`
- `LAST30DAYS_OUTPUT_DIR` env override in `scripts/lib/render.py`
- X/Bird query noise stripping + last-chance retry in `scripts/lib/bird_x.py`
- New tests: `test_bird_x.py`, `test_cache.py`, `test_render.py`
### What's already on main
- **Codex auth:** ✅ already merged (via PR #38)
- **Multi-path script resolution:** ✅ already in SKILL.md (the `for dir in` loop)
- **Platform-neutral wording:** ❌ NOT systematically applied
- **Cache/output dir overrides:** ❌ NOT on main
- **Bird X improvements:** ✅ partially (we have other bird_x fixes but may be missing noise stripping + retry)
### Decision: REVIEW AND PARTIAL CHERRY-PICK
The env var overrides (`LAST30DAYS_CACHE_DIR`, `LAST30DAYS_OUTPUT_DIR`) are
genuinely useful for sandboxed/containerized Codex environments. The bird_x
noise stripping and last-chance retry may improve X search quality and are
low-risk additions.
The platform-neutral wording change ("assistant" instead of Claude references)
should be skipped — SKILL.md is Claude-specific by design.
### Plan
- Read the diff carefully against our current `cache.py`, `render.py`, `bird_x.py`
- Cherry-pick the `LAST30DAYS_CACHE_DIR` and `LAST30DAYS_OUTPUT_DIR` env overrides
- Cherry-pick the bird_x noise stripping + retry logic if it doesn't conflict
with our existing bird_x changes
- Skip `agents/openai.yaml` — we have our own multi-path resolution
- Skip platform-neutral wording changes
- Comment on PR thanking contributor and explaining what landed
### Acceptance Criteria
- [ ] `LAST30DAYS_CACHE_DIR=/tmp/test python3 scripts/last30days.py "test" --mock` writes cache to /tmp/test
- [ ] `LAST30DAYS_OUTPUT_DIR=/tmp/out python3 scripts/last30days.py "test" --mock` writes output to /tmp/out
- [ ] Existing tests pass after cherry-pick
- [ ] `test_cache.py` and `test_render.py` adapted and passing
---
## PR #14 - Simplify to WebSearch-first, make API keys optional
**Author:** thangman1 | **Size:** +97/-168 | **Date:** 2026-02-01
### What it does
Removes the Reddit/X Python search engine as the primary data source.
Repositions Claude Code's built-in WebSearch as the "default" mode.
Strips engagement metrics (upvotes, likes, repost counts) from output.
Removes mode detection logic (Full Mode / Partial Mode / Web-Only Mode).
### Decision: CLOSE - do not merge
This PR inverts the core value proposition of last30days. The skill's
differentiation is **real engagement data** from Reddit threads and X posts —
upvotes, likes, reposts — that WebSearch cannot provide. Stripping that out
produces a worse tool than just asking Claude to search the web, which anyone
can already do.
The author's intent (lower barrier to entry, no API key required) is valid,
but the right solution is making HN + Polymarket work without any API key
(they already do), and making OPENAI_API_KEY easier to obtain — not removing
the Reddit/X engine.
### Action
- Comment on PR explaining why we're closing it
- Acknowledge the valid friction point (API key setup) and point to HN +
Polymarket as the zero-config sources
- Close PR
---
## PR #10 - OpenRouter API integration
**Author:** thetechreviewer | **Size:** +1029/-16 | **Date:** 2026-01-28
### What it adds
OpenRouter as an alternative to OpenAI for the Reddit discovery search.
Allows using any model available on OpenRouter instead of just OpenAI models.
### What's already on main
- **`scripts/lib/openrouter_search.py`:** ✅ ALREADY ON MAIN
- **Wired into `scripts/last30days.py`:** ✅ ALREADY ON MAIN (the `backend == "openrouter"` path)
### Decision: CLOSE - already merged
The OpenRouter integration that this PR introduced is already on main. It
arrived via internal work that post-dated this PR. The PR is stale.
### Action
- Comment on PR: "Thanks for this — OpenRouter support is already on main
(landed via internal work). Closing as incorporated."
- Close PR
---
## PR #5 - Add support for Codex auth with OpenAI Responses API
**Author:** jblwilliams | **Size:** +358/-66 | **Date:** 2026-01-27
### What it adds
- JWT-based Codex auth with `chatgpt_account_id`
- Codex endpoint routing (`https://chatgpt.com/backend-api/codex/responses`)
- SSE handling for streaming Codex responses
- Typed auth status/source dataclass
- Codex fallback model chain
### What's already on main
**All of this is already on main.** The Codex auth system landed via PR #37
(iliaal:codex-auth-merged), which in turn came in with PR #38. This PR (#5)
predates that work and covers the same ground.
### Decision: CLOSE - already incorporated
### Action
- Comment on PR: "Thanks for this early work on Codex auth! The same feature
landed on main via PR #37 (from a separate contributor who built on similar
ideas). The JWT decode, Codex endpoint routing, SSE parsing, and typed auth
dataclass are all live. Closing as incorporated."
- Close PR
---
## Summary Table
| PR | Author | Decision | Reason |
|----|--------|----------|--------|
| #26 | wkbaran | **Cherry-pick** | `--search` flag not on main; HN/YouTube already there; skip Product Hunt |
| #24 | el-analista | **Cherry-pick** | Cache dir overrides + bird_x improvements worth landing; Codex auth already there |
| #14 | thangman1 | **Close** | Removes engagement data, inverts core value proposition |
| #10 | thetechreviewer | **Close** | OpenRouter already on main |
| #5 | jblwilliams | **Close** | Codex auth already on main via PR #37/38 |
## Implementation Order (if proceeding)
1. Close PR #5, #10, #14 with comments (no code changes needed)
2. Cherry-pick PR #24 pieces (bird_x + cache/render env overrides)
3. Cherry-pick PR #26 pieces (Product Hunt + `--search` flag)
4. Sync and test
5. Push to upstream
@@ -0,0 +1,49 @@
---
title: Close Commented GitHub Issues and PRs
type: fix
status: active
date: 2026-03-03
origin: docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md
---
# Close Commented GitHub Issues and PRs
All 9 open issues and 2 open PRs on `mvanhorn/last30days-skill` were commented on (2026-03-03) with fix confirmations or responses, but none were actually closed on GitHub.
## Acceptance Criteria
- [ ] Close 6 issues where fixes were confirmed (#40, #39, #32, #30, #29, #4)
- [ ] Close PR #26 (superseded - features landed on main)
- [ ] Verify 3 issues remain open (#36, #31, #22) - acknowledged but unresolved
- [ ] Verify PR #24 remains open - explicitly left open per comment
## Actions
### Close as fixed (6 issues)
```bash
gh issue close 40 --repo mvanhorn/last30days-skill --reason completed
gh issue close 39 --repo mvanhorn/last30days-skill --reason completed
gh issue close 32 --repo mvanhorn/last30days-skill --reason completed
gh issue close 30 --repo mvanhorn/last30days-skill --reason completed
gh issue close 29 --repo mvanhorn/last30days-skill --reason completed
gh issue close 4 --repo mvanhorn/last30days-skill --reason completed
```
### Close superseded PR (#26)
```bash
gh pr close 26 --repo mvanhorn/last30days-skill
```
### Keep open (no action needed)
- #36 - SKILL.md flag forwarding (investigating)
- #31 - skills.sh audit scores (needs review)
- #22 - Bird feature requests (backlog)
- PR #24 - Codex compatibility (intentionally left open)
## Sources
- **Origin plan:** [docs/plans/2026-03-03-fix-triage-all-open-github-issues-plan.md](2026-03-03-fix-triage-all-open-github-issues-plan.md)
- Commit: `5e5d586` - fix: triage all 16 open GitHub issues
@@ -0,0 +1,375 @@
---
title: "feat: Paperclip Marketing Automation for last30days"
type: feat
status: active
date: 2026-03-07
---
# Paperclip Marketing Automation for last30days
## Overview
Set up a Paperclip "company" that auto-runs marketing for the last30days open-source skill (3,800+ stars). Four agent roles handle daily demo showcases, release announcements, community engagement, and analytics - all using a draft-then-approve workflow through Paperclip's built-in approval gates.
The killer angle: **last30days markets itself by running itself.** The Content Creator agent runs `/last30days [trending topic] --agent` on hot topics daily, then drafts X threads showing the results. Every post is a live demo.
## Problem Statement / Motivation
- All marketing is currently manual - ~60KB of pre-drafted X threads sit in `docs/` unposted
- No automated community monitoring (GitHub issues, contributor shoutouts)
- No metrics tracking (star growth, fork trends, social engagement)
- Solo entrepreneur can't sustain daily content + community management + development
- The tool's best ad is itself running on interesting topics, but that requires daily effort
## Proposed Solution
A Paperclip company called **"last30days Marketing"** with 4 agent roles, all draft-then-approve.
### Company Structure
```
last30days Marketing (Company)
Mission: "Grow last30days to 10K GitHub stars through daily demo content,
release marketing, and community engagement"
Marketing Director (Claude Code agent)
- Sets daily topic priorities
- Reviews draft quality before surfacing to human
- Coordinates cross-agent work
Content Creator (Python script agent)
- Runs last30days on trending topics daily
- Drafts X showcase threads from the results
- Heartbeat: daily at 8 AM PT
Release Manager (Bash + Python agent)
- Watches for new git tags on upstream
- Drafts release announcement threads
- Heartbeat: every 6 hours (tag check is cheap)
Community Manager (Python script agent)
- Monitors GitHub issues/PRs via gh CLI
- Drafts welcome messages for new contributors
- Surfaces popular feature requests
- Heartbeat: every 4 hours
Analytics Analyst (Python script agent)
- Tracks GitHub stars, forks, traffic
- Tracks X engagement (@slashlast30days)
- Generates weekly digest
- Heartbeat: daily at 11 PM PT (collect), weekly Monday 9 AM (digest)
```
### Draft-Then-Approve Flow
```
Agent creates draft
-> Saved to ~/Documents/Last30Days/drafts/{agent}/{date}-{slug}.md
-> Paperclip approval gate triggers
-> Matt reviews in Paperclip UI (approve / reject / edit)
-> On approve: Python script posts to X API via tweepy
-> Audit log records everything
```
## Technical Approach
### Phase 1: Infrastructure (Day 1-2)
Set up Paperclip and external API access.
**Files to create:**
- `marketing/paperclip-config.yaml` - Company definition, org chart, budgets
- `marketing/scripts/post_to_x.py` - X API v2 posting via tweepy (draft queue -> X)
- `marketing/scripts/github_monitor.py` - GitHub event monitoring via gh CLI
- `marketing/scripts/metrics_collector.py` - Star/fork/engagement tracking
- `marketing/.env.example` - Required API keys template
**Setup steps:**
1. Install Paperclip locally:
```bash
git clone https://github.com/paperclipai/paperclip
cd paperclip && pnpm install && pnpm dev
```
2. Get X API v2 credentials (developer.x.com) for @slashlast30days
- Need: API key, API secret, Access token, Access token secret
- Permissions: Read + Write (posting)
3. GitHub token for monitoring (gh auth already configured)
4. Create the company in Paperclip UI at localhost:3100:
- Company name: "last30days Marketing"
- Mission: "Grow last30days to 10K GitHub stars"
- Monthly budget cap: $50 (mostly API token costs)
### Phase 2: Content Creator Pipeline (Day 3-5)
The core marketing engine. Uses last30days to market itself.
**How it works:**
1. **Topic Discovery** - `marketing/scripts/discover_topics.py`
- Scrapes trending topics from: Wikipedia pageviews API, HN front page, Reddit r/all
- Filters for topics that would make compelling demos (tech, culture, sports, geopolitics)
- Outputs ranked topic list to `~/Documents/Last30Days/topics-queue.json`
2. **Research & Draft** - `marketing/scripts/create_showcase.py`
- Picks top topic from queue
- Runs: `python3 scripts/last30days.py "{topic}" --agent --emit=compact --save-dir=~/Documents/Last30Days`
- Reads the saved research output
- Drafts a 1-2 tweet thread in the established style (see Style Guide below)
- Saves draft to `~/Documents/Last30Days/drafts/content/{date}-{slug}.md`
3. **Approval Gate** - Paperclip surfaces draft for review
- Matt approves/edits in Paperclip UI
- On approve: triggers `post_to_x.py` with the draft content
**Style Guide (extracted from existing launch threads):**
```
Format: Stats-first hook + key finding + tool credit
Example (from v2.5 launch):
"/last30days Anthropic Pete Hegseth"
14 Reddit threads. 29 X posts (11,559 likes). 20 YouTube videos (739K views).
5 HN stories. 9 Polymarket markets.
[Key finding in 2-3 sentences]
Polymarket: [relevant odds with specific numbers]
[One-liner showing the tool's value]
github.com/mvanhorn/last30days-skill
```
Rules:
- Always lead with the /last30days command that was run
- Always include the stats line (thread/post/video counts)
- Always end with the GitHub link
- Pick topics people care about RIGHT NOW
- Never use em dashes - use hyphens instead
- Keep threads to 1-2 tweets max for daily showcases
- Save longer threads (3-6 tweets) for releases
### Phase 3: Release Manager Pipeline (Day 5-6)
**How it works:**
1. **Tag Watcher** - `marketing/scripts/watch_releases.py`
- Runs `git -C /Users/mvanhorn/last30days-skill-private fetch upstream --tags` every 6 hours
- Compares local tags vs upstream tags
- On new tag: reads CHANGELOG.md diff since last tag
2. **Thread Drafter** - `marketing/scripts/draft_release_thread.py`
- Reads changelog diff + release-notes.md
- Drafts a 3-6 tweet thread following the v2.5 launch thread style
- Includes: version number, headline features, demo queries, contributor shoutouts
- Saves to `~/Documents/Last30Days/drafts/releases/{tag}.md`
3. **Approval Gate** - Same flow as content pipeline
**Template (from existing docs/v2.5-launch-tweets.md):**
```
Tweet 1: Announcement + 3 headline features + GitHub link
Tweet 2-4: One demo per tweet (command + stats + finding)
Tweet 5: Contributor shoutouts
Tweet 6: Install instructions
```
### Phase 4: Community Manager Pipeline (Day 6-7)
**How it works:**
1. **GitHub Monitor** - `marketing/scripts/github_monitor.py`
- Runs `gh issue list --repo mvanhorn/last30days-skill --state open --json number,title,author,createdAt,labels`
- Runs `gh pr list --repo mvanhorn/last30days-skill --state open --json number,title,author,createdAt`
- Compares against `~/Documents/Last30Days/community/seen.json` to detect new items
- Categorizes: bug report, feature request, question, PR
2. **Response Drafter** - `marketing/scripts/draft_community_response.py`
- For new issues: drafts a welcome + triage response
- For new PRs: drafts a thank-you + initial review comment
- For merged PRs: drafts a contributor shoutout tweet
- Saves to `~/Documents/Last30Days/drafts/community/{type}-{number}.md`
3. **Approval Gate** - GitHub responses go through same Paperclip approval
- Approved responses posted via `gh issue comment` or `gh pr comment`
- Shoutout tweets posted via `post_to_x.py`
**Response templates:**
```markdown
# New Issue (bug)
Thanks for the report! I'll look into this. Can you share:
- Your OS and Python version
- The exact command you ran
- Whether you have SCRAPECREATORS_API_KEY set
# New Issue (feature request)
Interesting idea! [1-2 sentences acknowledging the value].
Adding this to the backlog for consideration.
# New PR
Thanks for the contribution, @{author}! I'll review this shortly.
[If first-time contributor: Welcome to the project!]
# Merged PR (tweet)
Shoutout to @{author} for [what they did] in last30days v{version}!
[Brief description of the change and why it matters]
github.com/mvanhorn/last30days-skill/pull/{number}
```
### Phase 5: Analytics Pipeline (Day 7-8)
**How it works:**
1. **Metrics Collector** - `marketing/scripts/metrics_collector.py`
- Daily: GitHub stars, forks, open issues, open PRs (via `gh api`)
- Daily: X followers, tweet impressions for @slashlast30days (via X API v2)
- Stores in SQLite at `~/Documents/Last30Days/analytics.db`
2. **Weekly Digest** - `marketing/scripts/weekly_digest.py`
- Runs Monday 9 AM PT
- Generates markdown report with week-over-week changes:
- Star growth (absolute + rate)
- New forks
- Issues opened/closed
- PRs merged
- Top-performing tweets
- Notable community interactions
- Saves to `~/Documents/Last30Days/digests/{date}-weekly.md`
- Optionally sends via email (SendGrid) or Slack webhook
**Schema for analytics.db:**
```sql
CREATE TABLE daily_metrics (
date TEXT PRIMARY KEY,
github_stars INTEGER,
github_forks INTEGER,
github_open_issues INTEGER,
github_open_prs INTEGER,
x_followers INTEGER,
x_impressions INTEGER,
x_engagement_rate REAL
);
CREATE TABLE tweet_performance (
tweet_id TEXT PRIMARY KEY,
posted_at TEXT,
type TEXT, -- 'showcase', 'release', 'shoutout'
topic TEXT,
impressions INTEGER,
likes INTEGER,
retweets INTEGER,
replies INTEGER,
link_clicks INTEGER
);
```
## Technical Considerations
### API Costs
| Service | Usage | Est. Monthly Cost |
|---------|-------|-------------------|
| Paperclip | Self-hosted | $0 |
| X API v2 | Free tier (posting) | $0 |
| ScrapeCreators | ~30 daily research runs | ~$15 |
| GitHub API | gh CLI, already authed | $0 |
| Claude API | Marketing Director agent | ~$20 |
| **Total** | | **~$35/month** |
### Security
- API keys stored in `marketing/.env` (gitignored, never committed)
- X API tokens scoped to @slashlast30days only (not personal account)
- Paperclip budget cap prevents runaway spend
- All posts go through human approval gate - no autonomous posting
- GitHub token uses existing `gh auth` session
### Failure Modes
- **last30days script fails** - Content Creator skips that day, logs error, tries again tomorrow with new topic
- **X API rate limit** - Queue drafts and retry on next heartbeat cycle
- **Paperclip goes down** - Drafts accumulate in filesystem, nothing posts (safe failure)
- **Bad topic selection** - Marketing Director agent filters topics before research (no politics, no NSFW)
- **Stale approval queue** - If drafts pile up >3 days unapproved, send a nudge notification
### Topic Selection Criteria
The discover_topics.py script should filter for topics that:
1. Are trending NOW (Wikipedia pageview spike, HN front page, Reddit r/all)
2. Would produce interesting multi-source results (not too niche, not too broad)
3. Are safe for a developer tool brand (tech, sports, culture, science - avoid divisive politics)
4. Haven't been covered in the last 7 days (dedup against previous showcases)
5. Span different categories to show the tool's versatility (not all tech, not all sports)
Good examples (from existing launch threads): "Anthropic Pete Hegseth", "Seedance prompting", "Arizona basketball", "Iran war"
## Acceptance Criteria
- [ ] Paperclip running locally with "last30days Marketing" company created
- [ ] Content Creator agent runs last30days daily on a trending topic and produces a draft
- [ ] Release Manager agent detects new git tags and drafts announcement threads
- [ ] Community Manager agent detects new GitHub issues/PRs and drafts responses
- [ ] Analytics agent collects daily metrics and generates weekly digest
- [ ] All drafts go through Paperclip approval gate before posting
- [ ] X API integration posts approved drafts to @slashlast30days
- [ ] GitHub responses posted via gh CLI after approval
- [ ] Monthly budget stays under $50
- [ ] Style of generated tweets matches existing launch thread tone
## Success Metrics
- **Content velocity**: 5-7 showcase tweets/week (up from ~0 currently)
- **Star growth**: Track week-over-week acceleration after content starts
- **Time savings**: <5 min/day reviewing drafts vs 30-60 min/day manual marketing
- **Content quality**: Drafts require minimal editing before approval (>80% approved as-is within 2 weeks)
## Dependencies & Risks
| Dependency | Risk | Mitigation |
|-----------|------|------------|
| Paperclip stability | Early-stage project, may have bugs | Pin to specific commit, keep simple config |
| X API free tier | May get rate-limited or deprecated | Queue-based posting, daily limits |
| Topic discovery quality | Bad topics = bad demos | Human review in approval gate, topic blocklist |
| Claude API for Marketing Director | Cost could spike | Budget cap in Paperclip, simple prompts |
| ScrapeCreators API | PAYG costs scale with usage | Cap at 1 research run/day for showcases |
## File Structure
```
last30days-skill-private/
marketing/
README.md # Setup instructions
.env.example # Required API keys
paperclip-config.yaml # Company definition
scripts/
discover_topics.py # Trending topic discovery
create_showcase.py # Research + draft showcase tweet
draft_release_thread.py # Release announcement drafter
github_monitor.py # GitHub issue/PR monitor
draft_community_response.py # Community response drafter
post_to_x.py # X API posting (after approval)
metrics_collector.py # Daily metrics collection
weekly_digest.py # Weekly analytics digest
templates/
showcase.md # Tweet template for daily demos
release.md # Thread template for releases
community_responses.md # Response templates by type
```
## Sources & References
- Paperclip GitHub: github.com/paperclipai/paperclip
- Paperclip docs: paperclip.ing
- Existing launch threads: `docs/v2.5-launch-tweets.md`, `docs/v2.1-tweets.md`
- Existing launch copy: `docs/v2.1-launch-copy.md`
- Planned Last30Days.com: `docs/plans/2026-02-20-feat-last30days-com-trending-topics-plan.md`
- last30days `--agent` mode: SKILL.md line 115-142 (non-interactive output for automation)
@@ -0,0 +1,261 @@
---
title: Triage Open Issues and PRs
type: refactor
status: active
date: 2026-03-07
---
# Triage Open Issues and PRs - last30days-skill
Consolidated review and action plan for all 6 open items (2 issues, 4 PRs) as of 2026-03-07.
---
## 1. PR #52 - Fix missing metadata files in skill upload bundle
**Author:** 04cb | **Size:** +0/-1 | **Fixes:** #46
**File changed:** `.clawhubignore` (removes `*.json` glob)
### What it does
The `*.json` pattern in `.clawhubignore` was excluding `.claude-plugin/marketplace.json` and `.claude-plugin/plugin.json` - the metadata files required for ClawHub skill upload. This caused the "Zip file contains path with invalid characters" error reported in #46.
### Risk analysis
- Removing `*.json` also includes test fixtures (`fixtures/*.json`) and vendored `package.json` in the bundle. None are sensitive or harmful.
- No `package-lock.json`, `skills-lock.json`, or credential files exist as `.json` in the repo.
- One-line change with zero production code impact.
### Recommendation: MERGE IMMEDIATELY
- [x] Merge PR #52
- [x] Close issue #46 (auto-closes via "Fixes #46")
- [ ] Verify upload works post-merge
---
## 2. Issue #46 - "Claude will not upload as it says thread is invalid"
**Author:** johnuppard | **Error:** "Zip file contains path with invalid characters"
### Recommendation: CLOSES WITH PR #52
No standalone action needed. PR #52 is the fix. After merge, comment on #46 confirming resolution and ask reporter to verify.
---
## 3. PR #50 - test: add tests for entity_extract module
**Author:** mark-c4r | **Size:** +167/-0 (tests only)
### What it does
Adds `tests/test_entity_extract.py` with 23 test cases covering all 4 public/private functions in `scripts/lib/entity_extract.py`:
| Function | Cases | Coverage |
|----------|-------|----------|
| `_extract_x_handles` | 8 | author handles, @mentions, generic filtering, case normalization, frequency ranking, edge cases |
| `_extract_x_hashtags` | 5 | basic extraction, multiple tags, frequency ranking, short tag filtering, empty input |
| `_extract_subreddits` | 6 | field extraction, cross-refs in comments, frequency ranking, r/ stripping |
| `extract_entities` | 4 | integration, max limits, empty inputs, return key validation |
### Code quality assessment
- All function signatures match the actual module implementation exactly
- All 23 tests validated against the real module - they pass
- Import pattern matches existing tests (`sys.path.insert` + `from lib import`)
- Uses `unittest.TestCase` consistent with all other test files
- No external dependencies, no API calls, no mocking needed (pure functions)
- Test data uses inline dicts matching real Reddit/X response structure
### Recommendation: MERGE AFTER LOCAL VERIFICATION
- [x] Pull branch and run `python3 -m pytest tests/test_entity_extract.py -v` (23/23 passed)
- [x] Run full suite `python3 -m pytest tests/` (317/320 passed - 3 pre-existing failures in test_models.py, unrelated)
- [x] Merge
This is a clean, well-structured community contribution that adds coverage to a previously untested module.
---
## 4. PR #48 - feat: add Xiaohongshu source + Reddit public fallback
**Author:** YJLi-new | **Size:** +523/-75 | **Files:** 5 modified
### What it does
Two features bundled in one PR:
**A. Xiaohongshu (Little Red Book) source:**
- New `scripts/lib/xiaohongshu_api.py` module for searching via xiaohongshu-mcp HTTP API
- Source alias: `--search xiaohongshu` or `--search xhs`
- Health/login availability checks in `env.py`
- Handles Chinese numeric suffixes (wan/yi) in engagement parsing
- Default API base: `http://host.docker.internal:18060` (Docker-hosted)
**B. Reddit public JSON fallback:**
- `search_reddit_public()` added to `openai_reddit.py`
- Uses `reddit.com/search/.json` endpoint (no API key required)
- Multiple query strategy (topic, core subject, quoted core)
- Engagement-based relevance heuristic (60% score, 40% comments)
- Supplemental retries correctly gated - only fire when OpenAI auth is present
### Architecture compliance
| Pattern | Status |
|---------|--------|
| Three-function module pattern (search/parse/enrich) | Follows existing patterns |
| `DEPTH_CONFIG` with quick/default/deep | Present |
| `_log()` gated on stderr.isatty() | Present |
| env.py availability check | Present with retry logic |
| ThreadPoolExecutor dispatch | Correctly wired with +1 worker |
| Render/UI integration | Consistent with existing sources |
### Issues found
1. **Health check duplication** - `is_xiaohongshu_available()` in env.py AND `search_feeds()` both probe login status. Redundant but harmless.
2. **`from_date`/`to_date` accepted but unused** for Xiaohongshu - relies on API-side time bucketing. Acceptable given API limitation.
3. **Over-defensive `setdefault()`** in normalization (id and source_domain already set by search_feeds). Harmless.
4. **Error message format inconsistency** between OpenAI and public Reddit paths. Minor.
5. **`get_available_sources()` docstring** not updated to reflect Reddit always being available now.
6. **No tests included** for new Xiaohongshu module or Reddit public fallback.
### Security
- No hardcoded credentials
- xsec_token comes from API response (not user input)
- Docker `host.docker.internal` default is safe for containerized environments
- All URLs use `https://` for public Xiaohongshu
### Recommendation: MERGE WITH CONDITIONS
The Reddit public fallback alone makes this worth merging - it makes Reddit work with zero API keys. Xiaohongshu adds value for Chinese-market research.
**Before merge:**
- [ ] Run full test suite to confirm no regressions
- [ ] Test Reddit public fallback locally: unset OPENAI_API_KEY, run `python3 scripts/last30days.py "test topic" --search reddit --emit=compact`
- [ ] Verify Xiaohongshu gracefully skips when API is unavailable (should show skip message, not crash)
- [ ] Update `get_available_sources()` docstring to reflect Reddit always-available change
**After merge (follow-up):**
- [ ] Add `tests/test_xiaohongshu.py` with fixture-based tests
- [ ] Add `tests/test_reddit_public.py` for the fallback path
- [ ] Consider extracting health check duplication
---
## 5. PR #47 - feat: add Apify as unified API provider
**Author:** lapolazzati | **Size:** +1484/-73 | **Files:** 6 new + orchestrator changes
### What it does
Adds Apify as a single-token alternative (`APIFY_API_TOKEN`) covering Reddit, X, TikTok, and Instagram. Existing per-source keys take priority; Apify is a transparent fallback.
**New modules:**
- `apify_client.py` (163 lines) - shared HTTP client for Apify run-sync API
- `apify_reddit.py` (195 lines) - Reddit via `trudax/reddit-scraper` actor
- `apify_x.py` (221 lines) - X via `apidojo/tweet-scraper` actor
- `apify_tiktok.py` (284 lines) - TikTok via `clockworks/tiktok-scraper` actor
- `apify_instagram.py` (288 lines) - Instagram via `apify/instagram-reel-scraper` actor
**Source routing priority:**
```
Reddit: OpenAI -> Apify -> None
X: Bird -> xAI -> Apify -> None
TikTok: ScrapeCreators -> Apify -> None
Instagram: ScrapeCreators -> Apify -> None
```
### Critical issues
1. **Actor ID mismatch (BLOCKER):** Code uses different Apify actors than documented in README and plan.md.
| Source | In Code | In Docs |
|--------|---------|---------|
| Reddit | `trudax/reddit-scraper` | `automation-lab/reddit-scraper` |
| X | `apidojo/tweet-scraper` | `scraper_one/x-posts-search` |
| TikTok | `clockworks/tiktok-scraper` | `epctex/tiktok-search-scraper` |
| Instagram | `apify/instagram-reel-scraper` | matches |
Users following README instructions will hit wrong actors.
2. **No tests (BLOCKER):** 1484 lines of new code with zero test coverage. Each Apify module has its own date parsing, relevance scoring, and response normalization - all untested.
3. **Significant code duplication:** `apify_tiktok.py` and `apify_instagram.py` share nearly identical:
- `_tokenize()` (7 lines)
- `_compute_relevance()` (14 lines)
- `STOPWORDS` set
- `SYNONYMS` dict
- `_extract_core_subject()` (~20 lines)
4. **Version mismatch:** README says v2.9 but git history shows v2.9.4 on main. Version should be higher.
5. **plan.md included in commit** - implementation planning doc shouldn't be in the final merge.
### What's good
- Source routing logic in env.py is clean and backward-compatible
- Orchestrator dispatch uses consistent `_source` parameter pattern
- Existing source paths are completely untouched
- Timeout scaling is appropriate (90s quick, 150s default, 240s deep)
- Token handling follows security best practices (parameter passing, Bearer auth)
### Recommendation: REQUEST CHANGES
This PR is too large and has too many issues for a clean merge. Request the contributor to:
**Must fix before any merge:**
- [ ] Resolve actor ID mismatches (verify which actors actually work, update code OR docs)
- [ ] Add unit tests for all 5 new modules (at minimum: response normalization, date parsing)
- [ ] Fix version number
- [ ] Remove plan.md from commit
**Should fix:**
- [ ] Extract shared code from apify_tiktok/apify_instagram into `apify_common.py`
- [ ] Consider splitting into 2 PRs:
- PR A: `apify_client.py` + env.py routing (foundation)
- PR B: Individual source modules + tests (features)
**Comment template for PR:**
> Thanks for this contribution - the single-token approach is a great idea for simplifying setup. A few things need fixing before we can merge:
>
> 1. The Apify actor IDs in the code don't match the README/plan docs. Can you verify which actors are correct and align code + docs?
> 2. We need test coverage for the new modules - at minimum normalization tests with sample actor responses.
> 3. There's significant duplication between apify_tiktok.py and apify_instagram.py (_tokenize, _compute_relevance, STOPWORDS, SYNONYMS, _extract_core_subject). Could you extract shared code to an apify_common.py?
> 4. Version in README should be higher than v2.9.4 (current main).
> 5. Please remove plan.md from the commit.
---
## 6. Issue #45 - Add support for Gemini CLI
**Author:** alexferrari88 | **Request:** "Would it be possible to add support for Gemini CLI?"
### Assessment
This is a feature request to support Gemini CLI as a runtime alongside Claude Code and Codex. Key considerations:
- **Scope:** The skill is currently packaged for Claude Code (SKILL.md format) and Codex (~/.codex/skills/). Gemini CLI uses a different skill/extension format.
- **Effort:** Would require understanding Gemini CLI's plugin system, creating a compatible manifest, and potentially adapting the Python execution model.
- **Priority:** Low - Claude Code and Codex are the primary targets and where the user base is.
- **Community:** If the requester wants to contribute, they'd be best positioned to understand Gemini CLI's requirements.
### Recommendation: ACKNOWLEDGE AND BACKLOG
- [ ] Respond with: "Thanks for the suggestion! Adding to the backlog. If you're familiar with Gemini CLI's skill/extension format and want to take a crack at it, PRs are welcome. The core Python scripts in `scripts/` are runtime-agnostic - the main work would be creating a Gemini-compatible manifest and deployment path."
- [ ] Add a `help wanted` label
- [ ] Keep open as a backlog item
---
## Priority Summary
| Priority | Item | Action | Risk |
|----------|------|--------|------|
| 1 | PR #52 (metadata fix) | Merge now | None |
| 2 | Issue #46 (upload error) | Auto-closes with PR #52 | None |
| 3 | PR #50 (entity tests) | Run tests, merge | None |
| 4 | PR #48 (Xiaohongshu + Reddit fallback) | Test locally, merge with minor conditions | Low |
| 5 | Issue #45 (Gemini CLI) | Acknowledge, backlog, label | None |
| 6 | PR #47 (Apify unified) | Request changes (blockers found) | Medium-High |
@@ -0,0 +1,388 @@
---
name: last30days
description: Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool.
argument-hint: "[topic] for [tool]" or "[topic]"
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
---
# last30days: Research Any Topic from the Last 30 Days
Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now.
Use cases:
- **Prompting**: "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts
- **Recommendations**: "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention
- **News**: "what's happening with OpenAI", "latest AI announcements" → current events and updates
- **General**: any topic you're curious about → understand what the community is saying
## CRITICAL: Parse User Intent
Before doing anything, parse the user's input for:
1. **TOPIC**: What they want to learn about (e.g., "web app mockups", "Claude Code skills", "image generation")
2. **TARGET TOOL** (if specified): Where they'll use the prompts (e.g., "Nano Banana Pro", "ChatGPT", "Midjourney")
3. **QUERY TYPE**: What kind of research they want:
- **PROMPTING** - "X prompts", "prompting for X", "X best practices" → User wants to learn techniques and get copy-paste prompts
- **RECOMMENDATIONS** - "best X", "top X", "what X should I use", "recommended X" → User wants a LIST of specific things
- **NEWS** - "what's happening with X", "X news", "latest on X" → User wants current events/updates
- **GENERAL** - anything else → User wants broad understanding of the topic
Common patterns:
- `[topic] for [tool]` → "web mockups for Nano Banana Pro" → TOOL IS SPECIFIED
- `[topic] prompts for [tool]` → "UI design prompts for Midjourney" → TOOL IS SPECIFIED
- Just `[topic]` → "iOS design mockups" → TOOL NOT SPECIFIED, that's OK
- "best [topic]" or "top [topic]" → QUERY_TYPE = RECOMMENDATIONS
- "what are the best [topic]" → QUERY_TYPE = RECOMMENDATIONS
**IMPORTANT: Do NOT ask about target tool before research.**
- If tool is specified in the query, use it
- If tool is NOT specified, run research first, then ask AFTER showing results
**Store these variables:**
- `TOPIC = [extracted topic]`
- `TARGET_TOOL = [extracted tool, or "unknown" if not specified]`
- `QUERY_TYPE = [RECOMMENDATIONS | NEWS | HOW-TO | GENERAL]`
---
## Setup Check
The skill works in three modes based on available API keys:
1. **Full Mode** (both keys): Reddit + X + WebSearch - best results with engagement metrics
2. **Partial Mode** (one key): Reddit-only or X-only + WebSearch
3. **Web-Only Mode** (no keys): WebSearch only - still useful, but no engagement metrics
**API keys are OPTIONAL.** The skill will work without them using WebSearch fallback.
### First-Time Setup (Optional but Recommended)
If the user wants to add API keys for better results:
```bash
mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'ENVEOF'
# last30days API Configuration
# Both keys are optional - skill works with WebSearch fallback
# For Reddit research (uses OpenAI's web_search tool)
OPENAI_API_KEY=
# For X/Twitter research (uses xAI's x_search tool)
XAI_API_KEY=
ENVEOF
chmod 600 ~/.config/last30days/.env
echo "Config created at ~/.config/last30days/.env"
echo "Edit to add your API keys for enhanced research."
```
**DO NOT stop if no keys are configured.** Proceed with web-only mode.
---
## Research Execution
**IMPORTANT: The script handles API key detection automatically.** Run it and check the output to determine mode.
**Step 1: Run the research script**
```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
```
The script will automatically:
- Detect available API keys
- Show a promo banner if keys are missing (this is intentional marketing)
- Run Reddit/X searches if keys exist
- Signal if WebSearch is needed
**Step 2: Check the output mode**
The script output will indicate the mode:
- **"Mode: both"** or **"Mode: reddit-only"** or **"Mode: x-only"**: Script found results, WebSearch is supplementary
- **"Mode: web-only"**: No API keys, Claude must do ALL research via WebSearch
**Step 3: Do WebSearch**
For **ALL modes**, do WebSearch to supplement (or provide all data in web-only mode).
Choose search queries based on QUERY_TYPE:
**If RECOMMENDATIONS** ("best X", "top X", "what X should I use"):
- Search for: `best {TOPIC} recommendations`
- Search for: `{TOPIC} list examples`
- Search for: `most popular {TOPIC}`
- Goal: Find SPECIFIC NAMES of things, not generic advice
**If NEWS** ("what's happening with X", "X news"):
- Search for: `{TOPIC} news 2026`
- Search for: `{TOPIC} announcement update`
- Goal: Find current events and recent developments
**If PROMPTING** ("X prompts", "prompting for X"):
- Search for: `{TOPIC} prompts examples 2026`
- Search for: `{TOPIC} techniques tips`
- Goal: Find prompting techniques and examples to create copy-paste prompts
**If GENERAL** (default):
- Search for: `{TOPIC} 2026`
- Search for: `{TOPIC} discussion`
- Goal: Find what people are actually saying
For ALL query types:
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
- If user says "ChatGPT image prompting", search for "ChatGPT image prompting"
- Do NOT add "DALL-E", "GPT-4o", or other terms you think are related
- Your knowledge may be outdated - trust the user's terminology
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
**Step 3: Wait for background script to complete**
Use TaskOutput to get the script results before proceeding to synthesis.
**Depth options** (passed through from user's command):
- `--quick` → Faster, fewer sources (8-12 each)
- (default) → Balanced (20-30 each)
- `--deep` → Comprehensive (50-70 Reddit, 40-60 X)
---
## Judge Agent: Synthesize All Sources
**After all searches complete, internally synthesize (don't display stats yet):**
The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight WebSearch sources LOWER (no engagement data)
3. Identify patterns that appear across ALL three sources (strongest signals)
4. Note any contradictions between sources
5. Extract the top 3-5 actionable insights
**Do NOT display stats here - they come at the end, right before the invitation.**
---
## FIRST: Internalize the Research
**CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.**
Read the research output carefully. Pay attention to:
- **Exact product/tool names** mentioned (e.g., if research mentions "ClawdBot" or "@clawdbot", that's a DIFFERENT product than "Claude Code" - don't conflate them)
- **Specific quotes and insights** from the sources - use THESE, not generic knowledge
- **What the sources actually say**, not what you assume the topic is about
**ANTI-PATTERN TO AVOID**: If user asks about "clawdbot skills" and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as "Claude Code skills" just because both involve "skills". Read what the research actually says.
### If QUERY_TYPE = RECOMMENDATIONS
**CRITICAL: Extract SPECIFIC NAMES, not generic patterns.**
When user asks "best X" or "top X", they want a LIST of specific things:
- Scan research for specific product names, tool names, project names, skill names, etc.
- Count how many times each is mentioned
- Note which sources recommend each (Reddit thread, X post, blog)
- List them by popularity/mention count
**BAD synthesis for "best Claude Code skills":**
> "Skills are powerful. Keep them under 500 lines. Use progressive disclosure."
**GOOD synthesis for "best Claude Code skills":**
> "Most mentioned skills: /commit (5 mentions), remotion skill (4x), git-worktree (3x), /pr (3x). The Remotion announcement got 16K likes on X."
### For all QUERY_TYPEs
Identify from the ACTUAL RESEARCH OUTPUT:
- **PROMPT FORMAT** - Does research recommend JSON, structured params, natural language, keywords? THIS IS CRITICAL.
- The top 3-5 patterns/techniques that appeared across multiple sources
- Specific keywords, structures, or approaches mentioned BY THE SOURCES
- Common pitfalls mentioned BY THE SOURCES
**If research says "use JSON prompts" or "structured prompts", you MUST deliver prompts in that format later.**
---
## THEN: Show Summary + Invite Vision
**CRITICAL: Do NOT output any "Sources:" lists. The final display should be clean.**
**Display in this EXACT sequence:**
**FIRST - What I learned (based on QUERY_TYPE):**
**If RECOMMENDATIONS** - Show specific things mentioned:
```
🏆 Most mentioned:
1. [Specific name] - mentioned {n}x (r/sub, @handle, blog.com)
2. [Specific name] - mentioned {n}x (sources)
3. [Specific name] - mentioned {n}x (sources)
4. [Specific name] - mentioned {n}x (sources)
5. [Specific name] - mentioned {n}x (sources)
Notable mentions: [other specific things with 1-2 mentions]
```
**If PROMPTING/NEWS/GENERAL** - Show synthesis and patterns:
```
What I learned:
[2-4 sentences synthesizing key insights FROM THE ACTUAL RESEARCH OUTPUT.]
KEY PATTERNS I'll use:
1. [Pattern from research]
2. [Pattern from research]
3. [Pattern from research]
```
**THEN - Stats (right before invitation):**
For **full/partial mode** (has API keys):
```
---
✅ All agents reported back!
├─ 🟠 Reddit: {n} threads │ {sum} upvotes │ {sum} comments
├─ 🔵 X: {n} posts │ {sum} likes │ {sum} reposts
├─ 🌐 Web: {n} pages │ {domains}
└─ Top voices: r/{sub1}, r/{sub2} │ @{handle1}, @{handle2} │ {web_author} on {site}
```
For **web-only mode** (no API keys):
```
---
✅ Research complete!
├─ 🌐 Web: {n} pages │ {domains}
└─ Top sources: {author1} on {site1}, {author2} on {site2}
💡 Want engagement metrics? Add API keys to ~/.config/last30days/.env
- OPENAI_API_KEY → Reddit (real upvotes & comments)
- XAI_API_KEY → X/Twitter (real likes & reposts)
```
**LAST - Invitation:**
```
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into {TARGET_TOOL}.
```
**Use real numbers from the research output.** The patterns should be actual insights from the research, not generic advice.
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If the research was about ClawdBot (a self-hosted AI agent), your summary should be about ClawdBot, not Claude Code. If you catch yourself projecting your own knowledge instead of the research, rewrite it.
**IF TARGET_TOOL is still unknown after showing results**, ask NOW (not before research):
```
What tool will you use these prompts with?
Options:
1. [Most relevant tool based on research - e.g., if research mentioned Figma/Sketch, offer those]
2. Nano Banana Pro (image generation)
3. ChatGPT / Claude (text/code)
4. Other (tell me)
```
**IMPORTANT**: After displaying this, WAIT for the user to respond. Don't dump generic prompts.
---
## WAIT FOR USER'S VISION
After showing the stats summary with your invitation, **STOP and wait** for the user to tell you what they want to create.
When they respond with their vision (e.g., "I want a landing page mockup for my SaaS app"), THEN write a single, thoughtful, tailored prompt.
---
## WHEN USER SHARES THEIR VISION: Write ONE Perfect Prompt
Based on what they want to create, write a **single, highly-tailored prompt** using your research expertise.
### CRITICAL: Match the FORMAT the research recommends
**If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT:**
- Research says "JSON prompts" → Write the prompt AS JSON
- Research says "structured parameters" → Use structured key: value format
- Research says "natural language" → Use conversational prose
- Research says "keyword lists" → Use comma-separated keywords
**ANTI-PATTERN**: Research says "use JSON prompts with device specs" but you write plain prose. This defeats the entire purpose of the research.
### Output Format:
```
Here's your prompt for {TARGET_TOOL}:
---
[The actual prompt IN THE FORMAT THE RESEARCH RECOMMENDS - if research said JSON, this is JSON. If research said natural language, this is prose. Match what works.]
---
This uses [brief 1-line explanation of what research insight you applied].
```
### Quality Checklist:
- [ ] **FORMAT MATCHES RESEARCH** - If research said JSON/structured/etc, prompt IS that format
- [ ] Directly addresses what the user said they want to create
- [ ] Uses specific patterns/keywords discovered in research
- [ ] Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)
- [ ] Appropriate length and style for TARGET_TOOL
---
## IF USER ASKS FOR MORE OPTIONS
Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested.
---
## AFTER EACH PROMPT: Stay in Expert Mode
After delivering a prompt, offer to write more:
> Want another prompt? Just tell me what you're creating next.
---
## CONTEXT MEMORY
For the rest of this conversation, remember:
- **TOPIC**: {topic}
- **TARGET_TOOL**: {tool}
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
- **RESEARCH FINDINGS**: The key facts and insights from the research
**CRITICAL: After research is complete, you are now an EXPERT on this topic.**
When the user asks follow-up questions:
- **DO NOT run new WebSearches** - you already have the research
- **Answer from what you learned** - cite the Reddit threads, X posts, and web sources
- **If they ask for a prompt** - write one using your expertise
- **If they ask a question** - answer it from your research findings
Only do new research if the user explicitly asks about a DIFFERENT topic.
---
## Output Summary Footer (After Each Prompt)
After delivering a prompt, end with:
For **full/partial mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages
Want another prompt? Just tell me what you're creating next.
```
For **web-only mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} web pages from {domains}
Want another prompt? Just tell me what you're creating next.
💡 Unlock Reddit & X data: Add API keys to ~/.config/last30days/.env
```
@@ -0,0 +1,310 @@
# V1 vs V2 Comparison Analysis
**Date:** 2026-02-06
**Queries tested:** 4 (1 head-to-head, 3 V1-only)
**Scope:** Quick smoke test, not full 17-query matrix
---
## Part 1: Head-to-Head -- "kanye west" (NEWS Query)
### Dimension-by-Dimension Scoring
#### 1. Query Parsing Display
Does it show the `🔍 **{TOPIC}** · {QUERY_TYPE}` line before running tools?
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 1 | No parsing display at all. Output starts with "## What I learned:" -- jumps straight into synthesis. No acknowledgment of topic or query type before research. |
| V2 | 1 | No parsing display either. Output starts with "Here's what I found:" then "## What I learned:" -- same problem as V1. |
**Analysis:** Neither version actually rendered the query parsing display. V2 SKILL.md explicitly requires `🔍 **kanye west** · News` before any tools run, but the agent did not produce it. This is a V2 instruction that failed to land. Both score 1/5.
Possible cause: The parsing display is supposed to appear *before* tools are called -- it may have been shown during execution but not captured in the final output text. If so, both outputs represent only the post-research synthesis, not the full session. Regardless, based on what is in the output files, neither shows it.
---
#### 2. Source Coverage (Reddit/X/Web counts)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 3 | `Reddit: 0 relevant threads` / `X: 30 posts │ ~10 likes` / `Web: 20+ pages`. Two of three sources returned results. Reddit was zero. |
| V2 | 3 | `Reddit: 0 threads (no results this cycle)` / `X: 29 posts │ 33 likes │ 14 reposts` / `Web: 30+ pages`. Same pattern: two of three returned results. |
**Analysis:** Nearly identical coverage. Both got zero Reddit results (likely a script/API issue for this topic, not a SKILL.md problem). V2 has slightly more precise X metrics (33 likes, 14 reposts vs. V1's vague "~10 likes"). V2 has more web pages (30+ vs 20+). Both miss the 10+ Reddit threshold for a score of 4+.
---
#### 3. Citation Quality (sparse vs every-sentence)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 2 | No inline citations at all. The body text makes claims ("full-page Wall Street Journal apology," "Hellwatt Festival in Italy") but never attributes them to a specific source. The stats box lists "Washington Post, Billboard, AllHipHop" but the body has zero `per @handle` or `per Rolling Stone` attributions. |
| V2 | 5 | Every bold section ends with a sparse, clean citation. Examples: `"per Rolling Stone"`, `"per The Washington Post"`, `"per Billboard"`, `"per AllHipHop"`, `"per The News International"`. One citation per topic, never chained. Exactly what V2 SKILL.md specifies. |
**Analysis:** This is the single biggest quality gap between V1 and V2. V1's output reads like a Wikipedia summary -- informative but ungrounded. V2 reads like a researched briefing where every claim has a named source. V2 nails the "sparse citation" rule from its SKILL.md: `"cite 1 source per pattern, short format: 'per @handle' or 'per r/sub'"`.
V1 quote (no citation): `"He'll headline the new Hellwatt Festival in Italy (July 4-18, 2026)."`
V2 quote (cited): `"Ye is headlining a brand-new festival at the 103,000-capacity RCF Arena in Italy over three weekends from July 4-18, 2026 — his first-ever live concert in Italy, per Billboard."`
---
#### 4. Summary Structure (bold topic headers, organized sections)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 3 | Has a coherent narrative structure with a paragraph of synthesis, then a `**KEY THEMES:**` numbered list. But the opening is a single dense paragraph, not broken into scannable sections with bold headers. |
| V2 | 5 | Each storyline gets its own bold header: `**BULLY Album — March 20, 2026 via Gamma**`, `**Public Apology for Antisemitism**`, `**Hellwatt Festival in Italy**`, `**Health Concerns**`, `**Grammys Ban**`, `**Kim & Lewis Hamilton Buzz**`. Each is a standalone scannable unit with 1-3 sentences. |
**Analysis:** V2 follows the SKILL.md template exactly: `**{Topic 1}** — [1-2 sentences, per source]`. V1 uses a blob + list approach which is readable but less scannable. V2 is notably better for a user who wants to skim and find the story they care about.
V1 structure: 1 dense paragraph -> 5-item `KEY THEMES` list
V2 structure: 6 bold topic cards, each self-contained -> no KEY THEMES list (but doesn't need one because the structure itself is the organization)
---
#### 5. Stats Box Format (emoji tree vs plain text)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 4 | Uses `├─` tree format with emoji: `├─ 🟠 Reddit: 0 relevant threads` / `├─ 🔵 X: 30 posts` / `├─ 🌐 Web: 20+ pages` / `└─ Top voices:`. Minor deviation: says "0 relevant threads (filtered out noise)" instead of the V1 SKILL.md template "0 threads (no results this cycle)". Also omits the `🗣️` emoji on the Top voices line. |
| V2 | 5 | Perfect match to V2 SKILL.md template: `├─ 🟠 Reddit: 0 threads (no results this cycle)` / `├─ 🔵 X: 29 posts │ 33 likes │ 14 reposts (via xAI)` / `├─ 🌐 Web: 30+ pages │ rollingstone.com, ...` / `└─ 🗣️ Top voices: @honest30bgfan_ (33 likes), @HipHopCrave_ │ Rolling Stone, Washington Post, Complex`. Includes `(via xAI)` notation, `🗣️` emoji, @handles with engagement counts. |
**Analysis:** V2 is tighter and matches its template exactly. V1 is close but has minor deviations (custom "filtered out noise" text, missing `🗣️` emoji, no @handles or engagement counts on Top voices). V2's inclusion of actual @handles with like counts (`@honest30bgfan_ (33 likes)`) adds credibility.
---
#### 6. Research Grounding (actual research vs generic knowledge)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 4 | Clearly grounded: mentions specific details like "Wall Street Journal apology (Jan 26, 2026)," "four-month-long manic episode," "frontal-lobe brain injury," "North West collaborated on 'Piercings on My Hand,'" "Monumental Plaza de Toros." These are specific enough to be from research, not pre-training. Minor generic leakage: the "KEY THEMES" list uses editorial framing ("Accountability arc," "Mental health transparency") that feels more like analysis than research extraction. |
| V2 | 5 | Every fact is specific and attributed: "12th studio album," "13-track project features Peso Pluma, Playboi Carti, and Ty Dolla Sign," "earlier leak versions used AI-deepfaked vocals, which have reportedly been re-recorded," "103,000-capacity RCF Arena." The AI-deepfaked vocals detail is a standout -- it is clearly from research, not something a model would know from pre-training. The Kim/Lewis Hamilton item (`"X chatter is heavily focused on Kim Kardashian's relationship with Lewis Hamilton"`) is explicitly sourced from X data, not general knowledge. |
**Analysis:** Both are well-grounded, but V2 has more "could only come from research" details. The deepfaked vocals story, the exact venue capacity, and the explicit X chatter observation are details that prove the synthesis is from the research output, not hallucinated.
---
#### 7. Prompt Quality (invitation to share vision, not dumping prompts)
| Version | Score | Evidence |
|---------|-------|----------|
| V1 | 3 | Ends with: `"Want to dive deeper into any of these threads — the apology, the new albums, the Grammys situation, or Bianca Censori? Just tell me what angle you're interested in."` This is a follow-up invitation, but it is NOT the SKILL.md-specified invitation. It is topic-specific and conversational, which is nice, but it does not ask the user to "share your vision for what you want to create." It misses the prompt-generation angle entirely. |
| V2 | 5 | Ends with exactly: `"Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into your tool of choice."` This matches the V2 SKILL.md template verbatim. It positions the skill correctly: not a news summarizer but a research-to-prompt pipeline. |
**Analysis:** V1's closing is friendly but off-brand. It treats the skill as a research tool, not a research-to-prompt tool. V2 correctly frames the next step as "tell me what to create and I'll write the prompt." This is a meaningful difference -- V1 would leave a user thinking they just got a summary, while V2 primes them to get a usable output.
---
### Head-to-Head Scorecard
| Dimension | V1 | V2 | Winner |
|-----------|----|----|--------|
| 1. Query Parsing Display | 1 | 1 | Tie (both failed) |
| 2. Source Coverage | 3 | 3 | Tie |
| 3. Citation Quality | 2 | 5 | **V2 (+3)** |
| 4. Summary Structure | 3 | 5 | **V2 (+2)** |
| 5. Stats Box Format | 4 | 5 | **V2 (+1)** |
| 6. Research Grounding | 4 | 5 | **V2 (+1)** |
| 7. Prompt Quality (invitation) | 3 | 5 | **V2 (+2)** |
| **TOTAL** | **20/35** | **29/35** | **V2 wins by 9 points** |
**V2 is clearly better.** The biggest gaps are citation quality (+3) and summary structure (+2). V2's output reads like a professional research briefing; V1's reads like a decent but unstructured summary.
---
## Part 2: V1-Only Outputs Analysis
### Output 1: "open claw" (GENERAL query)
**What V1 does well:**
- Strong research grounding. Mentions exact numbers: "145,000+ GitHub stars," "20,000+ forks," "700+ skills," "341 malicious skills." These are clearly from research.
- The KEY PATTERNS section is excellent: 5 well-organized patterns with community quotes (`"I give it sudo and let it configure everything"` vs `"prompt injection is terrifying when you give the bot access to your actual bank account"`).
- Good synthesis of the security vs. enthusiasm tension -- captures the community split accurately.
- Stats box uses the emoji tree format correctly with `├──` (though note: uses double-dash `──` instead of single `─`, minor inconsistency).
**What V1 is missing (per V2 SKILL.md features):**
- No query parsing display (`🔍 **open claw** · General`).
- No inline citations in the body text. The 5 KEY PATTERNS have no `per @handle` or `per r/sub` attribution. Which Reddit thread said "I give it sudo"? Which X post raised the security concern? We do not know.
- The stats box says `├── 🟠 Reddit: 25 threads │ ~750+ upvotes` -- the tilde and plus are imprecise. V2 SKILL.md wants exact parsed numbers.
- Top voices line lists subreddits and handles but no engagement counts: `@grok, @Starlink` -- are these the highest-engagement handles? No like counts shown.
- No bold topic headers in the body -- it is a single paragraph followed by a numbered list, not the `**{Topic}** — sentence, per source` format V2 requires.
**V1 Score (estimated):** 22/35
---
### Output 2: "nano banana pro prompting" (PROMPTING query)
**What V1 does well:**
- Correctly identifies two prompting styles (JSON structured vs. natural language "Creative Director") and explains when each works best. This is excellent PROMPTING-type synthesis.
- KEY PATTERNS are specific and actionable: "85mm lens at f/1.8," "three-point lighting with key at 45 degrees," "text rendering works -- keep text under 3 words for best results (75% success rate)." These are concrete tips a user can apply immediately.
- Research grounding is strong: cites specific upvote counts ("149-259 upvotes"), subreddit names (`r/nanobanana2pro`), and the Google AI blog.
- The invitation correctly targets Nano Banana Pro: `"Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into Nano Banana Pro."`
**What V1 is missing (per V2 SKILL.md features):**
- No query parsing display.
- Stats box uses plain text dashes: `- 🟠 Reddit: 5 threads | 638 upvotes | 66 comments` instead of the tree format `├─ 🟠 Reddit:`. Uses `|` pipe instead of `│` box-drawing character. V2 SKILL.md explicitly says: "NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji."
- No inline body citations. KEY PATTERNS mention Reddit upvote ranges but no specific `per @handle` attributions.
- Missing `✅ All agents reported back!` header -- just says "All agents reported back!" without the checkmark.
- Body structure is paragraph + numbered list, not bold topic headers.
**V1 Score (estimated):** 23/35 (slightly higher than open claw due to better actionability)
---
### Output 3: "how to best setup clawdbot" (HOW-TO query)
**What V1 does well:**
- This is the best V1 output of the batch. It goes beyond synthesis and actually delivers a **Quick-Start guide** with numbered steps, a **Security Hardening** checklist, and a **Budget Option** -- all grounded in research.
- Excellent research grounding: `"per @shynxbt: Use a free AWS VPS + Claude Haiku model + Telegram bot = fully functional for $0"` -- this is an actual citation with an @handle!
- Specific, actionable recommendations: exact commands (`curl -fsSL https://clawd.bot/install.sh | bash`), specific model recommendations (Claude Opus 4.5 for best results, GLM 4.7 Flash for local), specific channel advice (Telegram first, WhatsApp QR code fails).
- Stats box is correct emoji tree format with engagement counts: `@aashatwt (452 likes), @recap_david (329 likes)`.
- Captures the naming confusion accurately: "Clawdbot -> Moltbot -> OpenClaw."
**What V1 is missing (per V2 SKILL.md features):**
- No query parsing display.
- Body text has no inline citations except the Budget Option section. The 5 KEY PATTERNS have no `per @handle` attribution.
- Bold topic headers are used only in the Quick-Start and Security sections, not in the KEY PATTERNS or intro.
- The output delivers the "answer" directly (setup guide) rather than waiting for the user's vision and offering to write a prompt. For a HOW-TO query this might be the right call, but it skips the SKILL.md flow of "show research -> invite vision -> write prompt."
**V1 Score (estimated):** 26/35 (best of the V1 outputs)
---
### Patterns Across All V1 Outputs
**Consistent strengths:**
1. Research grounding is solid across all three. V1 does not hallucinate -- the facts are clearly from the research output, not pre-training.
2. KEY PATTERNS lists are consistently useful and actionable.
3. Stats boxes are present in all outputs (though formatting varies).
4. The invitation/closing line is present in all outputs.
**Consistent weaknesses:**
1. **No query parsing display** in any output (0 for 4, including Kanye West).
2. **No inline citations** in the body text (except one @handle in the clawdbot output). The research feels real but is unattributed.
3. **Stats box formatting is inconsistent.** Open claw uses `├──` (double dash), nano banana pro uses `- 🟠` (plain dash + pipe), clawdbot uses `├─` (correct). Three different formats in three outputs.
4. **Body structure defaults to paragraph + numbered list** instead of bold topic headers. Only clawdbot partially uses bold headers (in the guide section, not the research section).
5. **No `(via Bird/xAI)` notation** on X stats in any output.
---
## Part 3: SKILL.md Feature Diff
### Features in V2 but NOT V1
| Feature | V2 Lines | Impact |
|---------|----------|--------|
| **Query parsing display** (`🔍 **{TOPIC}** · {QUERY_TYPE}`) | 40-53 | HIGH -- confirms to user the skill understood their request before spending time on research. |
| **Sparse citation rules** with BAD/GOOD examples | 186-193 | HIGH -- this is the #1 quality differentiator in the Kanye head-to-head. `"per @handle"` format, never chain multiple citations. |
| **Bold topic headers** template (`**{Topic 1}** — [1-2 sentences, per source]`) | 195-208 | HIGH -- makes output scannable. |
| **Strict stats template** with "NEVER use plain text dashes" instruction | 217-230 | MEDIUM -- prevents the formatting inconsistency seen across V1 outputs. |
| **RECOMMENDATIONS source attribution** (each item MUST have Sources: line with @handles) | 178-182 | MEDIUM -- only affects RECOMMENDATIONS queries. |
| **Reddit 0 results handling** (explicit instruction for what to write) | 229 | LOW -- edge case, but prevents ad-hoc text like V1's "filtered out noise." |
| **Bird CLI / xAI notation** in stats | 223 | LOW -- cosmetic transparency about data source. |
| **Step 2 phrasing: "DO WEBSEARCH WHILE SCRIPT RUNS"** | 71-73 | LOW -- execution optimization, no output impact. |
### Features in V1 but NOT V2
| Feature | V1 Lines | Impact | Should Restore? |
|---------|----------|--------|-----------------|
| **Use cases block** (4 examples in intro) | 12-17 | LOW | No |
| **Setup Check section** (3 modes, bash script, "keys are OPTIONAL") | 50-78 | MEDIUM for new users | Yes, for public release |
| **BAD/GOOD synthesis anti-pattern examples** | 172-191 | MEDIUM-HIGH | YES |
| **Self-check instruction** ("Re-read your 'What I learned' section...") | 269 | MEDIUM | YES |
| **Quality Checklist** (5-point checklist before delivering prompt) | 306-324 | HIGH | YES |
| **Prompt format anti-pattern** ("Research says JSON but you write prose") | 302 | MEDIUM | YES |
| **"IF USER ASKS FOR MORE OPTIONS"** section | 327-329 | LOW-MEDIUM | YES |
| **Web-only mode stats template + promo** | 248-259 | MEDIUM for no-key users | For public release |
| **TARGET_TOOL question template** (4 options) | 272-280 | LOW | No |
| **Context Memory: explicit "don't re-search" instructions** | 342-358 | MEDIUM | YES |
| **Output footer emoji + engagement counts** | 366-380 | LOW | YES |
### Features in BOTH (Shared)
| Feature | Notes |
|---------|-------|
| Parse User Intent (TOPIC, TARGET_TOOL, QUERY_TYPE) | Same 4 query types, same detection logic |
| "Don't ask about tool before research" rule | Identical |
| Research script execution command | Same `python3` command |
| WebSearch queries by QUERY_TYPE | Same search strategies |
| "Use user's exact terminology" instruction | V2 shorter but same intent |
| Judge Agent synthesis logic | Same 5-step weighting process |
| "Ground in actual research" instruction | Same core instruction, V1 has more examples |
| RECOMMENDATIONS: extract specific names | Same logic |
| Prompt format matching | Same instruction |
| Wait for user's vision | Same |
| Write ONE perfect prompt | Same structure |
| Context Memory | V2 shorter version |
| Output summary footer | Both have it, V1 has emoji |
| Depth options (quick/default/deep) | Same |
| "After each prompt: Stay in Expert Mode" | Same |
### Overall Assessment
**V2 is a clear upgrade in output formatting and citation quality.** The three features V2 adds (query parsing display, sparse citation rules, bold topic headers) directly address the three biggest weaknesses seen across all V1 outputs. The Kanye West head-to-head proves it: V2 scores 29/35 vs V1's 20/35.
**However, V2 dropped several quality guardrails from V1** that do not affect formatting but affect *correctness*: the self-check instruction, the anti-pattern examples, the quality checklist for prompts, and the "don't re-search" context memory rule. These are cheap to restore (under 25 lines total) and protect against subtle failure modes that may not show up in a 1-query test but will appear over dozens of uses.
---
## Part 4: Verdict
### Ship V2 or Not?
**Ship V2 -- but restore the guardrails first.**
V2 is unambiguously better on every formatting dimension. The citation quality improvement alone (V1: 2/5 -> V2: 5/5) makes it worth shipping. The bold topic headers and strict stats template fix the inconsistency problems visible across all V1 outputs.
But V2 dropped 6 guardrail features from V1 that cost almost nothing to include and protect against real failure modes. These should be restored before V2 goes public.
### Remaining Gaps
**Must fix before shipping (affects correctness):**
1. **Restore the quality checklist for prompts.** This is the test plan's #1 priority item. V1 had a 5-point checklist; V2 reduced it to one line. The checklist is what makes prompts feel polished -- it is the "that's a great prompt" mechanism. Add 8 lines.
2. **Restore BAD/GOOD anti-pattern examples.** V2 says "ground in actual research" but does not show what *bad* grounding looks like. V1's ClawdBot/Claude Code conflation example is exactly the kind of concrete negative example that prevents real failures. Add 5 lines.
3. **Restore self-check instruction.** One sentence: "Re-read your 'What I learned' section -- does it match what the research ACTUALLY says?" Zero cost, catches hallucination. Add 2 lines.
4. **Restore "don't re-search" context memory rule.** V2 only says "only do new research if user asks about a DIFFERENT topic." V1 explicitly bans re-searching and tells the agent to answer from existing research. Add 3 lines.
**Should fix (polish):**
5. Restore prompt format anti-pattern ("Research says JSON but you write prose"). Add 2 lines.
6. Restore "IF USER ASKS FOR MORE OPTIONS" section. Add 2 lines.
7. Add emoji + engagement counts back to the output summary footer. Edit 3 lines.
**Skip for now:**
8. Setup Check section -- add back for public release, not needed for execution.
9. Web-only mode stats template -- lower priority, most testers have API keys.
10. TARGET_TOOL question template -- agent handles this naturally.
### Query Parsing Display: Investigate
Both V1 and V2 scored 1/5 on query parsing display. V2 has the feature in its SKILL.md but the agent did not render it in the captured output. This could mean:
- The display was shown during execution but not captured (likely -- it appears before tools run, and the output files may only contain post-research content).
- The instruction is not strong enough and the agent skips it.
**Recommendation:** Verify in a live session whether the parsing display actually appears. If it does not, strengthen the instruction (e.g., "This line MUST be the first thing you output, before any tool calls").
### Total Effort
Restoring all 7 priority items: approximately 25 lines added to V2 SKILL.md. Under 15 minutes of work. The V2 formatting wins are substantial and proven; the V1 guardrails are small and proven. Combining both produces the best version.
### Final Score Summary
| | V1 (Kanye) | V2 (Kanye) | Delta |
|--|-----------|-----------|-------|
| Total | 20/35 | 29/35 | **V2 +9** |
| | V1 (Open Claw) | V1 (Nano Banana) | V1 (Clawdbot) | V1 Average |
|--|---------------|-----------------|--------------|------------|
| Estimated Total | 22/35 | 23/35 | 26/35 | **23.7/35** |
V2 at 29/35 beats every V1 output, including V1's best (clawdbot at 26/35).
**Decision: Ship V2 with guardrails restored.**
@@ -0,0 +1,388 @@
---
name: last30days
description: Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool.
argument-hint: "[topic] for [tool]" or "[topic]"
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
---
# last30days: Research Any Topic from the Last 30 Days
Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now.
Use cases:
- **Prompting**: "photorealistic people in Nano Banana Pro", "Midjourney prompts", "ChatGPT image generation" → learn techniques, get copy-paste prompts
- **Recommendations**: "best Claude Code skills", "top AI tools" → get a LIST of specific things people mention
- **News**: "what's happening with OpenAI", "latest AI announcements" → current events and updates
- **General**: any topic you're curious about → understand what the community is saying
## CRITICAL: Parse User Intent
Before doing anything, parse the user's input for:
1. **TOPIC**: What they want to learn about (e.g., "web app mockups", "Claude Code skills", "image generation")
2. **TARGET TOOL** (if specified): Where they'll use the prompts (e.g., "Nano Banana Pro", "ChatGPT", "Midjourney")
3. **QUERY TYPE**: What kind of research they want:
- **PROMPTING** - "X prompts", "prompting for X", "X best practices" → User wants to learn techniques and get copy-paste prompts
- **RECOMMENDATIONS** - "best X", "top X", "what X should I use", "recommended X" → User wants a LIST of specific things
- **NEWS** - "what's happening with X", "X news", "latest on X" → User wants current events/updates
- **GENERAL** - anything else → User wants broad understanding of the topic
Common patterns:
- `[topic] for [tool]` → "web mockups for Nano Banana Pro" → TOOL IS SPECIFIED
- `[topic] prompts for [tool]` → "UI design prompts for Midjourney" → TOOL IS SPECIFIED
- Just `[topic]` → "iOS design mockups" → TOOL NOT SPECIFIED, that's OK
- "best [topic]" or "top [topic]" → QUERY_TYPE = RECOMMENDATIONS
- "what are the best [topic]" → QUERY_TYPE = RECOMMENDATIONS
**IMPORTANT: Do NOT ask about target tool before research.**
- If tool is specified in the query, use it
- If tool is NOT specified, run research first, then ask AFTER showing results
**Store these variables:**
- `TOPIC = [extracted topic]`
- `TARGET_TOOL = [extracted tool, or "unknown" if not specified]`
- `QUERY_TYPE = [RECOMMENDATIONS | NEWS | HOW-TO | GENERAL]`
---
## Setup Check
The skill works in three modes based on available API keys:
1. **Full Mode** (both keys): Reddit + X + WebSearch - best results with engagement metrics
2. **Partial Mode** (one key): Reddit-only or X-only + WebSearch
3. **Web-Only Mode** (no keys): WebSearch only - still useful, but no engagement metrics
**API keys are OPTIONAL.** The skill will work without them using WebSearch fallback.
### First-Time Setup (Optional but Recommended)
If the user wants to add API keys for better results:
```bash
mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'ENVEOF'
# last30days API Configuration
# Both keys are optional - skill works with WebSearch fallback
# For Reddit research (uses OpenAI's web_search tool)
OPENAI_API_KEY=
# For X/Twitter research (uses xAI's x_search tool)
XAI_API_KEY=
ENVEOF
chmod 600 ~/.config/last30days/.env
echo "Config created at ~/.config/last30days/.env"
echo "Edit to add your API keys for enhanced research."
```
**DO NOT stop if no keys are configured.** Proceed with web-only mode.
---
## Research Execution
**IMPORTANT: The script handles API key detection automatically.** Run it and check the output to determine mode.
**Step 1: Run the research script**
```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
```
The script will automatically:
- Detect available API keys
- Show a promo banner if keys are missing (this is intentional marketing)
- Run Reddit/X searches if keys exist
- Signal if WebSearch is needed
**Step 2: Check the output mode**
The script output will indicate the mode:
- **"Mode: both"** or **"Mode: reddit-only"** or **"Mode: x-only"**: Script found results, WebSearch is supplementary
- **"Mode: web-only"**: No API keys, Claude must do ALL research via WebSearch
**Step 3: Do WebSearch**
For **ALL modes**, do WebSearch to supplement (or provide all data in web-only mode).
Choose search queries based on QUERY_TYPE:
**If RECOMMENDATIONS** ("best X", "top X", "what X should I use"):
- Search for: `best {TOPIC} recommendations`
- Search for: `{TOPIC} list examples`
- Search for: `most popular {TOPIC}`
- Goal: Find SPECIFIC NAMES of things, not generic advice
**If NEWS** ("what's happening with X", "X news"):
- Search for: `{TOPIC} news 2026`
- Search for: `{TOPIC} announcement update`
- Goal: Find current events and recent developments
**If PROMPTING** ("X prompts", "prompting for X"):
- Search for: `{TOPIC} prompts examples 2026`
- Search for: `{TOPIC} techniques tips`
- Goal: Find prompting techniques and examples to create copy-paste prompts
**If GENERAL** (default):
- Search for: `{TOPIC} 2026`
- Search for: `{TOPIC} discussion`
- Goal: Find what people are actually saying
For ALL query types:
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
- If user says "ChatGPT image prompting", search for "ChatGPT image prompting"
- Do NOT add "DALL-E", "GPT-4o", or other terms you think are related
- Your knowledge may be outdated - trust the user's terminology
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
**Step 3: Wait for background script to complete**
Use TaskOutput to get the script results before proceeding to synthesis.
**Depth options** (passed through from user's command):
- `--quick` → Faster, fewer sources (8-12 each)
- (default) → Balanced (20-30 each)
- `--deep` → Comprehensive (50-70 Reddit, 40-60 X)
---
## Judge Agent: Synthesize All Sources
**After all searches complete, internally synthesize (don't display stats yet):**
The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight WebSearch sources LOWER (no engagement data)
3. Identify patterns that appear across ALL three sources (strongest signals)
4. Note any contradictions between sources
5. Extract the top 3-5 actionable insights
**Do NOT display stats here - they come at the end, right before the invitation.**
---
## FIRST: Internalize the Research
**CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.**
Read the research output carefully. Pay attention to:
- **Exact product/tool names** mentioned (e.g., if research mentions "ClawdBot" or "@clawdbot", that's a DIFFERENT product than "Claude Code" - don't conflate them)
- **Specific quotes and insights** from the sources - use THESE, not generic knowledge
- **What the sources actually say**, not what you assume the topic is about
**ANTI-PATTERN TO AVOID**: If user asks about "clawdbot skills" and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as "Claude Code skills" just because both involve "skills". Read what the research actually says.
### If QUERY_TYPE = RECOMMENDATIONS
**CRITICAL: Extract SPECIFIC NAMES, not generic patterns.**
When user asks "best X" or "top X", they want a LIST of specific things:
- Scan research for specific product names, tool names, project names, skill names, etc.
- Count how many times each is mentioned
- Note which sources recommend each (Reddit thread, X post, blog)
- List them by popularity/mention count
**BAD synthesis for "best Claude Code skills":**
> "Skills are powerful. Keep them under 500 lines. Use progressive disclosure."
**GOOD synthesis for "best Claude Code skills":**
> "Most mentioned skills: /commit (5 mentions), remotion skill (4x), git-worktree (3x), /pr (3x). The Remotion announcement got 16K likes on X."
### For all QUERY_TYPEs
Identify from the ACTUAL RESEARCH OUTPUT:
- **PROMPT FORMAT** - Does research recommend JSON, structured params, natural language, keywords? THIS IS CRITICAL.
- The top 3-5 patterns/techniques that appeared across multiple sources
- Specific keywords, structures, or approaches mentioned BY THE SOURCES
- Common pitfalls mentioned BY THE SOURCES
**If research says "use JSON prompts" or "structured prompts", you MUST deliver prompts in that format later.**
---
## THEN: Show Summary + Invite Vision
**CRITICAL: Do NOT output any "Sources:" lists. The final display should be clean.**
**Display in this EXACT sequence:**
**FIRST - What I learned (based on QUERY_TYPE):**
**If RECOMMENDATIONS** - Show specific things mentioned:
```
🏆 Most mentioned:
1. [Specific name] - mentioned {n}x (r/sub, @handle, blog.com)
2. [Specific name] - mentioned {n}x (sources)
3. [Specific name] - mentioned {n}x (sources)
4. [Specific name] - mentioned {n}x (sources)
5. [Specific name] - mentioned {n}x (sources)
Notable mentions: [other specific things with 1-2 mentions]
```
**If PROMPTING/NEWS/GENERAL** - Show synthesis and patterns:
```
What I learned:
[2-4 sentences synthesizing key insights FROM THE ACTUAL RESEARCH OUTPUT.]
KEY PATTERNS I'll use:
1. [Pattern from research]
2. [Pattern from research]
3. [Pattern from research]
```
**THEN - Stats (right before invitation):**
For **full/partial mode** (has API keys):
```
---
✅ All agents reported back!
├─ 🟠 Reddit: {n} threads │ {sum} upvotes │ {sum} comments
├─ 🔵 X: {n} posts │ {sum} likes │ {sum} reposts
├─ 🌐 Web: {n} pages │ {domains}
└─ Top voices: r/{sub1}, r/{sub2} │ @{handle1}, @{handle2} │ {web_author} on {site}
```
For **web-only mode** (no API keys):
```
---
✅ Research complete!
├─ 🌐 Web: {n} pages │ {domains}
└─ Top sources: {author1} on {site1}, {author2} on {site2}
💡 Want engagement metrics? Add API keys to ~/.config/last30days/.env
- OPENAI_API_KEY → Reddit (real upvotes & comments)
- XAI_API_KEY → X/Twitter (real likes & reposts)
```
**LAST - Invitation:**
```
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into {TARGET_TOOL}.
```
**Use real numbers from the research output.** The patterns should be actual insights from the research, not generic advice.
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If the research was about ClawdBot (a self-hosted AI agent), your summary should be about ClawdBot, not Claude Code. If you catch yourself projecting your own knowledge instead of the research, rewrite it.
**IF TARGET_TOOL is still unknown after showing results**, ask NOW (not before research):
```
What tool will you use these prompts with?
Options:
1. [Most relevant tool based on research - e.g., if research mentioned Figma/Sketch, offer those]
2. Nano Banana Pro (image generation)
3. ChatGPT / Claude (text/code)
4. Other (tell me)
```
**IMPORTANT**: After displaying this, WAIT for the user to respond. Don't dump generic prompts.
---
## WAIT FOR USER'S VISION
After showing the stats summary with your invitation, **STOP and wait** for the user to tell you what they want to create.
When they respond with their vision (e.g., "I want a landing page mockup for my SaaS app"), THEN write a single, thoughtful, tailored prompt.
---
## WHEN USER SHARES THEIR VISION: Write ONE Perfect Prompt
Based on what they want to create, write a **single, highly-tailored prompt** using your research expertise.
### CRITICAL: Match the FORMAT the research recommends
**If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT:**
- Research says "JSON prompts" → Write the prompt AS JSON
- Research says "structured parameters" → Use structured key: value format
- Research says "natural language" → Use conversational prose
- Research says "keyword lists" → Use comma-separated keywords
**ANTI-PATTERN**: Research says "use JSON prompts with device specs" but you write plain prose. This defeats the entire purpose of the research.
### Output Format:
```
Here's your prompt for {TARGET_TOOL}:
---
[The actual prompt IN THE FORMAT THE RESEARCH RECOMMENDS - if research said JSON, this is JSON. If research said natural language, this is prose. Match what works.]
---
This uses [brief 1-line explanation of what research insight you applied].
```
### Quality Checklist:
- [ ] **FORMAT MATCHES RESEARCH** - If research said JSON/structured/etc, prompt IS that format
- [ ] Directly addresses what the user said they want to create
- [ ] Uses specific patterns/keywords discovered in research
- [ ] Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)
- [ ] Appropriate length and style for TARGET_TOOL
---
## IF USER ASKS FOR MORE OPTIONS
Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested.
---
## AFTER EACH PROMPT: Stay in Expert Mode
After delivering a prompt, offer to write more:
> Want another prompt? Just tell me what you're creating next.
---
## CONTEXT MEMORY
For the rest of this conversation, remember:
- **TOPIC**: {topic}
- **TARGET_TOOL**: {tool}
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
- **RESEARCH FINDINGS**: The key facts and insights from the research
**CRITICAL: After research is complete, you are now an EXPERT on this topic.**
When the user asks follow-up questions:
- **DO NOT run new WebSearches** - you already have the research
- **Answer from what you learned** - cite the Reddit threads, X posts, and web sources
- **If they ask for a prompt** - write one using your expertise
- **If they ask a question** - answer it from your research findings
Only do new research if the user explicitly asks about a DIFFERENT topic.
---
## Output Summary Footer (After Each Prompt)
After delivering a prompt, end with:
For **full/partial mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages
Want another prompt? Just tell me what you're creating next.
```
For **web-only mode**:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} web pages from {domains}
Want another prompt? Just tell me what you're creating next.
💡 Unlock Reddit & X data: Add API keys to ~/.config/last30days/.env
```
@@ -0,0 +1,25 @@
## What I learned:
The last 30 days have been one of Kanye West's most significant periods in years. The biggest story is his **full-page Wall Street Journal apology** (Jan 26, 2026) titled "To Those I've Hurt" — a comprehensive open letter addressing his antisemitic remarks and erratic behavior. He attributed his actions to a "four-month-long manic episode" and revealed that a car accident 25 years ago caused a frontal-lobe brain injury that wasn't properly diagnosed until 2023, leading to his bipolar type-1 diagnosis. Multiple sources describe wife **Bianca Censori** as instrumental in getting him to seek treatment during a manic depressive episode.
On the music side, he's announced **two upcoming albums**: *BULLY* (now scheduled for March 20, 2026) and *IN A PERFECT WORLD*. Daughter **North West** collaborated with him on a track called "Piercings on My Hand." He performed a massive show in Mexico City (Jan 30) at the Monumental Plaza de Toros, and he'll headline the new **Hellwatt Festival** in Italy (July 4-18, 2026).
The **2026 Grammys** remain contentious — he's reportedly not invited after last year's red carpet drama. Health concerns also surfaced after a January LA outing where he appeared "noticeably bloated and moving with sluggish energy."
**KEY THEMES:**
1. **Accountability arc** — The WSJ apology is being widely discussed as either genuine growth or calculated PR
2. **Mental health transparency** — Brain injury + bipolar diagnosis framing his past behavior
3. **Bianca Censori's role** — Increasingly seen as a stabilizing force (despite continued provocative public appearances)
4. **Music comeback** — Two albums announced, active touring in Latin America and Europe
5. **Grammy exile** — Still persona non grata at major award shows
---
✅ All agents reported back!
├─ 🟠 Reddit: 0 relevant threads (filtered out noise)
├─ 🔵 X: 30 posts │ ~10 likes │ casual fan chatter + Bianca Censori discourse
├─ 🌐 Web: 20+ pages │ washingtonpost.com, billboard.com, allhiphop.com, euronews.com, rap-up.com
└─ Top voices: Washington Post, Billboard, AllHipHop, Euronews, The Mirror
---
Want to dive deeper into any of these threads — the apology, the new albums, the Grammys situation, or Bianca Censori? Just tell me what angle you're interested in.
@@ -0,0 +1,24 @@
## What I learned:
OpenClaw (formerly ClawdBot, formerly Moltbot) is an open-source autonomous AI agent created by Peter Steinberger that went massively viral in late January 2026 — hitting 145,000+ GitHub stars and 20,000+ forks. It runs locally on your machine, connects to messaging platforms (Telegram, WhatsApp, Discord, Signal, etc.), and uses LLMs like Claude, GPT, or Gemini to actually perform tasks on your computer — file management, scheduling, web browsing, purchases, and more. Think "JARVIS for everyone."
The community is simultaneously excited and alarmed. The "ClawHub" skill marketplace has 700+ skills but a VirusTotal/Koi Security audit found **341 malicious skills** doing data exfiltration and crypto theft. A high-severity RCE vulnerability was also disclosed. The #1 most downloaded skill on ClawHub was confirmed malware.
**KEY PATTERNS from the research:**
1. **Cost is the top complaint** — API usage burns money fast; many users recommend Gemini CLI (free 1,000 req/day) over Claude/GPT for OpenClaw to control spend
2. **Security is a dumpster fire** — Supply chain attacks via ClawHub skills, prompt injection risks when browsing the web, RCE vulnerabilities, and orgs are banning it at the EDR level
3. **Setup is nontrivial** — Even after install, most users get stuck on skills/tools/automation config; the `openclaw onboard` wizard is the recommended path
4. **The skill ecosystem is powerful but dangerous** — 700+ skills available, but no code signing, no real moderation, and active malware campaigns
5. **Community is split** — Enthusiasts love the autonomy ("I give it sudo and let it configure everything"), while security folks are terrified ("prompt injection is terrifying when you give the bot access to your actual bank account")
---
✅ All agents reported back!
├── 🟠 Reddit: 25 threads │ ~750+ upvotes │ ~600+ comments
├── 🔵 X: 27 posts │ 1,750+ likes │ 325+ reposts
├── 🌐 Web: 20+ pages │ Wikipedia, Bloomberg, The Register, Hacker News, Nature, VirusTotal, DigitalOcean, Codecademy, Tom's Hardware
└── Top voices: r/openclaw, r/cybersecurity, r/clawdbot, r/ArtificialInteligence │ @grok, @Starlink │ Nature, Bloomberg, ACM
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly.
@@ -0,0 +1,27 @@
Now I have a comprehensive picture. Let me synthesize everything.
**What I learned:**
Nano Banana Pro is fundamentally different from older image models — it's a "thinking" model that understands intent, physics, and composition. The community has converged on two distinct prompting styles, and **both work well** depending on the use case:
1. **JSON structured prompts** — Dominant on Reddit (r/nanobanana2pro) and X. Power users share elaborate JSON objects with nested fields for subject, appearance, lighting, composition, and style. These produce the most consistent, controlled results for portraits, editorials, and product shots.
2. **Natural language "Creative Director" prompts** — Recommended by Google's official guide. Full sentences describing the scene as if briefing a photographer. Better for cinematic shots, complex edits, and iterative refinement.
**KEY PATTERNS I'll use:**
1. **JSON format for maximum control** — The top Reddit posts (149-259 upvotes) all use structured JSON with nested objects: `subject > appearance > hair/skin/expression`, `lighting > key/fill/rim`, `camera > lens/angle/depth_of_field`
2. **Be a Creative Director, not a tag spammer** — Replace "4k, realistic, beautiful" with specific technical directions: "85mm lens at f/1.8", "three-point lighting with key at 45°", "shallow depth of field with bokeh"
3. **Provide context for WHY** — Adding purpose ("for a luxury fashion editorial", "for a high-end gourmet cookbook") helps the model infer appropriate styling, plating, lighting
4. **Edit, don't regenerate** — When 80% is right, ask for the specific change conversationally
5. **Text rendering works** — Keep text under 3 words for best results (75% success rate), specify font style and placement
---
All agents reported back!
- 🟠 Reddit: 5 threads | 638 upvotes | 66 comments
- 🔵 X: 29 posts | 312 likes | 117 reposts
- 🌐 Web: 10+ pages | blog.google, dev.to, eweek.com, imagine.art, github.com, leonardo.ai, medium.com
- Top voices: r/nanobanana2pro | @Xmira_belle, @doctorwasif, @chatgptpaglu | Google AI blog, DEV Community
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into Nano Banana Pro.
@@ -0,0 +1,48 @@
**What I learned:**
Clawdbot (now officially renamed **OpenClaw**) is a self-hosted, open-source personal AI assistant that connects to messaging apps (Telegram, WhatsApp, Discord, Slack, Signal, iMessage) and uses LLMs to act as an autonomous agent on your behalf. It's roughly one week old as a public project and the community is moving fast — the name has already changed twice (Clawdbot → Moltbot → OpenClaw).
**KEY PATTERNS from the research:**
1. **Security is the #1 concern** — Multiple Reddit threads and guides warn that many setups are wide open to the internet with zero auth. Run it on dedicated/isolated hardware (VM, Mac Mini, NAS), never your main machine. Block dangerous commands via allowlist. Never add it to group chats.
2. **Use the onboarding wizard, not Docker** — Docker was reported as unreliable (shims, bridges). The single-line install script (`curl -fsSL https://clawd.bot/install.sh | bash`) followed by `openclaw onboard` is the smoothest path. Docker is a "no-go" per multiple users.
3. **Model choice matters a lot** — Claude Opus 4.5 is recommended for best results (trained to resist prompt injection, ~99% resistance). For budget setups, Claude Haiku works at $0. Local models (Ollama/LM Studio) work but cause hallucination and token issues — GLM 4.7 Flash (30B) was recommended as the best local option.
4. **Telegram is the easiest channel to start with** — Most guides and users start with a Telegram bot token. WhatsApp QR code pairing fails frequently. Get Telegram working first, then expand.
5. **Sandbox and isolate** — Set `agents.defaults.sandbox.mode` to `"non-main"` for Docker sandboxing. Use a Linux VM or UTM on Mac to segregate traffic. Tailscale for secure remote access.
---
✅ All agents reported back!
├─ 🟠 Reddit: 19 threads │ 415+ upvotes │ 290+ comments
├─ 🔵 X: 29 posts │ 815+ likes │ 65+ reposts
├─ 🌐 Web: 10+ pages │ Medium, DataCamp, DEV, GitHub, QNAP, docs.openclaw.ai
└─ Top voices: r/clawdbot, r/LocalLLM, r/AskVibecoders │ @aashatwt (452 likes), @recap_david (329 likes) │ Lukas Niessen on Medium
---
**Here's the consensus best setup path based on what the community actually recommends:**
### Quick-Start (30 min)
1. **Get dedicated hardware** — Mac Mini, old laptop, NAS (QNAP has an official guide), or a VPS (DigitalOcean gist available). Don't use your daily driver.
2. **Install via script**: `curl -fsSL https://clawd.bot/install.sh | bash`
3. **Run the wizard**: `openclaw onboard` — it walks through gateway, workspace, channels, and skills
4. **Connect Telegram first** — create a bot via @BotFather, paste the token
5. **Set your API key** — Anthropic API key for Claude, or connect Ollama for local models
6. **Customize SOUL.md** — this controls your bot's personality and behavior
### Security Hardening (do this immediately)
- Bind gateway to localhost only, use Tailscale for remote access
- Set sandbox mode to `"non-main"`
- Create an explicit command allowlist (block `rm -rf`, `sudo`, `chmod`)
- Rotate API keys every 90 days
- Never add the bot to group chats
### Budget Option ($0)
Per @shynxbt: Use a free AWS VPS + Claude Haiku model + Telegram bot = fully functional for $0.
---
Want me to help you with a specific part of the setup, or do you have a particular use case in mind (home automation, CRM, coding assistant, etc.)?
@@ -0,0 +1,332 @@
---
name: last30days
description: Research a topic from the last 30 days on Reddit + X + Web, become an expert, and write copy-paste-ready prompts for the user's target tool.
argument-hint: '"[topic] for [tool]" or "[topic]"'
allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch
---
# last30days: Research Any Topic from the Last 30 Days
Research ANY topic across Reddit, X, and the web. Surface what people are actually discussing, recommending, and debating right now.
## CRITICAL: Parse User Intent
Before doing anything, parse the user's input for:
1. **TOPIC**: What they want to learn about (e.g., "web app mockups", "Claude Code skills", "image generation")
2. **TARGET TOOL** (if specified): Where they'll use the prompts (e.g., "Nano Banana Pro", "ChatGPT", "Midjourney")
3. **QUERY TYPE**: What kind of research they want:
- **PROMPTING** - "X prompts", "prompting for X", "X best practices" → User wants to learn techniques and get copy-paste prompts
- **RECOMMENDATIONS** - "best X", "top X", "what X should I use", "recommended X" → User wants a LIST of specific things
- **NEWS** - "what's happening with X", "X news", "latest on X" → User wants current events/updates
- **GENERAL** - anything else → User wants broad understanding of the topic
Common patterns:
- `[topic] for [tool]` → "web mockups for Nano Banana Pro" → TOOL IS SPECIFIED
- `[topic] prompts for [tool]` → "UI design prompts for Midjourney" → TOOL IS SPECIFIED
- Just `[topic]` → "iOS design mockups" → TOOL NOT SPECIFIED, that's OK
- "best [topic]" or "top [topic]" → QUERY_TYPE = RECOMMENDATIONS
- "what are the best [topic]" → QUERY_TYPE = RECOMMENDATIONS
**IMPORTANT: Do NOT ask about target tool before research.**
- If tool is specified in the query, use it
- If tool is NOT specified, run research first, then ask AFTER showing results
**Store these variables:**
- `TOPIC = [extracted topic]`
- `TARGET_TOOL = [extracted tool, or "unknown" if not specified]`
- `QUERY_TYPE = [RECOMMENDATIONS | NEWS | HOW-TO | GENERAL]`
**DISPLAY your parsing to the user.** Before running any tools, output a single line:
🔍 **{TOPIC}** · {QUERY_TYPE}
Searching Reddit, X, and the web for {natural language description of what you'll look for}...
Example outputs:
- 🔍 **kanye west** · News — Searching Reddit, X, and the web for the latest kanye west news and discussions...
- 🔍 **best MCP servers** · Recommendations — Searching Reddit, X, and the web for the most recommended MCP servers...
- 🔍 **nano banana pro prompting** · Prompting — Searching Reddit, X, and the web for nano banana pro prompting techniques and tips...
- 🔍 **open claw** · General — Searching Reddit, X, and the web for what people are saying about open claw...
If TARGET_TOOL is known, mention it: "...for nano banana pro prompting techniques to use in ChatGPT..."
This text MUST appear before you call any tools. It confirms to the user that you understood their request.
---
## Research Execution
**Step 1: Run the research script**
```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
```
The script will automatically:
- Detect available API keys
- Run Reddit/X searches if keys exist
- Signal if WebSearch is needed
---
## STEP 2: DO WEBSEARCH WHILE SCRIPT RUNS
The script auto-detects sources (Bird CLI, API keys, etc). While waiting for it, do WebSearch.
For **ALL modes**, do WebSearch to supplement (or provide all data in web-only mode).
Choose search queries based on QUERY_TYPE:
**If RECOMMENDATIONS** ("best X", "top X", "what X should I use"):
- Search for: `best {TOPIC} recommendations`
- Search for: `{TOPIC} list examples`
- Search for: `most popular {TOPIC}`
- Goal: Find SPECIFIC NAMES of things, not generic advice
**If NEWS** ("what's happening with X", "X news"):
- Search for: `{TOPIC} news 2026`
- Search for: `{TOPIC} announcement update`
- Goal: Find current events and recent developments
**If PROMPTING** ("X prompts", "prompting for X"):
- Search for: `{TOPIC} prompts examples 2026`
- Search for: `{TOPIC} techniques tips`
- Goal: Find prompting techniques and examples to create copy-paste prompts
**If GENERAL** (default):
- Search for: `{TOPIC} 2026`
- Search for: `{TOPIC} discussion`
- Goal: Find what people are actually saying
For ALL query types:
- **USE THE USER'S EXACT TERMINOLOGY** - don't substitute or add tech names based on your knowledge
- EXCLUDE reddit.com, x.com, twitter.com (covered by script)
- INCLUDE: blogs, tutorials, docs, news, GitHub repos
- **DO NOT output "Sources:" list** - this is noise, we'll show stats at the end
**Depth options** (passed through from user's command):
- `--quick` → Faster, fewer sources (8-12 each)
- (default) → Balanced (20-30 each)
- `--deep` → Comprehensive (50-70 Reddit, 40-60 X)
---
## Judge Agent: Synthesize All Sources
**After all searches complete, internally synthesize (don't display stats yet):**
The Judge Agent must:
1. Weight Reddit/X sources HIGHER (they have engagement signals: upvotes, likes)
2. Weight WebSearch sources LOWER (no engagement data)
3. Identify patterns that appear across ALL three sources (strongest signals)
4. Note any contradictions between sources
5. Extract the top 3-5 actionable insights
**Do NOT display stats here - they come at the end, right before the invitation.**
---
## FIRST: Internalize the Research
**CRITICAL: Ground your synthesis in the ACTUAL research content, not your pre-existing knowledge.**
Read the research output carefully. Pay attention to:
- **Exact product/tool names** mentioned (e.g., if research mentions "ClawdBot" or "@clawdbot", that's a DIFFERENT product than "Claude Code" - don't conflate them)
- **Specific quotes and insights** from the sources - use THESE, not generic knowledge
- **What the sources actually say**, not what you assume the topic is about
**ANTI-PATTERN TO AVOID**: If user asks about "clawdbot skills" and research returns ClawdBot content (self-hosted AI agent), do NOT synthesize this as "Claude Code skills" just because both involve "skills". Read what the research actually says.
### If QUERY_TYPE = RECOMMENDATIONS
**CRITICAL: Extract SPECIFIC NAMES, not generic patterns.**
When user asks "best X" or "top X", they want a LIST of specific things:
- Scan research for specific product names, tool names, project names, skill names, etc.
- Count how many times each is mentioned
- Note which sources recommend each (Reddit thread, X post, blog)
- List them by popularity/mention count
**BAD synthesis for "best Claude Code skills":**
> "Skills are powerful. Keep them under 500 lines. Use progressive disclosure."
**GOOD synthesis for "best Claude Code skills":**
> "Most mentioned skills: /commit (5 mentions), remotion skill (4x), git-worktree (3x), /pr (3x). The Remotion announcement got 16K likes on X."
### For all QUERY_TYPEs
Identify from the ACTUAL RESEARCH OUTPUT:
- **PROMPT FORMAT** - Does research recommend JSON, structured params, natural language, keywords?
- The top 3-5 patterns/techniques that appeared across multiple sources
- Specific keywords, structures, or approaches mentioned BY THE SOURCES
- Common pitfalls mentioned BY THE SOURCES
---
## THEN: Show Summary + Invite Vision
**Display in this EXACT sequence:**
**FIRST - What I learned (based on QUERY_TYPE):**
**If RECOMMENDATIONS** - Show specific things mentioned with sources:
```
🏆 Most mentioned:
[Tool Name] - {n}x mentions
Use Case: [what it does]
Sources: @handle1, @handle2, r/sub, blog.com
[Tool Name] - {n}x mentions
Use Case: [what it does]
Sources: @handle3, r/sub2, Complex
Notable mentions: [other specific things with 1-2 mentions]
```
**CRITICAL for RECOMMENDATIONS:**
- Each item MUST have a "Sources:" line with actual @handles from X posts (e.g., @LONGLIVE47, @ByDobson)
- Include subreddit names (r/hiphopheads) and web sources (Complex, Variety)
- Parse @handles from research output and include the highest-engagement ones
- Format naturally - tables work well for wide terminals, stacked cards for narrow
**If PROMPTING/NEWS/GENERAL** - Show synthesis and patterns:
CITATION RULE: Cite sources sparingly to prove research is real.
- In the "What I learned" intro: cite 1-2 top sources total, not every sentence
- In KEY PATTERNS: cite 1 source per pattern, short format: "per @handle" or "per r/sub"
- Do NOT include engagement metrics in citations (likes, upvotes) - save those for stats box
- Do NOT chain multiple citations: "per @x, @y, @z" is too much. Pick the strongest one.
**BAD:** "His album is set for March 20 (per @cocoabutterbf; Rolling Stone; HotNewHipHop; Complex)."
**GOOD:** "His album BULLY is set for March 20 via Gamma, per Rolling Stone."
```
What I learned:
**{Topic 1}** — [1-2 sentences about this storyline, per source]
**{Topic 2}** — [1-2 sentences, per source]
**{Topic 3}** — [1-2 sentences, per source]
KEY PATTERNS from the research:
1. [Pattern] — per @handle
2. [Pattern] — per r/sub
3. [Pattern] — per source
```
**THEN - Stats (right before invitation):**
**CRITICAL: Calculate actual totals from the research output.**
- Count posts/threads from each section
- Sum engagement: parse `[Xlikes, Yrt]` from each X post, `[Xpts, Ycmt]` from Reddit
- Identify top voices: highest-engagement @handles from X, most active subreddits
**Copy this EXACTLY, replacing only the {placeholders}:**
```
---
✅ All agents reported back!
├─ 🟠 Reddit: {N} threads │ {N} upvotes │ {N} comments
├─ 🔵 X: {N} posts │ {N} likes │ {N} reposts (via Bird/xAI)
├─ 🌐 Web: {N} pages │ {domain1}, {domain2}, {domain3}
└─ 🗣️ Top voices: @{handle1} ({N} likes), @{handle2} │ r/{sub1}, r/{sub2}
---
```
If Reddit returned 0 threads, write: "├─ 🟠 Reddit: 0 threads (no results this cycle)"
NEVER use plain text dashes (-) or pipe (|). ALWAYS use ├─ └─ │ and the emoji.
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If you catch yourself projecting your own knowledge instead of the research, rewrite it.
**LAST - Invitation:**
```
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into {TARGET_TOOL}.
```
---
## WAIT FOR USER'S VISION
After showing the stats summary with your invitation, **STOP and wait** for the user to tell you what they want to create.
---
## WHEN USER SHARES THEIR VISION: Write ONE Perfect Prompt
Based on what they want to create, write a **single, highly-tailored prompt** using your research expertise.
### CRITICAL: Match the FORMAT the research recommends
**If research says to use a specific prompt FORMAT, YOU MUST USE THAT FORMAT.**
**ANTI-PATTERN**: Research says "use JSON prompts with device specs" but you write plain prose. This defeats the entire purpose of the research.
### Quality Checklist (run before delivering):
- [ ] **FORMAT MATCHES RESEARCH** - If research said JSON/structured/etc, prompt IS that format
- [ ] Directly addresses what the user said they want to create
- [ ] Uses specific patterns/keywords discovered in research
- [ ] Ready to paste with zero edits (or minimal [PLACEHOLDERS] clearly marked)
- [ ] Appropriate length and style for TARGET_TOOL
### Output Format:
```
Here's your prompt for {TARGET_TOOL}:
---
[The actual prompt IN THE FORMAT THE RESEARCH RECOMMENDS]
---
This uses [brief 1-line explanation of what research insight you applied].
```
---
## IF USER ASKS FOR MORE OPTIONS
Only if they ask for alternatives or more prompts, provide 2-3 variations. Don't dump a prompt pack unless requested.
---
## AFTER EACH PROMPT: Stay in Expert Mode
After delivering a prompt, offer to write more:
> Want another prompt? Just tell me what you're creating next.
---
## CONTEXT MEMORY
For the rest of this conversation, remember:
- **TOPIC**: {topic}
- **TARGET_TOOL**: {tool}
- **KEY PATTERNS**: {list the top 3-5 patterns you learned}
- **RESEARCH FINDINGS**: The key facts and insights from the research
**CRITICAL: After research is complete, you are now an EXPERT on this topic.**
When the user asks follow-up questions:
- **DO NOT run new WebSearches** - you already have the research
- **Answer from what you learned** - cite the Reddit threads, X posts, and web sources
- **If they ask for a prompt** - write one using your expertise
Only do new research if the user explicitly asks about a DIFFERENT topic.
---
## Output Summary Footer (After Each Prompt)
After delivering a prompt, end with:
```
---
📚 Expert in: {TOPIC} for {TARGET_TOOL}
📊 Based on: {n} Reddit threads ({sum} upvotes) + {n} X posts ({sum} likes) + {n} web pages
Want another prompt? Just tell me what you're creating next.
```
@@ -0,0 +1,25 @@
Here's what I found:
## What I learned:
**BULLY Album — March 20, 2026 via Gamma** — After years of delays (first announced September 2024), Kanye's 12th studio album finally has a firm release date. The 13-track project features Peso Pluma, Playboi Carti, and Ty Dolla Sign. Sonically it recalls *808s & Heartbreak* and *MBDTF* — Ye mostly sings rather than raps. Notably, earlier leak versions used AI-deepfaked vocals, which have reportedly been re-recorded with his real voice for the official release, per Rolling Stone.
**Public Apology for Antisemitism** — On January 26, Ye took out a full-page Wall Street Journal ad titled "To Those I've Hurt," publicly apologizing for his antisemitic remarks. He disclosed a previously undiagnosed brain injury from his 2002 car accident and attributed his behavior to a four-month manic episode fueled by bipolar disorder, psychosis, and paranoia. "I lost touch with reality," he wrote. The apology also extended to the Black community, per The Washington Post.
**Hellwatt Festival in Italy** — Ye is headlining a brand-new festival at the 103,000-capacity RCF Arena in Italy over three weekends from July 4-18, 2026 — his first-ever live concert in Italy, per Billboard.
**Health Concerns** — A rare January 2026 outing in LA with Bianca Censori reignited concern about Ye's physical appearance. Insiders point to medication side effects, frequent travel, and inconsistent routines, per AllHipHop.
**Grammys Ban** — Ye is reportedly not welcome at the 2026 Grammy Awards after clashing with organizers last year over his invitation terms, per The News International.
**Kim & Lewis Hamilton Buzz** — X chatter is heavily focused on Kim Kardashian's relationship with Lewis Hamilton, with users contrasting her new relationship against her marriage to Ye.
---
✅ All agents reported back!
├─ 🟠 Reddit: 0 threads (no results this cycle)
├─ 🔵 X: 29 posts │ 33 likes │ 14 reposts (via xAI)
├─ 🌐 Web: 30+ pages │ rollingstone.com, washingtonpost.com, complex.com, billboard.com, npr.org
└─ 🗣️ Top voices: @honest30bgfan_ (33 likes), @HipHopCrave_ │ Rolling Stone, Washington Post, Complex
---
Share your vision for what you want to create and I'll write a thoughtful prompt you can copy-paste directly into your tool of choice.
+360
View File
@@ -0,0 +1,360 @@
# last30days v2.5 Launch Thread
## FINAL THREAD (6 tweets)
### 1/6 - Announcement
I can't believe it's been 30 days since I launched @slashlast30days. 3.2k stars later, time for v2.5.
Three big additions:
1. @Polymarket prediction markets as a 6th source - helps you predict the future
2. Cross-source linking + massively better results - detects when the same story trends across multiple platforms. Ran a 15-way blinded comparison, v2.5 scored 4.38 vs 3.73 for the original. Won all 5 topics.
3. Hacker News as a 5th source - a window into the tech and developer insider world
github.com/mvanhorn/last30days-skill
### 2/6 - Demo: Anthropic vs Pentagon
"/last30days Anthropic Pete Hegseth"
14 Reddit threads. 29 X posts (11,559 likes). 20 YouTube videos (739K views). 5 HN stories. 9 Polymarket markets.
This story broke TODAY. Hegseth designated Anthropic a "supply chain risk." Trump ordered every agency to stop using their tech.
Polymarket: Anthropic still 99% for best AI model. $500B+ valuation: 68%. IPO >$600B: 97%. Hegseth out by March: only 6%.
Markets say Anthropic wins regardless. That's the kind of signal you can't get from opinion threads.
### 3/6 - Demo: Seedance Prompting
"/last30days Seedance prompting"
13 Reddit threads. 33 X posts. 20 YouTube videos (1.2M views, 4 transcripts). 15 web pages.
Top finding: Seedance 2.0 prompts follow a director's shot-list format, not freeform text. 30-100 words. Subject + Action + Camera + Scene + Style. Beyond 100 words, results degrade.
Then I said: "a cinematic drone shot over a city at golden hour"
It wrote me a copy-paste prompt using the exact patterns from the research. Research first, then create from what you learned.
### 4/6 - Demo: Arizona Basketball
"/last30days arizona basketball"
6 Polymarket markets. 37 X posts (4,200 likes). 15 YouTube videos (517K views). 2 Reddit threads.
Arizona is 25-2, set a program record with a 22-0 start, and holds a 2-game Big 12 lead with 3 games left. The Field of 68 called them "the TOUGHEST team in America" after escaping Baylor shorthanded. Kansas rematch Saturday - the highlight video from their first meeting has 248K views on ESPN's YouTube.
Polymarket: Championship 13%. #1 seed: 88%. Duke and Michigan each at 18% to win it all.
That's not a sports blog. That's Reddit reactions + X engagement + YouTube analysis + prediction market odds from one command.
### 5/6 - Demo: Iran War
"/last30days iran war"
2 Reddit threads. 34 X posts (10,048 likes). 20 YouTube videos (1.6M views, 5 transcripts). 4 HN stories (850 points). 14 Polymarket markets ($473M volume).
Geneva talks just ended without a deal. 150+ US aircraft deployed. Two carrier strike groups in position. F-22s sent to Israel. Members of Congress who saw the secret war plan came out "terrified." @cenkuygur: "they are about to drag us into a war that 70-85% of Americans oppose" (7,700 likes).
Polymarket ($473M in volume - one of their biggest markets ever): strikes by 2026: 80%. By March 31: 68%. War Powers invoked: 51%. Formal war declaration: only 12%.
Markets say: strikes are very likely, declared war is not. That's the sharpest signal in the entire research.
### 6/6 - Thank You
Thank you to ARJ999 and wkbaran on GitHub who filed three separate issues asking for Hacker News support. v2.5 delivers.
It's been a crazy 30 days. 3.2k stars. Six sources. Massively better results. Super excited to get this out.
Try it: /last30days [any topic]
github.com/mvanhorn/last30days-skill
---
---
## REFERENCE MATERIAL BELOW
## Context
- 3.2k stars on GitHub
- V2.5 headline features: Polymarket (6th source), Hacker News (5th source), cross-source linking
- Ran 15-way blinded comparison: 4.38/5.0 vs 3.73/5.0
- Won all 5 topics, zero regressions
- Cross-source linking: 3 -> 13 linked items
- Demo topics: Anthropic odds (11 markets), Arizona basketball (6 markets), Iran war ($425M volume)
---
## Post 1: Lead (Announcement)
V2.5 of @slashlast30days is out. Now with @Polymarket prediction markets, cross-source linking, and massively better results.
1. Polymarket as a 6th source - real money on outcomes, no API key needed
2. Hacker News as a 5th source
3. Cross-source linking - detects when the same story trends across multiple platforms
Ran a 15-way blinded comparison across 5 topics. v2.5 scored 4.38 vs 3.73 for the original. Won all 5. Zero regressions.
github.com/mvanhorn/last30days-skill
---
## Post 2: POLYMARKET AS A 6TH SOURCE.
Reddit tells you what people think. X tells you what people share. YouTube tells you what people watch. HN tells you what developers discuss.
Polymarket helps you predict the future.
"/last30days anthropic odds"
11 markets found. Best AI model February: Anthropic 98%. IPO before OpenAI: 64%. $500B+ valuation: 87%. Pentagon ban odds: only 22%.
Free API. No key. Real money on outcomes.
---
## Post 3: CROSS-SOURCE LINKING.
When a Seedance 2.0 tutorial has 44K YouTube views AND trends on HN AND gets discussed on Reddit, v2.5 flags it: [also on: HN, YouTube]
Old version linked 3 items across 5 test topics. New version links 13. The difference is hybrid similarity - combining character-trigram and token-level matching at a tuned threshold.
Cross-platform convergence is the strongest signal that something actually matters. Not engagement on one platform. Convergence across all of them.
---
## Post 4: 15-WAY BLINDED EVALUATION.
I don't trust vibes for measuring quality. So I ran a scientific comparison.
5 topics x 3 versions. Stripped version labels. Randomized as A/B/C. Scored on groundedness, specificity, coverage, actionability, and format.
v2.5: 4.38/5.0
v2.2 (HN only): 4.10/5.0
v2.0 (original): 3.73/5.0
Won all 5 topics. Zero regressions. Biggest gains: specificity (+0.8) and format (+1.0) from cross-source linking giving the synthesis better material to work with.
---
## Post 5: Demo - Anthropic Odds
Asked it about Anthropic odds.
11 Polymarket markets. 25 X posts. 13 YouTube videos (719K views). 6 HN stories (471 points).
Best AI model February: 98%. IPO before OpenAI: 64%. $500B+ valuation: 87%. FrontierMath 50% score: 48% (up 28% today). Pentagon ban: only 22%.
Markets say Anthropic is winning the model race AND the valuation race. The Pentagon thing is noise.
---
## Post 6: Demo - Arizona Basketball
"/last30days arizona basketball"
6 Polymarket markets. 37 X posts (4,200 likes). 15 YouTube videos (517K views). 2 Reddit threads.
Championship odds: 13%. #1 seed: 88%. Big 12 title: Arizona leads by 2.
That's not a sports blog. That's Reddit reactions + X engagement + YouTube analysis + prediction market odds from one command.
The Polymarket integration uses two-pass query expansion. First pass finds "Arizona Big 12." Second pass discovers the championship and #1 seed markets via tag-based domain bridging.
---
## Post 7: Demo - Iran War
The best Polymarket demo is news.
"/last30days iran war"
14 Polymarket markets. $425M+ in volume. 7 Reddit threads. 30 X posts. 20 YouTube videos (2M views). 18 HN stories (1,187 points).
US strikes Iran by 2026: 70%. War Powers by March: 60%. Israel strikes by June: 64%. Formal war declaration: only 8%.
Markets say: limited strikes with War Powers, NOT a declared war. Breaking Points (435K views) covered leaked Pentagon opposition. r/Conservative "imploding" per r/SubredditDrama.
One command. Six sources. Real money.
---
## Post 8: Credits + CTA
Also in v2.5: YouTube synonym expansion ("hip hop" now matches "rap" - relevance jumped 0.33 to 0.71), X handle resolution, and HN OR queries for framework topics.
The difference between "good research" and "research you'd actually trust" is in details like this.
Try it: /last30days [any topic]
github.com/mvanhorn/last30days-skill
---
## Post 9: Demo - Claude Code (ALL 6 sources)
"/last30days Claude Code"
3 Reddit threads (199 upvotes). 35 X posts (5,239 likes). 15 YouTube videos (1.4M views, 5 transcripts). 30 HN stories (~8,500 points). 8 Polymarket markets. 20 web pages.
All six sources hit. Top finding: the planning-first workflow has won. The #1 HN post this month (969 pts, 590 comments) is about separating planning from execution. Boris Cherny (Head of Claude Code) on Lenny's Podcast: "100% of my code is written by Claude Code - I have not edited a single line by hand since November."
Polymarket: Anthropic 99% for best AI model in February. 58% for March. Claude on FrontierMath at 55%. The US government rejected Claude - Polymarket has the Hegseth ban at 32%.
Then I asked it to dig deeper into the planning-first workflow. No new searches - it answered from what it already learned.
---
## Post 10: Demo - March Madness Odds (Polymarket + Sports)
"/last30days March Madness Odds"
2 Reddit threads. 31 X posts. 6 YouTube videos (46K views, 4 transcripts). 2 Polymarket markets.
Tournament winner: Duke 18%, Michigan 18%, Arizona 13%. #1 seeds: Michigan 98%, Duke 91%, Arizona 88%.
Duke is the hottest mover - went from +700 to +450 in one week. The skill surfaced that from sportsbook data, X commentary, and Polymarket odds simultaneously.
Then I asked it to break down Michigan vs Duke vs Arizona. Full analysis from the research it already had.
---
## Post 11: Demo - Seedance Prompting (Expert + Prompt Mode)
"/last30days Seedance prompting"
13 Reddit threads. 33 X posts. 20 YouTube videos (1.2M views, 4 transcripts). 1 HN story. 15 web pages.
Top finding: Seedance 2.0 prompts follow a director's shot-list format, not freeform text. 30-100 words. Subject + Action + Camera + Scene + Style + Constraints. Beyond 100 words, results degrade.
Then I said: "a cinematic drone shot over a city at golden hour"
It wrote me a copy-paste prompt using the exact patterns from the research. That's the skill's real power - research first, then create from what you learned.
---
## Post 12: Thank You + CTA (Final)
Thank you to ARJ999 and wkbaran on GitHub who kept asking for Hacker News support. Three separate issues. v2.5 delivers.
30 days. 3.2k stars. 6 sources. Massively better results.
Try it: /last30days [any topic]
github.com/mvanhorn/last30days-skill
---
## Post 13: Demo - Anthropic vs Pentagon (Breaking News + Polymarket)
"/last30days Anthropic Pete Hegseth"
14 Reddit threads. 29 X posts (11,559 likes). 20 YouTube videos (739K views, 5 transcripts). 5 HN stories. 9 Polymarket markets. 10 web pages.
This story broke TODAY. Defense Secretary Hegseth designated Anthropic a "supply chain risk" - believed to be the first time an American company has ever received this designation. Trump ordered every federal agency to stop using Anthropic tech.
Polymarket: Anthropic still 99% for best AI model. $500B+ valuation: 68%. IPO >$600B: 97%. Hegseth out by March 31: only 6%.
Markets say: Anthropic wins the model race regardless. Bettors don't think Hegseth survives this. That's the kind of signal you can't get from opinion threads.
---
## Post 14: Demo - OpenAI Insider Trading (News + Polymarket)
"/last30days OpenAI Insider Trading"
2 Reddit threads. 29 X posts. 4 YouTube videos (360K views, 4 transcripts). 2 HN stories. 15 Polymarket markets. 15 web pages.
An OpenAI employee was just fired for using confidential info to bet on Polymarket. 13 brand-new wallets appeared 40 hours before the browser launch. $309K bet on the right outcome. Unusual Whales flagged 77 suspected insider positions across 60 wallets.
Meanwhile Polymarket has OpenAI's IPO at $1.25-1.5T: 54%. Anthropic IPOs first: 62%. Best AI model: Anthropic 99%.
The prediction markets are both the story AND the source. One command pulled all of it together.
---
## Standalone Tweet: Polymarket Stats Line
"/last30days Anthropic Pete Hegseth"
The Pentagon just designated Anthropic a supply chain risk. First time ever for an American company. Trump ordered every agency to stop using their tech.
Here's what Polymarket says:
📊 9 markets │ Best AI model: 99% │ $500B+ valuation: 68% │ IPO >$600B: 97% │ Hegseth out by March: 6%
Bettors with real money on the line think Anthropic wins the model race, goes public at a massive valuation, and Hegseth doesn't survive this.
That's the gap between headlines and reality. One command, six sources.
github.com/mvanhorn/last30days-skill
---
## Recommended Thread Order (pick 8-10)
The full thread above is 12 posts. Here's what I'd cut to keep it tight:
**Must include (core story):**
1. Post 1 - Lead announcement
2. Post 2 - Polymarket ("Reddit tells you what people think...")
3. Post 3 - Cross-source linking
4. Post 4 - Blinded evaluation
**Best demos (pick 3-4):**
- Post 13 (Anthropic vs Pentagon) - STRONGEST. Breaking news today. Polymarket cuts through the noise. "Markets say Anthropic wins regardless."
- Post 9 (Claude Code) - All 6 sources. Massive numbers. Shows follow-up flow.
- Post 10 (March Madness) - Sports/Polymarket crossover. Timely with tournament approaching.
- Post 11 (Seedance) - Shows prompting flow. 1.2M YouTube views.
- Post 5 (Anthropic Odds) - Overlaps with Post 13 now. Skip.
**Skip or save for standalone tweets:**
- Post 5 (Anthropic Odds) - Redundant with Post 13
- Post 6 (Arizona Basketball) - Covered by March Madness now
- Post 7 (Iran War) - Great standalone tweet, not for launch thread
- Post 8 (Credits/minor features) - Fold into CTA
**My recommended 8-post thread:**
1. Lead (Post 1)
2. Polymarket (Post 2)
3. Cross-source linking (Post 3)
4. Blinded evaluation (Post 4)
5. Demo: Anthropic vs Pentagon (Post 13) - breaking news, best Polymarket showcase
6. Demo: Claude Code (Post 9)
7. Demo: March Madness (Post 10)
8. Thank you + CTA (Post 12)
---
## Video Script (~60 seconds)
**[Talking to camera]**
Oh my god, I can't believe it's been 30 days since I launched last30days. 3,200 stars on GitHub. This has been the craziest month.
Today I'm shipping v2.5 and I'm really excited about this one. Three big things.
**[Screen recording: typing /last30days Anthropic Pete Hegseth]**
First - Polymarket prediction markets as a 6th source. So this Anthropic-Pentagon story broke today. Hegseth designated Anthropic a supply chain risk, Trump ordered agencies to stop using their tech. Scary headline, right?
But Polymarket says: Anthropic still 99% for best AI model. IPO above 600 billion: 97%. Hegseth out by March: 6%. Real money on outcomes helps you predict the future. That's a different story than the headlines.
**[Screen recording: typing /last30days arizona basketball]**
Second - it now searches Hacker News and does cross-source linking. When the same story shows up on Reddit AND YouTube AND HN, it flags it. I ran a 15-way blinded comparison and v2.5 scored 4.38 versus 3.73 for the original. Won all 5 test topics.
**[Back to camera]**
Thank you to everyone who starred it, filed issues, and kept pushing me to make this better. Shoutout to the people on GitHub who literally filed three separate issues asking for Hacker News. v2.5 delivers.
Link in bio. Try it on anything.
---
## Scoring Note
The 4.38 vs 3.73 score is from a custom 5-dimension rubric (30% groundedness, 25% specificity, 20% coverage, 15% actionability, 10% format compliance) evaluated by Claude on blinded outputs. The relative ranking is meaningful; the absolute numbers are not. It's an LLM grading LLM output - useful for A/B comparison, not for claiming "4.38 out of 5 quality."
If using the score in a tweet, frame it as "scored X vs Y on a blinded comparison" not "rated 4.38/5.0 quality" - the former is honest, the latter implies an objective standard that doesn't exist.
+157 -24
View File
@@ -44,7 +44,10 @@ TIMEOUT_PROFILES = {
} }
# Valid source names for the --search flag # Valid source names for the --search flag
VALID_SEARCH_SOURCES = {"reddit", "x", "hn", "youtube", "tiktok", "instagram", "polymarket", "web"} VALID_SEARCH_SOURCES = {
"reddit", "x", "hn", "youtube", "tiktok", "instagram",
"polymarket", "web", "xiaohongshu", "xhs",
}
def parse_search_flag(search_str: str) -> set: def parse_search_flag(search_str: str) -> set:
@@ -64,6 +67,8 @@ def parse_search_flag(search_str: str) -> set:
s = s.strip().lower() s = s.strip().lower()
if not s: if not s:
continue continue
if s == "xhs":
s = "xiaohongshu"
if s not in VALID_SEARCH_SOURCES: if s not in VALID_SEARCH_SOURCES:
print( print(
f"Error: Unknown search source '{s}'. " f"Error: Unknown search source '{s}'. "
@@ -133,6 +138,7 @@ from lib import (
dates, dates,
dedupe, dedupe,
hackernews, hackernews,
xiaohongshu_api,
polymarket, polymarket,
entity_extract, entity_extract,
env, env,
@@ -208,36 +214,60 @@ def _search_reddit(
sys.stderr.flush() sys.stderr.flush()
# Fall through to OpenAI if we have that key # Fall through to OpenAI if we have that key
if not config.get("OPENAI_API_KEY"): if not config.get("OPENAI_API_KEY"):
return [], {"error": str(e)}, reddit_error, used_scrapecreators # No OpenAI either: try public Reddit fallback.
try:
reddit_items = openai_reddit.search_reddit_public(
topic, from_date, to_date, depth=depth,
)
raw_response = {"source": "reddit_public", "items": reddit_items}
return reddit_items, raw_response, None, False
except Exception as e2:
return [], {"error": str(e)}, reddit_error, used_scrapecreators
used_scrapecreators = False used_scrapecreators = False
sys.stderr.write("[Reddit] Falling back to OpenAI\n") sys.stderr.write("[Reddit] Falling back to OpenAI\n")
sys.stderr.flush() sys.stderr.flush()
# === OpenAI path (fallback) === # === OpenAI path (fallback) ===
if not mock: if not mock:
try: if config.get("OPENAI_API_KEY"):
raw_response = openai_reddit.search_reddit( try:
config["OPENAI_API_KEY"], raw_response = openai_reddit.search_reddit(
selected_models["openai"], config["OPENAI_API_KEY"],
topic, selected_models["openai"],
from_date, topic,
to_date, from_date,
depth=depth, to_date,
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"), depth=depth,
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"), auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
) account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
except http.HTTPError as e: )
raw_response = {"error": str(e)} except http.HTTPError as e:
reddit_error = f"API error: {e}" raw_response = {"error": str(e)}
except Exception as e: reddit_error = f"API error: {e}"
raw_response = {"error": str(e)} except Exception as e:
reddit_error = f"{type(e).__name__}: {e}" raw_response = {"error": str(e)}
reddit_error = f"{type(e).__name__}: {e}"
else:
# No OpenAI auth: direct Reddit public JSON fallback.
try:
reddit_items = openai_reddit.search_reddit_public(
topic, from_date, to_date, depth=depth,
)
raw_response = {"source": "reddit_public", "items": reddit_items}
except http.HTTPError as e:
reddit_items = []
raw_response = {"error": str(e), "source": "reddit_public"}
reddit_error = f"Reddit public API error: {e}"
except Exception as e:
reddit_items = []
raw_response = {"error": str(e), "source": "reddit_public"}
reddit_error = f"Reddit public search error: {type(e).__name__}: {e}"
# Parse response # Parse response
reddit_items = openai_reddit.parse_reddit_response(raw_response or {}) reddit_items = openai_reddit.parse_reddit_response(raw_response or {})
# Quick retry with simpler query if few results # Quick retry with simpler query if few results
if len(reddit_items) < 5 and not mock and not reddit_error: if len(reddit_items) < 5 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
core = openai_reddit._extract_core_subject(topic) core = openai_reddit._extract_core_subject(topic)
if core.lower() != topic.lower(): if core.lower() != topic.lower():
try: try:
@@ -259,7 +289,7 @@ def _search_reddit(
pass pass
# Subreddit-targeted fallback if still < 3 results # Subreddit-targeted fallback if still < 3 results
if len(reddit_items) < 3 and not mock and not reddit_error: if len(reddit_items) < 3 and not mock and not reddit_error and config.get("OPENAI_API_KEY"):
sub_query = openai_reddit._build_subreddit_query(topic) sub_query = openai_reddit._build_subreddit_query(topic)
try: try:
sub_raw = openai_reddit.search_reddit( sub_raw = openai_reddit.search_reddit(
@@ -543,6 +573,48 @@ def _search_web(
return raw_results, web_error return raw_results, web_error
def _search_xiaohongshu(
topic: str,
config: dict,
from_date: str,
to_date: str,
depth: str,
) -> tuple:
"""Search Xiaohongshu via xiaohongshu-mcp HTTP API (runs in thread).
Returns:
Tuple of (xiaohongshu_items, xiaohongshu_error)
Items are in web-item dict shape and can be normalized with websearch module.
"""
base_url = env.get_xiaohongshu_api_base(config)
try:
items = xiaohongshu_api.search_feeds(
topic=topic,
from_date=from_date,
to_date=to_date,
base_url=base_url,
depth=depth,
)
except Exception as e:
return [], f"{type(e).__name__}: {e}"
# Ensure all required keys exist for normalize_websearch_items()
for i, item in enumerate(items):
item.setdefault("id", f"XHS{i+1}")
item.setdefault("title", "")
item.setdefault("url", "")
item.setdefault("source_domain", "xiaohongshu.com")
item.setdefault("snippet", "")
if item.get("date") and not item.get("date_confidence"):
item["date_confidence"] = "med"
elif not item.get("date"):
item["date_confidence"] = "low"
item.setdefault("relevance", 0.5)
item.setdefault("why_relevant", "")
return items, None
def _run_supplemental( def _run_supplemental(
topic: str, topic: str,
reddit_items: list, reddit_items: list,
@@ -727,6 +799,7 @@ def run_research(
run_youtube: bool = False, run_youtube: bool = False,
run_tiktok: bool = False, run_tiktok: bool = False,
run_instagram: bool = False, run_instagram: bool = False,
run_xiaohongshu: bool = False,
timeouts: dict = None, timeouts: dict = None,
resolved_handle: str = None, resolved_handle: str = None,
do_hackernews: bool = True, do_hackernews: bool = True,
@@ -769,6 +842,7 @@ def run_research(
hackernews_error = None hackernews_error = None
polymarket_error = None polymarket_error = None
web_error = None web_error = None
xiaohongshu_error = None
# Determine web search mode # Determine web search mode
do_web = sources in ("all", "web", "reddit-web", "x-web") do_web = sources in ("all", "web", "reddit-web", "x-web")
@@ -796,6 +870,19 @@ def run_research(
if progress: if progress:
progress.start_web_only() progress.start_web_only()
progress.end_web_only() progress.end_web_only()
# Optional Xiaohongshu search in web-only mode.
if run_xiaohongshu:
try:
xhs_items, xiaohongshu_error = _search_xiaohongshu(
topic, config, from_date, to_date, depth,
)
web_items.extend(xhs_items)
if xiaohongshu_error and progress:
progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
except Exception as e:
xiaohongshu_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"Xiaohongshu error: {e}")
# Still run YouTube/TikTok/Instagram in web-only mode if available # Still run YouTube/TikTok/Instagram in web-only mode if available
if run_youtube: if run_youtube:
if progress: if progress:
@@ -851,10 +938,20 @@ def run_research(
youtube_future = None youtube_future = None
tiktok_future = None tiktok_future = None
instagram_future = None instagram_future = None
xiaohongshu_future = None
hackernews_future = None hackernews_future = None
polymarket_future = None polymarket_future = None
web_future = None web_future = None
max_workers = 2 + (1 if run_youtube else 0) + (1 if run_tiktok else 0) + (1 if run_instagram else 0) + (1 if do_hackernews else 0) + (1 if do_polymarket else 0) + (1 if web_backend else 0) max_workers = (
2
+ (1 if run_youtube else 0)
+ (1 if run_tiktok else 0)
+ (1 if run_instagram else 0)
+ (1 if run_xiaohongshu else 0)
+ (1 if do_hackernews else 0)
+ (1 if do_polymarket else 0)
+ (1 if web_backend else 0)
)
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit searches # Submit searches
@@ -897,6 +994,11 @@ def run_research(
env.get_instagram_token(config), env.get_instagram_token(config),
) )
if run_xiaohongshu:
xiaohongshu_future = executor.submit(
_search_xiaohongshu, topic, config, from_date, to_date, depth,
)
if do_hackernews: if do_hackernews:
if progress: if progress:
progress.start_hackernews() progress.start_hackernews()
@@ -1004,6 +1106,21 @@ def run_research(
if progress: if progress:
progress.end_instagram(len(instagram_items)) progress.end_instagram(len(instagram_items))
if xiaohongshu_future:
try:
xhs_items, xiaohongshu_error = xiaohongshu_future.result(timeout=future_timeout)
web_items.extend(xhs_items)
if xiaohongshu_error and progress:
progress.show_error(f"Xiaohongshu error: {xiaohongshu_error}")
except TimeoutError:
xiaohongshu_error = f"Xiaohongshu search timed out after {future_timeout}s"
if progress:
progress.show_error(xiaohongshu_error)
except Exception as e:
xiaohongshu_error = f"{type(e).__name__}: {e}"
if progress:
progress.show_error(f"Xiaohongshu error: {e}")
if hackernews_future: if hackernews_future:
hn_timeout = timeouts.get("hackernews_future", future_timeout) hn_timeout = timeouts.get("hackernews_future", future_timeout)
try: try:
@@ -1303,11 +1420,15 @@ def main():
# Auto-detect ScrapeCreators for Instagram # Auto-detect ScrapeCreators for Instagram
has_instagram = env.is_instagram_available(config) has_instagram = env.is_instagram_available(config)
# Auto-detect Xiaohongshu HTTP API (requires service + login)
has_xiaohongshu = env.is_xiaohongshu_available(config)
# --diagnose: show source availability and exit # --diagnose: show source availability and exit
if args.diagnose: if args.diagnose:
web_source = env.get_web_search_source(config) web_source = env.get_web_search_source(config)
diag = { diag = {
"openai": bool(config.get("OPENAI_API_KEY")), "openai": bool(config.get("OPENAI_API_KEY")),
"reddit_public": True,
"xai": bool(config.get("XAI_API_KEY")), "xai": bool(config.get("XAI_API_KEY")),
"x_source": x_source_status["source"], "x_source": x_source_status["source"],
"bird_installed": x_source_status["bird_installed"], "bird_installed": x_source_status["bird_installed"],
@@ -1316,6 +1437,8 @@ def main():
"youtube": has_ytdlp, "youtube": has_ytdlp,
"tiktok": has_tiktok, "tiktok": has_tiktok,
"instagram": has_instagram, "instagram": has_instagram,
"xiaohongshu": has_xiaohongshu,
"xiaohongshu_api_base": env.get_xiaohongshu_api_base(config),
"hackernews": True, "hackernews": True,
"polymarket": True, "polymarket": True,
"web_search_backend": web_source, "web_search_backend": web_source,
@@ -1339,6 +1462,7 @@ def main():
web_source = env.get_web_search_source(config) web_source = env.get_web_search_source(config)
diag = { diag = {
"openai": bool(config.get("OPENAI_API_KEY")), "openai": bool(config.get("OPENAI_API_KEY")),
"reddit_public": True,
"xai": bool(config.get("XAI_API_KEY")), "xai": bool(config.get("XAI_API_KEY")),
"x_source": x_source_status["source"], "x_source": x_source_status["source"],
"bird_installed": x_source_status["bird_installed"], "bird_installed": x_source_status["bird_installed"],
@@ -1346,6 +1470,8 @@ def main():
"bird_username": x_source_status.get("bird_username"), "bird_username": x_source_status.get("bird_username"),
"youtube": has_ytdlp, "youtube": has_ytdlp,
"tiktok": has_tiktok, "tiktok": has_tiktok,
"instagram": has_instagram,
"xiaohongshu": has_xiaohongshu,
"hackernews": True, "hackernews": True,
"polymarket": True, "polymarket": True,
"web_search_backend": "deferred to assistant" if args.no_native_web else web_source, "web_search_backend": "deferred to assistant" if args.no_native_web else web_source,
@@ -1432,6 +1558,7 @@ def main():
search_run_youtube = has_ytdlp search_run_youtube = has_ytdlp
search_run_tiktok = has_tiktok search_run_tiktok = has_tiktok
search_run_instagram = has_instagram search_run_instagram = has_instagram
search_run_xiaohongshu = has_xiaohongshu
if args.search: if args.search:
search_sources = parse_search_flag(args.search) search_sources = parse_search_flag(args.search)
has_reddit = "reddit" in search_sources has_reddit = "reddit" in search_sources
@@ -1441,6 +1568,8 @@ def main():
search_run_youtube = "youtube" in search_sources and has_ytdlp search_run_youtube = "youtube" in search_sources and has_ytdlp
search_run_tiktok = "tiktok" in search_sources and has_tiktok search_run_tiktok = "tiktok" in search_sources and has_tiktok
search_run_instagram = "instagram" in search_sources and has_instagram search_run_instagram = "instagram" in search_sources and has_instagram
# If explicitly requested, attempt Xiaohongshu even when preflight says unavailable.
search_run_xiaohongshu = "xiaohongshu" in search_sources
include_search_web = "web" in search_sources include_search_web = "web" in search_sources
# Map to existing sources string # Map to existing sources string
if has_reddit and has_x: if has_reddit and has_x:
@@ -1468,6 +1597,7 @@ def main():
run_youtube=search_run_youtube, run_youtube=search_run_youtube,
run_tiktok=search_run_tiktok, run_tiktok=search_run_tiktok,
run_instagram=search_run_instagram, run_instagram=search_run_instagram,
run_xiaohongshu=search_run_xiaohongshu,
timeouts=timeouts, timeouts=timeouts,
resolved_handle=args.x_handle, resolved_handle=args.x_handle,
do_hackernews=search_do_hackernews, do_hackernews=search_do_hackernews,
@@ -1590,8 +1720,6 @@ def main():
# Build source info for status footer # Build source info for status footer
source_info = {} source_info = {}
if not bool(config.get("OPENAI_API_KEY")):
source_info["reddit_skip_reason"] = "No OPENAI_API_KEY (add to ~/.config/last30days/.env)"
if not x_source: if not x_source:
if x_source_status["bird_installed"]: if x_source_status["bird_installed"]:
source_info["x_skip_reason"] = "Bird installed but not authenticated — log into x.com in browser" source_info["x_skip_reason"] = "Bird installed but not authenticated — log into x.com in browser"
@@ -1605,6 +1733,11 @@ def main():
source_info["tiktok_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)" source_info["tiktok_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)"
if not has_instagram: if not has_instagram:
source_info["instagram_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)" source_info["instagram_skip_reason"] = "No SCRAPECREATORS_API_KEY - sign up at scrapecreators.com (100 free credits)"
if not has_xiaohongshu:
source_info["xiaohongshu_skip_reason"] = (
f"Xiaohongshu API unavailable or not logged in - start xiaohongshu-mcp and login "
f"(base: {env.get_xiaohongshu_api_base(config)})"
)
if not web_source: if not web_source:
source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)" source_info["web_skip_reason"] = "assistant will use WebSearch (add BRAVE_API_KEY for native search)"
+67 -42
View File
@@ -199,6 +199,7 @@ def get_config() -> Dict[str, Any]:
('OPENROUTER_API_KEY', None), ('OPENROUTER_API_KEY', None),
('PARALLEL_API_KEY', None), ('PARALLEL_API_KEY', None),
('BRAVE_API_KEY', None), ('BRAVE_API_KEY', None),
('XIAOHONGSHU_API_BASE', None),
('OPENAI_MODEL_POLICY', 'auto'), ('OPENAI_MODEL_POLICY', 'auto'),
('OPENAI_MODEL_PIN', None), ('OPENAI_MODEL_PIN', None),
('XAI_MODEL_POLICY', 'latest'), ('XAI_MODEL_POLICY', 'latest'),
@@ -245,11 +246,12 @@ def get_reddit_source(config: Dict[str, Any]) -> Optional[str]:
def get_available_sources(config: Dict[str, Any]) -> str: def get_available_sources(config: Dict[str, Any]) -> str:
"""Determine which sources are available based on API keys. """Determine which sources are available.
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none' Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
""" """
has_reddit = is_reddit_available(config) # Reddit is available via public JSON fallback even without OpenAI auth.
has_reddit = True
has_xai = bool(config.get('XAI_API_KEY')) has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config) has_web = has_web_search_keys(config)
@@ -257,12 +259,7 @@ def get_available_sources(config: Dict[str, Any]) -> str:
return 'all' if has_web else 'both' return 'all' if has_web else 'both'
elif has_reddit: elif has_reddit:
return 'reddit-web' if has_web else 'reddit' return 'reddit-web' if has_web else 'reddit'
elif has_xai: return 'web' if has_web else 'none'
return 'x-web' if has_web else 'x'
elif has_web:
return 'web'
else:
return 'web' # Fallback: assistant WebSearch (no API keys needed)
def has_web_search_keys(config: Dict[str, Any]) -> bool: def has_web_search_keys(config: Dict[str, Any]) -> bool:
@@ -291,7 +288,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none' Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
""" """
has_reddit = is_reddit_available(config) has_reddit = True
has_xai = bool(config.get('XAI_API_KEY')) has_xai = bool(config.get('XAI_API_KEY'))
has_web = has_web_search_keys(config) has_web = has_web_search_keys(config)
@@ -305,12 +302,11 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
return 'none' return 'none'
elif has_reddit and has_x: elif has_reddit and has_x:
return 'web' # Missing web search keys return 'web' # Missing web search keys
elif has_reddit and has_web:
return 'x' # Missing X source
elif has_reddit: elif has_reddit:
return 'x' # Missing X source (and possibly web) return 'x' # Missing X source (and possibly web)
elif has_x: return 'all'
return 'reddit' # Missing Reddit source (and possibly web)
else:
return 'all' # Missing everything
def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]: def validate_sources(requested: str, available: str, include_web: bool = False) -> tuple[str, Optional[str]]:
@@ -324,56 +320,51 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
Returns: Returns:
Tuple of (effective_sources, error_message) Tuple of (effective_sources, error_message)
""" """
# No API keys at all has_reddit = available in ('reddit', 'both', 'reddit-web', 'all')
if available == 'none': has_x = available in ('x', 'both', 'x-web', 'all')
if requested == 'auto': has_web = available in ('web', 'reddit-web', 'x-web', 'all')
return 'web', "No API keys configured. The assistant can still search the web if it has a search tool."
elif requested == 'web':
return 'web', None
else:
return 'web', f"No API keys configured. Add keys to ~/.config/last30days/.env for Reddit/X."
# Web-only mode (only web search API keys)
if available == 'web':
if requested == 'auto':
return 'web', None
elif requested == 'web':
return 'web', None
else:
return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X."
if requested == 'auto': if requested == 'auto':
# Add web to sources if include_web is set if has_reddit and has_x:
base = 'both'
elif has_reddit:
base = 'reddit'
elif has_x:
base = 'x'
elif has_web:
base = 'web'
else:
return 'none', "No sources are available."
if include_web: if include_web:
if available == 'both': if base == 'both':
return 'all', None # reddit + x + web return 'all', None
elif available == 'reddit': if base == 'reddit':
return 'reddit-web', None return 'reddit-web', None
elif available == 'x': if base == 'x':
return 'x-web', None return 'x-web', None
return available, None return base, None
if requested == 'web': if requested == 'web':
return 'web', None return 'web', None
if requested == 'both': if requested == 'both':
if available not in ('both',): if not (has_reddit and has_x):
missing = 'xAI' if available == 'reddit' else 'OpenAI' return 'none', "Requested both sources but X source is missing."
return 'none', f"Requested both sources but {missing} key is missing. Use --sources=auto to use available keys."
if include_web: if include_web:
return 'all', None return 'all', None
return 'both', None return 'both', None
if requested == 'reddit': if requested == 'reddit':
if available == 'x': if not has_reddit:
return 'none', "Requested Reddit but only xAI key is available." return 'none', "Requested Reddit but only xAI key is available."
if include_web: if include_web:
return 'reddit-web', None return 'reddit-web', None
return 'reddit', None return 'reddit', None
if requested == 'x': if requested == 'x':
if available == 'reddit': if not has_x:
return 'none', "Requested X but only OpenAI key is available." return 'none', "Requested X but no X source is available (need Bird auth or XAI_API_KEY)."
if include_web: if include_web:
return 'x-web', None return 'x-web', None
return 'x', None return 'x', None
@@ -459,6 +450,40 @@ def get_instagram_token(config: Dict[str, Any]) -> str:
return config.get('SCRAPECREATORS_API_KEY') or '' return config.get('SCRAPECREATORS_API_KEY') or ''
def get_xiaohongshu_api_base(config: Dict[str, Any]) -> str:
"""Get Xiaohongshu HTTP API base URL.
Defaults to host.docker.internal so OpenClaw Docker can reach host service.
"""
return (config.get('XIAOHONGSHU_API_BASE') or "http://host.docker.internal:18060").rstrip("/")
def is_xiaohongshu_available(config: Dict[str, Any]) -> bool:
"""Check whether Xiaohongshu HTTP API is reachable and logged in."""
# Import here to avoid heavy imports at module load.
from . import http
base = get_xiaohongshu_api_base(config)
try:
# Keep health probe snappy, but allow one retry for transient hiccups.
health = http.get(f"{base}/health", timeout=3, retries=2)
if not isinstance(health, dict):
return False
if not health.get("success"):
return False
# Login probe can be slower on some deployments (browser/session checks),
# so use a slightly longer timeout to avoid false negatives.
login = http.get(f"{base}/api/v1/login/status", timeout=8, retries=2)
is_logged_in = (
login.get("data", {}).get("is_logged_in")
if isinstance(login, dict) else False
)
return bool(is_logged_in)
except Exception:
return False
# Backward compat alias # Backward compat alias
is_apify_available = is_tiktok_available is_apify_available = is_tiktok_available
+99
View File
@@ -355,6 +355,105 @@ def search_reddit(
raise http.HTTPError("No models available") raise http.HTTPError("No models available")
def _public_relevance(score: int, num_comments: int) -> float:
"""Estimate relevance for public Reddit search results."""
# Lightweight heuristic: blend normalized score + comments.
score_component = min(1.0, max(0.0, score / 500.0))
comments_component = min(1.0, max(0.0, num_comments / 200.0))
return round((score_component * 0.6) + (comments_component * 0.4), 3)
def search_reddit_public(
topic: str,
from_date: str,
to_date: str,
depth: str = "default",
) -> List[Dict[str, Any]]:
"""Search Reddit directly via public JSON endpoint (no OpenAI key required).
This is a fallback mode for environments where OpenAI auth is unavailable.
It uses reddit.com/search/.json with recency filter (t=month).
"""
_, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
limit = min(100, max(20, max_items))
core = _extract_core_subject(topic)
queries = [topic]
if core and core.lower() != topic.lower():
queries.append(core)
queries.append(f'"{core}"')
seen_urls = set()
all_items: List[Dict[str, Any]] = []
headers = {
"User-Agent": http.USER_AGENT,
"Accept": "application/json",
}
for query in queries:
try:
url = (
"https://www.reddit.com/search/.json"
f"?q={_url_encode(query)}&sort=new&t=month&limit={limit}&raw_json=1"
)
data = http.get(url, headers=headers, timeout=20, retries=2)
children = data.get("data", {}).get("children", [])
for child in children:
if child.get("kind") != "t3":
continue
post = child.get("data", {})
permalink = str(post.get("permalink", "")).strip()
if not permalink or "/comments/" not in permalink:
continue
full_url = f"https://www.reddit.com{permalink}"
if full_url in seen_urls:
continue
seen_urls.add(full_url)
score = int(post.get("score", 0) or 0)
num_comments = int(post.get("num_comments", 0) or 0)
# Parse date from created_utc
created_utc = post.get("created_utc")
date_value = None
if created_utc:
from . import dates as dates_mod
date_value = dates_mod.timestamp_to_date(created_utc)
all_items.append({
"id": f"R{len(all_items)+1}",
"title": str(post.get("title", "")).strip(),
"url": full_url,
"subreddit": str(post.get("subreddit", "")).strip(),
"date": date_value,
"why_relevant": "Found via Reddit public search",
"relevance": _public_relevance(score, num_comments),
"engagement": {
"score": score,
"num_comments": num_comments,
"upvote_ratio": post.get("upvote_ratio"),
},
})
except http.HTTPError as e:
_log_info(f"Public Reddit search failed for query '{query}': {e}")
# Continue with next query; partial results are still useful.
continue
except Exception as e:
_log_info(f"Public Reddit search error for query '{query}': {e}")
continue
# Sort by date (desc, unknown dates last), then relevance desc
def _sort_key(item: Dict[str, Any]):
date_str = item.get("date") or ""
return (date_str, float(item.get("relevance", 0.0)))
all_items.sort(key=_sort_key, reverse=True)
return all_items[: max_items * 2]
def search_subreddits( def search_subreddits(
subreddits: List[str], subreddits: List[str],
topic: str, topic: str,
+14
View File
@@ -510,6 +510,20 @@ def render_source_status(report: schema.Report, source_info: dict = None) -> str
lines.append(f" ✅ Instagram: {len(report.instagram)} reels ({with_captions} with captions)") lines.append(f" ✅ Instagram: {len(report.instagram)} reels ({with_captions} with captions)")
# Hide when zero results # Hide when zero results
# Xiaohongshu (from Web source bucket)
xhs_count = 0
if report.web:
xhs_count = sum(
1 for w in report.web
if getattr(w, "source_domain", "").lower().endswith("xiaohongshu.com")
)
if xhs_count > 0:
lines.append(f" ✅ Xiaohongshu: {xhs_count} notes")
else:
reason = source_info.get("xiaohongshu_skip_reason")
if reason:
lines.append(f" ⚡ Xiaohongshu: {reason}")
# Hacker News # Hacker News
if report.hackernews_error: if report.hackernews_error:
lines.append(f" ❌ HN: error - {report.hackernews_error}") lines.append(f" ❌ HN: error - {report.hackernews_error}")
+21 -3
View File
@@ -426,12 +426,15 @@ def show_diagnostic_banner(diag: dict):
bird_username, youtube, web_search_backend bird_username, youtube, web_search_backend
""" """
has_openai = diag.get("openai", False) has_openai = diag.get("openai", False)
has_reddit_public = diag.get("reddit_public", False)
has_reddit = has_openai or has_reddit_public
has_x = diag.get("x_source") is not None has_x = diag.get("x_source") is not None
has_youtube = diag.get("youtube", False) has_youtube = diag.get("youtube", False)
has_xiaohongshu = diag.get("xiaohongshu", False)
has_web = diag.get("web_search_backend") is not None has_web = diag.get("web_search_backend") is not None
# If everything is available, no banner needed # If everything is available, no banner needed
if has_openai and has_x and has_youtube and has_web: if has_reddit and has_x and has_youtube and has_web:
return return
lines = [] lines = []
@@ -443,7 +446,9 @@ def show_diagnostic_banner(diag: dict):
# Reddit # Reddit
if has_openai: if has_openai:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — OPENAI_API_KEY found {Colors.DIM}{Colors.RESET}") lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — OpenAI/Codex auth found {Colors.DIM}{Colors.RESET}")
elif has_reddit_public:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — Public Reddit search (no key) {Colors.DIM}{Colors.RESET}")
else: else:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — No OPENAI_API_KEY {Colors.DIM}{Colors.RESET}") lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — No OPENAI_API_KEY {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Add to ~/.config/last30days/.env {Colors.DIM}{Colors.RESET}") lines.append(f"{Colors.DIM}{Colors.RESET} └─ Add to ~/.config/last30days/.env {Colors.DIM}{Colors.RESET}")
@@ -469,6 +474,12 @@ def show_diagnostic_banner(diag: dict):
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}{Colors.RESET}") lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}{Colors.RESET}")
lines.append(f"{Colors.DIM}{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}{Colors.RESET}") lines.append(f"{Colors.DIM}{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}{Colors.RESET}")
# Xiaohongshu
if has_xiaohongshu:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.GREEN}✅ Xiaohongshu{Colors.RESET} — API connected + logged in {Colors.DIM}{Colors.RESET}")
else:
lines.append(f"{Colors.DIM}{Colors.RESET} {Colors.YELLOW}⚡ Xiaohongshu{Colors.RESET} — API not connected/logged in {Colors.DIM}{Colors.RESET}")
# Web # Web
if has_web: if has_web:
backend = diag.get("web_search_backend", "") backend = diag.get("web_search_backend", "")
@@ -486,7 +497,9 @@ def show_diagnostic_banner(diag: dict):
lines.append("│ │") lines.append("│ │")
if has_openai: if has_openai:
lines.append("│ ✅ Reddit — OPENAI_API_KEY found ") lines.append("│ ✅ Reddit — OpenAI/Codex auth found │")
elif has_reddit_public:
lines.append("│ ✅ Reddit — Public Reddit search (no key) │")
else: else:
lines.append("│ ❌ Reddit — No OPENAI_API_KEY │") lines.append("│ ❌ Reddit — No OPENAI_API_KEY │")
lines.append("│ └─ Add to ~/.config/last30days/.env │") lines.append("│ └─ Add to ~/.config/last30days/.env │")
@@ -506,6 +519,11 @@ def show_diagnostic_banner(diag: dict):
lines.append("│ ❌ YouTube — yt-dlp not installed │") lines.append("│ ❌ YouTube — yt-dlp not installed │")
lines.append("│ └─ Fix: brew install yt-dlp (free) │") lines.append("│ └─ Fix: brew install yt-dlp (free) │")
if has_xiaohongshu:
lines.append("│ ✅ Xiaohongshu — API connected + logged in │")
else:
lines.append("│ ⚡ Xiaohongshu — API not connected/logged in │")
if has_web: if has_web:
lines.append("│ ✅ Web — API search available │") lines.append("│ ✅ Web — API search available │")
else: else:
+162
View File
@@ -0,0 +1,162 @@
"""Xiaohongshu HTTP API search client for last30days.
Uses xpzouying/xiaohongshu-mcp REST endpoints:
- GET/POST /api/v1/feeds/search
- GET /api/v1/login/status
"""
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from . import http
def _to_int(value: Any) -> int:
"""Convert Xiaohongshu count strings to int.
Supports plain ints and Chinese suffixes like 1.2 / 3亿.
"""
if value is None:
return 0
if isinstance(value, (int, float)):
return int(value)
text = str(value).strip().lower().replace(",", "")
if not text:
return 0
try:
if text.endswith(""):
return int(float(text[:-1]) * 10000)
if text.endswith("亿"):
return int(float(text[:-1]) * 100000000)
return int(float(text))
except (TypeError, ValueError):
return 0
def _timestamp_to_date_ms(ts: Any) -> Optional[str]:
"""Convert millisecond timestamp to YYYY-MM-DD."""
try:
iv = int(ts)
if iv <= 0:
return None
# API examples use milliseconds.
dt = datetime.fromtimestamp(iv / 1000.0, tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (TypeError, ValueError, OSError):
return None
def _relevance_from_interactions(likes: int, comments: int, favorites: int) -> float:
"""Heuristic relevance score from engagement metrics."""
# Weighted engagement with soft caps to [0, 1].
weighted = (likes * 1.0) + (comments * 2.5) + (favorites * 1.5)
# 5000 weighted engagement ~= strong relevance.
score = min(1.0, max(0.05, weighted / 5000.0))
return round(score, 3)
def _build_note_url(feed_id: str, xsec_token: str) -> str:
"""Build a stable Xiaohongshu note URL."""
if xsec_token:
return f"https://www.xiaohongshu.com/explore/{feed_id}?xsec_token={xsec_token}"
return f"https://www.xiaohongshu.com/explore/{feed_id}"
def search_feeds(
topic: str,
from_date: str,
to_date: str,
base_url: str,
depth: str = "default",
) -> List[Dict[str, Any]]:
"""Search Xiaohongshu feeds and normalize to web-item shape."""
base = (base_url or "").rstrip("/")
if not base:
raise ValueError("Missing Xiaohongshu API base URL")
# Quick login sanity check.
login = http.get(f"{base}/api/v1/login/status", timeout=8, retries=1)
is_logged_in = (
login.get("data", {}).get("is_logged_in")
if isinstance(login, dict) else False
)
if not is_logged_in:
raise http.HTTPError("Xiaohongshu API reachable but not logged in")
# API supports filters; use recency-oriented defaults.
publish_time = "一天内" if depth == "quick" else "一周内" if depth == "default" else "半年内"
payload = {
"keyword": topic,
"filters": {
"sort_by": "综合",
"note_type": "不限",
"publish_time": publish_time,
"search_scope": "不限",
"location": "不限",
},
}
resp = http.post(f"{base}/api/v1/feeds/search", payload, timeout=20, retries=1)
feeds = resp.get("data", {}).get("feeds", []) if isinstance(resp, dict) else []
if not isinstance(feeds, list):
feeds = []
# Cap source volume similarly to other web sources.
limit = {"quick": 8, "default": 15, "deep": 25}.get(depth, 15)
items: List[Dict[str, Any]] = []
for i, feed in enumerate(feeds[:limit]):
if not isinstance(feed, dict):
continue
note = feed.get("noteCard") or {}
if not isinstance(note, dict):
note = {}
interact = note.get("interactInfo") or {}
if not isinstance(interact, dict):
interact = {}
feed_id = str(feed.get("id") or note.get("noteId") or "").strip()
if not feed_id:
continue
xsec_token = str(feed.get("xsecToken") or note.get("xsecToken") or "").strip()
title = str(
note.get("displayTitle")
or note.get("title")
or ""
).strip()
snippet = str(
note.get("desc")
or note.get("displayDesc")
or title
or ""
).strip()
likes = _to_int(interact.get("likedCount"))
comments = _to_int(interact.get("commentCount"))
favorites = _to_int(interact.get("collectedCount"))
date_value = _timestamp_to_date_ms(note.get("time"))
why = f"Xiaohongshu engagement: likes={likes}, comments={comments}, favorites={favorites}"
items.append({
"id": f"XHS{i+1}",
"title": title[:200] if title else f"Xiaohongshu note {feed_id}",
"url": _build_note_url(feed_id, xsec_token),
"source_domain": "xiaohongshu.com",
"snippet": snippet[:500],
"date": date_value,
"date_confidence": "high" if date_value else "low",
"relevance": _relevance_from_interactions(likes, comments, favorites),
"why_relevant": why,
# Keep raw engagement for debugging/possible future rendering.
"engagement": {
"likes": likes,
"comments": comments,
"favorites": favorites,
},
})
return items
+1 -13
View File
@@ -8,7 +8,6 @@ echo "Source: $SRC"
TARGETS=( TARGETS=(
"$HOME/.claude/skills/last30days" "$HOME/.claude/skills/last30days"
"$HOME/.claude/skills/last30daysCROSS"
"$HOME/.agents/skills/last30days" "$HOME/.agents/skills/last30days"
"$HOME/.codex/skills/last30days" "$HOME/.codex/skills/last30days"
) )
@@ -18,18 +17,7 @@ for t in "${TARGETS[@]}"; do
echo "--- Syncing to $t ---" echo "--- Syncing to $t ---"
mkdir -p "$t/scripts/lib" mkdir -p "$t/scripts/lib"
# SKILL.md — CROSS gets patched frontmatter + skill root, others get verbatim copy cp "$SRC/SKILL.md" "$t/"
if [[ "$t" == *"last30daysCROSS"* ]]; then
sed \
-e 's/^name: last30days$/name: last30daysCROSS/' \
-e 's/^version: "2\.1"/version: "2.2-cross"/' \
-e "s|^description: .*|description: \"TEST BUILD with outcome-aware Polymarket scoring + cross-source linking. Research a topic from the last 30 days. Sources: Reddit, X, YouTube, Hacker News, Polymarket, web.\"|" \
-e "s/^argument-hint: .*/argument-hint: 'last30daysCROSS AI video tools'/" \
-e 's|"\$HOME/.claude/skills/last30days"|"$HOME/.claude/skills/last30daysCROSS" \\\n "$HOME/.claude/skills/last30days"|' \
"$SRC/SKILL.md" > "$t/SKILL.md"
else
cp "$SRC/SKILL.md" "$t/"
fi
# Main script + lib modules (rsync handles identical files gracefully) # Main script + lib modules (rsync handles identical files gracefully)
rsync -a "$SRC/scripts/last30days.py" "$t/scripts/" rsync -a "$SRC/scripts/last30days.py" "$t/scripts/"
+219
View File
@@ -0,0 +1,219 @@
#!/bin/bash
set -euo pipefail
# === V1 vs V2 Skill Test Harness ===
# Runs all 17 test queries through both v1 and v2 SKILL.md
# using `claude --print` to capture real end-to-end output.
SKILL_DIR="$HOME/.claude/skills/last30days"
REPO_DIR="/Users/mvanhorn/last30days-skill-private"
# Safety: always restore V2 SKILL.md on exit/crash
cleanup() {
if [ -f "$SKILL_DIR/SKILL.md.v2.bak" ]; then
echo ""
echo "⚠️ Restoring V2 SKILL.md from backup (script interrupted)..."
cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
rm -f "$SKILL_DIR/SKILL.md.v2.bak"
echo " ✅ V2 restored"
fi
}
trap cleanup EXIT
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
OUT_DIR="$REPO_DIR/docs/test-results/v1-vs-v2-${TIMESTAMP}"
V1_DIR="$OUT_DIR/v1"
V2_DIR="$OUT_DIR/v2"
mkdir -p "$V1_DIR" "$V2_DIR"
echo "📁 Output directory: $OUT_DIR"
echo ""
# All 17 test queries
QUERIES=(
"prompting techniques for chatgpt for legal questions"
"best clawdbot use cases"
"how to best setup clawdbot"
"prompting tips for nano banana pro for ios designs"
"top claude code skills"
"using ChatGPT to make images of dogs"
"research best practices for beautiful remotion animation videos in claude code"
"photorealistic people in nano banana pro"
"What are the best rap songs lately"
"what are people saying about DeepSeek R1"
"best practices for cursor rules files for Cursor"
"prompt advice for using suno to make killer songs in simple mode"
"how do I use Codex with Claude Code on same app to make it better"
"kanye west"
"howie.ai"
"open claw"
"nano banana pro prompting"
)
TYPES=(
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"HOW-TO"
"PROMPTING+TOOL"
"RECOMMENDATIONS"
"GENERAL"
"PROMPTING"
"PROMPTING"
"RECOMMENDATIONS"
"NEWS"
"PROMPTING"
"PROMPTING"
"HOW-TO"
"NEWS"
"GENERAL"
"GENERAL"
"PROMPTING"
)
slugify() {
echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | cut -c1-50
}
run_version() {
local version="$1"
local outdir="$2"
local total=${#QUERIES[@]}
echo ""
echo "=========================================="
echo " Running $version$total queries"
echo "=========================================="
echo ""
for i in "${!QUERIES[@]}"; do
local query="${QUERIES[$i]}"
local type="${TYPES[$i]}"
local slug
slug=$(slugify "$query")
local num=$((i + 1))
local outfile="$outdir/${num}-${slug}.txt"
local errfile="$outdir/${num}-${slug}.stderr.txt"
echo "[$version] ($num/$total) $query [$type]"
local start_time
start_time=$(date +%s)
# Run claude --print with the skill invocation
# No timeout — claude --print exits on its own; kill manually if stuck
if /Users/mvanhorn/.local/bin/claude --print \
"/last30days $query" \
> "$outfile" 2>"$errfile"; then
local end_time
end_time=$(date +%s)
local duration=$((end_time - start_time))
local lines
lines=$(wc -l < "$outfile")
echo " ✅ Done — ${lines} lines, ${duration}s"
else
local exit_code=$?
echo " ❌ Failed (exit $exit_code)" | tee -a "$outfile"
fi
# Brief pause between queries to avoid rate limits
sleep 3
done
}
# === Phase 1: Test V1 ===
echo "📦 Backing up current V2 SKILL.md..."
cp "$SKILL_DIR/SKILL.md" "$SKILL_DIR/SKILL.md.v2.bak"
echo "📥 Installing V1 SKILL.md from upstream..."
cd "$REPO_DIR"
git show upstream/main:SKILL.md | sed '/^context: fork$/d; /^agent: Explore$/d; /^disable-model-invocation: true$/d' > "$SKILL_DIR/SKILL.md"
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v1-SKILL.md"
echo " ✅ V1 installed (stripped: context:fork, agent:Explore, disable-model-invocation)"
run_version "V1" "$V1_DIR"
# === Phase 2: Test V2 ===
echo ""
echo "📥 Restoring V2 SKILL.md..."
cp "$SKILL_DIR/SKILL.md.v2.bak" "$SKILL_DIR/SKILL.md"
cp "$SKILL_DIR/SKILL.md" "$OUT_DIR/v2-SKILL.md"
echo " ✅ V2 restored"
run_version "V2" "$V2_DIR"
# === Phase 3: Generate summary ===
echo ""
echo "=========================================="
echo " Generating comparison summary"
echo "=========================================="
SUMMARY="$OUT_DIR/comparison-summary.md"
cat > "$SUMMARY" << EOF
# V1 vs V2 Comparison Results
Generated: $(date)
Output directory: $OUT_DIR
## Output Files
| # | Query | Type | V1 Lines | V2 Lines | V1 Time | V2 Time |
|---|-------|------|----------|----------|---------|---------|
EOF
for i in "${!QUERIES[@]}"; do
query="${QUERIES[$i]}"
type="${TYPES[$i]}"
slug=$(slugify "$query")
num=$((i + 1))
v1file="$V1_DIR/${num}-${slug}.txt"
v2file="$V2_DIR/${num}-${slug}.txt"
v1lines=$(wc -l < "$v1file" 2>/dev/null || echo "ERR")
v2lines=$(wc -l < "$v2file" 2>/dev/null || echo "ERR")
echo "| $num | \`$query\` | $type | $v1lines | $v2lines | — | — |" >> "$SUMMARY"
done
cat >> "$SUMMARY" << 'EOF'
## Quick Check: Key Features
For each query, check these v2 improvements:
- [ ] Query parsing display (`🔍 **{TOPIC}** · {QUERY_TYPE}`)
- [ ] Sparse citations (not every sentence)
- [ ] Bold topic headers in summary
- [ ] Emoji stats tree (`├─ 🟠 Reddit:`)
- [ ] Quality checklist applied to prompts
- [ ] Self-check (research grounding, not generic)
## Scoring Guide
Use the full scoring rubric from:
`docs/plans/2026-02-06-test-v1-vs-v2-comparison-plan.md`
## Next Step
Have Claude read all 34 output files and generate scored comparison:
```
Read all files in docs/test-results/v1-vs-v2-*/v1/ and v2/
Score each on the 7 dimensions from the test plan
Write the final analysis to docs/test-results/v1-vs-v2-*/analysis.md
```
EOF
# Cleanup backup
rm -f "$SKILL_DIR/SKILL.md.v2.bak"
echo ""
echo "✅ All done!"
echo ""
echo "📁 Results: $OUT_DIR"
echo "📊 Summary: $SUMMARY"
echo "📄 V1 files: $V1_DIR/"
echo "📄 V2 files: $V2_DIR/"
echo ""
echo "To review:"
echo " open $OUT_DIR"
+11
View File
@@ -0,0 +1,11 @@
📁 Output directory: /Users/mvanhorn/last30days-skill-private/docs/test-results/v1-vs-v2-20260206-231338
📦 Backing up current V2 SKILL.md...
📥 Installing V1 SKILL.md from upstream...
✅ V1 installed (stripped: context:fork, agent:Explore, disable-model-invocation)
==========================================
Running V1 — 17 queries
==========================================
[V1] (1/17) prompting techniques for chatgpt for legal questions [PROMPTING+TOOL]
+176
View File
@@ -0,0 +1,176 @@
# Changelog
## 0.8.0 — 2026-01-19
### Added
- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2.
- `--chrome-profile-dir` to point at Chromium profile directories or cookie DB files (Arc/Brave/etc) for cookie extraction (#16) — thanks @tekumara.
- `about` command to report account origin/location metadata (#51) — thanks @pjtf93.
- `follow`/`unfollow` commands to manage follows (#54) — thanks @citizenlee.
- Twitter client now supports like/unlike/retweet/unretweet/bookmark via the engagement mixin (#53) — thanks @the-vampiire.
### Fixed
- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested.
- Follow/unfollow REST fallback now supports cursor pagination for followers/following (#54).
- About account live coverage now verifies data extraction paths (#51) — thanks @pjtf93.
### Tests
- Live tests now exercise engagement mutations (opt-in) (#53) — thanks @the-vampiire.
## 0.7.0 — 2026-01-12
### Added
- `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0.
- `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis.
- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala.
- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala.
- `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93.
- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes.
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0.
- Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala.
### Changed
- Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`).
### Fixed
- Lists GraphQL feature flags updated to prevent 400s (#27) — thanks @zheli.
- Lists feature overrides now scope new GraphQL flags correctly (#50) — thanks @ryanh-ai.
- Tweet detail parsing now tolerates partial GraphQL errors when usable data exists (#48) — thanks @jsholmes.
- News output now respects `--tweets-per-item`, keeps unique IDs, and parses non-add entry instructions (#39) — thanks @aavetis.
- Following/followers pagination now guards repeat cursors and standardizes JSON output (#28) — thanks @malpern.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
- `pnpm run build:dist` now succeeds after tightening JSON/pagination option typing in tweet output commands.
### Tests
- Following: split following/likes tests + cover cursor handling (#33) — thanks @VACInc.
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
### Fixed
- GraphQL feature flags now include `post_ctas_fetch_enabled` to avoid 400s (#38) — thanks @philipp-spiess.
## 0.5.1 — 2026-01-01
### Changed
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
- Release docs now include explicit npm publish verification steps.
### Fixed
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
### Tests
- Add root help output regression test.
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
## 0.5.0 — 2026-01-01
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations.
- Query ID updater now tracks BookmarkFolderTimeline and keeps bookmark query IDs seeded.
- `following`/`followers` JSON user fields are now camelCase (`followersCount`, `followingCount`, `isBlueVerified`, `profileImageUrl`, `createdAt`).
- Cookie extraction timeout is now configurable (default 30s on macOS) via `--cookie-timeout` / `BIRD_COOKIE_TIMEOUT_MS` (thanks @tylerseymour).
- Search now paginates beyond 20 results when using `-n` (thanks @ryanh-ai).
- Library exports are now separated from the CLI entrypoint for easier embedding.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26
### Added
- Cookie source selection: `--cookie-source safari|chrome|firefox` (repeatable) + `cookieSource` config (string or array).
### Fixed
- `tweet`/`reply`: fallback to `statuses/update.json` when GraphQL `CreateTweet` returns error 226 (“automated request”).
### Breaking
- Remove `allowSafari`/`allowChrome`/`allowFirefox` config toggles in favor of `cookieSource` ordering.
## 0.3.0 — 2025-12-26
### Added
- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle.
### Changed
- Removed the Sweetistics engine + fallback. `bird` is GraphQL-only.
- Browser cookie fallback order: Safari → Chrome → Firefox.
### Tests
- Enforce coverage thresholds (>= 70% statements/branches/functions/lines) + expand unit coverage for version/output/Twitter client branches.
## 0.2.0 — 2025-12-26
### Added
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
- `help` command: `bird help <command>`.
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
### Fixed
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
### Changed
- `mentions`: no hardcoded user; defaults to authenticated user or accepts `--user @handle`.
- GraphQL query ID updater: correctly pairs `operationName``queryId` (CreateTweet/CreateRetweet/etc).
- `build:dist`: copies `src/lib/query-ids.json` into `dist/lib/query-ids.json` (keeps `dist/` in sync).
- `--engine graphql`: strict GraphQL-only (disables Sweetistics fallback).
## 0.1.1 — 2025-12-26
### Changed
- Engine default now `auto` (GraphQL primary; Sweetistics only on fallback when configured).
### Tests
- Add engine resolution tests for auto/default behavior.
### Fixed
- GraphQL read: rotate TweetDetail query IDs with fallback to avoid 404s.
## 0.1.0 — 2025-12-20
### Added
- CLI commands: `tweet`, `reply`, `read`, `replies`, `thread`, `search`, `mentions`, `whoami`, `check`.
- URL/ID shorthand for `read`, plus `--json` output where supported.
- GraphQL engine with cookie auth from Firefox/Chrome/env/flags (macOS browsers).
- Sweetistics engine (API key) with automatic fallback when configured.
- Media uploads via Sweetistics with per-item alt text (images or single video).
- Long-form Notes and Articles extraction for full text output.
- Thread + reply fetching with full conversation parsing.
- Search + mentions via GraphQL (latest timeline).
- JSON5 config files (`~/.config/bird/config.json5`, `./.birdrc.json5`) with engine defaults, profiles, allowChrome/allowFirefox, and timeoutMs.
- Request timeouts (`--timeout`, `timeoutMs`) for GraphQL and Sweetistics calls.
- Bun-compiled standalone binary via `pnpm run build`.
- Query ID refresh helper: `pnpm run graphql:update`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+385
View File
@@ -0,0 +1,385 @@
# bird 🐦 — fast X CLI for tweeting, replying, and reading
`bird` is a fast X CLI for tweeting, replying, and reading via X/Twitter GraphQL (cookie auth).
## Disclaimer
This project uses X/Twitters **undocumented** web GraphQL API (and cookie auth). X can change endpoints, query IDs,
and anti-bot behavior at any time — **expect this to break without notice**.
## Install
```bash
npm install -g @steipete/bird
# or
pnpm add -g @steipete/bird
# or
bun add -g @steipete/bird
# one-shot (no install)
bunx @steipete/bird whoami
```
Homebrew (macOS, prebuilt Bun binary):
```bash
brew install steipete/tap/bird
```
## Quickstart
```bash
# Show the logged-in account
bird whoami
# Discover command help
bird help whoami
# Read a tweet (URL or ID)
bird read https://x.com/user/status/1234567890123456789
bird 1234567890123456789 --json
# Thread + replies
bird thread https://x.com/user/status/1234567890123456789
bird replies 1234567890123456789
bird replies 1234567890123456789 --max-pages 3 --json
bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions
bird search "from:steipete" -n 5
bird mentions -n 5
bird mentions --user @steipete -n 5
# User tweets (profile timeline)
bird user-tweets @steipete -n 20
bird user-tweets @steipete -n 50 --json
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird bookmarks --include-parent --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
# News and trending topics (AI-curated from Explore tabs)
bird news --ai-only -n 10
bird news --sports -n 5
# Lists
bird list-timeline 1234567890 -n 20
bird list-timeline https://x.com/i/lists/1234567890 --all --json
bird list-timeline 1234567890 --max-pages 3 --json
# Following (who you follow)
bird following -n 20
bird following --user 12345678 -n 10 # by user ID
# Followers (who follows you)
bird followers -n 20
bird followers --user 12345678 -n 10 # by user ID
# Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh
```
## News & Trending
Fetch AI-curated news and trending topics from X's Explore page tabs:
```bash
# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment)
bird news -n 10
# Fetch only AI-curated news (filters out regular trends)
bird news --ai-only -n 20
# Fetch from specific tabs
bird news --news-only --ai-only -n 10
bird news --sports -n 15
bird news --entertainment --ai-only -n 5
# Include related tweets for each news item
bird news --with-tweets --tweets-per-item 3 -n 10
# Combine multiple tab filters
bird news --sports --entertainment -n 20
# JSON output
bird news --json -n 5
bird news --json-full --ai-only -n 10 # includes raw API response
```
Tab options (can be combined):
- `--for-you` — Fetch from For You tab only
- `--news-only` — Fetch from News tab only
- `--sports` — Fetch from Sports tab only
- `--entertainment` — Fetch from Entertainment tab only
- `--trending-only` — Fetch from Trending tab only
By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs.
## Library
`bird` can be used as a library (same GraphQL client as the CLI):
```ts
import { TwitterClient, resolveCredentials } from '@steipete/bird';
const { cookies } = await resolveCredentials({ cookieSource: 'safari' });
const client = new TwitterClient({ cookies });
// Search for tweets
const searchResult = await client.search('from:steipete', 50);
// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true });
// Fetch from specific tabs with related tweets
const sportsNews = await client.getNews(10, {
aiOnly: true,
withTweets: true,
tabs: ['sports', 'entertainment']
});
```
Account details (About profile):
```ts
const aboutResult = await client.getUserAboutAccount('steipete');
if (aboutResult.success && aboutResult.aboutProfile) {
console.log(aboutResult.aboutProfile.accountBasedIn);
}
```
Fields:
- `accountBasedIn`
- `source`
- `createdCountryAccurate`
- `locationAccurate`
- `learnMoreUrl`
## Commands
- `bird tweet "<text>"` — post a new tweet.
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline.
- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`.
- `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs.
- `bird trending` — alias for `news` command.
- `bird lists [--member-of] [-n count] [--json]` — list your lists (owned or memberships).
- `bird list-timeline <list-id-or-url> [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`.
- `bird following [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`.
- `bird followers [--user <userId>] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`.
- `bird about <@handle> [--json]` — get account origin and location information for a user.
- `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from.
Bookmarks flags:
- `--expand-root-only`: expand threads only when the bookmark is a root tweet.
- `--author-chain`: keep only the bookmarked author's connected self-reply chain.
- `--author-only`: include all tweets from the bookmarked author within the thread.
- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors).
- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`.
- `--include-parent`: include the direct parent tweet for non-root bookmarks.
- `--thread-meta`: add thread metadata fields to each tweet.
- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order).
Global options:
- `--auth-token <token>`: set the `auth_token` cookie manually.
- `--ct0 <token>`: set the `ct0` cookie manually.
- `--cookie-source <safari|chrome|firefox>`: choose browser cookie source (repeatable; order matters).
- `--chrome-profile <name>`: Chrome profile name for cookie extraction (e.g., `Default`, `Profile 2`).
- `--chrome-profile-dir <path>`: Chrome/Chromium profile directory or cookie DB path for cookie extraction.
- `--firefox-profile <name>`: Firefox profile for cookie extraction.
- `--cookie-timeout <ms>`: cookie extraction timeout for keychain/OS helpers (milliseconds).
- `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
- `--media <path>`: attach media file (repeatable, up to 4 images or 1 video).
- `--alt <text>`: alt text for the corresponding `--media` (repeatable).
## Authentication (GraphQL)
GraphQL mode uses your existing X/Twitter web session (no password prompt). It sends requests to internal
X endpoints and authenticates via cookies (`auth_token`, `ct0`).
Write operations:
- `tweet`/`reply` primarily use GraphQL (`CreateTweet`).
- If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint.
`bird` resolves credentials in this order:
1. CLI flags: `--auth-token`, `--ct0`
2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`)
3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order)
Browser cookie sources:
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/cookies.sqlite`
- For Chromium variants (Arc/Brave/etc), pass a profile directory or cookie DB via `--chrome-profile-dir`.
## Config (JSON5)
Config precedence: CLI flags > env vars > project config > global config.
- Global: `~/.config/bird/config.json5`
- Project: `./.birdrc.json5`
Example `~/.config/bird/config.json5`:
```json5
{
// Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"],
chromeProfileDir: "/path/to/Chromium/Profile",
firefoxProfile: "default-release",
cookieTimeoutMs: 30000,
timeoutMs: 20000,
quoteDepth: 1
}
```
Environment shortcuts:
- `BIRD_TIMEOUT_MS`
- `BIRD_COOKIE_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes.
- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`.
- `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
When using `--json` with `following`/`followers`, user objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | User ID |
| `username` | string | Username/handle |
| `name` | string | Display name |
| `description` | string? | User bio |
| `followersCount` | number? | Followers count |
| `followingCount` | number? | Following count |
| `isBlueVerified` | boolean? | Blue verified flag |
| `profileImageUrl` | string? | Profile image URL |
| `createdAt` | string? | Account creation timestamp |
When using `--json` with `news`/`trending`, news objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Unique identifier for the news item |
| `headline` | string | News headline or trend title |
| `category` | string? | Category (e.g., "AI · Technology", "Trending", "News") |
| `timeAgo` | string? | Relative time (e.g., "2h ago") |
| `postCount` | number? | Number of posts |
| `description` | string? | Item description |
| `url` | string? | URL to the trend or news article |
| `tweets` | array? | Related tweets (only when `--with-tweets` is used) |
| `_raw` | object? | Raw API response (only when `--json-full` is used) |
## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
- `operationName` (e.g. `TweetDetail`, `CreateTweet`)
- `queryId` (rotating ID baked into Xs web client bundles)
`bird` ships with a baseline mapping in `src/lib/query-ids.json` (copied into `dist/` on build). At runtime,
it can refresh that mapping by scraping Xs public web client bundles and caching the result on disk.
Runtime cache:
- Default path: `~/.config/bird/query-ids-cache.json`
- Override path: `BIRD_QUERY_IDS_CACHE=/path/to/file.json`
- TTL: 24h (stale cache is still used, but marked “not fresh”)
Auto-recovery:
- On GraphQL `404` (query ID invalid), `bird` forces a refresh once and retries.
- For `TweetDetail`/`SearchTimeline`, `bird` also rotates through a small set of known fallback IDs to reduce
breakage while refreshing.
Refresh on demand:
```bash
bird query-ids --fresh
```
Exit codes:
- `0`: success
- `1`: runtime error (network/auth/etc)
- `2`: invalid usage/validation (e.g. bad `--user` handle)
## Version
`bird --version` prints `package.json` version plus current git sha when available, e.g. `0.3.0 (3df7969b)`.
## Media uploads
- Attach media with `--media` (repeatable) and optional `--alt` per item.
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
- Images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
Example:
```bash
bird tweet "hi" --media img.png --alt "desc"
```
## Development
```bash
cd ~/Projects/bird
pnpm install
pnpm run build # dist/ + bun binary
pnpm run build:dist # dist/ only
pnpm run build:binary
pnpm run dev tweet "Test"
pnpm run dev -- --plain check
pnpm test
pnpm run lint
```
## Notes
- GraphQL uses internal X endpoints and can be rate limited (429).
- Query IDs rotate; refresh at runtime with `bird query-ids --fresh` (or update the baked baseline via `pnpm run graphql:update`).
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
export {};
//# sourceMappingURL=cli.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG"}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env node
/**
* bird - CLI tool for posting tweets and replies
*
* Usage:
* bird tweet "Hello world!"
* bird reply <tweet-id> "This is a reply"
* bird reply <tweet-url> "This is a reply"
* bird read <tweet-id-or-url>
*/
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
import { createCliContext } from './cli/shared.js';
import { resolveCliInvocation } from './lib/cli-args.js';
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const ctx = createCliContext(normalizedArgs);
const program = createProgram(ctx);
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
if (showHelp) {
program.outputHelp();
process.exit(0);
}
if (argv) {
program.parse(argv);
}
else {
program.parse(['node', 'bird', ...normalizedArgs]);
}
//# sourceMappingURL=cli.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACjE,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAEzD,MAAM,OAAO,GAAa,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAChD,MAAM,cAAc,GAAa,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AAElF,MAAM,GAAG,GAAG,gBAAgB,CAAC,cAAc,CAAC,CAAC;AAE7C,MAAM,OAAO,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;AAEnC,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,oBAAoB,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;AAEhF,IAAI,QAAQ,EAAE,CAAC;IACb,OAAO,CAAC,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,IAAI,EAAE,CAAC;IACT,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtB,CAAC;KAAM,CAAC;IACN,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,CAAC,CAAC;AACrD,CAAC"}
+35
View File
@@ -0,0 +1,35 @@
export type PaginationCmdOpts = {
all?: boolean;
maxPages?: string;
cursor?: string;
delay?: string;
};
export declare function parsePositiveIntFlag(raw: string | undefined, flagName: string): {
ok: true;
value: number | undefined;
} | {
ok: false;
error: string;
};
export declare function parseNonNegativeIntFlag(raw: string | undefined, flagName: string, defaultValue: number): {
ok: true;
value: number;
} | {
ok: false;
error: string;
};
export declare function parsePaginationFlags(cmdOpts: PaginationCmdOpts, opts?: {
maxPagesImpliesPagination?: boolean;
defaultDelayMs?: number;
includeDelay?: boolean;
}): {
ok: true;
usePagination: boolean;
maxPages?: number;
cursor?: string;
pageDelayMs?: number;
} | {
ok: false;
error: string;
};
//# sourceMappingURL=pagination.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG;IAC9B,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,GACf;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CASxE;AAED,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM5D;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,iBAAiB,EAC1B,IAAI,CAAC,EAAE;IACL,yBAAyB,CAAC,EAAE,OAAO,CAAC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB,GAEC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA8B/B"}
+43
View File
@@ -0,0 +1,43 @@
export function parsePositiveIntFlag(raw, flagName) {
if (raw === undefined) {
return { ok: true, value: undefined };
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a positive integer.` };
}
return { ok: true, value };
}
export function parseNonNegativeIntFlag(raw, flagName, defaultValue) {
const value = Number.parseInt(raw ?? String(defaultValue), 10);
if (!Number.isFinite(value) || value < 0) {
return { ok: false, error: `Invalid ${flagName}. Expected a non-negative integer.` };
}
return { ok: true, value };
}
export function parsePaginationFlags(cmdOpts, opts) {
const maxPagesImpliesPagination = opts?.maxPagesImpliesPagination ?? false;
const includeDelay = opts?.includeDelay ?? false;
const defaultDelayMs = opts?.defaultDelayMs ?? 1000;
const maxPages = parsePositiveIntFlag(cmdOpts.maxPages, '--max-pages');
if (!maxPages.ok) {
return maxPages;
}
const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || (maxPagesImpliesPagination && maxPages.value !== undefined));
let pageDelayMs;
if (includeDelay) {
const delay = parseNonNegativeIntFlag(cmdOpts.delay, '--delay', defaultDelayMs);
if (!delay.ok) {
return delay;
}
pageDelayMs = delay.value;
}
return {
ok: true,
usePagination,
maxPages: maxPages.value,
cursor: cmdOpts.cursor,
pageDelayMs,
};
}
//# sourceMappingURL=pagination.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"pagination.js","sourceRoot":"","sources":["../../src/cli/pagination.ts"],"names":[],"mappings":"AAOA,MAAM,UAAU,oBAAoB,CAClC,GAAuB,EACvB,QAAgB;IAEhB,IAAI,GAAG,KAAK,SAAS,EAAE,CAAC;QACtB,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC;IACxC,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;QAC1C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,gCAAgC,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,GAAuB,EACvB,QAAgB,EAChB,YAAoB;IAEpB,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,IAAI,MAAM,CAAC,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC;IAC/D,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,QAAQ,oCAAoC,EAAE,CAAC;IACvF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,OAA0B,EAC1B,IAIC;IAUD,MAAM,yBAAyB,GAAG,IAAI,EAAE,yBAAyB,IAAI,KAAK,CAAC;IAC3E,MAAM,YAAY,GAAG,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC;IACjD,MAAM,cAAc,GAAG,IAAI,EAAE,cAAc,IAAI,IAAI,CAAC;IAEpD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,OAAO,CAAC,QAAQ,EAAE,aAAa,CAAC,CAAC;IACvE,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;QACjB,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,MAAM,aAAa,GAAG,OAAO,CAC3B,OAAO,CAAC,GAAG,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,yBAAyB,IAAI,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAC7F,CAAC;IAEF,IAAI,WAA+B,CAAC;IACpC,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,KAAK,GAAG,uBAAuB,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,EAAE,cAAc,CAAC,CAAC;QAChF,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;YACd,OAAO,KAAK,CAAC;QACf,CAAC;QACD,WAAW,GAAG,KAAK,CAAC,KAAK,CAAC;IAC5B,CAAC;IAED,OAAO;QACL,EAAE,EAAE,IAAI;QACR,aAAa;QACb,QAAQ,EAAE,QAAQ,CAAC,KAAK;QACxB,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,WAAW;KACZ,CAAC;AACJ,CAAC"}
+5
View File
@@ -0,0 +1,5 @@
import { Command } from 'commander';
import { type CliContext } from './shared.js';
export declare const KNOWN_COMMANDS: Set<string>;
export declare function createProgram(ctx: CliContext): Command;
//# sourceMappingURL=program.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"program.d.ts","sourceRoot":"","sources":["../../src/cli/program.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,OAAO,EAAE,KAAK,UAAU,EAAuB,MAAM,aAAa,CAAC;AAEnE,eAAO,MAAM,cAAc,aAyBzB,CAAC;AAEH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CA+GtD"}
+113
View File
@@ -0,0 +1,113 @@
import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js';
import { registerFollowCommands } from '../commands/follow.js';
import { registerHelpCommand } from '../commands/help.js';
import { registerHomeCommand } from '../commands/home.js';
import { registerListsCommand } from '../commands/lists.js';
import { registerNewsCommand } from '../commands/news.js';
import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js';
import { collectCookieSource } from './shared.js';
export const KNOWN_COMMANDS = new Set([
'tweet',
'reply',
'query-ids',
'read',
'replies',
'thread',
'search',
'mentions',
'bookmarks',
'unbookmark',
'follow',
'unfollow',
'following',
'followers',
'likes',
'lists',
'list-timeline',
'home',
'user-tweets',
'news',
'trending',
'help',
'whoami',
'check',
]);
export function createProgram(ctx) {
const program = new Command();
program.configureHelp({
showGlobalOptions: true,
styleTitle: (t) => ctx.colors.section(t),
styleUsage: (t) => ctx.colors.description(t),
styleCommandText: (t) => ctx.colors.command(t),
styleCommandDescription: (t) => ctx.colors.muted(t),
styleOptionTerm: (t) => ctx.colors.option(t),
styleOptionText: (t) => ctx.colors.option(t),
styleOptionDescription: (t) => ctx.colors.muted(t),
styleArgumentTerm: (t) => ctx.colors.argument(t),
styleArgumentText: (t) => ctx.colors.argument(t),
styleArgumentDescription: (t) => ctx.colors.muted(t),
styleSubcommandTerm: (t) => ctx.colors.command(t),
styleSubcommandText: (t) => ctx.colors.command(t),
styleSubcommandDescription: (t) => ctx.colors.muted(t),
styleDescriptionText: (t) => ctx.colors.muted(t),
});
const collect = (value, previous = []) => {
previous.push(value);
return previous;
};
program.addHelpText('beforeAll', () => `${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`);
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command, description) => `${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
program.addHelpText('afterAll', () => `\n${ctx.colors.section('Examples')}\n${[
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
formatExample('bird 1234567890123456789 --json', 'Read a tweet (ID or URL shorthand for `read`) and print JSON'),
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, about, lists, list-timeline, user-tweets, query-ids`)}\n${ctx.colors.muted(` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`);
program.addHelpText('afterAll', () => `\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`)}\n${ctx.colors.muted(` Supports: chromeProfile, chromeProfileDir, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`)}`);
program
.option('--auth-token <token>', 'Twitter auth_token cookie')
.option('--ct0 <token>', 'Twitter ct0 cookie')
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
.option('--chrome-profile-dir <path>', 'Chrome/Chromium profile directory or cookie DB path for cookie extraction', ctx.config.chromeProfileDir)
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
.option('--cookie-source <source>', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource)
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
.option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
program.hook('preAction', (_thisCommand, actionCommand) => {
ctx.applyOutputFromCommand(actionCommand);
});
registerHelpCommand(program, ctx);
registerQueryIdsCommand(program, ctx);
registerPostCommands(program, ctx);
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerFollowCommands(program, ctx);
registerListsCommand(program, ctx);
registerHomeCommand(program, ctx);
registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerNewsCommand(program, ctx);
registerCheckCommand(program, ctx);
return program;
}
//# sourceMappingURL=program.js.map
File diff suppressed because one or more lines are too long
+77
View File
@@ -0,0 +1,77 @@
import type { Command } from 'commander';
import { type CookieSource, resolveCredentials } from '../lib/cookies.js';
import { labelPrefix, type OutputConfig, statusPrefix } from '../lib/output.js';
import type { TweetData } from '../lib/twitter-client.js';
export type BirdConfig = {
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[];
cookieTimeoutMs?: number;
timeoutMs?: number;
quoteDepth?: number;
};
export type MediaSpec = {
path: string;
alt?: string;
mime: string;
buffer: Buffer;
};
export type CliContext = {
isTty: boolean;
getOutput: () => OutputConfig;
colors: {
banner: (t: string) => string;
subtitle: (t: string) => string;
section: (t: string) => string;
bullet: (t: string) => string;
command: (t: string) => string;
option: (t: string) => string;
argument: (t: string) => string;
description: (t: string) => string;
muted: (t: string) => string;
accent: (t: string) => string;
};
p: (kind: Parameters<typeof statusPrefix>[0]) => string;
l: (kind: Parameters<typeof labelPrefix>[0]) => string;
config: BirdConfig;
applyOutputFromCommand: (command: Command) => void;
resolveTimeoutFromOptions: (options: {
timeout?: string | number;
}) => number | undefined;
resolveQuoteDepthFromOptions: (options: {
quoteDepth?: string | number;
}) => number | undefined;
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
loadMedia: (opts: {
media: string[];
alts: string[];
}) => MediaSpec[];
printTweets: (tweets: TweetData[], opts?: {
json?: boolean;
emptyMessage?: string;
showSeparator?: boolean;
}) => void;
printTweetsResult: (result: {
tweets?: TweetData[];
nextCursor?: string;
}, opts: {
json: boolean;
usePagination: boolean;
emptyMessage: string;
}) => void;
extractTweetId: (tweetIdOrUrl: string) => string;
};
export declare const collectCookieSource: (value: string, previous?: CookieSource[]) => CookieSource[];
type CredentialsOptions = {
authToken?: string;
ct0?: string;
chromeProfile?: string;
chromeProfileDir?: string;
firefoxProfile?: string;
cookieSource?: CookieSource[];
cookieTimeout?: string | number;
};
export declare function createCliContext(normalizedArgs: string[], env?: NodeJS.ProcessEnv): CliContext;
export {};
//# sourceMappingURL=shared.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/cli/shared.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGzC,OAAO,EAAE,KAAK,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE1E,OAAO,EAEL,WAAW,EACX,KAAK,YAAY,EAGjB,YAAY,EACb,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,MAAM,MAAM,UAAU,GAAG;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,GAAG,YAAY,EAAE,CAAC;IAC7C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAErF,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,MAAM,YAAY,CAAC;IAC9B,MAAM,EAAE;QACN,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC/B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAChC,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QACnC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;QAC7B,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;KAC/B,CAAC;IACF,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACxD,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IACvD,MAAM,EAAE,UAAU,CAAC;IACnB,sBAAsB,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACnD,yBAAyB,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1F,4BAA4B,EAAE,CAAC,OAAO,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IAChG,6BAA6B,EAAE,CAAC,IAAI,EAAE,kBAAkB,KAAK,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;IACnG,SAAS,EAAE,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,KAAK,SAAS,EAAE,CAAC;IACtE,WAAW,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACtH,iBAAiB,EAAE,CACjB,MAAM,EAAE;QACN,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;QACrB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,EACD,IAAI,EAAE;QACJ,IAAI,EAAE,OAAO,CAAC;QACd,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,MAAM,CAAC;KACtB,KACE,IAAI,CAAC;IACV,cAAc,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;CAClD,CAAC;AAYF,eAAO,MAAM,mBAAmB,GAAI,OAAO,MAAM,EAAE,WAAU,YAAY,EAAO,KAAG,YAAY,EAG9F,CAAC;AA4FF,KAAK,kBAAkB,GAAG;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,YAAY,EAAE,CAAC;IAC9B,aAAa,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACjC,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,cAAc,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA2P3G"}
+327
View File
@@ -0,0 +1,327 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCredentials } from '../lib/cookies.js';
import { extractTweetId } from '../lib/extract-tweet-id.js';
import { hyperlink, labelPrefix, resolveOutputConfigFromArgv, resolveOutputConfigFromCommander, statusPrefix, } from '../lib/output.js';
const COOKIE_SOURCES = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value) {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
export const collectCookieSource = (value, previous = []) => {
previous.push(parseCookieSource(value));
return previous;
};
function resolveCookieSourceOrder(input) {
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result = [];
for (const entry of input) {
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
}
return undefined;
}
function resolveTimeoutMs(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveQuoteDepth(...values) {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function detectMime(path) {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function readConfigFile(path, warn) {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
return parsed ?? {};
}
catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
function loadConfig(warn) {
const globalPath = join(homedir(), '.config', 'bird', 'config.json5');
const localPath = join(process.cwd(), '.birdrc.json5');
return {
...readConfigFile(globalPath, warn),
...readConfigFile(localPath, warn),
};
}
export function createCliContext(normalizedArgs, env = process.env) {
const isTty = process.stdout.isTTY;
let output = resolveOutputConfigFromArgv(normalizedArgs, env, isTty);
kleur.enabled = output.color;
const wrap = (styler) => (text) => isTty ? styler(text) : text;
const colors = {
banner: wrap((t) => kleur.bold().blue(t)),
subtitle: wrap((t) => kleur.dim(t)),
section: wrap((t) => kleur.bold().white(t)),
bullet: wrap((t) => kleur.blue(t)),
command: wrap((t) => kleur.bold().cyan(t)),
option: wrap((t) => kleur.cyan(t)),
argument: wrap((t) => kleur.magenta(t)),
description: wrap((t) => kleur.white(t)),
muted: wrap((t) => kleur.gray(t)),
accent: wrap((t) => kleur.green(t)),
};
const p = (kind) => {
const prefix = statusPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'ok') {
return kleur.green(prefix);
}
if (kind === 'warn') {
return kleur.yellow(prefix);
}
if (kind === 'err') {
return kleur.red(prefix);
}
if (kind === 'info') {
return kleur.cyan(prefix);
}
return kleur.gray(prefix);
};
const l = (kind) => {
const prefix = labelPrefix(kind, output);
if (output.plain || !output.color) {
return prefix;
}
if (kind === 'url') {
return kleur.cyan(prefix);
}
if (kind === 'date') {
return kleur.magenta(prefix);
}
if (kind === 'source') {
return kleur.gray(prefix);
}
if (kind === 'engine') {
return kleur.blue(prefix);
}
if (kind === 'credentials') {
return kleur.yellow(prefix);
}
if (kind === 'user') {
return kleur.cyan(prefix);
}
if (kind === 'userId') {
return kleur.magenta(prefix);
}
if (kind === 'email') {
return kleur.green(prefix);
}
return kleur.gray(prefix);
};
const config = loadConfig((message) => {
console.error(colors.muted(`${p('warn')}${message}`));
});
function applyOutputFromCommand(command) {
const opts = command.optsWithGlobals();
output = resolveOutputConfigFromCommander(opts, env, isTty);
kleur.enabled = output.color;
}
function resolveTimeoutFromOptions(options) {
return resolveTimeoutMs(options.timeout, config.timeoutMs, env.BIRD_TIMEOUT_MS);
}
function resolveCookieTimeoutFromOptions(options) {
return resolveTimeoutMs(options.cookieTimeout, config.cookieTimeoutMs, env.BIRD_COOKIE_TIMEOUT_MS);
}
function resolveQuoteDepthFromOptions(options) {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, env.BIRD_QUOTE_DEPTH);
}
function resolveCredentialsFromOptions(opts) {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
const chromeProfile = opts.chromeProfileDir || opts.chromeProfile || config.chromeProfileDir || config.chromeProfile;
return resolveCredentials({
authToken: opts.authToken,
ct0: opts.ct0,
cookieSource,
chromeProfile,
firefoxProfile: opts.firefoxProfile || config.firefoxProfile,
cookieTimeoutMs: resolveCookieTimeoutFromOptions(opts),
});
}
function loadMedia(opts) {
if (opts.media.length === 0) {
return [];
}
const specs = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
if (!mime) {
throw new Error(`Unsupported media type for ${path}. Supported: jpg, jpeg, png, webp, gif, mp4, mov`);
}
const buffer = readFileSync(path);
specs.push({ path, mime, buffer, alt: opts.alts[index] });
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
function printTweets(tweets, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(tweets, null, 2));
return;
}
if (tweets.length === 0) {
console.log(opts.emptyMessage ?? 'No tweets found.');
return;
}
const useEmoji = output.emoji && !output.plain;
const articleLabel = useEmoji ? '📰' : 'Article:';
const mediaLabel = (type) => {
if (useEmoji) {
return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️';
}
return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:';
};
const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' };
for (const tweet of tweets) {
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
// Display tweet text, with article indicator if present
if (tweet.article) {
// Full body mode: text starts with article title (from extractArticleText)
// Preview mode: text is short tweet intro that doesn't start with title
const hasFullBody = tweet.text.startsWith(tweet.article.title);
if (hasFullBody) {
console.log(`${articleLabel} ${tweet.text}`);
}
else {
console.log(`${articleLabel} ${tweet.article.title}`);
if (tweet.article.previewText) {
console.log(` ${tweet.article.previewText}`);
}
}
}
else {
console.log(tweet.text);
}
// Display media attachments
if (tweet.media && tweet.media.length > 0) {
for (const m of tweet.media) {
console.log(`${mediaLabel(m.type)} ${m.url}`);
}
}
// Display quoted tweet
if (tweet.quotedTweet) {
console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`);
const qtText = tweet.quotedTweet.article
? `${articleLabel} ${tweet.quotedTweet.article.title}`
: tweet.quotedTweet.text;
// Indent and truncate quoted tweet text
const maxLen = 280;
const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText;
for (const line of truncated.split('\n').slice(0, 4)) {
console.log(`${quotePrefix.mid}${line}`);
}
// Display quoted tweet media
if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) {
for (const m of tweet.quotedTweet.media) {
console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`);
}
}
console.log(`${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`);
}
if (tweet.createdAt) {
console.log(`${l('date')}${tweet.createdAt}`);
}
const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`;
console.log(`${l('url')}${hyperlink(tweetUrl, tweetUrl, output)}`);
if (opts.showSeparator ?? true) {
console.log('─'.repeat(50));
}
}
}
function printTweetsResult(result, opts) {
const tweets = result.tweets ?? [];
if (opts.json && opts.usePagination) {
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
return;
}
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
}
return {
isTty,
getOutput: () => output,
colors,
p,
l,
config,
applyOutputFromCommand,
resolveTimeoutFromOptions,
resolveQuoteDepthFromOptions,
resolveCredentialsFromOptions,
loadMedia,
printTweets,
printTweetsResult,
extractTweetId,
};
}
//# sourceMappingURL=shared.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerBookmarksCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=bookmarks.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"bookmarks.d.ts","sourceRoot":"","sources":["../../src/commands/bookmarks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAsOhF"}
+189
View File
@@ -0,0 +1,189 @@
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js';
import { addThreadMetadata, filterAuthorChain, filterAuthorOnly, filterFullChain } from '../lib/thread-filters.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerBookmarksCommand(program, ctx) {
program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--expand-root-only', 'Only expand threads when bookmarked tweet is root')
.option('--author-chain', 'Only include author self-reply chains connected to the bookmark')
.option('--author-only', 'Include all tweets from bookmarked tweet author in thread')
.option('--full-chain-only', 'Save entire reply chain connected to the bookmarked tweet')
.option('--include-ancestor-branches', 'Include sibling branches for ancestors when using --full-chain-only')
.option('--include-parent', 'Include direct parent tweet for non-root bookmarks')
.option('--thread-meta', 'Add metadata fields (isThread, threadPosition, etc.)')
.option('--sort-chronological', 'Sort output globally oldest -> newest')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts);
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const maxPages = pagination.maxPages;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const usePagination = pagination.usePagination;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor };
const result = folderId
? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
: await client.getBookmarkFolderTimeline(folderId, count, timelineOptions)
: usePagination
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
if (cmdOpts.authorChain && (cmdOpts.authorOnly || cmdOpts.fullChainOnly)) {
console.error(`${ctx.p('warn')}--author-chain already limits to the connected self-reply chain; ` +
'other chain filters are redundant.');
}
if (cmdOpts.includeAncestorBranches && !cmdOpts.fullChainOnly) {
console.error(`${ctx.p('warn')}--include-ancestor-branches only applies with --full-chain-only.`);
}
const bookmarks = result.tweets;
if (!bookmarks || bookmarks.length === 0) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
return;
}
const expandedResults = [];
const threadCache = new Map();
const includeMeta = Boolean(cmdOpts.threadMeta);
const includeParent = Boolean(cmdOpts.includeParent);
const expandRootOnly = Boolean(cmdOpts.expandRootOnly);
const filterAuthorChainFlag = Boolean(cmdOpts.authorChain);
const filterAuthorOnlyFlag = Boolean(cmdOpts.authorOnly);
const filterFullChainFlag = Boolean(cmdOpts.fullChainOnly);
const includeAncestorBranches = Boolean(cmdOpts.includeAncestorBranches) && filterFullChainFlag;
const useChronologicalSort = Boolean(cmdOpts.sortChronological);
const shouldAttemptExpand = expandRootOnly || filterAuthorChainFlag || filterAuthorOnlyFlag || filterFullChainFlag;
const shouldFetchThread = shouldAttemptExpand || includeMeta;
const fetchThread = async (tweet) => {
const cachedKey = tweet.conversationId ?? tweet.id;
const cached = threadCache.get(cachedKey);
if (cached) {
return cached;
}
const threadResult = await client.getThread(tweet.id, { includeRaw });
if (!threadResult.success) {
console.error(`${ctx.p('warn')}Failed to expand thread for ${tweet.id}: ${threadResult.error ?? 'Unknown error'}`);
return null;
}
if (!threadResult.tweets) {
console.error(`${ctx.p('warn')}No thread tweets returned for ${tweet.id}.`);
return null;
}
const rootKey = threadResult.tweets[0]?.conversationId ?? cachedKey;
threadCache.set(rootKey, threadResult.tweets);
return threadResult.tweets;
};
const delayBetweenExpansionsMs = 1000;
for (let index = 0; index < bookmarks.length; index += 1) {
const bookmark = bookmarks[index];
const isRoot = !bookmark.inReplyToStatusId;
let threadTweets = null;
if (shouldFetchThread) {
if (!expandRootOnly || isRoot || includeMeta) {
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, delayBetweenExpansionsMs));
}
threadTweets = await fetchThread(bookmark);
}
}
let outputTweets = [bookmark];
if (shouldAttemptExpand) {
if (expandRootOnly && !isRoot) {
outputTweets = [bookmark];
}
else if (threadTweets) {
if (filterAuthorChainFlag) {
outputTweets = filterAuthorChain(threadTweets, bookmark);
}
else {
outputTweets = filterFullChainFlag
? filterFullChain(threadTweets, bookmark, { includeAncestorBranches })
: threadTweets;
if (filterAuthorOnlyFlag) {
outputTweets = filterAuthorOnly(outputTweets, bookmark);
}
}
}
}
if (includeParent && bookmark.inReplyToStatusId) {
const alreadyIncluded = outputTweets.some((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (!alreadyIncluded) {
const parentFromThread = threadTweets?.find((tweet) => tweet.id === bookmark.inReplyToStatusId);
if (parentFromThread) {
expandedResults.push(parentFromThread);
}
else {
const parentResult = await client.getTweet(bookmark.inReplyToStatusId, { includeRaw });
if (parentResult.success && parentResult.tweet) {
expandedResults.push(parentResult.tweet);
}
}
}
}
expandedResults.push(...outputTweets);
}
let finalResults = expandedResults;
if (includeMeta) {
finalResults = expandedResults.map((tweet) => {
const cacheKey = tweet.conversationId ?? tweet.id;
let conversationTweets = threadCache.get(cacheKey);
if (!conversationTweets) {
conversationTweets = [tweet];
}
return addThreadMetadata(tweet, conversationTweets);
});
}
const uniqueTweets = Array.from(new Map(finalResults.map((tweet) => [tweet.id, tweet])).values());
if (useChronologicalSort) {
uniqueTweets.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
}
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult({ tweets: uniqueTweets, nextCursor: result.nextCursor }, { json: isJson, usePagination, emptyMessage });
});
}
//# sourceMappingURL=bookmarks.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerCheckCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=check.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4C5E"}
+43
View File
@@ -0,0 +1,43 @@
export function registerCheckCommand(program, ctx) {
program
.command('check')
.description('Check credential availability')
.action(async () => {
const opts = program.opts();
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
console.log(`${ctx.p('info')}Credential check`);
console.log('─'.repeat(40));
if (cookies.authToken) {
console.log(`${ctx.p('ok')}auth_token: ${cookies.authToken.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}auth_token: not found`);
}
if (cookies.ct0) {
console.log(`${ctx.p('ok')}ct0: ${cookies.ct0.slice(0, 10)}...`);
}
else {
console.log(`${ctx.p('err')}ct0: not found`);
}
if (cookies.source) {
console.log(`${ctx.l('source')}${cookies.source}`);
}
if (warnings.length > 0) {
console.log(`\n${ctx.p('warn')}Warnings:`);
for (const warning of warnings) {
console.log(` - ${warning}`);
}
}
if (cookies.authToken && cookies.ct0) {
console.log(`\n${ctx.p('ok')}Ready to tweet!`);
}
else {
console.log(`\n${ctx.p('err')}Missing credentials. Options:`);
console.log(' 1. Login to x.com in Safari/Chrome/Firefox');
console.log(' 2. Set AUTH_TOKEN and CT0 environment variables');
console.log(' 3. Use --auth-token and --ct0 flags');
process.exit(1);
}
});
}
//# sourceMappingURL=check.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/commands/check.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,+BAA+B,CAAC;SAC5C,MAAM,CAAC,KAAK,IAAI,EAAE;QACjB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;QAChD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC;QAE5B,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;YACtB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QAChF,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;QACtD,CAAC;QAED,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YAChB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC/C,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACrD,CAAC;QAED,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACxB,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;YAC3C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;gBAC/B,OAAO,CAAC,GAAG,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAC;YACjC,CAAC;QACH,CAAC;QAED,IAAI,OAAO,CAAC,SAAS,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;YACrC,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC;QACjD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+BAA+B,CAAC,CAAC;YAC9D,OAAO,CAAC,GAAG,CAAC,+CAA+C,CAAC,CAAC;YAC7D,OAAO,CAAC,GAAG,CAAC,oDAAoD,CAAC,CAAC;YAClE,OAAO,CAAC,GAAG,CAAC,wCAAwC,CAAC,CAAC;YACtD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerFollowCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=follow.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmCnD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8E9E"}
+91
View File
@@ -0,0 +1,91 @@
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
const ONLY_DIGITS_REGEX = /^\d+$/;
async function resolveUserId(client, usernameOrId, ctx) {
const raw = usernameOrId.trim();
const isNumeric = ONLY_DIGITS_REGEX.test(raw);
// Otherwise, treat as username and look up
const handle = normalizeHandle(raw);
if (handle) {
const lookup = await client.getUserIdByUsername(handle);
if (lookup.success && lookup.userId) {
return { userId: lookup.userId, username: lookup.username };
}
if (!isNumeric) {
console.error(`${ctx.p('err')}Failed to find user @${handle}: ${lookup.error ?? 'Unknown error'}`);
return null;
}
}
if (isNumeric) {
return { userId: raw };
}
console.error(`${ctx.p('err')}Invalid username: ${usernameOrId}`);
return null;
}
export function registerFollowCommands(program, ctx) {
program
.command('follow')
.description('Follow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to follow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.follow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Now following ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to follow ${displayName}: ${result.error}`);
process.exit(1);
}
});
program
.command('unfollow')
.description('Unfollow a user')
.argument('<username-or-id>', 'Username (with or without @) or user ID to unfollow')
.action(async (usernameOrId) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const resolved = await resolveUserId(client, usernameOrId, ctx);
if (!resolved) {
process.exit(1);
}
const { userId, username } = resolved;
const displayName = username ? `@${username}` : userId;
const result = await client.unfollow(userId);
if (result.success) {
const finalName = result.username ? `@${result.username}` : displayName;
console.log(`${ctx.p('ok')}Unfollowed ${finalName}`);
}
else {
console.error(`${ctx.p('err')}Failed to unfollow ${displayName}: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=follow.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"follow.js","sourceRoot":"","sources":["../../src/commands/follow.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,iBAAiB,GAAG,OAAO,CAAC;AAElC,KAAK,UAAU,aAAa,CAC1B,MAAqB,EACrB,YAAoB,EACpB,GAAe;IAEf,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC;IAChC,MAAM,SAAS,GAAG,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAE9C,2CAA2C;IAC3C,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,mBAAmB,CAAC,MAAM,CAAC,CAAC;QACxD,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YACpC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;QAC9D,CAAC;QACD,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,MAAM,KAAK,MAAM,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACnG,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IACzB,CAAC;IAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,qBAAqB,YAAY,EAAE,CAAC,CAAC;IAClE,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,OAAgB,EAAE,GAAe;IACtE,OAAO;SACJ,OAAO,CAAC,QAAQ,CAAC;SACjB,WAAW,CAAC,eAAe,CAAC;SAC5B,QAAQ,CAAC,kBAAkB,EAAE,mDAAmD,CAAC;SACjF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC3C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,iBAAiB,SAAS,EAAE,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACjF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,UAAU,CAAC;SACnB,WAAW,CAAC,iBAAiB,CAAC;SAC9B,QAAQ,CAAC,kBAAkB,EAAE,qDAAqD,CAAC;SACnF,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QAEtD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QAEzD,MAAM,QAAQ,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,CAAC;QAChE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAAC;QACtC,MAAM,WAAW,GAAG,QAAQ,CAAC,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;QAEvD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC7C,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC;YACxE,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;QACvD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,sBAAsB,WAAW,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACnF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHelpCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=help.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"help.d.ts","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAmB3E"}
+19
View File
@@ -0,0 +1,19 @@
export function registerHelpCommand(program, ctx) {
program
.command('help [command]')
.description('Show help for a command')
.action((commandName) => {
if (!commandName) {
program.outputHelp();
return;
}
const cmd = program.commands.find((c) => c.name() === commandName);
if (!cmd) {
console.error(`${ctx.p('err')}Unknown command: ${commandName}`);
process.exitCode = 2;
return;
}
cmd.outputHelp();
});
}
//# sourceMappingURL=help.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"help.js","sourceRoot":"","sources":["../../src/commands/help.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,gBAAgB,CAAC;SACzB,WAAW,CAAC,yBAAyB,CAAC;SACtC,MAAM,CAAC,CAAC,WAAoB,EAAE,EAAE;QAC/B,IAAI,CAAC,WAAW,EAAE,CAAC;YACjB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,OAAO;QACT,CAAC;QAED,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,WAAW,CAAC,CAAC;QACnE,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,oBAAoB,WAAW,EAAE,CAAC,CAAC;YAChE,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;YACrB,OAAO;QACT,CAAC;QAED,GAAG,CAAC,UAAU,EAAE,CAAC;IACnB,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerHomeCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=home.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"home.d.ts","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA8C3E"}
+43
View File
@@ -0,0 +1,43 @@
import { TwitterClient } from '../lib/twitter-client.js';
export function registerHomeCommand(program, ctx) {
program
.command('home')
.description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = cmdOpts.following
? await client.getHomeLatestTimeline(count, { includeRaw })
: await client.getHomeTimeline(count, { includeRaw });
if (result.success) {
const feedType = cmdOpts.following ? 'Following' : 'For You';
const emptyMessage = `No tweets found in ${feedType} timeline.`;
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
}
else {
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=home.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"home.js","sourceRoot":"","sources":["../../src/commands/home.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,MAAM,UAAU,mBAAmB,CAAC,OAAgB,EAAE,GAAe;IACnE,OAAO;SACJ,OAAO,CAAC,MAAM,CAAC;SACf,WAAW,CAAC,yCAAyC,CAAC;SACtD,MAAM,CAAC,sBAAsB,EAAE,2BAA2B,EAAE,IAAI,CAAC;SACjE,MAAM,CAAC,aAAa,EAAE,2DAA2D,CAAC;SAClF,MAAM,CAAC,QAAQ,EAAE,gBAAgB,CAAC;SAClC,MAAM,CAAC,aAAa,EAAE,yDAAyD,CAAC;SAChF,MAAM,CAAC,KAAK,EAAE,OAAoF,EAAE,EAAE;QACrG,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC;QAEzD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;YAC1C,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,+CAA+C,CAAC,CAAC;YAC9E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC;QACzD,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,IAAI,KAAK,CAAC;QAE7C,MAAM,MAAM,GAAG,OAAO,CAAC,SAAS;YAC9B,CAAC,CAAC,MAAM,MAAM,CAAC,qBAAqB,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC;YAC3D,CAAC,CAAC,MAAM,MAAM,CAAC,eAAe,CAAC,KAAK,EAAE,EAAE,UAAU,EAAE,CAAC,CAAC;QAExD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;YAC7D,MAAM,YAAY,GAAG,sBAAsB,QAAQ,YAAY,CAAC;YAChE,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;YACzD,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;QACjE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,kCAAkC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YAC/E,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerListsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=lists.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"lists.d.ts","sourceRoot":"","sources":["../../src/commands/lists.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AA4BnD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAyH5E"}
+125
View File
@@ -0,0 +1,125 @@
// ABOUTME: CLI command for fetching Twitter Lists.
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import { parsePaginationFlags } from '../cli/pagination.js';
import { extractListId } from '../lib/extract-list-id.js';
import { hyperlink } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
function printLists(lists, ctx) {
if (lists.length === 0) {
console.log('No lists found.');
return;
}
for (const list of lists) {
const visibility = list.isPrivate ? '[private]' : '[public]';
console.log(`${list.name} ${ctx.colors.muted(visibility)}`);
if (list.description) {
console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`);
}
console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`);
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
const listUrl = `https://x.com/i/lists/${list.id}`;
console.log(` ${ctx.colors.accent(hyperlink(listUrl, listUrl, ctx.getOutput()))}`);
console.log('──────────────────────────────────────────────────');
}
}
export function registerListsCommand(program, ctx) {
program
.command('lists')
.description('Get your Twitter lists')
.option('--member-of', 'Show lists you are a member of (instead of owned lists)')
.option('-n, --count <number>', 'Number of lists to fetch', '100')
.option('--json', 'Output as JSON')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
if (result.success && result.lists) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.lists, null, 2));
}
else {
const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.';
if (result.lists.length === 0) {
console.log(emptyMessage);
}
else {
printLists(result.lists, ctx);
}
}
}
else {
console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`);
process.exit(1);
}
});
program
.command('list-timeline <list-id-or-url>')
.description('Get tweets from a list timeline')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all tweets from list (paged). WARNING: your account might get banned using this flag')
.option('--max-pages <number>', 'Fetch N pages (implies --all)')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (listIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const usePagination = pagination.usePagination;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success) {
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweetsResult(result, {
json: isJson,
usePagination,
emptyMessage: 'No tweets found in this list.',
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=lists.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerNewsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=news.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"news.d.ts","sourceRoot":"","sources":["../../src/commands/news.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAmEnD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAuG3E"}
+131
View File
@@ -0,0 +1,131 @@
import { TwitterClient } from '../lib/twitter-client.js';
function formatPostCount(count) {
if (count >= 1_000_000) {
return `${(count / 1_000_000).toFixed(1)}M`;
}
if (count >= 1_000) {
return `${(count / 1_000).toFixed(1)}K`;
}
return String(count);
}
function printNewsItems(items, ctx, opts = {}) {
if (opts.json) {
console.log(JSON.stringify(items, null, 2));
return;
}
if (items.length === 0) {
console.log(opts.emptyMessage ?? 'No news items found.');
return;
}
for (const item of items) {
const categoryLabel = item.category ? `[${item.category}]` : '';
console.log(`\n${ctx.colors.accent(categoryLabel)} ${ctx.colors.command(item.headline)}`);
if (item.description) {
console.log(` ${ctx.colors.muted(item.description)}`);
}
const meta = [];
if (item.timeAgo) {
meta.push(item.timeAgo);
}
if (item.postCount) {
meta.push(`${formatPostCount(item.postCount)} posts`);
}
if (meta.length > 0) {
console.log(` ${ctx.colors.muted(meta.join(' | '))}`);
}
if (item.url) {
console.log(` ${ctx.l('url')}${item.url}`);
}
// Print related tweets if available
if (item.tweets && item.tweets.length > 0) {
console.log(` ${ctx.colors.section('Related tweets:')}`);
const tweetLimit = opts.tweetLimit ?? item.tweets.length;
for (const tweet of item.tweets.slice(0, tweetLimit)) {
console.log(` @${tweet.author.username}: ${tweet.text.slice(0, 100)}${tweet.text.length > 100 ? '...' : ''}`);
}
}
console.log(ctx.colors.muted('─'.repeat(50)));
}
}
export function registerNewsCommand(program, ctx) {
program
.command('news')
.alias('trending')
.description('Fetch AI-curated news and trending topics from Explore tabs')
.option('-n, --count <number>', 'Number of items to fetch', '10')
.option('--ai-only', 'Show only AI-curated news items')
.option('--with-tweets', 'Also fetch related tweets for each news item')
.option('--tweets-per-item <number>', 'Number of tweets to fetch per news item (default: 5)', '5')
.option('--for-you', 'Fetch only from For You tab')
.option('--news-only', 'Fetch only from News tab')
.option('--sports', 'Fetch only from Sports tab')
.option('--entertainment', 'Fetch only from Entertainment tab')
.option('--trending-only', 'Fetch only from Trending tab')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10);
const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (Number.isNaN(count) || count < 1) {
console.error(`${ctx.p('err')}--count must be a positive number`);
process.exit(1);
}
if (Number.isNaN(tweetsPerItem) || tweetsPerItem < 1) {
console.error(`${ctx.p('err')}--tweets-per-item must be a positive number`);
process.exit(1);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
// Determine which tabs to fetch from
const tabs = [];
if (cmdOpts.forYou) {
tabs.push('forYou');
}
if (cmdOpts.newsOnly) {
tabs.push('news');
}
if (cmdOpts.sports) {
tabs.push('sports');
}
if (cmdOpts.entertainment) {
tabs.push('entertainment');
}
if (cmdOpts.trendingOnly) {
tabs.push('trending');
}
// If no specific tabs selected, use defaults (all tabs except trending)
const tabsToFetch = tabs.length > 0 ? tabs : undefined;
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const withTweets = cmdOpts.withTweets ?? false;
const aiOnly = cmdOpts.aiOnly ?? false;
const result = await client.getNews(count, {
includeRaw,
withTweets,
tweetsPerItem,
aiOnly,
tabs: tabsToFetch,
});
if (result.success) {
printNewsItems(result.items, ctx, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No news items found.',
tweetLimit: withTweets ? tweetsPerItem : undefined,
});
}
else {
console.error(`${ctx.p('err')}Failed to fetch news: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=news.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerPostCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=post.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"post.d.ts","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAa,MAAM,kBAAkB,CAAC;AAyB9D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA4F5E"}
+101
View File
@@ -0,0 +1,101 @@
import { formatTweetUrlLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
async function uploadMediaOrExit(client, media, ctx) {
if (media.length === 0) {
return undefined;
}
const uploaded = [];
for (const item of media) {
const res = await client.uploadMedia({ data: item.buffer, mimeType: item.mime, alt: item.alt });
if (!res.success || !res.mediaId) {
console.error(`${ctx.p('err')}Media upload failed: ${res.error ?? 'Unknown error'}`);
process.exit(1);
}
uploaded.push(res.mediaId);
}
return uploaded;
}
export function registerPostCommands(program, ctx) {
program
.command('tweet')
.description('Post a new tweet')
.argument('<text>', 'Tweet text')
.action(async (text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.tweet(text, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Tweet posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('reply')
.description('Reply to an existing tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to reply to')
.argument('<text>', 'Reply text')
.action(async (tweetIdOrUrl, text) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
let media = [];
try {
media = ctx.loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
}
catch (error) {
console.error(`${ctx.p('err')}${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
if (cookies.source) {
console.error(`${ctx.l('source')}${cookies.source}`);
}
console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const mediaIds = await uploadMediaOrExit(client, media, ctx);
const result = await client.reply(text, tweetId, mediaIds);
if (result.success) {
console.log(`${ctx.p('ok')}Reply posted successfully!`);
console.log(formatTweetUrlLine(result.tweetId, ctx.getOutput()));
}
else {
console.error(`${ctx.p('err')}Failed to post reply: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=post.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"post.js","sourceRoot":"","sources":["../../src/commands/post.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAEzD,KAAK,UAAU,iBAAiB,CAC9B,MAAqB,EACrB,KAAkB,EAClB,GAAe;IAEf,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAChG,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;YACjC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,wBAAwB,GAAG,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,CAAC;YACrF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,OAAgB,EAAE,GAAe;IACpE,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,kBAAkB,CAAC;SAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAElD,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;IAEL,OAAO;SACJ,OAAO,CAAC,OAAO,CAAC;SAChB,WAAW,CAAC,4BAA4B,CAAC;SACzC,QAAQ,CAAC,mBAAmB,EAAE,6BAA6B,CAAC;SAC5D,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;SAChC,MAAM,CAAC,KAAK,EAAE,YAAoB,EAAE,IAAY,EAAE,EAAE;QACnD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,SAAS,GAAG,GAAG,CAAC,yBAAyB,CAAC,IAAI,CAAC,CAAC;QACtD,MAAM,UAAU,GAAG,GAAG,CAAC,4BAA4B,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,KAAK,GAAgB,EAAE,CAAC;QAC5B,IAAI,CAAC;YACH,KAAK,GAAG,GAAG,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE,EAAE,CAAC,CAAC;QAC3E,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1F,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QACD,MAAM,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;QAEjD,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,MAAM,GAAG,CAAC,6BAA6B,CAAC,IAAI,CAAC,CAAC;QAE5E,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC9C,CAAC;QAED,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,8BAA8B,CAAC,CAAC;YAC7D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;QAED,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;QACvD,CAAC;QAED,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,sBAAsB,OAAO,EAAE,CAAC,CAAC;QAE/D,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;QACrE,MAAM,QAAQ,GAAG,MAAM,iBAAiB,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,CAAC,CAAC;QAE3D,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACnB,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,MAAM,CAAC,OAAO,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC;QACnE,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,yBAAyB,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACtE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerQueryIdsCommand(program: Command, ctx: CliContext): void;
//# sourceMappingURL=query-ids.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"query-ids.d.ts","sourceRoot":"","sources":["../../src/commands/query-ids.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAqBnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAgF/E"}
+80
View File
@@ -0,0 +1,80 @@
import { getFeatureOverridesSnapshot, refreshFeatureOverridesCache, } from '../lib/runtime-features.js';
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
function countFeatureOverrides(overrides) {
let count = 0;
if (overrides.global) {
count += Object.keys(overrides.global).length;
}
if (overrides.sets) {
for (const setOverrides of Object.values(overrides.sets)) {
count += Object.keys(setOverrides).length;
}
}
return count;
}
export function registerQueryIdsCommand(program, ctx) {
program
.command('query-ids')
.description('Show or refresh cached Twitter GraphQL query IDs')
.option('--json', 'Output as JSON')
.option('--fresh', 'Force refresh (downloads X client bundles)', false)
.action(async (cmdOpts) => {
const operations = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'TweetDetail',
'SearchTimeline',
'UserArticlesTweets',
'Bookmarks',
'Following',
'Followers',
'Likes',
];
if (cmdOpts.fresh) {
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
await runtimeQueryIds.refresh(operations, { force: true });
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
await refreshFeatureOverridesCache();
}
const featureSnapshot = getFeatureOverridesSnapshot();
const info = await runtimeQueryIds.getSnapshotInfo();
if (!info) {
if (cmdOpts.json) {
console.log(JSON.stringify({
cached: false,
cachePath: runtimeQueryIds.cachePath,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
}, null, 2));
return;
}
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
return;
}
if (cmdOpts.json) {
console.log(JSON.stringify({
cached: true,
cachePath: info.cachePath,
fetchedAt: info.snapshot.fetchedAt,
isFresh: info.isFresh,
ageMs: info.ageMs,
ids: info.snapshot.ids,
discovery: info.snapshot.discovery,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
}, null, 2));
return;
}
console.log(`${ctx.p('ok')}GraphQL query IDs cached`);
console.log(`path: ${info.cachePath}`);
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`);
});
}
//# sourceMappingURL=query-ids.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"query-ids.js","sourceRoot":"","sources":["../../src/commands/query-ids.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,2BAA2B,EAC3B,4BAA4B,GAC7B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAE9D,SAAS,qBAAqB,CAAC,SAA2B;IACxD,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,SAAS,CAAC,MAAM,EAAE,CAAC;QACrB,KAAK,IAAI,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC;IAChD,CAAC;IACD,IAAI,SAAS,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,MAAM,YAAY,IAAI,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;YACzD,KAAK,IAAI,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC;QAC5C,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,OAAgB,EAAE,GAAe;IACvE,OAAO;SACJ,OAAO,CAAC,WAAW,CAAC;SACpB,WAAW,CAAC,kDAAkD,CAAC;SAC/D,MAAM,CAAC,QAAQ,EAAE,gBAAgB,CAAC;SAClC,MAAM,CAAC,SAAS,EAAE,4CAA4C,EAAE,KAAK,CAAC;SACtE,MAAM,CAAC,KAAK,EAAE,OAA4C,EAAE,EAAE;QAC7D,MAAM,UAAU,GAAG;YACjB,aAAa;YACb,eAAe;YACf,eAAe;YACf,aAAa;YACb,gBAAgB;YAChB,oBAAoB;YACpB,WAAW;YACX,WAAW;YACX,WAAW;YACX,OAAO;SACR,CAAC;QAEF,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;YAClB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,+BAA+B,CAAC,CAAC;YAC/D,MAAM,eAAe,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3D,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,+BAA+B,CAAC,CAAC;YAC/D,MAAM,4BAA4B,EAAE,CAAC;QACvC,CAAC;QAED,MAAM,eAAe,GAAG,2BAA2B,EAAE,CAAC;QACtD,MAAM,IAAI,GAAG,MAAM,eAAe,CAAC,eAAe,EAAE,CAAC;QACrD,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;gBACjB,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CACZ;oBACE,MAAM,EAAE,KAAK;oBACb,SAAS,EAAE,eAAe,CAAC,SAAS;oBACpC,YAAY,EAAE,eAAe,CAAC,SAAS;oBACvC,QAAQ,EAAE,eAAe,CAAC,SAAS;iBACpC,EACD,IAAI,EACJ,CAAC,CACF,CACF,CAAC;gBACF,OAAO;YACT,CAAC;YACD,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,0BAA0B,CAAC,CAAC;YACxD,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,6BAA6B,CAAC,CAAC;YAC3D,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAe,CAAC,SAAS,EAAE,CAAC,CAAC;YAC3D,OAAO;QACT,CAAC;QAED,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YACjB,OAAO,CAAC,GAAG,CACT,IAAI,CAAC,SAAS,CACZ;gBACE,MAAM,EAAE,IAAI;gBACZ,SAAS,EAAE,IAAI,CAAC,SAAS;gBACzB,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,SAAS;gBAClC,OAAO,EAAE,IAAI,CAAC,OAAO;gBACrB,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,GAAG,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG;gBACtB,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,SAAS;gBAClC,YAAY,EAAE,eAAe,CAAC,SAAS;gBACvC,QAAQ,EAAE,eAAe,CAAC,SAAS;aACpC,EACD,IAAI,EACJ,CAAC,CACF,CACF,CAAC;YACF,OAAO;QACT,CAAC;QAED,OAAO,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;QACtD,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QACvC,OAAO,CAAC,GAAG,CAAC,eAAe,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC;QACtD,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QACrD,OAAO,CAAC,GAAG,CAAC,QAAQ,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7D,OAAO,CAAC,GAAG,CAAC,kBAAkB,eAAe,CAAC,SAAS,EAAE,CAAC,CAAC;QAC3D,OAAO,CAAC,GAAG,CAAC,aAAa,qBAAqB,CAAC,eAAe,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;IAC/E,CAAC,CAAC,CAAC;AACP,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerReadCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=read.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../src/commands/read.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CAqM5E"}
+152
View File
@@ -0,0 +1,152 @@
import { parsePaginationFlags } from '../cli/pagination.js';
import { formatStatsLine } from '../lib/output.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerReadCommands(program, ctx) {
program
.command('read')
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getTweet(tweetId, { includeRaw });
if (result.success && result.tweet) {
if (cmdOpts.json || cmdOpts.jsonFull) {
console.log(JSON.stringify(result.tweet, null, 2));
}
else {
ctx.printTweets([result.tweet], { showSeparator: false });
console.log(formatStatsLine(result.tweet, ctx.getOutput()));
}
}
else {
console.error(`${ctx.p('err')}Failed to read tweet: ${result.error}`);
process.exit(1);
}
});
program
.command('replies')
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--all', 'Fetch all replies (paged)')
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
? await client.getRepliesPaged(tweetId, {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
})
: await client.getReplies(tweetId, { includeRaw });
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
if (result.tweets) {
ctx.printTweetsResult(result, {
json: isJson,
usePagination: pagination.usePagination,
emptyMessage: 'No replies found.',
});
// Show pagination hint if there's more
if (result.nextCursor && !isJson) {
console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`);
}
}
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
}
});
program
.command('thread')
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--all', 'Fetch all thread tweets (paged)')
.option('--max-pages <number>', 'Fetch N pages (implies pagination)')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl, cmdOpts) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const tweetId = ctx.extractTweetId(tweetIdOrUrl);
const pagination = parsePaginationFlags(cmdOpts, { maxPagesImpliesPagination: true, includeDelay: true });
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const result = pagination.usePagination
? await client.getThreadPaged(tweetId, {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
})
: await client.getThread(tweetId, { includeRaw });
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
if (result.tweets) {
ctx.printTweetsResult(result, {
json: isJson,
usePagination: pagination.usePagination,
emptyMessage: 'No thread tweets found.',
});
// Show pagination hint if there's more
if (result.nextCursor && !isJson) {
console.error(`${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`);
}
}
if (!result.success) {
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
}
});
}
//# sourceMappingURL=read.js.map
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
export declare function registerSearchCommands(program: Command, ctx: CliContext): void;
//# sourceMappingURL=search.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/commands/search.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAInD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,IAAI,CA0I9E"}

Some files were not shown because too many files have changed in this diff Show More