web-search-plus-plugin 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Web Search Plus - Interactive Setup Wizard
4
+ ==========================================
5
+
6
+ Runs on first use (when no config.json exists) to configure providers and API keys.
7
+ Creates config.json with your settings. API keys are stored locally only.
8
+
9
+ Usage:
10
+ python3 scripts/setup.py # Interactive setup
11
+ python3 scripts/setup.py --reset # Reset and reconfigure
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # ANSI colors for terminal output
20
+ class Colors:
21
+ HEADER = '\033[95m'
22
+ BLUE = '\033[94m'
23
+ CYAN = '\033[96m'
24
+ GREEN = '\033[92m'
25
+ YELLOW = '\033[93m'
26
+ RED = '\033[91m'
27
+ BOLD = '\033[1m'
28
+ DIM = '\033[2m'
29
+ RESET = '\033[0m'
30
+
31
+ def color(text: str, c: str) -> str:
32
+ """Wrap text in color codes."""
33
+ return f"{c}{text}{Colors.RESET}"
34
+
35
+ def print_header():
36
+ """Print the setup wizard header."""
37
+ print()
38
+ print(color("╔════════════════════════════════════════════════════════════╗", Colors.CYAN))
39
+ print(color("║ 🔍 Web Search Plus - Setup Wizard ║", Colors.CYAN))
40
+ print(color("╚════════════════════════════════════════════════════════════╝", Colors.CYAN))
41
+ print()
42
+ print(color("This wizard will help you configure your search providers.", Colors.DIM))
43
+ print(color("API keys are stored locally in config.json (gitignored).", Colors.DIM))
44
+ print()
45
+
46
+ def print_provider_info():
47
+ """Print information about each provider."""
48
+ print(color("📚 Available Providers:", Colors.BOLD))
49
+ print()
50
+
51
+ providers = [
52
+ {
53
+ "name": "Serper",
54
+ "emoji": "🔎",
55
+ "best_for": "Google results, shopping, local businesses, news",
56
+ "free_tier": "2,500 queries/month",
57
+ "signup": "https://serper.dev",
58
+ "strengths": ["Fastest response times", "Product prices & specs", "Knowledge Graph", "Local business data"]
59
+ },
60
+ {
61
+ "name": "Tavily",
62
+ "emoji": "📖",
63
+ "best_for": "Research, explanations, in-depth analysis",
64
+ "free_tier": "1,000 queries/month",
65
+ "signup": "https://tavily.com",
66
+ "strengths": ["AI-synthesized answers", "Full page content", "Domain filtering", "Academic research"]
67
+ },
68
+ {
69
+ "name": "Exa",
70
+ "emoji": "🧠",
71
+ "best_for": "Semantic search, finding similar content, discovery",
72
+ "free_tier": "1,000 queries/month",
73
+ "signup": "https://exa.ai",
74
+ "strengths": ["Neural/semantic understanding", "Similar page discovery", "Startup/company finder", "Date filtering"]
75
+ },
76
+ {
77
+ "name": "You.com",
78
+ "emoji": "🤖",
79
+ "best_for": "RAG applications, real-time info, LLM-ready snippets",
80
+ "free_tier": "Limited free tier",
81
+ "signup": "https://api.you.com",
82
+ "strengths": ["LLM-ready snippets", "Combined web + news", "Live page crawling", "Real-time information"]
83
+ },
84
+ {
85
+ "name": "SearXNG",
86
+ "emoji": "🔒",
87
+ "best_for": "Privacy-first search, multi-source aggregation, $0 API cost",
88
+ "free_tier": "FREE (self-hosted)",
89
+ "signup": "https://docs.searxng.org/admin/installation.html",
90
+ "strengths": ["Privacy-preserving (no tracking)", "70+ search engines", "Self-hosted = $0 API cost", "Diverse results"]
91
+ }
92
+ ]
93
+
94
+ for p in providers:
95
+ print(f" {p['emoji']} {color(p['name'], Colors.BOLD)}")
96
+ print(f" Best for: {color(p['best_for'], Colors.GREEN)}")
97
+ print(f" Free tier: {p['free_tier']}")
98
+ print(f" Sign up: {color(p['signup'], Colors.BLUE)}")
99
+ print()
100
+
101
+ def ask_yes_no(prompt: str, default: bool = True) -> bool:
102
+ """Ask a yes/no question."""
103
+ suffix = "[Y/n]" if default else "[y/N]"
104
+ while True:
105
+ response = input(f"{prompt} {color(suffix, Colors.DIM)}: ").strip().lower()
106
+ if response == "":
107
+ return default
108
+ if response in ("y", "yes"):
109
+ return True
110
+ if response in ("n", "no"):
111
+ return False
112
+ print(color(" Please enter 'y' or 'n'", Colors.YELLOW))
113
+
114
+ def ask_choice(prompt: str, options: list, default: str = None) -> str:
115
+ """Ask user to choose from a list of options."""
116
+ print(f"\n{prompt}")
117
+ for i, opt in enumerate(options, 1):
118
+ marker = color("→", Colors.GREEN) if opt == default else " "
119
+ print(f" {marker} {i}. {opt}")
120
+
121
+ while True:
122
+ hint = f" [default: {default}]" if default else ""
123
+ response = input(f"Enter number (1-{len(options)}){color(hint, Colors.DIM)}: ").strip()
124
+
125
+ if response == "" and default:
126
+ return default
127
+
128
+ try:
129
+ idx = int(response)
130
+ if 1 <= idx <= len(options):
131
+ return options[idx - 1]
132
+ except ValueError:
133
+ pass
134
+
135
+ print(color(f" Please enter a number between 1 and {len(options)}", Colors.YELLOW))
136
+
137
+ def ask_api_key(provider: str, signup_url: str) -> str:
138
+ """Ask for an API key with validation."""
139
+ print()
140
+ print(f" {color(f'Get your {provider} API key:', Colors.DIM)} {color(signup_url, Colors.BLUE)}")
141
+
142
+ while True:
143
+ key = input(f" Enter your {provider} API key: ").strip()
144
+
145
+ if not key:
146
+ print(color(" ⚠️ No key entered. This provider will be disabled.", Colors.YELLOW))
147
+ return None
148
+
149
+ # Basic validation
150
+ if len(key) < 10:
151
+ print(color(" ⚠️ Key seems too short. Please check and try again.", Colors.YELLOW))
152
+ continue
153
+
154
+ # Mask key for confirmation
155
+ masked = key[:4] + "..." + key[-4:] if len(key) > 12 else key[:2] + "..."
156
+ print(color(f" ✓ Key saved: {masked}", Colors.GREEN))
157
+ return key
158
+
159
+
160
+ def ask_searxng_instance(docs_url: str) -> str:
161
+ """Ask for SearXNG instance URL with connection test."""
162
+ print()
163
+ print(f" {color('SearXNG is self-hosted. You need your own instance.', Colors.DIM)}")
164
+ print(f" {color('Setup guide:', Colors.DIM)} {color(docs_url, Colors.BLUE)}")
165
+ print()
166
+ print(f" {color('Example URLs:', Colors.DIM)}")
167
+ print(f" • http://localhost:8080 (local Docker)")
168
+ print(f" • https://searx.your-domain.com (self-hosted)")
169
+ print()
170
+
171
+ while True:
172
+ url = input(f" Enter your SearXNG instance URL: ").strip()
173
+
174
+ if not url:
175
+ print(color(" ⚠️ No URL entered. SearXNG will be disabled.", Colors.YELLOW))
176
+ return None
177
+
178
+ # Basic URL validation
179
+ if not url.startswith(("http://", "https://")):
180
+ print(color(" ⚠️ URL must start with http:// or https://", Colors.YELLOW))
181
+ continue
182
+
183
+ # SSRF protection: validate URL before connecting
184
+ try:
185
+ import ipaddress
186
+ import socket
187
+ from urllib.parse import urlparse as _urlparse
188
+ _parsed = _urlparse(url)
189
+ _hostname = _parsed.hostname or ""
190
+ _blocked = {"169.254.169.254", "metadata.google.internal", "metadata.internal"}
191
+ if _hostname in _blocked:
192
+ print(color(f" ❌ Blocked: {_hostname} is a cloud metadata endpoint.", Colors.RED))
193
+ continue
194
+ if not os.environ.get("SEARXNG_ALLOW_PRIVATE", "").strip() == "1":
195
+ _resolved = socket.getaddrinfo(_hostname, _parsed.port or 80, proto=socket.IPPROTO_TCP)
196
+ for _fam, _t, _p, _cn, _sa in _resolved:
197
+ _ip = ipaddress.ip_address(_sa[0])
198
+ if _ip.is_loopback or _ip.is_private or _ip.is_link_local or _ip.is_reserved:
199
+ print(color(f" ❌ Blocked: {_hostname} resolves to private IP {_ip}.", Colors.RED))
200
+ print(color(f" Set SEARXNG_ALLOW_PRIVATE=1 if intentional.", Colors.DIM))
201
+ raise ValueError("private_ip")
202
+ except ValueError as _ve:
203
+ if str(_ve) == "private_ip":
204
+ continue
205
+ raise
206
+ except socket.gaierror:
207
+ print(color(f" ❌ Cannot resolve hostname: {_hostname}", Colors.RED))
208
+ continue
209
+
210
+ # Test connection
211
+ print(color(f" Testing connection to {url}...", Colors.DIM))
212
+ try:
213
+ import urllib.request
214
+ import urllib.error
215
+
216
+ test_url = f"{url.rstrip('/')}/search?q=test&format=json"
217
+ req = urllib.request.Request(
218
+ test_url,
219
+ headers={"User-Agent": "ClawdBot-WebSearchPlus/2.5", "Accept": "application/json"}
220
+ )
221
+
222
+ with urllib.request.urlopen(req, timeout=10) as response:
223
+ data = response.read().decode("utf-8")
224
+ import json
225
+ result = json.loads(data)
226
+
227
+ # Check if it looks like SearXNG JSON response
228
+ if "results" in result or "query" in result:
229
+ print(color(f" ✓ Connection successful! SearXNG instance is working.", Colors.GREEN))
230
+ return url.rstrip("/")
231
+ else:
232
+ print(color(f" ⚠️ Connected but response doesn't look like SearXNG JSON.", Colors.YELLOW))
233
+ if ask_yes_no(" Use this URL anyway?", default=False):
234
+ return url.rstrip("/")
235
+
236
+ except urllib.error.HTTPError as e:
237
+ if e.code == 403:
238
+ print(color(f" ⚠️ JSON API is disabled (403 Forbidden).", Colors.YELLOW))
239
+ print(color(f" Enable JSON in settings.yml: search.formats: [html, json]", Colors.DIM))
240
+ else:
241
+ print(color(f" ⚠️ HTTP error: {e.code} {e.reason}", Colors.YELLOW))
242
+
243
+ if ask_yes_no(" Try a different URL?", default=True):
244
+ continue
245
+ return None
246
+
247
+ except urllib.error.URLError as e:
248
+ print(color(f" ⚠️ Cannot reach instance: {e.reason}", Colors.YELLOW))
249
+ if ask_yes_no(" Try a different URL?", default=True):
250
+ continue
251
+ return None
252
+
253
+ except Exception as e:
254
+ print(color(f" ⚠️ Error: {e}", Colors.YELLOW))
255
+ if ask_yes_no(" Try a different URL?", default=True):
256
+ continue
257
+ return None
258
+
259
+ def ask_result_count() -> int:
260
+ """Ask for default result count."""
261
+ options = ["3 (fast, minimal)", "5 (balanced - recommended)", "10 (comprehensive)"]
262
+ choice = ask_choice("Default number of results per search?", options, "5 (balanced - recommended)")
263
+
264
+ if "3" in choice:
265
+ return 3
266
+ elif "10" in choice:
267
+ return 10
268
+ return 5
269
+
270
+ def run_setup(skill_dir: Path, force_reset: bool = False):
271
+ """Run the interactive setup wizard."""
272
+ config_path = skill_dir / "config.json"
273
+ example_path = skill_dir / "config.example.json"
274
+
275
+ # Check if config already exists
276
+ if config_path.exists() and not force_reset:
277
+ print(color("✓ config.json already exists!", Colors.GREEN))
278
+ print()
279
+ if not ask_yes_no("Do you want to reconfigure?", default=False):
280
+ print(color("Setup cancelled. Your existing config is unchanged.", Colors.DIM))
281
+ return False
282
+ print()
283
+
284
+ print_header()
285
+ print_provider_info()
286
+
287
+ # Load example config as base
288
+ if example_path.exists():
289
+ with open(example_path) as f:
290
+ config = json.load(f)
291
+ else:
292
+ config = {
293
+ "defaults": {"provider": "serper", "max_results": 5},
294
+ "auto_routing": {"enabled": True, "fallback_provider": "serper"},
295
+ "serper": {},
296
+ "tavily": {},
297
+ "exa": {}
298
+ }
299
+
300
+ # Remove any existing API keys from example
301
+ for provider in ["serper", "tavily", "exa"]:
302
+ if provider in config:
303
+ config[provider].pop("api_key", None)
304
+
305
+ enabled_providers = []
306
+
307
+ # ===== Question 1: Which providers to enable =====
308
+ print(color("─" * 60, Colors.DIM))
309
+ print(color("\n📋 Step 1: Choose Your Providers\n", Colors.BOLD))
310
+ print("Select which search providers you want to enable.")
311
+ print(color("(You need at least one API key to use this skill)", Colors.DIM))
312
+ print()
313
+
314
+ providers_info = {
315
+ "serper": ("Serper", "https://serper.dev", "Google results, shopping, local"),
316
+ "tavily": ("Tavily", "https://tavily.com", "Research, explanations, analysis"),
317
+ "exa": ("Exa", "https://exa.ai", "Semantic search, similar content"),
318
+ "you": ("You.com", "https://api.you.com", "RAG applications, real-time info"),
319
+ "searxng": ("SearXNG", "https://docs.searxng.org/admin/installation.html", "Privacy-first, self-hosted, $0 cost")
320
+ }
321
+
322
+ for provider, (name, url, desc) in providers_info.items():
323
+ print(f" {color(name, Colors.BOLD)}: {desc}")
324
+
325
+ # Special handling for SearXNG
326
+ if provider == "searxng":
327
+ print(color(" Note: SearXNG requires a self-hosted instance (no API key needed)", Colors.DIM))
328
+ if ask_yes_no(f" Do you have a SearXNG instance?", default=False):
329
+ instance_url = ask_searxng_instance(url)
330
+ if instance_url:
331
+ if "searxng" not in config:
332
+ config["searxng"] = {}
333
+ config["searxng"]["instance_url"] = instance_url
334
+ enabled_providers.append(provider)
335
+ else:
336
+ print(color(f" → {name} disabled (no instance URL)", Colors.DIM))
337
+ else:
338
+ print(color(f" → {name} skipped (no instance)", Colors.DIM))
339
+ else:
340
+ if ask_yes_no(f" Enable {name}?", default=True):
341
+ # ===== Question 2: API key for each enabled provider =====
342
+ api_key = ask_api_key(name, url)
343
+ if api_key:
344
+ config[provider]["api_key"] = api_key
345
+ enabled_providers.append(provider)
346
+ else:
347
+ print(color(f" → {name} disabled (no API key)", Colors.DIM))
348
+ else:
349
+ print(color(f" → {name} disabled", Colors.DIM))
350
+ print()
351
+
352
+ if not enabled_providers:
353
+ print()
354
+ print(color("⚠️ No providers enabled!", Colors.RED))
355
+ print("You need at least one API key to use web-search-plus.")
356
+ print("Run this setup again when you have an API key.")
357
+ return False
358
+
359
+ # ===== Question 3: Default provider =====
360
+ print(color("─" * 60, Colors.DIM))
361
+ print(color("\n⚙️ Step 2: Default Settings\n", Colors.BOLD))
362
+
363
+ if len(enabled_providers) > 1:
364
+ default_provider = ask_choice(
365
+ "Which provider should be the default for general queries?",
366
+ enabled_providers,
367
+ enabled_providers[0]
368
+ )
369
+ else:
370
+ default_provider = enabled_providers[0]
371
+ print(f"Default provider: {color(default_provider, Colors.GREEN)} (only one enabled)")
372
+
373
+ config["defaults"]["provider"] = default_provider
374
+ config["auto_routing"]["fallback_provider"] = default_provider
375
+
376
+ # ===== Question 4: Auto-routing =====
377
+ print()
378
+ print(color("Auto-routing", Colors.BOLD) + " automatically picks the best provider for each query:")
379
+ print(color(" • 'iPhone price' → Serper (shopping intent)", Colors.DIM))
380
+ print(color(" • 'how does TCP work' → Tavily (research intent)", Colors.DIM))
381
+ print(color(" • 'companies like Stripe' → Exa (discovery intent)", Colors.DIM))
382
+ print()
383
+
384
+ auto_routing = ask_yes_no("Enable auto-routing?", default=True)
385
+ config["auto_routing"]["enabled"] = auto_routing
386
+
387
+ if not auto_routing:
388
+ print(color(f" → All queries will use {default_provider}", Colors.DIM))
389
+
390
+ # ===== Question 5: Result count =====
391
+ print()
392
+ max_results = ask_result_count()
393
+ config["defaults"]["max_results"] = max_results
394
+
395
+ # Set disabled providers
396
+ all_providers = ["serper", "tavily", "exa", "you", "searxng"]
397
+ disabled = [p for p in all_providers if p not in enabled_providers]
398
+ config["auto_routing"]["disabled_providers"] = disabled
399
+
400
+ # ===== Save config =====
401
+ print()
402
+ print(color("─" * 60, Colors.DIM))
403
+ print(color("\n💾 Saving Configuration\n", Colors.BOLD))
404
+
405
+ with open(config_path, 'w') as f:
406
+ json.dump(config, f, indent=2)
407
+
408
+ print(color(f"✓ Configuration saved to: {config_path}", Colors.GREEN))
409
+ print()
410
+
411
+ # ===== Summary =====
412
+ print(color("📋 Configuration Summary:", Colors.BOLD))
413
+ print(f" Enabled providers: {', '.join(enabled_providers)}")
414
+ print(f" Default provider: {default_provider}")
415
+ print(f" Auto-routing: {'enabled' if auto_routing else 'disabled'}")
416
+ print(f" Results per search: {max_results}")
417
+ print()
418
+
419
+ # ===== Test suggestion =====
420
+ print(color("🚀 Ready to search! Try:", Colors.BOLD))
421
+ print(color(f" python3 scripts/search.py -q \"your query here\"", Colors.CYAN))
422
+ print()
423
+
424
+ return True
425
+
426
+ def check_first_run(skill_dir: Path) -> bool:
427
+ """Check if this is the first run (no config.json)."""
428
+ config_path = skill_dir / "config.json"
429
+ return not config_path.exists()
430
+
431
+ def main():
432
+ # Determine skill directory
433
+ script_path = Path(__file__).resolve()
434
+ skill_dir = script_path.parent.parent
435
+
436
+ # Check for --reset flag
437
+ force_reset = "--reset" in sys.argv
438
+
439
+ # Check for --check flag (just check if setup needed)
440
+ if "--check" in sys.argv:
441
+ if check_first_run(skill_dir):
442
+ print("Setup required: config.json not found")
443
+ sys.exit(1)
444
+ else:
445
+ print("Setup complete: config.json exists")
446
+ sys.exit(0)
447
+
448
+ # Run setup
449
+ success = run_setup(skill_dir, force_reset)
450
+ sys.exit(0 if success else 1)
451
+
452
+ if __name__ == "__main__":
453
+ main()