Browse Source

feat: Add perplexity tool and update install scripts

- Add perplexity.py CLI tool for Perplexity API integration
- Update install-unix.sh with new tool installations
- Update install-windows.ps1 with new tool installations
- Update tools/README.md documentation
- Improve pulse/fetch.py functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
0xDarkMatter 4 months ago
parent
commit
a2d043859d
5 changed files with 443 additions and 4 deletions
  1. 39 4
      pulse/fetch.py
  2. 58 0
      tools/README.md
  3. 27 0
      tools/install-unix.sh
  4. 36 0
      tools/install-windows.ps1
  5. 283 0
      tools/perplexity.py

+ 39 - 4
pulse/fetch.py

@@ -120,15 +120,50 @@ def fetch_url_firecrawl(app: 'FirecrawlApp', source: dict) -> dict:
         }
 
 
+def get_firecrawl_api_key():
+    """Get Firecrawl API key from env or config file."""
+    import re
+
+    # Try environment variable first
+    key = os.getenv('FIRECRAWL_API_KEY')
+    if key:
+        return key
+
+    # Try ~/.claude/delegate.yaml
+    config_path = os.path.expanduser("~/.claude/delegate.yaml")
+    if os.path.exists(config_path):
+        try:
+            with open(config_path, encoding="utf-8") as f:
+                content = f.read()
+            # Parse the api_keys block and find firecrawl
+            in_api_keys = False
+            for line in content.split('\n'):
+                stripped = line.strip()
+                if stripped.startswith('api_keys:'):
+                    in_api_keys = True
+                    continue
+                if in_api_keys and stripped and not line.startswith(' ') and not line.startswith('\t'):
+                    if not stripped.startswith('#'):
+                        in_api_keys = False
+                if in_api_keys and 'firecrawl:' in stripped.lower():
+                    match = re.search(r'firecrawl:\s*["\']?([^"\'\n#]+)', stripped, re.IGNORECASE)
+                    if match:
+                        return match.group(1).strip()
+        except Exception:
+            pass
+
+    return None
+
+
 def fetch_all_parallel(sources: list, max_workers: int = 10) -> list:
     """Fetch all URLs in parallel using ThreadPoolExecutor."""
     if not FIRECRAWL_AVAILABLE:
         print("Error: firecrawl not available")
         return []
 
-    api_key = os.getenv('FIRECRAWL_API_KEY')
+    api_key = get_firecrawl_api_key()
     if not api_key:
-        print("Error: FIRECRAWL_API_KEY environment variable not set")
+        print("Error: FIRECRAWL_API_KEY not set. Set env var or add to ~/.claude/delegate.yaml")
         return []
 
     app = FirecrawlApp(api_key=api_key)
@@ -213,9 +248,9 @@ def discover_articles(sources: list, max_workers: int = 10, max_articles_per_sou
         print("Error: firecrawl not available")
         return []
 
-    api_key = os.getenv('FIRECRAWL_API_KEY')
+    api_key = get_firecrawl_api_key()
     if not api_key:
-        print("Error: FIRECRAWL_API_KEY environment variable not set")
+        print("Error: FIRECRAWL_API_KEY not set. Set env var or add to ~/.claude/delegate.yaml")
         return []
 
     # First, fetch all blog homepages

+ 58 - 0
tools/README.md

@@ -96,6 +96,61 @@ Token-efficient CLI tools that replace verbose legacy commands. These tools are
 |--------|--------|-------------|
 | `make` | `just` | Simpler syntax, better errors |
 
+### AI Provider CLIs
+
+Custom CLI wrappers included in this toolkit for multi-LLM delegation:
+
+| Provider | CLI | Strength |
+|----------|-----|----------|
+| Gemini | `gemini` | 1M context, code analysis (install separately) |
+| OpenAI | `codex` | Deep reasoning (install separately) |
+| **Perplexity** | `perplexity` | **Web search + citations** (included) |
+
+**Perplexity CLI** (included - runs via `perplexity.py`):
+```bash
+# Direct question with web-grounded answer
+perplexity "What's new in TypeScript 5.7?"
+
+# Use reasoning model for complex analysis
+perplexity -m sonar-reasoning "Explain microservices vs monolith tradeoffs"
+
+# Pipe content for analysis
+cat code.py | perplexity "Review this code for security issues"
+
+# Filter by recency (day, week, month, year)
+perplexity --recency day "Latest AI news"
+
+# Restrict search to specific domains
+perplexity --domains "github.com,docs.python.org" "Python asyncio patterns"
+
+# JSON output for programmatic use
+perplexity --json "query" > output.json
+
+# List available models
+perplexity --list-models
+```
+
+**Models:**
+| Model | Use Case |
+|-------|----------|
+| `sonar` | Fast, cost-effective for quick facts |
+| `sonar-pro` | Complex queries, more citations (default) |
+| `sonar-reasoning` | Multi-step problem solving |
+| `sonar-reasoning-pro` | Deep reasoning (DeepSeek-R1) |
+| `sonar-deep-research` | Comprehensive agentic research |
+
+**Setup:**
+```bash
+# Set API key (get from https://www.perplexity.ai/settings/api)
+export PERPLEXITY_API_KEY="your-key-here"
+
+# Or add to ~/.claude/delegate.yaml:
+# api_keys:
+#   perplexity: "your-key-here"
+```
+
+---
+
 ### Web Fetching (URL Retrieval Hierarchy)
 
 When Claude's built-in `WebFetch` gets blocked (403, Cloudflare, etc.), use these alternatives in order:
@@ -158,6 +213,9 @@ After installation, verify all tools:
 ```bash
 # Check all tools are available
 which fd rg eza bat zoxide delta difft jq yq sd lazygit gh tokei uv just ast-grep fzf dust btm procs tldr
+
+# Check custom CLI wrappers
+perplexity --list-models
 ```
 
 ## Experimental / Future

+ 27 - 0
tools/install-unix.sh

@@ -210,6 +210,33 @@ echo '  [ -f ~/.fzf.bash ] && source ~/.fzf.bash  # or .zsh'
 echo '  export FZF_DEFAULT_COMMAND="fd --type f --hidden --follow --exclude .git"'
 echo ""
 
+# Install custom CLI wrappers
+echo ""
+echo -e "${BLUE}Installing custom CLI wrappers...${NC}"
+echo "────────────────────────────────"
+
+LOCAL_BIN="$HOME/.local/bin"
+mkdir -p "$LOCAL_BIN"
+
+# Perplexity CLI wrapper
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+if [ -f "$SCRIPT_DIR/perplexity.py" ]; then
+    cp "$SCRIPT_DIR/perplexity.py" "$LOCAL_BIN/perplexity"
+    chmod +x "$LOCAL_BIN/perplexity"
+    echo -e "  perplexity CLI: ${GREEN}OK${NC}"
+else
+    echo -e "  perplexity CLI: ${YELLOW}SKIP (perplexity.py not found)${NC}"
+fi
+
+# Check if ~/.local/bin is in PATH
+if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then
+    echo ""
+    echo -e "${YELLOW}Add ~/.local/bin to your PATH:${NC}"
+    echo '  export PATH="$HOME/.local/bin:$PATH"'
+fi
+
+echo ""
 echo -e "${BLUE}Verify installation with:${NC}"
 echo '  which fd rg eza bat zoxide delta difft jq yq sd lazygit gh tokei uv just fzf dust btm procs tldr'
+echo '  perplexity --list-models'
 echo ""

+ 36 - 0
tools/install-windows.ps1

@@ -134,6 +134,42 @@ if (Get-Command fzf -ErrorAction SilentlyContinue) {
 }
 
 Write-Host ""
+# Install custom CLI wrappers
+Write-Host ""
+Write-Host "Installing custom CLI wrappers..." -ForegroundColor Cyan
+Write-Host "────────────────────────────────"
+
+$localBin = "$env:USERPROFILE\.local\bin"
+if (-not (Test-Path $localBin)) {
+    New-Item -ItemType Directory -Path $localBin -Force | Out-Null
+    Write-Host "  Created $localBin" -ForegroundColor Green
+}
+
+# Perplexity CLI wrapper
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$perplexitySrc = Join-Path $scriptDir "perplexity.py"
+if (Test-Path $perplexitySrc) {
+    Copy-Item $perplexitySrc "$localBin\perplexity.py" -Force
+    # Create batch wrapper for Windows
+    $batchContent = "@echo off`npython `"%USERPROFILE%\.local\bin\perplexity.py`" %*"
+    Set-Content -Path "$localBin\perplexity.cmd" -Value $batchContent
+    Write-Host "  perplexity CLI: " -NoNewline
+    Write-Host "OK" -ForegroundColor Green
+} else {
+    Write-Host "  perplexity CLI: " -NoNewline
+    Write-Host "SKIP (perplexity.py not found)" -ForegroundColor Yellow
+}
+
+# Check if ~/.local/bin is in PATH
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+if ($userPath -notlike "*$localBin*") {
+    Write-Host ""
+    Write-Host "Add ~/.local/bin to your PATH:" -ForegroundColor Yellow
+    Write-Host "  [Environment]::SetEnvironmentVariable('Path', `$env:Path + ';$localBin', 'User')" -ForegroundColor Yellow
+}
+
+Write-Host ""
 Write-Host "Verify installation with:" -ForegroundColor Cyan
 Write-Host '  which fd rg eza bat zoxide delta difft jq yq sd lazygit gh tokei uv just fzf dust btm procs tldr' -ForegroundColor Yellow
+Write-Host '  perplexity --list-models' -ForegroundColor Yellow
 Write-Host ""

+ 283 - 0
tools/perplexity.py

@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""
+Perplexity CLI - Simple wrapper for Perplexity API.
+
+Zero external dependencies - uses only Python stdlib.
+Provides web-grounded AI answers with automatic citations.
+
+Usage:
+    perplexity "What is Claude Code?"
+    perplexity -m sonar-reasoning "Complex analysis question"
+    cat code.py | perplexity "Review this code"
+    perplexity --json "query" > output.json
+
+Environment:
+    PERPLEXITY_API_KEY - API key (or set in ~/.claude/delegate.yaml)
+    PERPLEXITY_VERBOSE - Show token usage when set
+"""
+import argparse
+import json
+import os
+import sys
+import urllib.request
+import urllib.error
+
+API_URL = "https://api.perplexity.ai/chat/completions"
+MODELS = {
+    "sonar": "Fast, cost-effective for quick facts",
+    "sonar-pro": "Complex queries, more citations (default)",
+    "sonar-reasoning": "Multi-step problem solving",
+    "sonar-reasoning-pro": "Deep reasoning (DeepSeek-R1)",
+    "sonar-deep-research": "Comprehensive research with agentic search",
+}
+DEFAULT_MODEL = "sonar-pro"
+
+
+def get_api_key():
+    """Get API key from env or config file."""
+    import re
+
+    # Try environment variable first
+    key = os.getenv("PERPLEXITY_API_KEY")
+    if key:
+        return key
+
+    # Try ~/.claude/delegate.yaml
+    config_path = os.path.expanduser("~/.claude/delegate.yaml")
+    if os.path.exists(config_path):
+        try:
+            with open(config_path, encoding="utf-8") as f:
+                content = f.read()
+            # Look for perplexity key in api_keys section
+            # Parse the api_keys block and find perplexity
+            in_api_keys = False
+            for line in content.split('\n'):
+                stripped = line.strip()
+                # Detect api_keys section
+                if stripped.startswith('api_keys:'):
+                    in_api_keys = True
+                    continue
+                # Exit section on non-indented line (new section)
+                if in_api_keys and stripped and not line.startswith(' ') and not line.startswith('\t'):
+                    if not stripped.startswith('#'):
+                        in_api_keys = False
+                # Look for perplexity key within api_keys section
+                if in_api_keys and 'perplexity:' in stripped.lower():
+                    match = re.search(r'perplexity:\s*["\']?([^"\'\n#]+)', stripped, re.IGNORECASE)
+                    if match:
+                        return match.group(1).strip()
+        except Exception:
+            pass
+
+    return None
+
+
+def query_perplexity(prompt, model=DEFAULT_MODEL, system_prompt=None, recency=None, domains=None):
+    """Send query to Perplexity API and return response."""
+    api_key = get_api_key()
+    if not api_key:
+        sys.exit(
+            "Error: PERPLEXITY_API_KEY not set.\n"
+            "Set via: export PERPLEXITY_API_KEY='your-key'\n"
+            "Or add to ~/.claude/delegate.yaml under api_keys:"
+        )
+
+    messages = []
+    if system_prompt:
+        messages.append({"role": "system", "content": system_prompt})
+    messages.append({"role": "user", "content": prompt})
+
+    payload = {
+        "model": model,
+        "messages": messages,
+    }
+
+    # Optional search filters
+    if recency:
+        payload["search_recency_filter"] = recency
+    if domains:
+        payload["search_domain_filter"] = domains
+
+    data = json.dumps(payload).encode("utf-8")
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json",
+        "Accept": "application/json",
+    }
+
+    req = urllib.request.Request(API_URL, data=data, headers=headers)
+
+    try:
+        with urllib.request.urlopen(req, timeout=120) as resp:
+            result = json.loads(resp.read().decode("utf-8"))
+    except urllib.error.HTTPError as e:
+        if e.code == 401:
+            sys.exit("Error: Invalid API key")
+        elif e.code == 429:
+            sys.exit("Error: Rate limited. Wait and retry.")
+        else:
+            body = ""
+            try:
+                body = e.read().decode("utf-8")
+            except Exception:
+                pass
+            sys.exit(f"Error: HTTP {e.code} - {e.reason}\n{body}")
+    except urllib.error.URLError as e:
+        sys.exit(f"Error: Network error - {e.reason}")
+    except Exception as e:
+        sys.exit(f"Error: {e}")
+
+    return result
+
+
+def safe_print(text):
+    """Print text safely, handling encoding issues on Windows."""
+    try:
+        print(text)
+    except UnicodeEncodeError:
+        # Fallback: encode with replacement for unsupported chars
+        print(text.encode(sys.stdout.encoding, errors='replace').decode(sys.stdout.encoding))
+
+
+def format_output(result, show_citations=True, json_output=False):
+    """Format and print the response."""
+    if json_output:
+        print(json.dumps(result, indent=2, ensure_ascii=False))
+        return
+
+    # Extract content
+    try:
+        content = result["choices"][0]["message"]["content"]
+    except (KeyError, IndexError):
+        print("Error: Unexpected response format")
+        print(json.dumps(result, indent=2))
+        return
+
+    citations = result.get("citations", [])
+    usage = result.get("usage", {})
+
+    safe_print(content)
+
+    # Show citations if available and requested
+    if show_citations and citations:
+        safe_print("\n---")
+        safe_print("Sources:")
+        for i, cite in enumerate(citations, 1):
+            safe_print(f"  [{i}] {cite}")
+
+    # Show usage if verbose
+    if os.getenv("PERPLEXITY_VERBOSE"):
+        total = usage.get("total_tokens", "N/A")
+        print(f"\n[Tokens: {total}]")
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Perplexity CLI - Web-grounded AI answers with citations",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Models:
+  sonar                Fast, cost-effective for quick facts
+  sonar-pro            Complex queries, more citations (default)
+  sonar-reasoning      Multi-step problem solving
+  sonar-reasoning-pro  Deep reasoning (DeepSeek-R1 based)
+  sonar-deep-research  Comprehensive agentic research
+
+Examples:
+  perplexity "What's new in TypeScript 5.7?"
+  perplexity -m sonar-reasoning "Analyze this security pattern"
+  cat code.py | perplexity "Review this code for issues"
+  perplexity --json "query" > output.json
+
+  # Filter by recency (day, week, month, year)
+  perplexity --recency day "Latest AI news"
+
+  # Restrict to specific domains
+  perplexity --domains "github.com,docs.python.org" "Python asyncio best practices"
+
+Environment:
+  PERPLEXITY_API_KEY  API key (required, or set in ~/.claude/delegate.yaml)
+  PERPLEXITY_VERBOSE  Show token usage when set
+""",
+    )
+    parser.add_argument("prompt", nargs="?", help="Query prompt")
+    parser.add_argument(
+        "-m",
+        "--model",
+        default=DEFAULT_MODEL,
+        choices=list(MODELS.keys()),
+        help=f"Model to use (default: {DEFAULT_MODEL})",
+    )
+    parser.add_argument("-s", "--system", default=None, help="System prompt")
+    parser.add_argument(
+        "--no-citations", action="store_true", help="Suppress citation output"
+    )
+    parser.add_argument(
+        "--json", action="store_true", help="Output raw JSON response"
+    )
+    parser.add_argument(
+        "--list-models", action="store_true", help="List available models"
+    )
+    parser.add_argument(
+        "--recency",
+        choices=["day", "week", "month", "year"],
+        help="Filter search results by recency",
+    )
+    parser.add_argument(
+        "--domains",
+        type=str,
+        help="Comma-separated domains to include (e.g., 'github.com,stackoverflow.com')",
+    )
+
+    args = parser.parse_args()
+
+    # List models
+    if args.list_models:
+        print("Available models:")
+        for name, desc in MODELS.items():
+            marker = " (default)" if name == DEFAULT_MODEL else ""
+            print(f"  {name}{marker}: {desc}")
+        return 0
+
+    # Get prompt from argument and/or stdin
+    prompt = args.prompt
+    stdin_content = ""
+
+    # Read stdin once if available (piped input)
+    if not sys.stdin.isatty():
+        try:
+            stdin_content = sys.stdin.read().strip()
+        except Exception:
+            stdin_content = ""
+
+    # Combine stdin and prompt argument
+    if stdin_content and prompt:
+        # Both stdin and argument: stdin as context, prompt as instruction
+        prompt = f"{stdin_content}\n\n{prompt}"
+    elif stdin_content:
+        # Only stdin content
+        prompt = stdin_content
+    elif not prompt:
+        # No input at all
+        parser.print_help()
+        return 1
+
+    # Parse domains if provided
+    domains = None
+    if args.domains:
+        domains = [d.strip() for d in args.domains.split(",")]
+
+    # Query and output
+    result = query_perplexity(
+        prompt,
+        model=args.model,
+        system_prompt=args.system,
+        recency=args.recency,
+        domains=domains,
+    )
+    format_output(result, show_citations=not args.no_citations, json_output=args.json)
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())