@blxzer/cursor-trellis 0.1.3 → 0.2.2

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +45 -23
  3. package/dist/configurators/workflow.d.ts +1 -1
  4. package/dist/configurators/workflow.d.ts.map +1 -1
  5. package/dist/configurators/workflow.js +20 -9
  6. package/dist/configurators/workflow.js.map +1 -1
  7. package/dist/migrations/manifests/0.1.3.json +9 -0
  8. package/dist/migrations/manifests/0.1.4.json +9 -0
  9. package/dist/migrations/manifests/0.2.1.json +9 -0
  10. package/dist/templates/markdown/agents.md +1 -1
  11. package/dist/templates/markdown/index.d.ts +1 -0
  12. package/dist/templates/markdown/index.d.ts.map +1 -1
  13. package/dist/templates/markdown/index.js +1 -0
  14. package/dist/templates/markdown/index.js.map +1 -1
  15. package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +13 -29
  16. package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +0 -49
  17. package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +0 -85
  18. package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +7 -21
  19. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +6 -10
  20. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +7 -13
  21. package/dist/templates/shared-hooks/inject-retrieval-plan.py +42 -23
  22. package/dist/templates/shared-hooks/inject-subagent-context.py +6 -2
  23. package/dist/templates/shared-hooks/session-start.py +17 -9
  24. package/dist/templates/trellis/index.d.ts +7 -4
  25. package/dist/templates/trellis/index.d.ts.map +1 -1
  26. package/dist/templates/trellis/index.js +11 -7
  27. package/dist/templates/trellis/index.js.map +1 -1
  28. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +2 -2
  29. package/dist/templates/trellis/scripts/common/config.py +1 -1
  30. package/dist/templates/trellis/scripts/common/parent_orchestration.py +27 -15
  31. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
  32. package/dist/templates/trellis/scripts/common/smart_search_resolve.py +28 -3
  33. package/dist/templates/trellis/scripts/common/task_gates.py +372 -73
  34. package/dist/templates/trellis/scripts/common/task_map.py +29 -1
  35. package/dist/templates/trellis/workflow.md +9 -53
  36. package/package.json +5 -44
  37. package/scripts/postinstall.js +4 -42
  38. package/bin/smart-search.js +0 -79
  39. package/dist/templates/trellis/scripts/aggregate_retrieval_telemetry.py +0 -342
  40. package/dist/templates/trellis/scripts/batch_plan_envelope.py +0 -105
  41. package/vendor/smart-search/LICENSE +0 -21
  42. package/vendor/smart-search/README.md +0 -412
  43. package/vendor/smart-search/README.zh-CN.md +0 -430
  44. package/vendor/smart-search/npm/bin/smart-search.js +0 -63
  45. package/vendor/smart-search/npm/scripts/postinstall.js +0 -87
  46. package/vendor/smart-search/npm/scripts/resolve-prerelease-version.js +0 -108
  47. package/vendor/smart-search/npm/scripts/set-package-version.js +0 -35
  48. package/vendor/smart-search/npm/scripts/sync-python-version.js +0 -22
  49. package/vendor/smart-search/npm/scripts/test-wrapper-repair.js +0 -99
  50. package/vendor/smart-search/npm/scripts/test.js +0 -85
  51. package/vendor/smart-search/package.json +0 -42
  52. package/vendor/smart-search/pyproject.toml +0 -37
  53. package/vendor/smart-search/skills/smart-search-cli/SKILL.md +0 -387
  54. package/vendor/smart-search/skills/smart-search-cli/agents/openai.yaml +0 -3
  55. package/vendor/smart-search/skills/smart-search-cli/examples/batch-search.md +0 -98
  56. package/vendor/smart-search/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
  57. package/vendor/smart-search/skills/smart-search-cli/references/cli-contract.md +0 -337
  58. package/vendor/smart-search/src/smart_search/__init__.py +0 -1
  59. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/SKILL.md +0 -387
  60. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +0 -3
  61. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/batch-search.md +0 -98
  62. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
  63. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +0 -337
  64. package/vendor/smart-search/src/smart_search/cli.py +0 -2003
  65. package/vendor/smart-search/src/smart_search/config.py +0 -580
  66. package/vendor/smart-search/src/smart_search/logger.py +0 -43
  67. package/vendor/smart-search/src/smart_search/providers/__init__.py +0 -16
  68. package/vendor/smart-search/src/smart_search/providers/base.py +0 -41
  69. package/vendor/smart-search/src/smart_search/providers/context7.py +0 -141
  70. package/vendor/smart-search/src/smart_search/providers/exa.py +0 -206
  71. package/vendor/smart-search/src/smart_search/providers/jina.py +0 -136
  72. package/vendor/smart-search/src/smart_search/providers/openai_compatible.py +0 -370
  73. package/vendor/smart-search/src/smart_search/providers/zhipu.py +0 -143
  74. package/vendor/smart-search/src/smart_search/service.py +0 -2865
  75. package/vendor/smart-search/src/smart_search/sources.py +0 -429
  76. package/vendor/smart-search/src/smart_search/utils.py +0 -220
@@ -1,2003 +0,0 @@
1
- import argparse
2
- import asyncio
3
- import contextlib
4
- import getpass
5
- import json
6
- from importlib import metadata
7
- import subprocess
8
- import sys
9
- from pathlib import Path
10
- from typing import Any
11
- from urllib.parse import urlsplit, urlunsplit
12
-
13
- from . import service
14
-
15
-
16
- EXIT_OK = 0
17
- EXIT_PARAMETER_ERROR = 2
18
- EXIT_CONFIG_ERROR = 3
19
- EXIT_NETWORK_ERROR = 4
20
- EXIT_RUNTIME_ERROR = 5
21
-
22
- COMMAND_ALIASES = {
23
- "search": ["s"],
24
- "fetch": ["f"],
25
- "map": ["m"],
26
- "exa-search": ["exa", "x"],
27
- "exa-similar": ["xs"],
28
- "zhipu-search": ["z", "zp"],
29
- "context7-library": ["c7", "ctx7"],
30
- "context7-docs": ["c7d", "c7docs", "ctx7-docs"],
31
- "research": ["rs"],
32
- "doctor": ["d"],
33
- "diagnose": ["diag"],
34
- "setup": ["init"],
35
- "config": ["cfg"],
36
- }
37
-
38
- CONFIG_COMMAND_ALIASES = {
39
- "path": ["p"],
40
- "list": ["ls", "l"],
41
- "set": ["s"],
42
- "unset": ["rm", "u"],
43
- }
44
-
45
-
46
- class SmartSearchArgumentParser(argparse.ArgumentParser):
47
- def __init__(self, *args, **kwargs):
48
- kwargs.setdefault("allow_abbrev", False)
49
- super().__init__(*args, **kwargs)
50
-
51
-
52
- TAVILY_DEFAULT_API_URL = "https://api.tavily.com"
53
- FIRECRAWL_DEFAULT_API_URL = "https://api.firecrawl.dev/v2"
54
- ZHIPU_DEFAULT_API_URL = "https://open.bigmodel.cn/api"
55
- ZHIPU_SEARCH_ENGINE_CHOICES = [
56
- "search_std",
57
- "search_pro",
58
- "search_pro_sogou",
59
- "search_pro_quark",
60
- ]
61
-
62
- _STATIC_SMART_SEARCH_BANNER = r"""
63
- ____ _ ____ _
64
- / ___| _ __ ___ __ _ _ __| |_ / ___| ___ __ _ _ __ ___| |__
65
- \___ \| '_ ` _ \ / _` | '__| __| \___ \ / _ \/ _` | '__/ __| '_ \
66
- ___) | | | | | | (_| | | | |_ ___) | __/ (_| | | | (__| | | |
67
- |____/|_| |_| |_|\__,_|_| \__| |____/ \___|\__,_|_| \___|_| |_|
68
- """.strip("\n")
69
-
70
-
71
- def _get_version() -> str:
72
- root = Path(__file__).resolve().parents[2]
73
- package_json = root / "package.json"
74
- try:
75
- version = json.loads(package_json.read_text(encoding="utf-8")).get("version", "")
76
- if version:
77
- return str(version)
78
- except (OSError, json.JSONDecodeError):
79
- pass
80
-
81
- pyproject = root / "pyproject.toml"
82
- try:
83
- for line in pyproject.read_text(encoding="utf-8").splitlines():
84
- if line.startswith("version = "):
85
- return line.split("=", 1)[1].strip().strip('"')
86
- except OSError:
87
- pass
88
-
89
- try:
90
- return metadata.version("smart-search")
91
- except metadata.PackageNotFoundError:
92
- pass
93
-
94
- return "unknown"
95
-
96
-
97
- def _json(data: Any) -> str:
98
- return json.dumps(data, ensure_ascii=False, indent=2)
99
-
100
-
101
- def _json_stdout_safe(data: Any) -> str:
102
- text = _json(data)
103
- encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
104
- errors = getattr(sys.stdout, "errors", None) or "strict"
105
- try:
106
- text.encode(encoding, errors=errors)
107
- return text
108
- except UnicodeEncodeError:
109
- return "".join(_escape_unencodable_json_char(char, encoding) for char in text)
110
-
111
-
112
- def _escape_unencodable_json_char(char: str, encoding: str) -> str:
113
- try:
114
- char.encode(encoding)
115
- return char
116
- except UnicodeEncodeError:
117
- return json.dumps(char, ensure_ascii=True)[1:-1]
118
-
119
-
120
- def _format_seconds(seconds: float) -> str:
121
- return f"{seconds:g}"
122
-
123
-
124
- def _search_timeout_result(query: str, timeout: float, search_kwargs: dict[str, Any] | None = None) -> dict[str, Any]:
125
- seconds = _format_seconds(timeout)
126
- search_kwargs = search_kwargs or {}
127
- stream = search_kwargs.get("stream")
128
- if stream is None:
129
- stream = service.config.openai_compatible_stream
130
- model = search_kwargs.get("model") or service.config.openai_compatible_model
131
- return {
132
- "ok": False,
133
- "error_type": "network_error",
134
- "error": f"Search timed out after {seconds} seconds",
135
- "query": query,
136
- "content": "",
137
- "sources": [],
138
- "sources_count": 0,
139
- "primary_sources": [],
140
- "primary_sources_count": 0,
141
- "extra_sources": [],
142
- "extra_sources_count": 0,
143
- "source_warning": "",
144
- "routing_decision": {},
145
- "providers_used": [],
146
- "provider_attempts": [],
147
- "fallback_used": False,
148
- "validation_level": "",
149
- "timeout_seconds": timeout,
150
- "provider": search_kwargs.get("providers", "auto"),
151
- "model": model,
152
- "stream": stream,
153
- "diagnose_command": "smart-search diagnose openai-compatible --format markdown",
154
- "recommendation": "Run `smart-search diagnose openai-compatible --format markdown` to check whether OpenAI-compatible stream/no-stream search requests are hanging upstream.",
155
- }
156
-
157
-
158
- def _one_line(value: Any, limit: int = 160) -> str:
159
- text = "" if value is None else str(value)
160
- text = " ".join(text.replace("\r", " ").replace("\n", " ").split())
161
- if limit > 0 and len(text) > limit:
162
- return text[: max(0, limit - 3)].rstrip() + "..."
163
- return text
164
-
165
-
166
- def _md_cell(value: Any) -> str:
167
- return _one_line(value).replace("|", r"\|")
168
-
169
-
170
- def _markdown_table(headers: list[str], rows: list[list[Any]]) -> list[str]:
171
- if not rows:
172
- return []
173
- lines = [
174
- "| " + " | ".join(_md_cell(header) for header in headers) + " |",
175
- "| " + " | ".join("---" for _ in headers) + " |",
176
- ]
177
- for row in rows:
178
- cells = list(row)[: len(headers)]
179
- cells.extend([""] * (len(headers) - len(cells)))
180
- lines.append("| " + " | ".join(_md_cell(cell) for cell in cells) + " |")
181
- return lines
182
-
183
-
184
- def _markdown_code_block(value: Any) -> list[str]:
185
- text = "" if value is None else str(value)
186
- fence = "```"
187
- if fence in text:
188
- text = text.replace(fence, "` ` `")
189
- return ["```text", text, "```"]
190
-
191
-
192
- def _status_label(value: Any) -> str:
193
- if isinstance(value, bool):
194
- return "OK" if value else "FAIL"
195
- status = str(value or "").strip()
196
- normalized = status.lower()
197
- labels = {
198
- "ok": "OK",
199
- "true": "OK",
200
- "configured": "CONFIGURED",
201
- "warning": "WARN",
202
- "timeout": "TIMEOUT",
203
- "error": "ERROR",
204
- "config_error": "CONFIG ERROR",
205
- "not_configured": "NOT CONFIGURED",
206
- "false": "FAIL",
207
- "failed": "FAIL",
208
- "empty": "EMPTY",
209
- "skipped": "SKIPPED",
210
- }
211
- return labels.get(normalized, status.upper() if status else "-")
212
-
213
-
214
- def _yes_no(value: Any) -> str:
215
- return "YES" if bool(value) else "NO"
216
-
217
-
218
- def _latency_text(value: Any) -> str:
219
- if value in (None, ""):
220
- return "-"
221
- if isinstance(value, (int, float)):
222
- return f"{value:.2f} ms"
223
- return str(value)
224
-
225
-
226
- def _configured_text(items: Any) -> str:
227
- if isinstance(items, (list, tuple)):
228
- return ", ".join(str(item) for item in items) if items else "-"
229
- return str(items) if items else "-"
230
-
231
-
232
- def _error_lines(data: dict[str, Any]) -> list[str]:
233
- lines: list[str] = []
234
- if data.get("error_type") or data.get("error"):
235
- lines.extend(["", "## Errors"])
236
- if data.get("error_type"):
237
- lines.append(f"- Type: `{data.get('error_type')}`")
238
- if data.get("error"):
239
- lines.append(f"- Message: {data.get('error')}")
240
- parameter_errors = data.get("config_parameter_errors") or []
241
- for error in parameter_errors:
242
- lines.append(f"- Config: {error}")
243
- return lines
244
-
245
-
246
- def _error_summary(data: dict[str, Any]) -> str:
247
- error_type = data.get("error_type")
248
- error = data.get("error")
249
- if error_type and error:
250
- return f"{error_type}: {error}"
251
- if error:
252
- return str(error)
253
- if error_type:
254
- return str(error_type)
255
- return ""
256
-
257
-
258
- def _result_title(item: Any, index: int) -> str:
259
- if not isinstance(item, dict):
260
- return f"Result {index}"
261
- return (
262
- item.get("title")
263
- or item.get("id")
264
- or item.get("library_id")
265
- or item.get("url")
266
- or item.get("provider")
267
- or f"Result {index}"
268
- )
269
-
270
-
271
- def _result_target(item: Any) -> str:
272
- if not isinstance(item, dict):
273
- return str(item)
274
- return item.get("url") or item.get("id") or item.get("library_id") or ""
275
-
276
-
277
- def _result_summary(item: Any) -> str:
278
- if not isinstance(item, dict):
279
- return ""
280
- highlights = item.get("highlights")
281
- if isinstance(highlights, list):
282
- highlights = " ".join(str(part) for part in highlights[:2])
283
- return (
284
- item.get("description")
285
- or item.get("content")
286
- or item.get("snippet")
287
- or item.get("text")
288
- or highlights
289
- or item.get("source")
290
- or ""
291
- )
292
-
293
-
294
- def _result_rows(results: list[Any]) -> list[list[Any]]:
295
- rows: list[list[Any]] = []
296
- for index, item in enumerate(results, 1):
297
- rows.append([index, _result_title(item, index), _result_target(item), _result_summary(item)])
298
- return rows
299
-
300
-
301
- def _format_result_markdown(command: str, data: dict[str, Any], title: str) -> str:
302
- lines = [
303
- f"# {title}",
304
- "",
305
- f"Status: {_status_label(data.get('ok'))}",
306
- ]
307
- if data.get("query"):
308
- lines.append(f"Query: `{data.get('query')}`")
309
- if data.get("url"):
310
- lines.append(f"URL: {data.get('url')}")
311
- if data.get("base_url"):
312
- lines.append(f"Base URL: {data.get('base_url')}")
313
- if data.get("provider"):
314
- lines.append(f"Provider: {data.get('provider')}")
315
- if data.get("tool"):
316
- lines.append(f"Tool: `{data.get('tool')}`")
317
- if data.get("elapsed_ms") is not None:
318
- lines.append(f"Elapsed: {_latency_text(data.get('elapsed_ms'))}")
319
-
320
- results = data.get("results") or []
321
- lines.append("")
322
- if results:
323
- lines.append("## Results")
324
- lines.extend(_markdown_table(["#", "Title", "URL / ID", "Summary"], _result_rows(results)))
325
- elif data.get("content"):
326
- lines.append("## Content")
327
- lines.extend(_markdown_code_block(data.get("content")))
328
- elif data.get("ok"):
329
- lines.append("No results.")
330
- lines.extend(_error_lines(data))
331
- return "\n".join(lines).strip() + "\n"
332
-
333
-
334
- def _format_doctor_markdown(data: dict[str, Any]) -> str:
335
- lines = [
336
- "# Smart Search Doctor",
337
- "",
338
- f"Overall: {_status_label(data.get('ok'))}",
339
- f"Config file: `{data.get('config_file', '')}`",
340
- f"Config dir: `{data.get('config_dir', '')}`",
341
- f"Config dir source: `{data.get('config_dir_source', '-')}`",
342
- f"Default config file: `{data.get('default_config_file', '')}`",
343
- f"Config status: {data.get('config_status', '-')}",
344
- f"Minimum profile: {_status_label(data.get('minimum_profile_ok'))}",
345
- f"Log dir config value: `{data.get('log_dir_config_value', data.get('SMART_SEARCH_LOG_DIR', ''))}`",
346
- f"Resolved log dir: `{data.get('resolved_log_dir', '')}`",
347
- f"Evidence dir config value: `{data.get('evidence_dir_config_value', data.get('SMART_SEARCH_EVIDENCE_DIR', ''))}`",
348
- f"Resolved evidence dir: `{data.get('resolved_evidence_dir', '')}`",
349
- f"File logging enabled: {_yes_no(data.get('file_logging_enabled'))}",
350
- ]
351
- if data.get("legacy_windows_config_file"):
352
- lines.append(f"Legacy Windows config file: `{data.get('legacy_windows_config_file')}`")
353
- lines.append(f"Legacy Windows config exists: {_status_label(data.get('legacy_windows_config_exists'))}")
354
- if data.get("config_dir_override_value"):
355
- lines.append(f"SMART_SEARCH_CONFIG_DIR: `{data.get('config_dir_override_value')}`")
356
- lines.append(f"Override matches default: {_yes_no(data.get('config_dir_override_matches_default'))}")
357
- if data.get("config_dir_source") == "environment" and data.get("config_dir_override_matches_default"):
358
- lines.append(
359
- "The active config path comes from `SMART_SEARCH_CONFIG_DIR`, but that override matches the current Windows default path."
360
- )
361
- if data.get("config_dir_source") == "legacy_windows_home":
362
- lines.append(
363
- "Active config is using the old Windows `~\\.config\\smart-search` location because the new default file does not exist."
364
- )
365
- missing = data.get("minimum_profile_missing") or []
366
- if missing:
367
- lines.append(f"Missing: `{', '.join(str(item) for item in missing)}`")
368
-
369
- config_sources = data.get("config_sources") or {}
370
- if config_sources:
371
- rows = []
372
- for key in sorted(config_sources):
373
- rows.append([key, config_sources.get(key), data.get(key, "-")])
374
- lines.extend(["", "## Configuration Values"])
375
- lines.extend(_markdown_table(["Key", "Source", "Value"], rows))
376
-
377
- capability_status = data.get("capability_status") or {}
378
- if capability_status:
379
- rows = []
380
- for capability, status in capability_status.items():
381
- if isinstance(status, dict):
382
- rows.append(
383
- [
384
- capability,
385
- _status_label(status.get("ok")),
386
- _configured_text(status.get("configured")),
387
- _configured_text(status.get("fallback_chain")),
388
- ]
389
- )
390
- if rows:
391
- lines.extend(["", "## Capabilities"])
392
- lines.extend(_markdown_table(["Capability", "Status", "Configured", "Fallback chain"], rows))
393
-
394
- main_tests = data.get("main_search_connection_tests") or {}
395
- if main_tests:
396
- rows = []
397
- for provider, test in main_tests.items():
398
- if isinstance(test, dict):
399
- rows.append(
400
- [
401
- provider,
402
- _status_label(test.get("status")),
403
- _latency_text(test.get("response_time_ms")),
404
- test.get("message", ""),
405
- ]
406
- )
407
- lines.extend(["", "## Main Search Providers"])
408
- lines.extend(_markdown_table(["Provider", "Status", "Latency", "Message"], rows))
409
- lines.extend(_provider_detail_lines("Provider Details", main_tests))
410
-
411
- provider_tests = [
412
- ("exa", data.get("exa_connection_test") or {}),
413
- ("tavily", data.get("tavily_connection_test") or {}),
414
- ("jina", data.get("jina_connection_test") or {}),
415
- ("firecrawl", data.get("firecrawl_connection_test") or {}),
416
- ("zhipu", data.get("zhipu_connection_test") or {}),
417
- ("context7", data.get("context7_connection_test") or {}),
418
- ]
419
- rows = []
420
- for provider, test in provider_tests:
421
- if isinstance(test, dict) and test:
422
- rows.append(
423
- [
424
- provider,
425
- _status_label(test.get("status")),
426
- _latency_text(test.get("response_time_ms")),
427
- test.get("message", ""),
428
- ]
429
- )
430
- if rows:
431
- lines.extend(["", "## Provider Checks"])
432
- lines.extend(_markdown_table(["Provider", "Status", "Latency", "Message"], rows))
433
- lines.extend(_provider_detail_lines("Provider Check Details", dict(provider_tests)))
434
-
435
- lines.extend(_error_lines(data))
436
- return "\n".join(lines).strip() + "\n"
437
-
438
-
439
- def _provider_detail_lines(title: str, provider_tests: dict[str, Any]) -> list[str]:
440
- details: list[str] = []
441
- for provider, test in provider_tests.items():
442
- if not isinstance(test, dict) or not test:
443
- continue
444
- message = test.get("message")
445
- available_models = test.get("available_models") or []
446
- nested_checks = [
447
- ("models_endpoint_test", test.get("models_endpoint_test")),
448
- ("chat_completion_test", test.get("chat_completion_test")),
449
- ]
450
- if not message and not available_models and not any(isinstance(item, dict) for _, item in nested_checks):
451
- continue
452
- details.extend(
453
- [
454
- "",
455
- f"### {provider}",
456
- "",
457
- f"- Status: {_status_label(test.get('status'))}",
458
- f"- Latency: {_latency_text(test.get('response_time_ms'))}",
459
- ]
460
- )
461
- if message:
462
- details.extend(["- Message:"])
463
- details.extend(_markdown_code_block(message))
464
- if available_models:
465
- details.append("- Available models: `" + "`, `".join(str(model) for model in available_models) + "`")
466
- for name, nested in nested_checks:
467
- if not isinstance(nested, dict):
468
- continue
469
- details.extend(
470
- [
471
- f"- {name}: {_status_label(nested.get('status'))}, {_latency_text(nested.get('response_time_ms'))}",
472
- ]
473
- )
474
- if nested.get("message"):
475
- details.extend(_markdown_code_block(nested.get("message")))
476
- if not details:
477
- return []
478
- return ["", f"## {title}", *details]
479
-
480
-
481
- def _format_diagnose_markdown(data: dict[str, Any]) -> str:
482
- lines = [
483
- "# Smart Search Diagnose",
484
- "",
485
- f"Provider: `{data.get('provider', '')}`",
486
- f"Status: {_status_label(data.get('ok'))}",
487
- f"Summary: {data.get('summary', '-')}",
488
- f"Recommendation: {data.get('recommendation', '-')}",
489
- f"Config file: `{data.get('config_file', '')}`",
490
- f"Config dir source: `{data.get('config_dir_source', '-')}`",
491
- f"API URL: `{data.get('api_url', '')}`",
492
- f"API key: `{data.get('api_key', '')}`",
493
- f"Model: `{data.get('model', '')}`",
494
- f"Configured stream: {_yes_no(data.get('configured_stream'))}",
495
- f"Timeout: {_format_seconds(float(data.get('timeout_seconds', 0) or 0))} seconds",
496
- ]
497
- checks = data.get("checks") or []
498
- if checks:
499
- rows = []
500
- for check in checks:
501
- rows.append(
502
- [
503
- check.get("name", ""),
504
- _status_label(check.get("status")),
505
- _latency_text(check.get("response_time_ms")),
506
- check.get("http_status", "-"),
507
- check.get("content_type", "-"),
508
- _yes_no(check.get("has_content")),
509
- check.get("message", ""),
510
- ]
511
- )
512
- lines.extend(["", "## Checks"])
513
- lines.extend(_markdown_table(["Check", "Status", "Latency", "HTTP", "Content-Type", "Has content", "Message"], rows))
514
- if data.get("next_command"):
515
- lines.extend(["", "## Next Command"])
516
- lines.extend(_markdown_code_block(data.get("next_command")))
517
- lines.extend(_error_lines(data))
518
- return "\n".join(lines).strip() + "\n"
519
-
520
-
521
- def _format_config_markdown(data: dict[str, Any]) -> str:
522
- lines = ["# Smart Search Config", "", f"Status: {_status_label(data.get('ok'))}"]
523
- if data.get("config_file"):
524
- lines.append(f"Config file: `{data.get('config_file')}`")
525
- if data.get("config_dir"):
526
- lines.append(f"Config dir: `{data.get('config_dir')}`")
527
- if data.get("config_dir_source"):
528
- lines.append(f"Config dir source: `{data.get('config_dir_source')}`")
529
- if data.get("default_config_file"):
530
- lines.append(f"Default config file: `{data.get('default_config_file')}`")
531
- if data.get("legacy_windows_config_file"):
532
- lines.append(f"Legacy Windows config file: `{data.get('legacy_windows_config_file')}`")
533
- lines.append(f"Legacy Windows config exists: {_status_label(data.get('legacy_windows_config_exists'))}")
534
- if data.get("config_dir_override_value"):
535
- lines.append(f"SMART_SEARCH_CONFIG_DIR: `{data.get('config_dir_override_value')}`")
536
- lines.append(f"Override matches default: {_yes_no(data.get('config_dir_override_matches_default'))}")
537
- if data.get("evidence_dir_config_value"):
538
- lines.append(f"Evidence dir config value: `{data.get('evidence_dir_config_value')}`")
539
- if data.get("resolved_evidence_dir"):
540
- lines.append(f"Resolved evidence dir: `{data.get('resolved_evidence_dir')}`")
541
- if "exists" in data:
542
- lines.append(f"Exists: {_status_label(bool(data.get('exists')))}")
543
- if data.get("key"):
544
- lines.append(f"Key: `{data.get('key')}`")
545
- if data.get("value"):
546
- lines.append(f"Value: `{data.get('value')}`")
547
- values = data.get("values") or {}
548
- if values:
549
- lines.extend(["", "## Values"])
550
- lines.extend(_markdown_table(["Key", "Value"], [[key, value] for key, value in values.items()]))
551
- lines.extend(_error_lines(data))
552
- return "\n".join(lines).strip() + "\n"
553
-
554
-
555
- def _format_setup_markdown(data: dict[str, Any]) -> str:
556
- lines = ["# Smart Search Setup", "", f"Status: {_status_label(data.get('ok'))}"]
557
- if data.get("config_file"):
558
- lines.append(f"Config file: `{data.get('config_file')}`")
559
- saved = data.get("saved") or data.get("values") or {}
560
- if saved:
561
- lines.extend(["", "## Saved Values"])
562
- lines.extend(_markdown_table(["Key", "Value"], [[key, value] for key, value in saved.items()]))
563
- lines.extend(_error_lines(data))
564
- return "\n".join(lines).strip() + "\n"
565
-
566
-
567
- def _format_markdown(command: str, data: dict[str, Any]) -> str:
568
- if command == "search":
569
- if not data.get("ok", False) and (data.get("error") or data.get("error_type")):
570
- lines = ["# Smart Search Search", ""]
571
- if data.get("query"):
572
- lines.append(f"Query: `{data.get('query')}`")
573
- if data.get("provider") is not None:
574
- lines.append(f"Provider: `{data.get('provider')}`")
575
- if data.get("model") is not None:
576
- lines.append(f"Model: `{data.get('model')}`")
577
- if data.get("stream") is not None:
578
- lines.append(f"Stream: {_yes_no(data.get('stream'))}")
579
- if data.get("recommendation"):
580
- lines.extend(["", "## Recommendation", str(data.get("recommendation"))])
581
- if data.get("diagnose_command"):
582
- lines.extend(["", "## Next Command"])
583
- lines.extend(_markdown_code_block(data.get("diagnose_command")))
584
- lines.extend(_error_lines(data))
585
- return "\n".join(lines).strip() + "\n"
586
- lines = [data.get("content", "")]
587
- primary_sources = data.get("primary_sources") or []
588
- extra_sources = data.get("extra_sources") or []
589
- if primary_sources or extra_sources:
590
- warning = data.get("source_warning") or ""
591
- if warning:
592
- lines.append(f"\n> {warning}")
593
- if primary_sources:
594
- lines.append("\n## Primary Sources")
595
- for item in primary_sources:
596
- url = item.get("url", "")
597
- title = item.get("title") or item.get("provider") or url
598
- lines.append(f"- [{title}]({url})")
599
- if extra_sources:
600
- lines.append("\n## Extra Sources")
601
- for item in extra_sources:
602
- url = item.get("url", "")
603
- title = item.get("title") or item.get("provider") or url
604
- lines.append(f"- [{title}]({url})")
605
- return "\n".join(lines).strip() + "\n"
606
-
607
- sources = data.get("sources") or []
608
- if sources:
609
- lines.append("\n## Sources")
610
- for item in sources:
611
- url = item.get("url", "")
612
- title = item.get("title") or item.get("provider") or url
613
- lines.append(f"- [{title}]({url})")
614
- return "\n".join(lines).strip() + "\n"
615
- if command == "fetch":
616
- return (data.get("content") or "") + ("\n" if data.get("content") else "")
617
- if command == "context7-docs":
618
- content = data.get("content") or ""
619
- lines = [
620
- "# Context7 Docs",
621
- "",
622
- f"Status: {_status_label(data.get('ok'))}",
623
- f"Library: `{data.get('library_id', '')}`",
624
- f"Query: `{data.get('query', '')}`",
625
- ]
626
- if content:
627
- lines.extend(["", content])
628
- lines.extend(_error_lines(data))
629
- return "\n".join(lines).strip() + "\n"
630
- if command == "research":
631
- lines = [
632
- "# Research Report",
633
- "",
634
- f"**Question:** {data.get('question', '')}",
635
- f"**Status:** {_status_label(data.get('ok'))}",
636
- f"**Route policy:** {data.get('route_policy_version', '')}",
637
- f"**Evidence dir:** `{data.get('evidence_dir', '')}`",
638
- f"**Fallback used:** {bool(data.get('fallback_used'))}",
639
- f"**Degraded:** {bool(data.get('degraded'))}",
640
- "",
641
- "## Answer",
642
- data.get("final_answer") or data.get("content") or "",
643
- ]
644
- citations = data.get("citations") or []
645
- if citations:
646
- lines.extend(["", "## Citations"])
647
- for item in citations:
648
- url = item.get("url", "")
649
- title = item.get("title") or url
650
- provider = item.get("provider") or ""
651
- lines.append(f"- [{title}]({url})" + (f" ({provider})" if provider else ""))
652
- gaps = (data.get("gap_check") or {}).get("gaps") or []
653
- if gaps:
654
- lines.extend(["", "## Gaps"])
655
- for gap in gaps:
656
- reason = gap.get("reason", "")
657
- url = gap.get("url", "")
658
- lines.append(f"- {reason}" + (f" - {url}" if url else ""))
659
- return "\n".join(lines).strip() + "\n"
660
- if command == "doctor":
661
- return _format_doctor_markdown(data)
662
- if command == "diagnose":
663
- return _format_diagnose_markdown(data)
664
- if command == "config":
665
- return _format_config_markdown(data)
666
- if command == "setup":
667
- return _format_setup_markdown(data)
668
- titles = {
669
- "map": "Site Map",
670
- "exa-search": "Exa Search",
671
- "exa-similar": "Exa Similar Pages",
672
- "zhipu-search": "Zhipu Search",
673
- "context7-library": "Context7 Library Search",
674
- }
675
- if command in titles:
676
- return _format_result_markdown(command, data, titles[command])
677
- return _format_config_markdown(data)
678
-
679
-
680
- def _plain_result_lines(data: dict[str, Any]) -> list[str]:
681
- results = data.get("results") or []
682
- if not results:
683
- return ["No results."] if data.get("ok") else []
684
- lines = []
685
- for index, item in enumerate(results, 1):
686
- title = _result_title(item, index)
687
- target = _result_target(item)
688
- summary = _one_line(_result_summary(item), 120)
689
- line = f"{index}. {title}"
690
- if target:
691
- line += f" - {target}"
692
- if summary:
693
- line += f" - {summary}"
694
- lines.append(line)
695
- return lines
696
-
697
-
698
- def _format_content(command: str, data: dict[str, Any]) -> str:
699
- if command in {"search", "fetch", "context7-docs", "research"}:
700
- content = data.get("content")
701
- if content:
702
- return str(content) + "\n"
703
- if data.get("ok"):
704
- return ""
705
- error = _error_summary(data)
706
- if error:
707
- return f"{_status_label(data.get('ok'))}: {error}\n"
708
- return ""
709
- if data.get("mode") == "deep_research":
710
- lines = [
711
- f"Deep Research plan for: {data.get('question', '')}",
712
- "This command only plans; execute the listed CLI steps to perform live research.",
713
- ]
714
- return "\n".join(lines) + "\n"
715
- if command == "doctor":
716
- configured = data.get("capability_status", {})
717
- capability_bits = []
718
- for name, status in configured.items():
719
- if isinstance(status, dict):
720
- capability_bits.append(f"{name}={_status_label(status.get('ok'))}")
721
- lines = [
722
- f"Doctor {_status_label(data.get('ok'))}: {data.get('config_status', '')}".strip(),
723
- f"Minimum profile: {_status_label(data.get('minimum_profile_ok'))}",
724
- ]
725
- if capability_bits:
726
- lines.append("Capabilities: " + ", ".join(capability_bits))
727
- if data.get("error"):
728
- lines.append(f"Error: {_error_summary(data)}")
729
- return "\n".join(lines).strip() + "\n"
730
- if command == "diagnose":
731
- lines = [
732
- f"Diagnose {data.get('provider', '')} {_status_label(data.get('ok'))}: {data.get('summary', '')}".strip(),
733
- ]
734
- if data.get("recommendation"):
735
- lines.append(f"Recommendation: {data.get('recommendation')}")
736
- if data.get("error"):
737
- lines.append(f"Error: {_error_summary(data)}")
738
- return "\n".join(lines).strip() + "\n"
739
- if command == "config":
740
- parts = [f"Config {_status_label(data.get('ok'))}"]
741
- if data.get("config_file"):
742
- parts.append(f"file={data.get('config_file')}")
743
- if data.get("config_dir_source"):
744
- parts.append(f"source={data.get('config_dir_source')}")
745
- if data.get("config_dir_override_value"):
746
- parts.append(f"override={data.get('config_dir_override_value')}")
747
- if data.get("key"):
748
- parts.append(f"key={data.get('key')}")
749
- if data.get("value"):
750
- parts.append(f"value={data.get('value')}")
751
- values = data.get("values") or {}
752
- if values:
753
- parts.append(f"values={len(values)}")
754
- if data.get("error"):
755
- parts.append(f"error={_error_summary(data)}")
756
- return "; ".join(parts) + "\n"
757
- if command == "setup":
758
- if data.get("error"):
759
- return f"Setup {_status_label(data.get('ok'))}: {_error_summary(data)}\n"
760
- saved = data.get("saved") or data.get("values") or {}
761
- return f"Setup {_status_label(data.get('ok'))}: {len(saved)} values saved\n"
762
- if command in {
763
- "map",
764
- "exa-search",
765
- "exa-similar",
766
- "zhipu-search",
767
- "context7-library",
768
- }:
769
- lines = _plain_result_lines(data)
770
- if data.get("error"):
771
- lines.append(f"Error: {_error_summary(data)}")
772
- return "\n".join(lines).strip() + "\n"
773
- if data.get("error"):
774
- return f"{_status_label(data.get('ok'))}: {_error_summary(data)}\n"
775
- return f"{command}: {_status_label(data.get('ok'))}\n"
776
-
777
-
778
- def _render(command: str, data: dict[str, Any], fmt: str) -> str:
779
- if fmt == "content":
780
- return _format_content(command, data)
781
- if fmt == "markdown":
782
- return _format_markdown(command, data)
783
- return _json(data)
784
-
785
-
786
- def _stdout_safe(text: str) -> str:
787
- return _stream_safe(sys.stdout, text)
788
-
789
-
790
- def _stream_safe(stream: Any, text: str) -> str:
791
- encoding = getattr(stream, "encoding", None) or "utf-8"
792
- errors = getattr(stream, "errors", None) or "strict"
793
- try:
794
- text.encode(encoding, errors=errors)
795
- return text
796
- except UnicodeEncodeError:
797
- return text.encode(encoding, errors="backslashreplace").decode(encoding)
798
-
799
-
800
- def _write_stdout(text: str) -> None:
801
- sys.stdout.write(_stdout_safe(text))
802
-
803
-
804
- def _write_stderr(text: str) -> None:
805
- sys.stderr.write(_stream_safe(sys.stderr, text))
806
-
807
-
808
- def _smart_search_banner_text() -> str:
809
- try:
810
- import pyfiglet
811
-
812
- banner = pyfiglet.figlet_format("Smart Search", font="slant")
813
- return banner.rstrip()
814
- except Exception:
815
- return _STATIC_SMART_SEARCH_BANNER
816
-
817
-
818
- def _write_setup_banner(lang: str) -> None:
819
- banner = _smart_search_banner_text()
820
- tagline = _t(lang, "CLI-first multi-source search for AI agents", "CLI-first multi-source search for AI agents")
821
- _write_stderr(f"\n{banner}\n\n Smart Search\n {tagline}\n")
822
-
823
-
824
- def _write_panel(text: str, lang: str) -> None:
825
- if not _is_interactive_setup_stream():
826
- _write_stderr(text)
827
- return
828
- try:
829
- from rich.console import Console
830
- from rich.panel import Panel
831
- except Exception:
832
- _write_stderr(text)
833
- return
834
- console = Console(file=sys.stderr, force_terminal=True)
835
- title = _t(lang, "Smart Search 配置", "Smart Search Setup")
836
- console.print(Panel(text.strip(), title=title, expand=False, safe_box=True))
837
-
838
-
839
- def _exit_code(data: dict[str, Any]) -> int:
840
- if data.get("ok", False):
841
- return EXIT_OK
842
- error_type = data.get("error_type")
843
- if error_type == "config_error":
844
- return EXIT_CONFIG_ERROR
845
- if error_type == "parameter_error":
846
- return EXIT_PARAMETER_ERROR
847
- if error_type == "network_error":
848
- return EXIT_NETWORK_ERROR
849
- if error_type == "evidence_error":
850
- return EXIT_NETWORK_ERROR
851
- return EXIT_RUNTIME_ERROR
852
-
853
-
854
- def _print_result(command: str, data: dict[str, Any], fmt: str, output: str = "") -> int:
855
- rendered = _render(command, data, fmt)
856
- if output:
857
- service.write_output(output, rendered)
858
- if fmt == "json":
859
- rendered = _json_stdout_safe(data)
860
- _write_stdout(rendered)
861
- if rendered and not rendered.endswith("\n"):
862
- _write_stdout("\n")
863
- return _exit_code(data)
864
-
865
-
866
- def _add_format_args(parser: argparse.ArgumentParser) -> None:
867
- parser.add_argument("--format", choices=["json", "markdown", "content"], default="json")
868
- parser.add_argument("--output", default="", help="Write rendered output to a file.")
869
-
870
-
871
- def _is_secret_key(key: str) -> bool:
872
- upper_key = key.upper()
873
- return "KEY" in upper_key or "TOKEN" in upper_key or "SECRET" in upper_key
874
-
875
-
876
- def _is_private_display_key(key: str) -> bool:
877
- return key.upper().endswith("_URL") or key.upper().endswith("_BASE_URL")
878
-
879
-
880
- def _t(lang: str, zh: str, en: str) -> str:
881
- return zh if lang == "zh" else en
882
-
883
-
884
- def _display_provider(provider: str, lang: str) -> str:
885
- names = {
886
- "openai-compatible": "OpenAI-compatible",
887
- "zhipu": _t(lang, "智谱", "Zhipu"),
888
- "exa": "Exa",
889
- "context7": "Context7",
890
- "jina": "Jina Reader",
891
- "tavily": "Tavily",
892
- "firecrawl": "Firecrawl",
893
- }
894
- return names.get(provider, provider)
895
-
896
-
897
- def _with_scheme(url: str) -> str:
898
- value = url.strip()
899
- if not value:
900
- return ""
901
- if "://" not in value:
902
- return f"https://{value}"
903
- return value
904
-
905
-
906
- def _normalize_custom_base_url(url: str) -> str:
907
- value = _with_scheme(url).strip()
908
- return value.rstrip("/") if value else ""
909
-
910
-
911
- def _normalize_tavily_api_url(url: str, *, hikari: bool = True) -> str:
912
- value = _normalize_custom_base_url(url)
913
- if not value:
914
- return ""
915
- parsed = urlsplit(value)
916
- host = parsed.netloc.lower()
917
- path = parsed.path.rstrip("/")
918
- if host == "api.tavily.com":
919
- return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
920
- if hikari and path in {"", "/mcp"}:
921
- return urlunsplit((parsed.scheme, parsed.netloc, "/api/tavily", "", ""))
922
- return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
923
-
924
-
925
- def _normalize_tavily_flag_api_url(url: str, api_key: str = "") -> str:
926
- value = _normalize_custom_base_url(url)
927
- if not value:
928
- return ""
929
- parsed = urlsplit(value)
930
- path = parsed.path.rstrip("/")
931
- if path == "/mcp" or _is_tavily_hikari_key(api_key):
932
- return _normalize_tavily_api_url(value)
933
- return _normalize_tavily_api_url(value, hikari=False)
934
-
935
-
936
- def _normalize_firecrawl_api_url(url: str) -> str:
937
- return _normalize_custom_base_url(url)
938
-
939
-
940
- def _normalize_zhipu_api_url(url: str) -> str:
941
- return _normalize_custom_base_url(url)
942
-
943
-
944
- def _normalize_jina_reader_api_url(url: str) -> str:
945
- return _normalize_custom_base_url(url)
946
-
947
-
948
- def _is_tavily_hikari_key(api_key: str) -> bool:
949
- return api_key.strip().lower().startswith("th-")
950
-
951
-
952
- def _is_interactive_setup_stream() -> bool:
953
- return bool(getattr(sys.stdin, "isatty", lambda: False)() and getattr(sys.stderr, "isatty", lambda: False)())
954
-
955
-
956
- def _setup_status_from_values(values: dict[str, str]) -> dict[str, Any]:
957
- def has(key: str) -> bool:
958
- return bool(values.get(key))
959
-
960
- main_configured: set[str] = set()
961
- if has("OPENAI_COMPATIBLE_API_URL") and has("OPENAI_COMPATIBLE_API_KEY"):
962
- main_configured.add("openai-compatible")
963
-
964
- status = {
965
- "main_search": {
966
- "configured": [provider for provider in ("openai-compatible",) if provider in main_configured],
967
- "fallback_chain": ["openai-compatible"],
968
- },
969
- "web_search": {
970
- "configured": [
971
- provider
972
- for provider, configured in [
973
- ("tavily", has("TAVILY_API_KEY")),
974
- ("firecrawl", has("FIRECRAWL_API_KEY")),
975
- ]
976
- if configured
977
- ],
978
- "fallback_chain": ["tavily", "firecrawl"],
979
- "deprecated_configured": ["zhipu"] if has("ZHIPU_API_KEY") else [],
980
- },
981
- "docs_search": {
982
- "configured": [
983
- provider
984
- for provider, configured in [
985
- ("context7", has("CONTEXT7_API_KEY")),
986
- ("exa", has("EXA_API_KEY")),
987
- ]
988
- if configured
989
- ],
990
- "fallback_chain": ["context7", "exa"],
991
- },
992
- "web_fetch": {
993
- "configured": [
994
- provider
995
- for provider, configured in [
996
- ("tavily", has("TAVILY_API_KEY")),
997
- ("jina", has("JINA_API_KEY")),
998
- ("firecrawl", has("FIRECRAWL_API_KEY")),
999
- ]
1000
- if configured
1001
- ],
1002
- "fallback_chain": ["tavily", "jina", "firecrawl"],
1003
- },
1004
- }
1005
- for item in status.values():
1006
- item["ok"] = bool(item["configured"])
1007
- return status
1008
-
1009
-
1010
- def _merge_setup_values(current: dict[str, str], values: dict[str, str]) -> dict[str, str]:
1011
- merged = dict(current)
1012
- merged.update({key: value for key, value in values.items() if value})
1013
- return merged
1014
-
1015
-
1016
- def _write_setup_status(status: dict[str, Any], lang: str, *, final: bool = False) -> None:
1017
- title = _t(lang, "最低配置检查", "Minimum profile check") if final else _t(lang, "当前状态", "Current status")
1018
- _write_stderr(f"\n{title}:\n")
1019
- required = {"main_search", "docs_search", "web_fetch"}
1020
- labels = {
1021
- "main_search": _t(lang, "main_search 主搜索", "main_search primary search"),
1022
- "docs_search": _t(lang, "docs_search 文档搜索", "docs_search documentation search"),
1023
- "web_fetch": _t(lang, "web_fetch 网页抓取", "web_fetch page fetch"),
1024
- "web_search": _t(lang, "web_search 网页补强", "web_search web reinforcement"),
1025
- }
1026
- for capability in ("main_search", "docs_search", "web_fetch", "web_search"):
1027
- item = status.get(capability, {})
1028
- configured = item.get("configured") or []
1029
- configured_text = ", ".join(_display_provider(provider, lang) for provider in configured)
1030
- if item.get("ok"):
1031
- marker = "OK"
1032
- value = configured_text
1033
- elif capability in required:
1034
- marker = "MISSING"
1035
- value = _t(lang, "需要至少配置一个 provider", "at least one provider is required")
1036
- else:
1037
- marker = "OPTIONAL"
1038
- value = _t(lang, "未配置", "not configured")
1039
- _write_stderr(f" [{marker}] {labels[capability]}: {value}\n")
1040
-
1041
-
1042
- def _prompt_choice(prompt: str, default: str = "") -> str:
1043
- _write_stderr(prompt)
1044
- value = input("").strip()
1045
- return value or default
1046
-
1047
-
1048
- def _prompt_yes_no(prompt: str, default: bool = False) -> bool:
1049
- default_text = "Y/n" if default else "y/N"
1050
- answer = _prompt_choice(f"{prompt} [{default_text}]: ", "y" if default else "n").strip().lower()
1051
- return answer in {"y", "yes", "是", "好", "1", "true"}
1052
-
1053
-
1054
- def _prompt_value(key: str, label: str, current: str = "", optional: bool = False, lang: str = "en") -> str:
1055
- suffix = _t(lang, " 可选", " optional") if optional else _t(lang, " 必填", " required")
1056
- current_display = (
1057
- _t(lang, "已配置,回车保留", "configured, press Enter to keep")
1058
- if current and (_is_secret_key(key) or _is_private_display_key(key))
1059
- else current
1060
- )
1061
- if current:
1062
- prompt = f"{label}{suffix} [{current_display}]: "
1063
- else:
1064
- prompt = f"{label}{suffix}: "
1065
- if _is_secret_key(key):
1066
- value = getpass.getpass(_stream_safe(sys.stderr, prompt)).strip()
1067
- else:
1068
- _write_stderr(prompt)
1069
- value = input("").strip()
1070
- return value or current
1071
-
1072
-
1073
- def _ascii_choice_values(choices: list[dict[str, Any]]) -> list[dict[str, Any]]:
1074
- return [
1075
- {**choice, "name": _stream_safe(sys.stderr, str(choice.get("name", "")))}
1076
- for choice in choices
1077
- ]
1078
-
1079
-
1080
- def _select_with_tui(message: str, choices: list[dict[str, Any]], default: Any = None) -> Any:
1081
- if not _is_interactive_setup_stream():
1082
- return None
1083
- try:
1084
- from InquirerPy import inquirer
1085
- except Exception:
1086
- return None
1087
- try:
1088
- with contextlib.redirect_stdout(sys.stderr):
1089
- return inquirer.select(
1090
- message=_stream_safe(sys.stderr, message),
1091
- choices=_ascii_choice_values(choices),
1092
- default=default,
1093
- qmark="",
1094
- pointer=">",
1095
- marker=">",
1096
- ).execute()
1097
- except (KeyboardInterrupt, EOFError):
1098
- raise
1099
- except Exception:
1100
- return None
1101
-
1102
-
1103
- def _checkbox_with_tui(message: str, choices: list[dict[str, Any]]) -> list[str] | None:
1104
- if not _is_interactive_setup_stream():
1105
- return None
1106
- try:
1107
- from InquirerPy import inquirer
1108
- except Exception:
1109
- return None
1110
- try:
1111
- with contextlib.redirect_stdout(sys.stderr):
1112
- result = inquirer.checkbox(
1113
- message=_stream_safe(sys.stderr, message),
1114
- choices=_ascii_choice_values(choices),
1115
- instruction="(Up/Down move, Space select, Enter confirm)",
1116
- qmark="",
1117
- pointer=">",
1118
- enabled_symbol="[x]",
1119
- disabled_symbol="[ ]",
1120
- ).execute()
1121
- return [str(item) for item in result]
1122
- except (KeyboardInterrupt, EOFError):
1123
- raise
1124
- except Exception:
1125
- return None
1126
-
1127
-
1128
- def _provider_choices(providers: list[str], selected: list[str], lang: str) -> list[dict[str, Any]]:
1129
- selected_set = set(selected)
1130
- return [
1131
- {"name": _display_provider(provider, lang), "value": provider, "enabled": provider in selected_set}
1132
- for provider in providers
1133
- ]
1134
-
1135
-
1136
- def _prompt_provider_multi_select(
1137
- message: str,
1138
- providers: list[str],
1139
- default_selected: list[str],
1140
- lang: str,
1141
- ) -> list[str]:
1142
- tui_value = _checkbox_with_tui(message, _provider_choices(providers, default_selected, lang))
1143
- if tui_value is not None:
1144
- return [provider for provider in providers if provider in set(tui_value)]
1145
-
1146
- default_text = ",".join(default_selected) if default_selected else "skip"
1147
- _write_stderr(f"{message} [{'/'.join(providers)}/skip] ({default_text}): ")
1148
- raw = input("").strip().lower()
1149
- if not raw:
1150
- return [provider for provider in providers if provider in set(default_selected)]
1151
- aliases = {
1152
- "跳过": "skip",
1153
- "无": "skip",
1154
- "n": "skip",
1155
- "no": "skip",
1156
- "否": "skip",
1157
- "都配": "all",
1158
- "全部": "all",
1159
- "两个": "all",
1160
- "both": "all",
1161
- "all": "all",
1162
- "openai": "openai-compatible",
1163
- "ctx7": "context7",
1164
- "context": "context7",
1165
- }
1166
- tokens = [aliases.get(part.strip(), part.strip()) for part in raw.replace("+", ",").replace(";", ",").split(",")]
1167
- if len(tokens) == 1 and " " in tokens[0]:
1168
- tokens = [aliases.get(part.strip(), part.strip()) for part in tokens[0].split()]
1169
- if "skip" in tokens or "none" in tokens:
1170
- return []
1171
- if "all" in tokens:
1172
- return providers
1173
- selected = [provider for provider in providers if provider in tokens]
1174
- return selected if selected else [provider for provider in providers if provider in set(default_selected)]
1175
-
1176
-
1177
- def _prompt_select(message: str, choices: list[dict[str, Any]], default: str) -> str:
1178
- tui_value = _select_with_tui(message, choices, default)
1179
- if tui_value is not None:
1180
- return str(tui_value)
1181
- choice_values = [str(choice["value"]) for choice in choices]
1182
- _write_stderr(f"{message} [{'/'.join(choice_values)}] ({default}): ")
1183
- value = input("").strip().lower()
1184
- return value if value in set(choice_values) else default
1185
-
1186
-
1187
- def _select_setup_language(lang: str = "") -> str:
1188
- if lang in {"zh", "en"}:
1189
- return lang
1190
- choices = [
1191
- {"name": "中文", "value": "zh"},
1192
- {"name": "English", "value": "en"},
1193
- ]
1194
- answer = _prompt_select("Language / 语言", choices, "zh").strip().lower()
1195
- if answer in {"en", "english"}:
1196
- return "en"
1197
- return "zh"
1198
-
1199
-
1200
- def _setup_choice(prompt: str, choices: set[str], default: str) -> str:
1201
- value = _prompt_choice(prompt, default).strip().lower()
1202
- aliases = {
1203
- "保持": "keep",
1204
- "跳过": "skip",
1205
- "都配": "both",
1206
- "两个": "both",
1207
- "是": "yes",
1208
- "否": "no",
1209
- }
1210
- value = aliases.get(value, value)
1211
- return value if value in choices else default
1212
-
1213
-
1214
- def _prompt_main_search(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1215
- status = _setup_status_from_values(_merge_setup_values(current, values))
1216
- configured = status["main_search"]["configured"]
1217
- default_selected = configured or ["openai-compatible"]
1218
- _write_stderr(
1219
- _t(
1220
- lang,
1221
- "\n[1/3 必选] main_search 主搜索\n用途: 负责综合搜索回答和最终合成。\n推荐: 配置 OpenAI-compatible 中转服务。\n",
1222
- "\n[1/3 Required] main_search primary search\nPurpose: broad search answers and final synthesis.\nRecommended: configure an OpenAI-compatible relay.\n",
1223
- )
1224
- )
1225
- selected = _prompt_provider_multi_select(
1226
- _t(
1227
- lang,
1228
- "选择 main_search provider",
1229
- "Choose main_search providers",
1230
- ),
1231
- ["openai-compatible"],
1232
- default_selected,
1233
- lang,
1234
- )
1235
- if "openai-compatible" in selected:
1236
- values["OPENAI_COMPATIBLE_API_URL"] = _prompt_value(
1237
- "OPENAI_COMPATIBLE_API_URL",
1238
- _t(
1239
- lang,
1240
- "OpenAI-compatible API 地址(示例: https://api.openai.com/v1)",
1241
- "OpenAI-compatible API URL (example: https://api.openai.com/v1)",
1242
- ),
1243
- current.get("OPENAI_COMPATIBLE_API_URL", ""),
1244
- lang=lang,
1245
- )
1246
- values["OPENAI_COMPATIBLE_API_KEY"] = _prompt_value(
1247
- "OPENAI_COMPATIBLE_API_KEY",
1248
- "OpenAI-compatible API key",
1249
- current.get("OPENAI_COMPATIBLE_API_KEY", ""),
1250
- lang=lang,
1251
- )
1252
- values["OPENAI_COMPATIBLE_MODEL"] = _prompt_value(
1253
- "OPENAI_COMPATIBLE_MODEL",
1254
- _t(lang, "OpenAI-compatible 模型", "OpenAI-compatible model"),
1255
- current.get("OPENAI_COMPATIBLE_MODEL", ""),
1256
- optional=True,
1257
- lang=lang,
1258
- )
1259
- stream_default = current.get("OPENAI_COMPATIBLE_STREAM", "")
1260
- if _prompt_yes_no(
1261
- _t(
1262
- lang,
1263
- f"是否启用 OpenAI-compatible stream=true?用于部分中转长请求兼容 [{stream_default or 'false'}]: ",
1264
- f"Enable OpenAI-compatible stream=true for relay long-request compatibility [{stream_default or 'false'}]: ",
1265
- ),
1266
- default=(str(stream_default).lower() in {"true", "1", "yes"}),
1267
- ):
1268
- values["OPENAI_COMPATIBLE_STREAM"] = "true"
1269
- elif stream_default:
1270
- values["OPENAI_COMPATIBLE_STREAM"] = "false"
1271
-
1272
-
1273
- def _prompt_docs_search(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1274
- status = _setup_status_from_values(_merge_setup_values(current, values))
1275
- default_selected = status["docs_search"]["configured"] or ["context7"]
1276
- _write_stderr(
1277
- _t(
1278
- lang,
1279
- "\n[2/3 必选] docs_search 文档搜索\n用途: 查官方文档、SDK、API、框架和库说明。\n推荐: 文档/API/库优先 Context7;官方域名、论文和低噪声发现再配 Exa。\n",
1280
- "\n[2/3 Required] docs_search documentation search\nPurpose: official docs, SDKs, APIs, frameworks, and library references.\nRecommended: Context7 for docs/API/library intent; Exa for official domains, papers, and low-noise discovery.\n",
1281
- )
1282
- )
1283
- selected = _prompt_provider_multi_select(
1284
- _t(
1285
- lang,
1286
- "选择 docs_search provider",
1287
- "Choose docs_search providers",
1288
- ),
1289
- ["exa", "context7"],
1290
- default_selected,
1291
- lang,
1292
- )
1293
- if "exa" in selected:
1294
- values["EXA_API_KEY"] = _prompt_value("EXA_API_KEY", "Exa API key", current.get("EXA_API_KEY", ""), lang=lang)
1295
- if "context7" in selected:
1296
- values["CONTEXT7_API_KEY"] = _prompt_value(
1297
- "CONTEXT7_API_KEY",
1298
- "Context7 API key",
1299
- current.get("CONTEXT7_API_KEY", ""),
1300
- lang=lang,
1301
- )
1302
-
1303
-
1304
- def _prompt_tavily_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1305
- current_url = current.get("TAVILY_API_URL", "")
1306
- tavily_key = values.get("TAVILY_API_KEY") or current.get("TAVILY_API_KEY", "")
1307
- if current_url:
1308
- default_choice = "current"
1309
- elif _is_tavily_hikari_key(tavily_key):
1310
- default_choice = "hikari"
1311
- else:
1312
- default_choice = "official"
1313
- choices = []
1314
- if current_url:
1315
- choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1316
- choices.extend([
1317
- {"name": _t(lang, "官方 Tavily (https://api.tavily.com)", "Official Tavily (https://api.tavily.com)"), "value": "official"},
1318
- {"name": _t(lang, "Tavily Hikari / 号池", "Tavily Hikari / pooled endpoint"), "value": "hikari"},
1319
- {"name": _t(lang, "自定义 Tavily REST base", "Custom Tavily REST base"), "value": "custom"},
1320
- ])
1321
- choice = _prompt_select(_t(lang, "选择 Tavily endpoint", "Choose Tavily endpoint"), choices, default_choice)
1322
- if choice == "current":
1323
- return
1324
- if choice == "official":
1325
- values["TAVILY_API_URL"] = TAVILY_DEFAULT_API_URL
1326
- return
1327
- if choice == "hikari":
1328
- _write_stderr(
1329
- _t(
1330
- lang,
1331
- "号池地址填服务商给你的域名或 URL,例如 https://pool.example.com 或 https://pool.example.com/mcp;setup 会保存为 https://pool.example.com/api/tavily。\n",
1332
- "For pooled endpoints, paste the provider domain or URL, for example https://pool.example.com or https://pool.example.com/mcp; setup saves it as https://pool.example.com/api/tavily.\n",
1333
- )
1334
- )
1335
- label = _t(
1336
- lang,
1337
- "Tavily REST 地址",
1338
- "Tavily REST URL",
1339
- )
1340
- raw = _prompt_value("TAVILY_API_URL", label, current_url, optional=False, lang=lang)
1341
- normalized = _normalize_tavily_api_url(raw) if choice == "hikari" else _normalize_tavily_api_url(raw, hikari=False)
1342
- if normalized:
1343
- values["TAVILY_API_URL"] = normalized
1344
- if normalized != raw.rstrip("/"):
1345
- _write_stderr(_t(lang, f"已规范化 Tavily REST base: {normalized}\n", f"Normalized Tavily REST base: {normalized}\n"))
1346
-
1347
-
1348
- def _prompt_firecrawl_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1349
- current_url = current.get("FIRECRAWL_API_URL", "")
1350
- choices = []
1351
- if current_url:
1352
- choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1353
- choices.extend([
1354
- {
1355
- "name": _t(
1356
- lang,
1357
- "官方 Firecrawl (https://api.firecrawl.dev/v2)",
1358
- "Official Firecrawl (https://api.firecrawl.dev/v2)",
1359
- ),
1360
- "value": "official",
1361
- },
1362
- {"name": _t(lang, "自定义 Firecrawl REST base", "Custom Firecrawl REST base"), "value": "custom"},
1363
- ])
1364
- default_choice = "current" if current_url else "official"
1365
- choice = _prompt_select(_t(lang, "选择 Firecrawl endpoint", "Choose Firecrawl endpoint"), choices, default_choice)
1366
- if choice == "current":
1367
- return
1368
- if choice == "official":
1369
- values["FIRECRAWL_API_URL"] = FIRECRAWL_DEFAULT_API_URL
1370
- return
1371
- raw = _prompt_value(
1372
- "FIRECRAWL_API_URL",
1373
- _t(lang, "Firecrawl 自定义 REST base", "Firecrawl custom REST base"),
1374
- current_url,
1375
- optional=False,
1376
- lang=lang,
1377
- )
1378
- normalized = _normalize_firecrawl_api_url(raw)
1379
- if normalized:
1380
- values["FIRECRAWL_API_URL"] = normalized
1381
-
1382
-
1383
- def _prompt_zhipu_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1384
- current_url = current.get("ZHIPU_API_URL", "")
1385
- choices = []
1386
- if current_url:
1387
- choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1388
- choices.extend([
1389
- {
1390
- "name": _t(
1391
- lang,
1392
- "官方智谱 Web Search API (https://open.bigmodel.cn/api)",
1393
- "Official Zhipu Web Search API (https://open.bigmodel.cn/api)",
1394
- ),
1395
- "value": "official",
1396
- },
1397
- {
1398
- "name": _t(
1399
- lang,
1400
- "自定义智谱 API 地址",
1401
- "Custom Zhipu API URL",
1402
- ),
1403
- "value": "custom",
1404
- },
1405
- ])
1406
- default_choice = "current" if current_url else "official"
1407
- choice = _prompt_select(_t(lang, "选择智谱 API 地址", "Choose Zhipu API URL"), choices, default_choice)
1408
- if choice == "current":
1409
- return
1410
- if choice == "official":
1411
- values["ZHIPU_API_URL"] = ZHIPU_DEFAULT_API_URL
1412
- return
1413
- raw = _prompt_value(
1414
- "ZHIPU_API_URL",
1415
- _t(lang, "智谱 API 地址", "Zhipu API URL"),
1416
- current_url,
1417
- optional=False,
1418
- lang=lang,
1419
- )
1420
- normalized = _normalize_zhipu_api_url(raw)
1421
- if normalized:
1422
- values["ZHIPU_API_URL"] = normalized
1423
-
1424
-
1425
- def _prompt_zhipu_search_engine(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1426
- current_engine = current.get("ZHIPU_SEARCH_ENGINE", "")
1427
- choices = []
1428
- if current_engine:
1429
- choices.append(
1430
- {
1431
- "name": _t(
1432
- lang,
1433
- f"保留当前搜索服务({current_engine})",
1434
- f"Keep current search service ({current_engine})",
1435
- ),
1436
- "value": "current",
1437
- }
1438
- )
1439
- choices.extend(
1440
- {"name": engine, "value": engine}
1441
- for engine in ZHIPU_SEARCH_ENGINE_CHOICES
1442
- )
1443
- choices.append({"name": _t(lang, "自定义搜索服务", "Custom search service"), "value": "custom"})
1444
- default_choice = "current" if current_engine else "search_std"
1445
- choice = _prompt_select(_t(lang, "选择智谱搜索服务", "Choose Zhipu search service"), choices, default_choice)
1446
- if choice == "current":
1447
- return
1448
- if choice == "custom":
1449
- raw = _prompt_value(
1450
- "ZHIPU_SEARCH_ENGINE",
1451
- _t(lang, "智谱搜索服务", "Zhipu search service"),
1452
- current_engine,
1453
- optional=False,
1454
- lang=lang,
1455
- )
1456
- if raw:
1457
- values["ZHIPU_SEARCH_ENGINE"] = raw.strip()
1458
- return
1459
- values["ZHIPU_SEARCH_ENGINE"] = choice
1460
-
1461
-
1462
- def _prompt_web_fetch(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1463
- status = _setup_status_from_values(_merge_setup_values(current, values))
1464
- default_selected = status["web_fetch"]["configured"] or ["tavily"]
1465
- _write_stderr(
1466
- _t(
1467
- lang,
1468
- "\n[3/3 必选] web_fetch 网页抓取\n用途: 已知 URL 抓正文;高风险事实核验必须用。\n推荐: Tavily 优先;Jina 需要 key 才算标准配置;Firecrawl 可作为抓取兜底。\n",
1469
- "\n[3/3 Required] web_fetch page fetch\nPurpose: extract known URLs; required for high-risk fact checks.\nRecommended: Tavily first; Jina requires a key to satisfy standard config; Firecrawl as fetch fallback.\n",
1470
- )
1471
- )
1472
- selected = _prompt_provider_multi_select(
1473
- _t(
1474
- lang,
1475
- "选择 web_fetch provider",
1476
- "Choose web_fetch providers",
1477
- ),
1478
- ["tavily", "jina", "firecrawl"],
1479
- default_selected,
1480
- lang,
1481
- )
1482
- if "tavily" in selected:
1483
- values["TAVILY_API_KEY"] = _prompt_value("TAVILY_API_KEY", "Tavily API key", current.get("TAVILY_API_KEY", ""), lang=lang)
1484
- _prompt_tavily_api_url(values, current, lang)
1485
- if "jina" in selected:
1486
- values["JINA_API_KEY"] = _prompt_value("JINA_API_KEY", "Jina API key", current.get("JINA_API_KEY", ""), lang=lang)
1487
- raw_url = _prompt_value(
1488
- "JINA_READER_API_URL",
1489
- "Jina Reader API URL",
1490
- current.get("JINA_READER_API_URL", "https://r.jina.ai"),
1491
- optional=True,
1492
- lang=lang,
1493
- )
1494
- values["JINA_READER_API_URL"] = _normalize_jina_reader_api_url(raw_url)
1495
- if "firecrawl" in selected:
1496
- values["FIRECRAWL_API_KEY"] = _prompt_value(
1497
- "FIRECRAWL_API_KEY",
1498
- "Firecrawl API key",
1499
- current.get("FIRECRAWL_API_KEY", ""),
1500
- lang=lang,
1501
- )
1502
- _prompt_firecrawl_api_url(values, current, lang)
1503
-
1504
-
1505
- def _prompt_optional_enhancements(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1506
- _write_stderr(
1507
- _t(
1508
- lang,
1509
- "\n[可选增强] web_search 网页补强\n用途: 通过 Tavily / Firecrawl 做中英双语来源检索。\n说明: Zhipu 已弃用为默认路径;保留旧命令仅用于手动兼容。\n",
1510
- "\n[Optional] web_search web reinforcement\nPurpose: bilingual Chinese/English source discovery through Tavily / Firecrawl.\nNote: Zhipu is deprecated from default routing; the legacy command remains for manual compatibility only.\n",
1511
- )
1512
- )
1513
- if _prompt_yes_no(_t(lang, "是否调整验证/兜底默认值?", "Adjust validation/fallback defaults?"), default=False):
1514
- values["SMART_SEARCH_VALIDATION_LEVEL"] = _prompt_value(
1515
- "SMART_SEARCH_VALIDATION_LEVEL",
1516
- _t(lang, "验证强度 (fast/balanced/strict)", "Validation level (fast/balanced/strict)"),
1517
- current.get("SMART_SEARCH_VALIDATION_LEVEL", ""),
1518
- optional=True,
1519
- lang=lang,
1520
- )
1521
- values["SMART_SEARCH_FALLBACK_MODE"] = _prompt_value(
1522
- "SMART_SEARCH_FALLBACK_MODE",
1523
- _t(lang, "兜底模式 (auto/off)", "Fallback mode (auto/off)"),
1524
- current.get("SMART_SEARCH_FALLBACK_MODE", ""),
1525
- optional=True,
1526
- lang=lang,
1527
- )
1528
- values["SMART_SEARCH_MINIMUM_PROFILE"] = _prompt_value(
1529
- "SMART_SEARCH_MINIMUM_PROFILE",
1530
- _t(lang, "最低配置门槛 (standard/off)", "Minimum profile (standard/off)"),
1531
- current.get("SMART_SEARCH_MINIMUM_PROFILE", ""),
1532
- optional=True,
1533
- lang=lang,
1534
- )
1535
-
1536
-
1537
- def _write_setup_keep_note(lang: str) -> None:
1538
- _write_stderr(
1539
- _t(
1540
- lang,
1541
- "\n提示: setup 不会删除旧配置;删除请运行 `smart-search config unset KEY`。\n",
1542
- "\nNote: setup does not delete saved values; use `smart-search config unset KEY` to remove one.\n",
1543
- )
1544
- )
1545
-
1546
-
1547
- def _write_setup_examples(lang: str) -> None:
1548
- _write_stderr(
1549
- _t(
1550
- lang,
1551
- "\n不知道怎么填: 先配齐 main_search + docs_search + web_fetch。\n"
1552
- " main_search: OpenAI-compatible(示例: https://api.openai.com/v1)\n"
1553
- " docs_search: 文档/API 优先 Context7;官方域名、论文和低噪声发现再配 Exa。\n"
1554
- " web_fetch: Tavily 官方地址是 https://api.tavily.com;号池填 https://<host>/api/tavily。\n"
1555
- " key 都填你自己控制台里的;Firecrawl 可之后再补;Zhipu 只在显式 legacy 兼容时手动配置。\n",
1556
- "\nIf unsure: first configure main_search + docs_search + web_fetch.\n"
1557
- " main_search: OpenAI-compatible (example: https://api.openai.com/v1)\n"
1558
- " docs_search: Context7 for docs/API first; add Exa for official domains, papers, and low-noise discovery.\n"
1559
- " web_fetch: official Tavily endpoint is https://api.tavily.com; pooled endpoints use https://<host>/api/tavily.\n"
1560
- " Use keys from your own provider consoles. Firecrawl can be added later; configure Zhipu only for explicit legacy compatibility.\n",
1561
- )
1562
- )
1563
-
1564
-
1565
- def _run_guided_setup_prompts(
1566
- values: dict[str, str],
1567
- current: dict[str, str],
1568
- lang: str,
1569
- *,
1570
- show_banner: bool = True,
1571
- ) -> None:
1572
- config_file = service.config_path()["config_file"]
1573
- if show_banner:
1574
- _write_setup_banner(lang)
1575
- _write_panel(
1576
- _t(
1577
- lang,
1578
- f"\nSmart Search 配置向导\n配置文件: {config_file}\n\n目标: standard 最低可用配置\n操作: 方向键移动,空格勾选,回车确认;API key 输入不显示。\n最低要求: main_search + docs_search + web_fetch 各至少一个 provider。\n",
1579
- f"\nSmart Search setup wizard\nConfig file: {config_file}\n\nGoal: standard minimum profile\nKeys: move with arrow keys, select with Space, confirm with Enter; API key input is hidden.\nMinimum: at least one provider in each of main_search + docs_search + web_fetch.\n",
1580
- ),
1581
- lang,
1582
- )
1583
- _write_setup_keep_note(lang)
1584
- _write_setup_examples(lang)
1585
- _write_setup_status(_setup_status_from_values(_merge_setup_values(current, values)), lang)
1586
- _prompt_main_search(values, current, lang)
1587
- _prompt_docs_search(values, current, lang)
1588
- _prompt_web_fetch(values, current, lang)
1589
- _prompt_optional_enhancements(values, current, lang)
1590
-
1591
-
1592
- def _run_advanced_setup_prompts(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1593
- _write_stderr(
1594
- _t(
1595
- lang,
1596
- "\n高级模式: 逐项配置底层键。一般用户建议直接使用默认分组向导。\n",
1597
- "\nAdvanced mode: configure low-level keys one by one. Most users should use the grouped wizard.\n",
1598
- )
1599
- )
1600
- prompts = [
1601
- ("OPENAI_COMPATIBLE_API_URL", "OpenAI-compatible API URL", True),
1602
- ("OPENAI_COMPATIBLE_API_KEY", "OpenAI-compatible API key", True),
1603
- ("OPENAI_COMPATIBLE_MODEL", "OpenAI-compatible model", True),
1604
- ("OPENAI_COMPATIBLE_STREAM", "OpenAI-compatible stream mode (true/false)", True),
1605
- ("SMART_SEARCH_VALIDATION_LEVEL", "Validation level (fast/balanced/strict)", True),
1606
- ("SMART_SEARCH_FALLBACK_MODE", "Fallback mode (auto/off)", True),
1607
- ("SMART_SEARCH_MINIMUM_PROFILE", "Minimum profile (standard/off)", True),
1608
- ("EXA_API_KEY", "Exa API key", True),
1609
- ("CONTEXT7_API_KEY", "Context7 API key", True),
1610
- ("ZHIPU_API_KEY", "Zhipu API key", True),
1611
- ("ZHIPU_API_URL", "Zhipu Web Search API URL", True),
1612
- ("ZHIPU_SEARCH_ENGINE", "Zhipu search service (search_std/search_pro/search_pro_sogou/search_pro_quark/custom)", True),
1613
- ("JINA_API_KEY", "Jina API key", True),
1614
- ("JINA_READER_API_URL", "Jina Reader API URL", True),
1615
- ("JINA_RESPOND_WITH", "Jina respond-with mode (optional, e.g. readerlm-v2)", True),
1616
- ("JINA_TIMEOUT_SECONDS", "Jina timeout seconds", True),
1617
- ("TAVILY_API_URL", "Tavily API URL", True),
1618
- ("TAVILY_API_KEY", "Tavily API key", True),
1619
- ("FIRECRAWL_API_URL", "Firecrawl API URL", True),
1620
- ("FIRECRAWL_API_KEY", "Firecrawl API key", True),
1621
- ]
1622
- for key, label, optional in prompts:
1623
- if values[key]:
1624
- continue
1625
- value = _prompt_value(key, label, current.get(key, ""), optional=optional, lang=lang)
1626
- if key == "TAVILY_API_URL":
1627
- value = _normalize_tavily_api_url(value)
1628
- elif key == "FIRECRAWL_API_URL":
1629
- value = _normalize_firecrawl_api_url(value)
1630
- elif key == "ZHIPU_API_URL":
1631
- value = _normalize_zhipu_api_url(value)
1632
- elif key == "JINA_READER_API_URL":
1633
- value = _normalize_jina_reader_api_url(value)
1634
- values[key] = value
1635
-
1636
-
1637
- async def _run_async(args: argparse.Namespace) -> int:
1638
- if args.command == "search":
1639
- search_kwargs = {
1640
- "platform": args.platform,
1641
- "model": args.model,
1642
- "extra_sources": args.extra_sources,
1643
- "validation": args.validation,
1644
- "fallback": args.fallback,
1645
- "providers": args.providers,
1646
- }
1647
- if args.stream is not None:
1648
- search_kwargs["stream"] = args.stream
1649
- try:
1650
- data = await asyncio.wait_for(
1651
- service.search(args.query, **search_kwargs),
1652
- timeout=args.timeout,
1653
- )
1654
- except asyncio.TimeoutError:
1655
- data = _search_timeout_result(args.query, args.timeout, search_kwargs)
1656
- return _print_result("search", data, args.format, args.output)
1657
- return _print_result("search", data, args.format, args.output)
1658
- if args.command == "fetch":
1659
- data = await service.fetch(args.url)
1660
- return _print_result("fetch", data, args.format, args.output)
1661
- if args.command == "map":
1662
- data = await service.map_site(
1663
- args.url,
1664
- instructions=args.instructions,
1665
- max_depth=args.max_depth,
1666
- max_breadth=args.max_breadth,
1667
- limit=args.limit,
1668
- timeout=args.timeout,
1669
- )
1670
- return _print_result("map", data, args.format, args.output)
1671
- if args.command == "exa-search":
1672
- data = await service.exa_search(
1673
- args.query,
1674
- num_results=args.num_results,
1675
- search_type=args.search_type,
1676
- include_text=args.include_text,
1677
- include_highlights=args.include_highlights,
1678
- start_published_date=args.start_published_date,
1679
- include_domains=args.include_domains,
1680
- exclude_domains=args.exclude_domains,
1681
- category=args.category,
1682
- )
1683
- return _print_result("exa-search", data, args.format, args.output)
1684
- if args.command == "exa-similar":
1685
- data = await service.exa_find_similar(args.url, num_results=args.num_results)
1686
- return _print_result("exa-similar", data, args.format, args.output)
1687
- if args.command == "zhipu-search":
1688
- data = await service.zhipu_search(
1689
- args.query,
1690
- count=args.count,
1691
- search_engine=args.search_engine,
1692
- search_recency_filter=args.search_recency_filter,
1693
- search_domain_filter=args.search_domain_filter,
1694
- content_size=args.content_size,
1695
- )
1696
- return _print_result("zhipu-search", data, args.format, args.output)
1697
- if args.command == "context7-library":
1698
- data = await service.context7_library(args.name, args.query)
1699
- return _print_result("context7-library", data, args.format, args.output)
1700
- if args.command == "context7-docs":
1701
- data = await service.context7_docs(args.library_id, args.query)
1702
- return _print_result("context7-docs", data, args.format, args.output)
1703
- if args.command == "research":
1704
- data = await service.research(
1705
- args.query,
1706
- budget=args.budget,
1707
- evidence_dir=args.evidence_dir,
1708
- fallback=args.fallback,
1709
- )
1710
- return _print_result("research", data, args.format, args.output)
1711
- if args.command == "doctor":
1712
- data = await service.doctor()
1713
- return _print_result("doctor", data, args.format, args.output)
1714
- if args.command == "diagnose":
1715
- if args.diagnose_target == "openai-compatible":
1716
- data = await service.diagnose_openai_compatible(timeout_seconds=args.timeout)
1717
- return _print_result("diagnose", data, args.format, args.output)
1718
- return _print_result(
1719
- "diagnose",
1720
- {"ok": False, "error_type": "parameter_error", "error": f"Unknown diagnose target: {args.diagnose_target}"},
1721
- args.format,
1722
- args.output,
1723
- )
1724
- return EXIT_PARAMETER_ERROR
1725
-
1726
-
1727
- def _run_config(args: argparse.Namespace) -> int:
1728
- if args.config_command == "path":
1729
- data = service.config_path()
1730
- elif args.config_command == "list":
1731
- data = service.config_list(show_secrets=False)
1732
- elif args.config_command == "set":
1733
- data = service.config_set(args.key, args.value)
1734
- elif args.config_command == "unset":
1735
- data = service.config_unset(args.key)
1736
- else:
1737
- data = {"ok": False, "error_type": "parameter_error", "error": "Unknown config command"}
1738
- return _print_result("config", data, args.format, args.output)
1739
-
1740
-
1741
- def _run_setup(args: argparse.Namespace) -> int:
1742
- values = {
1743
- "OPENAI_COMPATIBLE_API_URL": args.openai_compatible_api_url,
1744
- "OPENAI_COMPATIBLE_API_KEY": args.openai_compatible_api_key,
1745
- "OPENAI_COMPATIBLE_MODEL": args.openai_compatible_model,
1746
- "OPENAI_COMPATIBLE_STREAM": args.openai_compatible_stream,
1747
- "SMART_SEARCH_VALIDATION_LEVEL": args.validation_level,
1748
- "SMART_SEARCH_FALLBACK_MODE": args.fallback_mode,
1749
- "SMART_SEARCH_MINIMUM_PROFILE": args.minimum_profile,
1750
- "EXA_API_KEY": args.exa_key,
1751
- "CONTEXT7_API_KEY": args.context7_key,
1752
- "ZHIPU_API_KEY": args.zhipu_key,
1753
- "ZHIPU_API_URL": _normalize_zhipu_api_url(args.zhipu_api_url),
1754
- "ZHIPU_SEARCH_ENGINE": args.zhipu_search_engine,
1755
- "JINA_API_KEY": args.jina_key,
1756
- "JINA_READER_API_URL": _normalize_jina_reader_api_url(args.jina_reader_api_url),
1757
- "JINA_RESPOND_WITH": args.jina_respond_with,
1758
- "JINA_TIMEOUT_SECONDS": args.jina_timeout,
1759
- "TAVILY_API_URL": _normalize_tavily_flag_api_url(args.tavily_api_url, args.tavily_key),
1760
- "TAVILY_API_KEY": args.tavily_key,
1761
- "FIRECRAWL_API_URL": _normalize_firecrawl_api_url(args.firecrawl_api_url),
1762
- "FIRECRAWL_API_KEY": args.firecrawl_key,
1763
- }
1764
-
1765
- lang = args.lang if args.lang in {"zh", "en"} else "zh"
1766
-
1767
- if not args.non_interactive:
1768
- current = service.config_list(show_secrets=True)["values"]
1769
- _write_setup_banner(args.lang if args.lang in {"zh", "en"} else "zh")
1770
- lang = _select_setup_language(args.lang)
1771
- if args.advanced:
1772
- _run_advanced_setup_prompts(values, current, lang)
1773
- else:
1774
- _run_guided_setup_prompts(values, current, lang, show_banner=False)
1775
-
1776
- saved: dict[str, str] = {}
1777
- for key, value in values.items():
1778
- if value:
1779
- result = service.config_set(key, value)
1780
- saved[key] = result.get("value", "")
1781
-
1782
- data = {"ok": True, "config_file": service.config_path()["config_file"], "saved": saved}
1783
- if not args.non_interactive:
1784
- current_after = service.config_list(show_secrets=True)["values"]
1785
- final_values = _merge_setup_values(current_after, values)
1786
- final_status = _setup_status_from_values(final_values)
1787
- _write_stderr(_t(lang, "\n保存完成。\n", "\nSaved.\n"))
1788
- _write_setup_status(final_status, lang, final=True)
1789
- missing = [capability for capability in ("main_search", "docs_search", "web_fetch") if not final_status[capability]["ok"]]
1790
- if missing:
1791
- _write_stderr(
1792
- _t(
1793
- lang,
1794
- "\n当前配置尚未满足 standard 最低配置。\nsearch / doctor 会 fail closed,不会假装可用。\n",
1795
- "\nThe current config does not satisfy the standard minimum profile.\nsearch / doctor will fail closed instead of pretending to work.\n",
1796
- )
1797
- )
1798
- else:
1799
- _write_stderr(
1800
- _t(
1801
- lang,
1802
- "\n下一步建议:\n smart-search doctor --format json\n",
1803
- "\nNext steps:\n smart-search doctor --format json\n",
1804
- )
1805
- )
1806
- data["minimum_profile_ok"] = not missing
1807
- data["minimum_profile_missing"] = missing
1808
- data["capability_status"] = final_status
1809
- return _print_result("setup", data, args.format, args.output)
1810
-
1811
-
1812
- def build_parser() -> argparse.ArgumentParser:
1813
- parser = SmartSearchArgumentParser(
1814
- prog="smart-search",
1815
- description="Smart Search CLI for AI-agent web research.",
1816
- )
1817
- parser.add_argument("-v", "--v", "--version", action="version", version=f"%(prog)s {_get_version()}")
1818
- sub = parser.add_subparsers(dest="command", required=True, parser_class=SmartSearchArgumentParser)
1819
-
1820
- search_parser = sub.add_parser(
1821
- "search", aliases=COMMAND_ALIASES["search"], help="Run OpenAI-compatible web search."
1822
- )
1823
- search_parser.set_defaults(command="search")
1824
- search_parser.add_argument("query")
1825
- search_parser.add_argument("--platform", default="")
1826
- search_parser.add_argument("--model", default="")
1827
- search_parser.add_argument("--extra-sources", type=int, default=0)
1828
- search_parser.add_argument("--validation", choices=["fast", "balanced", "strict"], default="")
1829
- search_parser.add_argument("--fallback", choices=["auto", "off"], default="")
1830
- search_parser.add_argument("--providers", default="auto")
1831
- stream_group = search_parser.add_mutually_exclusive_group()
1832
- stream_group.add_argument("--stream", dest="stream", action="store_true", default=None, help="Use stream=true for OpenAI-compatible main search.")
1833
- stream_group.add_argument("--no-stream", dest="stream", action="store_false", help="Force stream=false for OpenAI-compatible main search.")
1834
- search_parser.add_argument("--timeout", type=float, default=120, metavar="SECONDS", help="Hard timeout in seconds.")
1835
- _add_format_args(search_parser)
1836
-
1837
- fetch_parser = sub.add_parser("fetch", aliases=COMMAND_ALIASES["fetch"], help="Fetch a URL as markdown.")
1838
- fetch_parser.set_defaults(command="fetch")
1839
- fetch_parser.add_argument("url")
1840
- _add_format_args(fetch_parser)
1841
-
1842
- map_parser = sub.add_parser("map", aliases=COMMAND_ALIASES["map"], help="Map a website structure.")
1843
- map_parser.set_defaults(command="map")
1844
- map_parser.add_argument("url")
1845
- map_parser.add_argument("--instructions", default="")
1846
- map_parser.add_argument("--max-depth", type=int, default=1)
1847
- map_parser.add_argument("--max-breadth", type=int, default=20)
1848
- map_parser.add_argument("--limit", type=int, default=50)
1849
- map_parser.add_argument("--timeout", type=int, default=150)
1850
- _add_format_args(map_parser)
1851
-
1852
- exa_parser = sub.add_parser(
1853
- "exa-search", aliases=COMMAND_ALIASES["exa-search"], help="Run Exa source-first search."
1854
- )
1855
- exa_parser.set_defaults(command="exa-search")
1856
- exa_parser.add_argument("query")
1857
- exa_parser.add_argument("--num-results", type=int, default=5)
1858
- exa_parser.add_argument("--search-type", choices=["neural", "keyword", "auto"], default="neural")
1859
- exa_parser.add_argument("--include-text", action="store_true")
1860
- exa_parser.add_argument("--include-highlights", action="store_true")
1861
- exa_parser.add_argument("--start-published-date", default="")
1862
- exa_parser.add_argument("--include-domains", nargs="+", default="")
1863
- exa_parser.add_argument("--exclude-domains", nargs="+", default="")
1864
- exa_parser.add_argument("--category", default="")
1865
- _add_format_args(exa_parser)
1866
-
1867
- similar_parser = sub.add_parser(
1868
- "exa-similar", aliases=COMMAND_ALIASES["exa-similar"], help="Find pages similar to a URL with Exa."
1869
- )
1870
- similar_parser.set_defaults(command="exa-similar")
1871
- similar_parser.add_argument("url")
1872
- similar_parser.add_argument("--num-results", type=int, default=5)
1873
- _add_format_args(similar_parser)
1874
-
1875
- zhipu_parser = sub.add_parser(
1876
- "zhipu-search", aliases=COMMAND_ALIASES["zhipu-search"], help="Run Zhipu Web Search source-first search."
1877
- )
1878
- zhipu_parser.set_defaults(command="zhipu-search")
1879
- zhipu_parser.add_argument("query")
1880
- zhipu_parser.add_argument("--count", type=int, default=10)
1881
- zhipu_parser.add_argument("--search-engine", default="")
1882
- zhipu_parser.add_argument("--search-recency-filter", default="noLimit")
1883
- zhipu_parser.add_argument("--search-domain-filter", default="")
1884
- zhipu_parser.add_argument("--content-size", choices=["medium", "high"], default="medium")
1885
- _add_format_args(zhipu_parser)
1886
-
1887
- context7_library_parser = sub.add_parser(
1888
- "context7-library",
1889
- aliases=COMMAND_ALIASES["context7-library"],
1890
- help="Resolve Context7 library candidates.",
1891
- )
1892
- context7_library_parser.set_defaults(command="context7-library")
1893
- context7_library_parser.add_argument("name")
1894
- context7_library_parser.add_argument("query", nargs="?", default="")
1895
- _add_format_args(context7_library_parser)
1896
-
1897
- context7_docs_parser = sub.add_parser(
1898
- "context7-docs",
1899
- aliases=COMMAND_ALIASES["context7-docs"],
1900
- help="Fetch Context7 docs for a library.",
1901
- )
1902
- context7_docs_parser.set_defaults(command="context7-docs")
1903
- context7_docs_parser.add_argument("library_id")
1904
- context7_docs_parser.add_argument("query")
1905
- _add_format_args(context7_docs_parser)
1906
-
1907
- research_parser = sub.add_parser(
1908
- "research",
1909
- aliases=COMMAND_ALIASES["research"],
1910
- help="Run live Deep Research with provider-advantage routing and evidence-only synthesis.",
1911
- )
1912
- research_parser.set_defaults(command="research")
1913
- research_parser.add_argument("query")
1914
- research_parser.add_argument("--budget", choices=["quick", "standard", "deep"], default="deep")
1915
- research_parser.add_argument("--evidence-dir", default="")
1916
- research_parser.add_argument("--fallback", choices=["auto", "off"], default="auto")
1917
- _add_format_args(research_parser)
1918
-
1919
- doctor_parser = sub.add_parser(
1920
- "doctor", aliases=COMMAND_ALIASES["doctor"], help="Show masked configuration and connection checks."
1921
- )
1922
- doctor_parser.set_defaults(command="doctor")
1923
- _add_format_args(doctor_parser)
1924
-
1925
- diagnose_parser = sub.add_parser(
1926
- "diagnose",
1927
- aliases=COMMAND_ALIASES["diagnose"],
1928
- help="Run focused troubleshooting checks for a provider.",
1929
- )
1930
- diagnose_parser.set_defaults(command="diagnose")
1931
- diagnose_parser.add_argument("diagnose_target", choices=["openai-compatible"])
1932
- diagnose_parser.add_argument("--timeout", type=float, default=30, metavar="SECONDS", help="Per search-shape probe timeout in seconds.")
1933
- diagnose_parser.add_argument("--format", choices=["json", "markdown"], default="markdown")
1934
- diagnose_parser.add_argument("--output", default="", help="Write rendered output to a file.")
1935
-
1936
- setup_parser = sub.add_parser(
1937
- "setup", aliases=COMMAND_ALIASES["setup"], help="Interactively save local provider configuration."
1938
- )
1939
- setup_parser.set_defaults(command="setup")
1940
- setup_parser.add_argument("--non-interactive", action="store_true", help="Only save values passed as flags.")
1941
- setup_parser.add_argument("--lang", choices=["zh", "en"], default="", help="Interactive setup language.")
1942
- setup_parser.add_argument("--advanced", action="store_true", help="Show every low-level config key in interactive setup.")
1943
- setup_parser.add_argument("--openai-compatible-api-url", default="", help="Save OPENAI_COMPATIBLE_API_URL.")
1944
- setup_parser.add_argument("--openai-compatible-api-key", default="", help="Save OPENAI_COMPATIBLE_API_KEY.")
1945
- setup_parser.add_argument("--openai-compatible-model", default="", help="Save OPENAI_COMPATIBLE_MODEL.")
1946
- setup_parser.add_argument("--openai-compatible-stream", default="", help="Save OPENAI_COMPATIBLE_STREAM.")
1947
- setup_parser.add_argument("--validation-level", default="", help="Save SMART_SEARCH_VALIDATION_LEVEL.")
1948
- setup_parser.add_argument("--fallback-mode", default="", help="Save SMART_SEARCH_FALLBACK_MODE.")
1949
- setup_parser.add_argument("--minimum-profile", default="", help="Save SMART_SEARCH_MINIMUM_PROFILE.")
1950
- setup_parser.add_argument("--exa-key", default="", help="Save EXA_API_KEY.")
1951
- setup_parser.add_argument("--context7-key", default="", help="Save CONTEXT7_API_KEY.")
1952
- setup_parser.add_argument("--zhipu-key", default="", help="Save ZHIPU_API_KEY.")
1953
- setup_parser.add_argument("--zhipu-api-url", default="", help="Save ZHIPU_API_URL.")
1954
- setup_parser.add_argument("--zhipu-search-engine", default="", help="Save ZHIPU_SEARCH_ENGINE.")
1955
- setup_parser.add_argument("--jina-key", default="", help="Save JINA_API_KEY.")
1956
- setup_parser.add_argument("--jina-reader-api-url", default="", help="Save JINA_READER_API_URL.")
1957
- setup_parser.add_argument("--jina-respond-with", default="", help="Save JINA_RESPOND_WITH, e.g. readerlm-v2.")
1958
- setup_parser.add_argument("--jina-timeout", default="", help="Save JINA_TIMEOUT_SECONDS.")
1959
- setup_parser.add_argument("--tavily-api-url", default="", help="Save TAVILY_API_URL.")
1960
- setup_parser.add_argument("--tavily-key", default="", help="Save TAVILY_API_KEY.")
1961
- setup_parser.add_argument("--firecrawl-api-url", default="", help="Save FIRECRAWL_API_URL.")
1962
- setup_parser.add_argument("--firecrawl-key", default="", help="Save FIRECRAWL_API_KEY.")
1963
- _add_format_args(setup_parser)
1964
-
1965
- config_parser = sub.add_parser(
1966
- "config", aliases=COMMAND_ALIASES["config"], help="Read or edit the local Smart Search config file."
1967
- )
1968
- config_parser.set_defaults(command="config")
1969
- config_sub = config_parser.add_subparsers(dest="config_command", required=True, parser_class=SmartSearchArgumentParser)
1970
- config_path = config_sub.add_parser("path", aliases=CONFIG_COMMAND_ALIASES["path"])
1971
- config_path.set_defaults(config_command="path")
1972
- _add_format_args(config_path)
1973
- config_list = config_sub.add_parser("list", aliases=CONFIG_COMMAND_ALIASES["list"])
1974
- config_list.set_defaults(config_command="list")
1975
- _add_format_args(config_list)
1976
- config_set = config_sub.add_parser("set", aliases=CONFIG_COMMAND_ALIASES["set"])
1977
- config_set.set_defaults(config_command="set")
1978
- config_set.add_argument("key")
1979
- config_set.add_argument("value")
1980
- _add_format_args(config_set)
1981
- config_unset = config_sub.add_parser("unset", aliases=CONFIG_COMMAND_ALIASES["unset"])
1982
- config_unset.set_defaults(config_command="unset")
1983
- config_unset.add_argument("key")
1984
- _add_format_args(config_unset)
1985
-
1986
- return parser
1987
-
1988
-
1989
- def main(argv: list[str] | None = None) -> int:
1990
- parser = build_parser()
1991
- args = parser.parse_args(argv)
1992
- try:
1993
- if args.command == "setup":
1994
- return _run_setup(args)
1995
- if args.command == "config":
1996
- return _run_config(args)
1997
- return asyncio.run(_run_async(args))
1998
- except KeyboardInterrupt:
1999
- return EXIT_RUNTIME_ERROR
2000
-
2001
-
2002
- if __name__ == "__main__":
2003
- raise SystemExit(main())