feat: Add web-only fallback mode with API key promo

- Skill now works without any API keys using WebSearch fallback
- Shows promo banner marketing Reddit/X data when keys are missing
- Partial mode (one key) shows shorter tip for the missing source
- Updated SKILL.md to document three modes: Full, Partial, Web-Only
- Added get_missing_keys() to env.py for promo logic
- Added show_promo(), start_web_only(), show_web_only_complete() to ui.py
- Updated render_compact() to include inline promo for web-only mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-01-25 09:32:24 -08:00
parent 6831e624b8
commit 6fbfbb9ccc
5 changed files with 210 additions and 42 deletions
+56 -32
View File
@@ -50,66 +50,64 @@ Common patterns:
## Setup Check ## Setup Check
Verify API key configuration exists: The skill works in three modes based on available API keys:
```bash 1. **Full Mode** (both keys): Reddit + X + WebSearch - best results with engagement metrics
if [ ! -f ~/.config/last30days/.env ]; then 2. **Partial Mode** (one key): Reddit-only or X-only + WebSearch
echo "SETUP_NEEDED" 3. **Web-Only Mode** (no keys): WebSearch only - still useful, but no engagement metrics
else
echo "CONFIGURED"
fi
```
### If SETUP_NEEDED **API keys are OPTIONAL.** The skill will work without them using WebSearch fallback.
Run NUX flow to configure API keys. Use AskUserQuestion to collect: ### First-Time Setup (Optional but Recommended)
1. **OpenAI API Key** (optional but recommended for Reddit research) If the user wants to add API keys for better results:
2. **xAI API Key** (optional but recommended for X research)
Then create the config:
```bash ```bash
mkdir -p ~/.config/last30days mkdir -p ~/.config/last30days
cat > ~/.config/last30days/.env << 'ENVEOF' cat > ~/.config/last30days/.env << 'ENVEOF'
# last30days API Configuration # last30days API Configuration
# At least one key is required # Both keys are optional - skill works with WebSearch fallback
# For Reddit research (uses OpenAI's web_search tool)
OPENAI_API_KEY= OPENAI_API_KEY=
# For X/Twitter research (uses xAI's x_search tool)
XAI_API_KEY= XAI_API_KEY=
ENVEOF ENVEOF
chmod 600 ~/.config/last30days/.env chmod 600 ~/.config/last30days/.env
echo "Config created at ~/.config/last30days/.env" echo "Config created at ~/.config/last30days/.env"
echo "Please edit it to add your API keys, then run the skill again." echo "Edit to add your API keys for enhanced research."
``` ```
**STOP HERE if setup was needed.** **DO NOT stop if no keys are configured.** Proceed with web-only mode.
--- ---
## Research Execution ## Research Execution
**IMPORTANT: Run Reddit/X script IN BACKGROUND first, then WebSearch.** This way both run in parallel. **IMPORTANT: The script handles API key detection automatically.** Run it and check the output to determine mode.
**Step 1: Display the research banner immediately:** **Step 1: Run the research script**
```
🔍 Researching "{TOPIC}" across the last 30 days...
🚀 Deploying research agents in parallel...
├─ 🟠 Reddit Agent: Scanning subreddits for gold...
├─ 🔵 X Agent: Catching the latest takes...
├─ 🌐 Web Agent: Crawling blogs, docs & news...
└─ ⚖️ Judge Agent: Standing by to synthesize...
```
**Step 2: Start Reddit/X script in background**
```bash ```bash
python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1 python3 ~/.claude/skills/last30days/scripts/last30days.py "$ARGUMENTS" --emit=compact 2>&1
``` ```
Use `run_in_background: true` so it starts immediately and runs while we do WebSearch.
**Step 2: While script runs, do WebSearch** 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: Choose search queries based on QUERY_TYPE:
@@ -240,6 +238,8 @@ KEY PATTERNS I'll use:
``` ```
**THEN - Stats (right before invitation):** **THEN - Stats (right before invitation):**
For **full/partial mode** (has API keys):
``` ```
--- ---
✅ All agents reported back! ✅ All agents reported back!
@@ -249,6 +249,18 @@ KEY PATTERNS I'll use:
└─ Top voices: r/{sub1}, r/{sub2} │ @{handle1}, @{handle2} │ {web_author} on {site} └─ 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:** **LAST - Invitation:**
``` ```
--- ---
@@ -358,6 +370,7 @@ Only do new research if the user explicitly asks about a DIFFERENT topic.
After delivering a prompt, end with: After delivering a prompt, end with:
For **full/partial mode**:
``` ```
--- ---
📚 Expert in: {TOPIC} for {TARGET_TOOL} 📚 Expert in: {TOPIC} for {TARGET_TOOL}
@@ -365,3 +378,14 @@ After delivering a prompt, end with:
Want another prompt? Just tell me what you're creating next. 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
```
+21 -3
View File
@@ -79,9 +79,16 @@ def run_research(
reddit_error = None reddit_error = None
x_error = None x_error = None
# Check if WebSearch is needed # Check if WebSearch is needed (always needed in web-only mode)
web_needed = sources in ("all", "web", "reddit-web", "x-web") web_needed = sources in ("all", "web", "reddit-web", "x-web")
# Web-only mode: no API calls needed, Claude handles everything
if sources == "web":
if progress:
progress.start_web_only()
progress.end_web_only()
return reddit_items, x_items, True, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error
# Reddit search via OpenAI # Reddit search via OpenAI
if sources in ("both", "reddit", "all", "reddit-web"): if sources in ("both", "reddit", "all", "reddit-web"):
if progress: if progress:
@@ -263,9 +270,16 @@ def main():
# Get date range # Get date range
from_date, to_date = dates.get_date_range(30) from_date, to_date = dates.get_date_range(30)
# Check what keys are missing for promo messaging
missing_keys = env.get_missing_keys(config)
# Initialize progress display # Initialize progress display
progress = ui.ProgressDisplay(args.topic, show_banner=True) progress = ui.ProgressDisplay(args.topic, show_banner=True)
# Show promo for missing keys BEFORE research
if missing_keys != 'none':
progress.show_promo(missing_keys)
# Select models # Select models
if args.mock: if args.mock:
# Use mock models # Use mock models
@@ -361,10 +375,13 @@ def main():
render.write_outputs(report, raw_openai, raw_xai, raw_reddit_enriched) render.write_outputs(report, raw_openai, raw_xai, raw_reddit_enriched)
# Show completion # Show completion
if sources == "web":
progress.show_web_only_complete()
else:
progress.show_complete(len(deduped_reddit), len(deduped_x)) progress.show_complete(len(deduped_reddit), len(deduped_x))
# Output result # Output result
output_result(report, args.emit, web_needed, args.topic, from_date, to_date) output_result(report, args.emit, web_needed, args.topic, from_date, to_date, missing_keys)
def output_result( def output_result(
@@ -374,10 +391,11 @@ def output_result(
topic: str = "", topic: str = "",
from_date: str = "", from_date: str = "",
to_date: str = "", to_date: str = "",
missing_keys: str = "none",
): ):
"""Output the result based on emit mode.""" """Output the result based on emit mode."""
if emit_mode == "compact": if emit_mode == "compact":
print(render.render_compact(report)) print(render.render_compact(report, missing_keys=missing_keys))
elif emit_mode == "json": elif emit_mode == "json":
print(json.dumps(report.to_dict(), indent=2)) print(json.dumps(report.to_dict(), indent=2))
elif emit_mode == "md": elif emit_mode == "md":
+18
View File
@@ -72,6 +72,24 @@ def get_available_sources(config: Dict[str, Any]) -> str:
return 'web' # Fallback: WebSearch only (no API keys needed) return 'web' # Fallback: WebSearch only (no API keys needed)
def get_missing_keys(config: Dict[str, Any]) -> str:
"""Determine which API keys are missing.
Returns: 'both', 'reddit', 'x', or 'none'
"""
has_openai = bool(config.get('OPENAI_API_KEY'))
has_xai = bool(config.get('XAI_API_KEY'))
if has_openai and has_xai:
return 'none'
elif has_openai:
return 'x' # Missing xAI key
elif has_xai:
return 'reddit' # Missing OpenAI key
else:
return 'both' # Missing both keys
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]]:
"""Validate requested sources against available keys. """Validate requested sources against available keys.
+19 -6
View File
@@ -14,12 +14,13 @@ def ensure_output_dir():
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def render_compact(report: schema.Report, limit: int = 15) -> str: def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str:
"""Render compact output for Claude to synthesize. """Render compact output for Claude to synthesize.
Args: Args:
report: Report data report: Report data
limit: Max items per source limit: Max items per source
missing_keys: 'both', 'reddit', 'x', or 'none'
Returns: Returns:
Compact markdown string Compact markdown string
@@ -30,6 +31,18 @@ def render_compact(report: schema.Report, limit: int = 15) -> str:
lines.append(f"## Research Results: {report.topic}") lines.append(f"## Research Results: {report.topic}")
lines.append("") lines.append("")
# Web-only mode banner (when no API keys)
if report.mode == "web-only":
lines.append("**🌐 WEB SEARCH MODE** - Claude will search blogs, docs & news")
lines.append("")
lines.append("---")
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
lines.append("- Edit `~/.config/last30days/.env` to add keys")
lines.append("---")
lines.append("")
# Cache indicator # Cache indicator
if report.from_cache: if report.from_cache:
age_str = f"{report.cache_age_hours:.1f}h old" if report.cache_age_hours else "cached" age_str = f"{report.cache_age_hours:.1f}h old" if report.cache_age_hours else "cached"
@@ -44,12 +57,12 @@ def render_compact(report: schema.Report, limit: int = 15) -> str:
lines.append(f"**xAI Model:** {report.xai_model_used}") lines.append(f"**xAI Model:** {report.xai_model_used}")
lines.append("") lines.append("")
# Coverage note # Coverage note for partial coverage
if report.mode == "reddit-only": if report.mode == "reddit-only" and missing_keys == "x":
lines.append("*Tip: Add xAI key for X coverage and better triangulation.*") lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
lines.append("") lines.append("")
elif report.mode == "x-only": elif report.mode == "x-only" and missing_keys == "reddit":
lines.append("*Tip: Add OpenAI key for Reddit coverage and better triangulation.*") lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
lines.append("") lines.append("")
# Reddit items # Reddit items
+95
View File
@@ -72,6 +72,61 @@ PROCESSING_MESSAGES = [
"Organizing findings...", "Organizing findings...",
] ]
WEB_ONLY_MESSAGES = [
"Searching the web...",
"Finding blogs and docs...",
"Crawling news sites...",
"Discovering tutorials...",
]
# Promo message for users without API keys
PROMO_MESSAGE = f"""
{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}
{Colors.YELLOW}⚡ UNLOCK THE FULL POWER OF /last30days{Colors.RESET}
{Colors.DIM}Right now you're using web search only. Add API keys to unlock:{Colors.RESET}
{Colors.YELLOW}🟠 Reddit{Colors.RESET} - Real upvotes, comments, and community insights
└─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)
{Colors.CYAN}🔵 X (Twitter){Colors.RESET} - Real-time posts, likes, reposts from creators
└─ Add XAI_API_KEY (uses xAI's live X search)
{Colors.DIM}Setup:{Colors.RESET} Edit {Colors.BOLD}~/.config/last30days/.env{Colors.RESET}
{Colors.YELLOW}{Colors.BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{Colors.RESET}
"""
PROMO_MESSAGE_PLAIN = """
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚡ UNLOCK THE FULL POWER OF /last30days
Right now you're using web search only. Add API keys to unlock:
🟠 Reddit - Real upvotes, comments, and community insights
└─ Add OPENAI_API_KEY (uses OpenAI's web_search for Reddit)
🔵 X (Twitter) - Real-time posts, likes, reposts from creators
└─ Add XAI_API_KEY (uses xAI's live X search)
Setup: Edit ~/.config/last30days/.env
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
# Shorter promo for single missing key
PROMO_SINGLE_KEY = {
"reddit": f"""
{Colors.DIM}💡 Tip: Add {Colors.YELLOW}OPENAI_API_KEY{Colors.RESET}{Colors.DIM} to ~/.config/last30days/.env for Reddit data with real engagement metrics!{Colors.RESET}
""",
"x": f"""
{Colors.DIM}💡 Tip: Add {Colors.CYAN}XAI_API_KEY{Colors.RESET}{Colors.DIM} to ~/.config/last30days/.env for X/Twitter data with real likes & reposts!{Colors.RESET}
""",
}
PROMO_SINGLE_KEY_PLAIN = {
"reddit": "\n💡 Tip: Add OPENAI_API_KEY to ~/.config/last30days/.env for Reddit data with real engagement metrics!\n",
"x": "\n💡 Tip: Add XAI_API_KEY to ~/.config/last30days/.env for X/Twitter data with real likes & reposts!\n",
}
# Spinner frames # Spinner frames
SPINNER_FRAMES = ['', '', '', '', '', '', '', '', '', ''] SPINNER_FRAMES = ['', '', '', '', '', '', '', '', '', '']
DOTS_FRAMES = [' ', '. ', '.. ', '...'] DOTS_FRAMES = [' ', '. ', '.. ', '...']
@@ -214,6 +269,46 @@ class ProgressDisplay:
sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n") sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n")
sys.stderr.flush() sys.stderr.flush()
def start_web_only(self):
"""Show web-only mode indicator."""
msg = random.choice(WEB_ONLY_MESSAGES)
self.spinner = Spinner(f"{Colors.GREEN}Web{Colors.RESET} {msg}", Colors.GREEN)
self.spinner.start()
def end_web_only(self):
"""End web-only spinner."""
if self.spinner:
self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} Claude will search the web")
def show_web_only_complete(self):
"""Show completion for web-only mode."""
elapsed = time.time() - self.start_time
if IS_TTY:
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ")
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n")
sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} Claude will search blogs, docs & news\n\n")
else:
sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n")
sys.stderr.flush()
def show_promo(self, missing: str = "both"):
"""Show promotional message for missing API keys.
Args:
missing: 'both', 'reddit', or 'x' - which keys are missing
"""
if missing == "both":
if IS_TTY:
sys.stderr.write(PROMO_MESSAGE)
else:
sys.stderr.write(PROMO_MESSAGE_PLAIN)
elif missing in PROMO_SINGLE_KEY:
if IS_TTY:
sys.stderr.write(PROMO_SINGLE_KEY[missing])
else:
sys.stderr.write(PROMO_SINGLE_KEY_PLAIN[missing])
sys.stderr.flush()
def print_phase(phase: str, message: str): def print_phase(phase: str, message: str):
"""Print a phase message.""" """Print a phase message."""