perplexity.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env python3
  2. """
  3. Perplexity CLI - Simple wrapper for Perplexity API.
  4. Zero external dependencies - uses only Python stdlib.
  5. Provides web-grounded AI answers with automatic citations.
  6. Usage:
  7. perplexity "What is Claude Code?"
  8. perplexity -m sonar-reasoning "Complex analysis question"
  9. cat code.py | perplexity "Review this code"
  10. perplexity --json "query" > output.json
  11. Environment:
  12. PERPLEXITY_API_KEY - API key (or set in ~/.claude/conclave.yaml)
  13. PERPLEXITY_VERBOSE - Show token usage when set
  14. """
  15. import argparse
  16. import json
  17. import os
  18. import sys
  19. import urllib.request
  20. import urllib.error
  21. API_URL = "https://api.perplexity.ai/chat/completions"
  22. MODELS = {
  23. "sonar": "Fast, cost-effective for quick facts",
  24. "sonar-pro": "Complex queries, more citations (default)",
  25. "sonar-reasoning": "Multi-step problem solving",
  26. "sonar-reasoning-pro": "Deep reasoning (DeepSeek-R1)",
  27. "sonar-deep-research": "Comprehensive research with agentic search",
  28. }
  29. DEFAULT_MODEL = "sonar-pro"
  30. def get_api_key():
  31. """Get API key from env or config file."""
  32. import re
  33. # Try environment variable first
  34. key = os.getenv("PERPLEXITY_API_KEY")
  35. if key:
  36. return key
  37. # Try ~/.claude/conclave.yaml
  38. config_path = os.path.expanduser("~/.claude/conclave.yaml")
  39. if os.path.exists(config_path):
  40. try:
  41. with open(config_path, encoding="utf-8") as f:
  42. content = f.read()
  43. # Look for perplexity key in api_keys section
  44. # Parse the api_keys block and find perplexity
  45. in_api_keys = False
  46. for line in content.split('\n'):
  47. stripped = line.strip()
  48. # Detect api_keys section
  49. if stripped.startswith('api_keys:'):
  50. in_api_keys = True
  51. continue
  52. # Exit section on non-indented line (new section)
  53. if in_api_keys and stripped and not line.startswith(' ') and not line.startswith('\t'):
  54. if not stripped.startswith('#'):
  55. in_api_keys = False
  56. # Look for perplexity key within api_keys section
  57. if in_api_keys and 'perplexity:' in stripped.lower():
  58. match = re.search(r'perplexity:\s*["\']?([^"\'\n#]+)', stripped, re.IGNORECASE)
  59. if match:
  60. return match.group(1).strip()
  61. except Exception:
  62. pass
  63. return None
  64. def query_perplexity(prompt, model=DEFAULT_MODEL, system_prompt=None, recency=None, domains=None):
  65. """Send query to Perplexity API and return response."""
  66. api_key = get_api_key()
  67. if not api_key:
  68. sys.exit(
  69. "Error: PERPLEXITY_API_KEY not set.\n"
  70. "Set via: export PERPLEXITY_API_KEY='your-key'\n"
  71. "Or add to ~/.claude/conclave.yaml under api_keys:"
  72. )
  73. messages = []
  74. if system_prompt:
  75. messages.append({"role": "system", "content": system_prompt})
  76. messages.append({"role": "user", "content": prompt})
  77. payload = {
  78. "model": model,
  79. "messages": messages,
  80. }
  81. # Optional search filters
  82. if recency:
  83. payload["search_recency_filter"] = recency
  84. if domains:
  85. payload["search_domain_filter"] = domains
  86. data = json.dumps(payload).encode("utf-8")
  87. headers = {
  88. "Authorization": f"Bearer {api_key}",
  89. "Content-Type": "application/json",
  90. "Accept": "application/json",
  91. }
  92. req = urllib.request.Request(API_URL, data=data, headers=headers)
  93. try:
  94. with urllib.request.urlopen(req, timeout=120) as resp:
  95. result = json.loads(resp.read().decode("utf-8"))
  96. except urllib.error.HTTPError as e:
  97. if e.code == 401:
  98. sys.exit("Error: Invalid API key")
  99. elif e.code == 429:
  100. sys.exit("Error: Rate limited. Wait and retry.")
  101. else:
  102. body = ""
  103. try:
  104. body = e.read().decode("utf-8")
  105. except Exception:
  106. pass
  107. sys.exit(f"Error: HTTP {e.code} - {e.reason}\n{body}")
  108. except urllib.error.URLError as e:
  109. sys.exit(f"Error: Network error - {e.reason}")
  110. except Exception as e:
  111. sys.exit(f"Error: {e}")
  112. return result
  113. def safe_print(text):
  114. """Print text safely, handling encoding issues on Windows."""
  115. try:
  116. print(text)
  117. except UnicodeEncodeError:
  118. # Fallback: encode with replacement for unsupported chars
  119. print(text.encode(sys.stdout.encoding, errors='replace').decode(sys.stdout.encoding))
  120. def format_output(result, show_citations=True, json_output=False):
  121. """Format and print the response."""
  122. if json_output:
  123. print(json.dumps(result, indent=2, ensure_ascii=False))
  124. return
  125. # Extract content
  126. try:
  127. content = result["choices"][0]["message"]["content"]
  128. except (KeyError, IndexError):
  129. print("Error: Unexpected response format")
  130. print(json.dumps(result, indent=2))
  131. return
  132. citations = result.get("citations", [])
  133. usage = result.get("usage", {})
  134. safe_print(content)
  135. # Show citations if available and requested
  136. if show_citations and citations:
  137. safe_print("\n---")
  138. safe_print("Sources:")
  139. for i, cite in enumerate(citations, 1):
  140. safe_print(f" [{i}] {cite}")
  141. # Show usage if verbose
  142. if os.getenv("PERPLEXITY_VERBOSE"):
  143. total = usage.get("total_tokens", "N/A")
  144. print(f"\n[Tokens: {total}]")
  145. def main():
  146. parser = argparse.ArgumentParser(
  147. description="Perplexity CLI - Web-grounded AI answers with citations",
  148. formatter_class=argparse.RawDescriptionHelpFormatter,
  149. epilog="""
  150. Models:
  151. sonar Fast, cost-effective for quick facts
  152. sonar-pro Complex queries, more citations (default)
  153. sonar-reasoning Multi-step problem solving
  154. sonar-reasoning-pro Deep reasoning (DeepSeek-R1 based)
  155. sonar-deep-research Comprehensive agentic research
  156. Examples:
  157. perplexity "What's new in TypeScript 5.7?"
  158. perplexity -m sonar-reasoning "Analyze this security pattern"
  159. cat code.py | perplexity "Review this code for issues"
  160. perplexity --json "query" > output.json
  161. # Filter by recency (day, week, month, year)
  162. perplexity --recency day "Latest AI news"
  163. # Restrict to specific domains
  164. perplexity --domains "github.com,docs.python.org" "Python asyncio best practices"
  165. Environment:
  166. PERPLEXITY_API_KEY API key (required, or set in ~/.claude/conclave.yaml)
  167. PERPLEXITY_VERBOSE Show token usage when set
  168. """,
  169. )
  170. parser.add_argument("prompt", nargs="?", help="Query prompt")
  171. parser.add_argument(
  172. "-m",
  173. "--model",
  174. default=DEFAULT_MODEL,
  175. choices=list(MODELS.keys()),
  176. help=f"Model to use (default: {DEFAULT_MODEL})",
  177. )
  178. parser.add_argument("-s", "--system", default=None, help="System prompt")
  179. parser.add_argument(
  180. "--no-citations", action="store_true", help="Suppress citation output"
  181. )
  182. parser.add_argument(
  183. "--json", action="store_true", help="Output raw JSON response"
  184. )
  185. parser.add_argument(
  186. "--list-models", action="store_true", help="List available models"
  187. )
  188. parser.add_argument(
  189. "--recency",
  190. choices=["day", "week", "month", "year"],
  191. help="Filter search results by recency",
  192. )
  193. parser.add_argument(
  194. "--domains",
  195. type=str,
  196. help="Comma-separated domains to include (e.g., 'github.com,stackoverflow.com')",
  197. )
  198. args = parser.parse_args()
  199. # List models
  200. if args.list_models:
  201. print("Available models:")
  202. for name, desc in MODELS.items():
  203. marker = " (default)" if name == DEFAULT_MODEL else ""
  204. print(f" {name}{marker}: {desc}")
  205. return 0
  206. # Get prompt from argument and/or stdin
  207. prompt = args.prompt
  208. stdin_content = ""
  209. # Read stdin once if available (piped input)
  210. if not sys.stdin.isatty():
  211. try:
  212. stdin_content = sys.stdin.read().strip()
  213. except Exception:
  214. stdin_content = ""
  215. # Combine stdin and prompt argument
  216. if stdin_content and prompt:
  217. # Both stdin and argument: stdin as context, prompt as instruction
  218. prompt = f"{stdin_content}\n\n{prompt}"
  219. elif stdin_content:
  220. # Only stdin content
  221. prompt = stdin_content
  222. elif not prompt:
  223. # No input at all
  224. parser.print_help()
  225. return 1
  226. # Parse domains if provided
  227. domains = None
  228. if args.domains:
  229. domains = [d.strip() for d in args.domains.split(",")]
  230. # Query and output
  231. result = query_perplexity(
  232. prompt,
  233. model=args.model,
  234. system_prompt=args.system,
  235. recency=args.recency,
  236. domains=domains,
  237. )
  238. format_output(result, show_citations=not args.no_citations, json_output=args.json)
  239. return 0
  240. if __name__ == "__main__":
  241. sys.exit(main())