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
+22 -4
View File
@@ -79,9 +79,16 @@ def run_research(
reddit_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-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
if sources in ("both", "reddit", "all", "reddit-web"):
if progress:
@@ -263,9 +270,16 @@ def main():
# Get date range
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
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
if args.mock:
# Use mock models
@@ -361,10 +375,13 @@ def main():
render.write_outputs(report, raw_openai, raw_xai, raw_reddit_enriched)
# Show completion
progress.show_complete(len(deduped_reddit), len(deduped_x))
if sources == "web":
progress.show_web_only_complete()
else:
progress.show_complete(len(deduped_reddit), len(deduped_x))
# 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(
@@ -374,10 +391,11 @@ def output_result(
topic: str = "",
from_date: str = "",
to_date: str = "",
missing_keys: str = "none",
):
"""Output the result based on emit mode."""
if emit_mode == "compact":
print(render.render_compact(report))
print(render.render_compact(report, missing_keys=missing_keys))
elif emit_mode == "json":
print(json.dumps(report.to_dict(), indent=2))
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)
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]]:
"""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)
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.
Args:
report: Report data
limit: Max items per source
missing_keys: 'both', 'reddit', 'x', or 'none'
Returns:
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("")
# 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
if report.from_cache:
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("")
# Coverage note
if report.mode == "reddit-only":
lines.append("*Tip: Add xAI key for X coverage and better triangulation.*")
# Coverage note for partial coverage
if report.mode == "reddit-only" and missing_keys == "x":
lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
lines.append("")
elif report.mode == "x-only":
lines.append("*Tip: Add OpenAI key for Reddit coverage and better triangulation.*")
elif report.mode == "x-only" and missing_keys == "reddit":
lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
lines.append("")
# Reddit items
+95
View File
@@ -72,6 +72,61 @@ PROCESSING_MESSAGES = [
"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 = ['', '', '', '', '', '', '', '', '', '']
DOTS_FRAMES = [' ', '. ', '.. ', '...']
@@ -214,6 +269,46 @@ class ProgressDisplay:
sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n")
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):
"""Print a phase message."""