@shirlytaylor73/smart-search 0.2.0-beta.1

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/README.zh-CN.md +180 -0
  4. package/npm/bin/smart-search.js +68 -0
  5. package/npm/scripts/postinstall.js +87 -0
  6. package/npm/scripts/resolve-prerelease-version.js +108 -0
  7. package/npm/scripts/set-package-version.js +35 -0
  8. package/npm/scripts/sync-python-version.js +22 -0
  9. package/npm/scripts/test-wrapper-repair.js +137 -0
  10. package/npm/scripts/test.js +76 -0
  11. package/package.json +42 -0
  12. package/pyproject.toml +36 -0
  13. package/skills/smart-search-cli/SKILL.md +41 -0
  14. package/skills/smart-search-cli/agents/openai.yaml +3 -0
  15. package/skills/smart-search-cli/references/cli-contract.md +7 -0
  16. package/skills/smart-search-cli/references/cli-core.md +5 -0
  17. package/skills/smart-search-cli/references/command-patterns.md +12 -0
  18. package/skills/smart-search-cli/references/provider-routing.md +3 -0
  19. package/skills/smart-search-cli/references/regression-release.md +28 -0
  20. package/skills/smart-search-cli/references/setup-config.md +3 -0
  21. package/src/smart_search/__init__.py +1 -0
  22. package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
  23. package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
  24. package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
  25. package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
  26. package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
  27. package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
  28. package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
  29. package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
  30. package/src/smart_search/cli.py +3118 -0
  31. package/src/smart_search/config.py +809 -0
  32. package/src/smart_search/embedding_presets.py +40 -0
  33. package/src/smart_search/intent_router.py +757 -0
  34. package/src/smart_search/logger.py +43 -0
  35. package/src/smart_search/providers/__init__.py +20 -0
  36. package/src/smart_search/providers/base.py +41 -0
  37. package/src/smart_search/providers/context7.py +141 -0
  38. package/src/smart_search/providers/exa.py +206 -0
  39. package/src/smart_search/providers/jina.py +136 -0
  40. package/src/smart_search/providers/openai_compatible.py +541 -0
  41. package/src/smart_search/providers/xai_responses.py +117 -0
  42. package/src/smart_search/providers/zhipu.py +143 -0
  43. package/src/smart_search/providers/zhipu_mcp.py +230 -0
  44. package/src/smart_search/service.py +3445 -0
  45. package/src/smart_search/skill_installer.py +342 -0
  46. package/src/smart_search/sources.py +429 -0
  47. package/src/smart_search/utils.py +220 -0
@@ -0,0 +1,3118 @@
1
+ import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import getpass
5
+ import inspect
6
+ import json
7
+ from importlib import metadata
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from . import service
15
+ from .embedding_presets import (
16
+ QWEN3_EMBEDDING_8B_PRESET,
17
+ embedding_preset_for_model,
18
+ )
19
+ from .skill_installer import (
20
+ DEFAULT_SKILL_TARGET_IDS,
21
+ SKILL_TARGETS,
22
+ SkillInstallError,
23
+ install_skill_targets,
24
+ parse_skill_targets,
25
+ status_skill_targets,
26
+ )
27
+
28
+
29
+ EXIT_OK = 0
30
+ EXIT_PARAMETER_ERROR = 2
31
+ EXIT_CONFIG_ERROR = 3
32
+ EXIT_NETWORK_ERROR = 4
33
+ EXIT_RUNTIME_ERROR = 5
34
+
35
+ COMMAND_ALIASES = {
36
+ "search": ["s"],
37
+ "route": ["rt"],
38
+ "fetch": ["f"],
39
+ "map": ["m"],
40
+ "exa-search": ["exa", "x"],
41
+ "exa-similar": ["xs"],
42
+ "zhipu-search": ["z", "zp"],
43
+ "zhipu-mcp-search": ["zmcp-search"],
44
+ "zhipu-mcp-reader": ["zmcp-reader"],
45
+ "zhipu-mcp-search-doc": ["zmcp-doc"],
46
+ "zhipu-mcp-repo-structure": ["zmcp-tree"],
47
+ "zhipu-mcp-read-file": ["zmcp-file"],
48
+ "context7-library": ["c7", "ctx7"],
49
+ "context7-docs": ["c7d", "c7docs", "ctx7-docs"],
50
+ "route-calibrate": ["route-cal", "rcal"],
51
+ "smoke": ["sm"],
52
+ "doctor": ["d"],
53
+ "diagnose": ["diag"],
54
+ "model": ["mdl"],
55
+ "setup": ["init"],
56
+ "skills": ["skill"],
57
+ "config": ["cfg"],
58
+ "regression": ["reg"],
59
+ }
60
+
61
+ CONFIG_COMMAND_ALIASES = {
62
+ "path": ["p"],
63
+ "list": ["ls", "l"],
64
+ "set": ["s"],
65
+ "unset": ["rm", "u"],
66
+ }
67
+
68
+ MODEL_COMMAND_ALIASES = {
69
+ "set": ["s"],
70
+ "current": ["cur", "c"],
71
+ }
72
+
73
+ SKILLS_COMMAND_ALIASES = {
74
+ "status": ["st"],
75
+ "update": ["up"],
76
+ }
77
+
78
+
79
+ class SmartSearchArgumentParser(argparse.ArgumentParser):
80
+ def __init__(self, *args, **kwargs):
81
+ kwargs.setdefault("allow_abbrev", False)
82
+ super().__init__(*args, **kwargs)
83
+
84
+
85
+ TAVILY_DEFAULT_API_URL = "https://api.tavily.com"
86
+ FIRECRAWL_DEFAULT_API_URL = "https://api.firecrawl.dev/v2"
87
+ ZHIPU_DEFAULT_API_URL = "https://open.bigmodel.cn/api"
88
+ ZHIPU_SEARCH_ENGINE_CHOICES = [
89
+ "search_std",
90
+ "search_pro",
91
+ "search_pro_sogou",
92
+ "search_pro_quark",
93
+ ]
94
+
95
+ _STATIC_SMART_SEARCH_BANNER = r"""
96
+ ____ _ ____ _
97
+ / ___| _ __ ___ __ _ _ __| |_ / ___| ___ __ _ _ __ ___| |__
98
+ \___ \| '_ ` _ \ / _` | '__| __| \___ \ / _ \/ _` | '__/ __| '_ \
99
+ ___) | | | | | | (_| | | | |_ ___) | __/ (_| | | | (__| | | |
100
+ |____/|_| |_| |_|\__,_|_| \__| |____/ \___|\__,_|_| \___|_| |_|
101
+ """.strip("\n")
102
+
103
+
104
+ def _get_version() -> str:
105
+ root = Path(__file__).resolve().parents[2]
106
+ package_json = root / "package.json"
107
+ try:
108
+ version = json.loads(package_json.read_text(encoding="utf-8")).get("version", "")
109
+ if version:
110
+ return str(version)
111
+ except (OSError, json.JSONDecodeError):
112
+ pass
113
+
114
+ pyproject = root / "pyproject.toml"
115
+ try:
116
+ for line in pyproject.read_text(encoding="utf-8").splitlines():
117
+ if line.startswith("version = "):
118
+ return line.split("=", 1)[1].strip().strip('"')
119
+ except OSError:
120
+ pass
121
+
122
+ try:
123
+ return metadata.version("smart-search")
124
+ except metadata.PackageNotFoundError:
125
+ pass
126
+
127
+ return "unknown"
128
+
129
+
130
+ def _json(data: Any) -> str:
131
+ return json.dumps(data, ensure_ascii=False, indent=2)
132
+
133
+
134
+ def _json_stdout_safe(data: Any) -> str:
135
+ text = _json(data)
136
+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
137
+ errors = getattr(sys.stdout, "errors", None) or "strict"
138
+ try:
139
+ text.encode(encoding, errors=errors)
140
+ return text
141
+ except UnicodeEncodeError:
142
+ return "".join(_escape_unencodable_json_char(char, encoding) for char in text)
143
+
144
+
145
+ def _escape_unencodable_json_char(char: str, encoding: str) -> str:
146
+ try:
147
+ char.encode(encoding)
148
+ return char
149
+ except UnicodeEncodeError:
150
+ return json.dumps(char, ensure_ascii=True)[1:-1]
151
+
152
+
153
+ def _format_seconds(seconds: float) -> str:
154
+ return f"{seconds:g}"
155
+
156
+
157
+ def _search_timeout_result(query: str, timeout: float, search_kwargs: dict[str, Any] | None = None) -> dict[str, Any]:
158
+ seconds = _format_seconds(timeout)
159
+ search_kwargs = search_kwargs or {}
160
+ stream = search_kwargs.get("stream")
161
+ if stream is None:
162
+ stream = service.config.openai_compatible_stream
163
+ model = search_kwargs.get("model") or service.config.openai_compatible_model
164
+ return {
165
+ "ok": False,
166
+ "error_type": "network_error",
167
+ "error": f"Search timed out after {seconds} seconds",
168
+ "query": query,
169
+ "content": "",
170
+ "sources": [],
171
+ "sources_count": 0,
172
+ "primary_sources": [],
173
+ "primary_sources_count": 0,
174
+ "extra_sources": [],
175
+ "extra_sources_count": 0,
176
+ "source_warning": "",
177
+ "routing_decision": {},
178
+ "providers_used": [],
179
+ "provider_attempts": [],
180
+ "fallback_used": False,
181
+ "validation_level": "",
182
+ "timeout_seconds": timeout,
183
+ "provider": search_kwargs.get("providers", "auto"),
184
+ "model": model,
185
+ "stream": stream,
186
+ "diagnose_command": "smart-search diagnose openai-compatible --format markdown",
187
+ "recommendation": "Run `smart-search diagnose openai-compatible --format markdown` to check whether OpenAI-compatible stream/no-stream search requests are hanging upstream.",
188
+ }
189
+
190
+
191
+ def _one_line(value: Any, limit: int = 160) -> str:
192
+ text = "" if value is None else str(value)
193
+ text = " ".join(text.replace("\r", " ").replace("\n", " ").split())
194
+ if limit > 0 and len(text) > limit:
195
+ return text[: max(0, limit - 3)].rstrip() + "..."
196
+ return text
197
+
198
+
199
+ def _md_cell(value: Any) -> str:
200
+ return _one_line(value).replace("|", r"\|")
201
+
202
+
203
+ def _markdown_table(headers: list[str], rows: list[list[Any]]) -> list[str]:
204
+ if not rows:
205
+ return []
206
+ lines = [
207
+ "| " + " | ".join(_md_cell(header) for header in headers) + " |",
208
+ "| " + " | ".join("---" for _ in headers) + " |",
209
+ ]
210
+ for row in rows:
211
+ cells = list(row)[: len(headers)]
212
+ cells.extend([""] * (len(headers) - len(cells)))
213
+ lines.append("| " + " | ".join(_md_cell(cell) for cell in cells) + " |")
214
+ return lines
215
+
216
+
217
+ def _markdown_code_block(value: Any) -> list[str]:
218
+ text = "" if value is None else str(value)
219
+ fence = "```"
220
+ if fence in text:
221
+ text = text.replace(fence, "` ` `")
222
+ return ["```text", text, "```"]
223
+
224
+
225
+ def _status_label(value: Any) -> str:
226
+ if isinstance(value, bool):
227
+ return "OK" if value else "FAIL"
228
+ status = str(value or "").strip()
229
+ normalized = status.lower()
230
+ labels = {
231
+ "ok": "OK",
232
+ "true": "OK",
233
+ "configured": "CONFIGURED",
234
+ "warning": "WARN",
235
+ "timeout": "TIMEOUT",
236
+ "error": "ERROR",
237
+ "config_error": "CONFIG ERROR",
238
+ "not_configured": "NOT CONFIGURED",
239
+ "false": "FAIL",
240
+ "failed": "FAIL",
241
+ "empty": "EMPTY",
242
+ "skipped": "SKIPPED",
243
+ }
244
+ return labels.get(normalized, status.upper() if status else "-")
245
+
246
+
247
+ def _yes_no(value: Any) -> str:
248
+ return "YES" if bool(value) else "NO"
249
+
250
+
251
+ def _latency_text(value: Any) -> str:
252
+ if value in (None, ""):
253
+ return "-"
254
+ if isinstance(value, (int, float)):
255
+ return f"{value:.2f} ms"
256
+ return str(value)
257
+
258
+
259
+ def _configured_text(items: Any) -> str:
260
+ if isinstance(items, (list, tuple)):
261
+ return ", ".join(str(item) for item in items) if items else "-"
262
+ return str(items) if items else "-"
263
+
264
+
265
+ def _error_lines(data: dict[str, Any]) -> list[str]:
266
+ lines: list[str] = []
267
+ if data.get("error_type") or data.get("error"):
268
+ lines.extend(["", "## Errors"])
269
+ if data.get("error_type"):
270
+ lines.append(f"- Type: `{data.get('error_type')}`")
271
+ if data.get("error"):
272
+ lines.append(f"- Message: {data.get('error')}")
273
+ parameter_errors = data.get("config_parameter_errors") or []
274
+ for error in parameter_errors:
275
+ lines.append(f"- Config: {error}")
276
+ return lines
277
+
278
+
279
+ def _error_summary(data: dict[str, Any]) -> str:
280
+ error_type = data.get("error_type")
281
+ error = data.get("error")
282
+ if error_type and error:
283
+ return f"{error_type}: {error}"
284
+ if error:
285
+ return str(error)
286
+ if error_type:
287
+ return str(error_type)
288
+ return ""
289
+
290
+
291
+ def _result_title(item: Any, index: int) -> str:
292
+ if not isinstance(item, dict):
293
+ return f"Result {index}"
294
+ return (
295
+ item.get("title")
296
+ or item.get("id")
297
+ or item.get("library_id")
298
+ or item.get("url")
299
+ or item.get("provider")
300
+ or f"Result {index}"
301
+ )
302
+
303
+
304
+ def _result_target(item: Any) -> str:
305
+ if not isinstance(item, dict):
306
+ return str(item)
307
+ return item.get("url") or item.get("id") or item.get("library_id") or ""
308
+
309
+
310
+ def _result_summary(item: Any) -> str:
311
+ if not isinstance(item, dict):
312
+ return ""
313
+ highlights = item.get("highlights")
314
+ if isinstance(highlights, list):
315
+ highlights = " ".join(str(part) for part in highlights[:2])
316
+ return (
317
+ item.get("description")
318
+ or item.get("content")
319
+ or item.get("snippet")
320
+ or item.get("text")
321
+ or highlights
322
+ or item.get("source")
323
+ or ""
324
+ )
325
+
326
+
327
+ def _result_rows(results: list[Any]) -> list[list[Any]]:
328
+ rows: list[list[Any]] = []
329
+ for index, item in enumerate(results, 1):
330
+ rows.append([index, _result_title(item, index), _result_target(item), _result_summary(item)])
331
+ return rows
332
+
333
+
334
+ def _format_result_markdown(command: str, data: dict[str, Any], title: str) -> str:
335
+ lines = [
336
+ f"# {title}",
337
+ "",
338
+ f"Status: {_status_label(data.get('ok'))}",
339
+ ]
340
+ if data.get("query"):
341
+ lines.append(f"Query: `{data.get('query')}`")
342
+ if data.get("url"):
343
+ lines.append(f"URL: {data.get('url')}")
344
+ if data.get("base_url"):
345
+ lines.append(f"Base URL: {data.get('base_url')}")
346
+ if data.get("provider"):
347
+ lines.append(f"Provider: {data.get('provider')}")
348
+ if data.get("tool"):
349
+ lines.append(f"Tool: `{data.get('tool')}`")
350
+ if data.get("elapsed_ms") is not None:
351
+ lines.append(f"Elapsed: {_latency_text(data.get('elapsed_ms'))}")
352
+
353
+ results = data.get("results") or []
354
+ lines.append("")
355
+ if results:
356
+ lines.append("## Results")
357
+ lines.extend(_markdown_table(["#", "Title", "URL / ID", "Summary"], _result_rows(results)))
358
+ elif data.get("content"):
359
+ lines.append("## Content")
360
+ lines.extend(_markdown_code_block(data.get("content")))
361
+ elif data.get("ok"):
362
+ lines.append("No results.")
363
+ lines.extend(_error_lines(data))
364
+ return "\n".join(lines).strip() + "\n"
365
+
366
+
367
+ def _format_doctor_markdown(data: dict[str, Any]) -> str:
368
+ lines = [
369
+ "# Smart Search Doctor",
370
+ "",
371
+ f"Overall: {_status_label(data.get('ok'))}",
372
+ f"Config file: `{data.get('config_file', '')}`",
373
+ f"Config dir: `{data.get('config_dir', '')}`",
374
+ f"Config dir source: `{data.get('config_dir_source', '-')}`",
375
+ f"Default config file: `{data.get('default_config_file', '')}`",
376
+ f"Config status: {data.get('config_status', '-')}",
377
+ f"Minimum profile: {_status_label(data.get('minimum_profile_ok'))}",
378
+ f"Log dir config value: `{data.get('log_dir_config_value', data.get('SMART_SEARCH_LOG_DIR', ''))}`",
379
+ f"Resolved log dir: `{data.get('resolved_log_dir', '')}`",
380
+ f"File logging enabled: {_yes_no(data.get('file_logging_enabled'))}",
381
+ ]
382
+ if data.get("legacy_windows_config_file"):
383
+ lines.append(f"Legacy Windows config file: `{data.get('legacy_windows_config_file')}`")
384
+ lines.append(f"Legacy Windows config exists: {_status_label(data.get('legacy_windows_config_exists'))}")
385
+ if data.get("config_dir_override_value"):
386
+ lines.append(f"SMART_SEARCH_CONFIG_DIR: `{data.get('config_dir_override_value')}`")
387
+ lines.append(f"Override matches default: {_yes_no(data.get('config_dir_override_matches_default'))}")
388
+ if data.get("config_dir_source") == "environment" and data.get("config_dir_override_matches_default"):
389
+ lines.append(
390
+ "The active config path comes from `SMART_SEARCH_CONFIG_DIR`, but that override matches the current Windows default path."
391
+ )
392
+ if data.get("config_dir_source") == "legacy_windows_home":
393
+ lines.append(
394
+ "Active config is using the old Windows `~\\.config\\smart-search` location because the new default file does not exist."
395
+ )
396
+ missing = data.get("minimum_profile_missing") or []
397
+ if missing:
398
+ lines.append(f"Missing: `{', '.join(str(item) for item in missing)}`")
399
+
400
+ config_sources = data.get("config_sources") or {}
401
+ if config_sources:
402
+ rows = []
403
+ for key in sorted(config_sources):
404
+ rows.append([key, config_sources.get(key), data.get(key, "-")])
405
+ lines.extend(["", "## Configuration Values"])
406
+ lines.extend(_markdown_table(["Key", "Source", "Value"], rows))
407
+
408
+ capability_status = data.get("capability_status") or {}
409
+ if capability_status:
410
+ rows = []
411
+ for capability, status in capability_status.items():
412
+ if isinstance(status, dict):
413
+ rows.append(
414
+ [
415
+ capability,
416
+ _status_label(status.get("ok")),
417
+ _configured_text(status.get("configured")),
418
+ _configured_text(status.get("fallback_chain")),
419
+ ]
420
+ )
421
+ if rows:
422
+ lines.extend(["", "## Capabilities"])
423
+ lines.extend(_markdown_table(["Capability", "Status", "Configured", "Fallback chain"], rows))
424
+
425
+ main_tests = data.get("main_search_connection_tests") or {}
426
+ if main_tests:
427
+ rows = []
428
+ for provider, test in main_tests.items():
429
+ if isinstance(test, dict):
430
+ rows.append(
431
+ [
432
+ provider,
433
+ _status_label(test.get("status")),
434
+ _latency_text(test.get("response_time_ms")),
435
+ test.get("message", ""),
436
+ ]
437
+ )
438
+ lines.extend(["", "## Main Search Providers"])
439
+ lines.extend(_markdown_table(["Provider", "Status", "Latency", "Message"], rows))
440
+ lines.extend(_provider_detail_lines("Provider Details", main_tests))
441
+
442
+ provider_tests = [
443
+ ("exa", data.get("exa_connection_test") or {}),
444
+ ("tavily", data.get("tavily_connection_test") or {}),
445
+ ("jina", data.get("jina_connection_test") or {}),
446
+ ("firecrawl", data.get("firecrawl_connection_test") or {}),
447
+ ("zhipu", data.get("zhipu_connection_test") or {}),
448
+ ("zhipu-mcp", data.get("zhipu_mcp_connection_test") or {}),
449
+ ("context7", data.get("context7_connection_test") or {}),
450
+ ]
451
+ rows = []
452
+ for provider, test in provider_tests:
453
+ if isinstance(test, dict) and test:
454
+ rows.append(
455
+ [
456
+ provider,
457
+ _status_label(test.get("status")),
458
+ _latency_text(test.get("response_time_ms")),
459
+ test.get("message", ""),
460
+ ]
461
+ )
462
+ if rows:
463
+ lines.extend(["", "## Provider Checks"])
464
+ lines.extend(_markdown_table(["Provider", "Status", "Latency", "Message"], rows))
465
+ lines.extend(_provider_detail_lines("Provider Check Details", dict(provider_tests)))
466
+
467
+ router = data.get("intent_router_status") or {}
468
+ if router:
469
+ lines.extend(["", "## Intent Router"])
470
+ lines.extend(
471
+ _markdown_table(
472
+ ["Field", "Value"],
473
+ [
474
+ ["mode", router.get("mode", "")],
475
+ ["embeddings_configured", _yes_no(router.get("embeddings_configured"))],
476
+ ["classifier_configured", _yes_no(router.get("classifier_configured"))],
477
+ ["embedding_model", router.get("embedding_model", "")],
478
+ ["embedding_threshold", router.get("embedding_threshold", "")],
479
+ ["embedding_margin", router.get("embedding_margin", "")],
480
+ ["embedding_threshold_source", router.get("embedding_threshold_source", "")],
481
+ ["embedding_margin_source", router.get("embedding_margin_source", "")],
482
+ ["embedding_preset", router.get("embedding_preset_id", "")],
483
+ ["embedding_preset_threshold", router.get("embedding_preset_threshold", "")],
484
+ ["embedding_preset_margin", router.get("embedding_preset_margin", "")],
485
+ ["embedding_preset_recommended", _yes_no(router.get("embedding_preset_recommended"))],
486
+ ["classifier_model", router.get("classifier_model", "")],
487
+ ["timeout_seconds", router.get("timeout_seconds", "")],
488
+ ["degrades_to_rules", _yes_no(router.get("degrades_to_rules"))],
489
+ ],
490
+ )
491
+ )
492
+ if router.get("embedding_preset_recommendation"):
493
+ lines.extend(["", "### Embedding Preset Recommendation", "", router.get("embedding_preset_recommendation")])
494
+ commands = router.get("embedding_preset_commands") or []
495
+ if commands:
496
+ lines.extend(_markdown_code_block("\n".join(str(command) for command in commands)))
497
+ if router.get("error"):
498
+ lines.append(f"Intent router error: {router.get('error')}")
499
+
500
+ lines.extend(_error_lines(data))
501
+ return "\n".join(lines).strip() + "\n"
502
+
503
+
504
+ def _provider_detail_lines(title: str, provider_tests: dict[str, Any]) -> list[str]:
505
+ details: list[str] = []
506
+ for provider, test in provider_tests.items():
507
+ if not isinstance(test, dict) or not test:
508
+ continue
509
+ message = test.get("message")
510
+ available_models = test.get("available_models") or []
511
+ nested_checks = [
512
+ ("models_endpoint_test", test.get("models_endpoint_test")),
513
+ ("chat_completion_test", test.get("chat_completion_test")),
514
+ ]
515
+ if not message and not available_models and not any(isinstance(item, dict) for _, item in nested_checks):
516
+ continue
517
+ details.extend(
518
+ [
519
+ "",
520
+ f"### {provider}",
521
+ "",
522
+ f"- Status: {_status_label(test.get('status'))}",
523
+ f"- Latency: {_latency_text(test.get('response_time_ms'))}",
524
+ ]
525
+ )
526
+ if message:
527
+ details.extend(["- Message:"])
528
+ details.extend(_markdown_code_block(message))
529
+ if available_models:
530
+ details.append("- Available models: `" + "`, `".join(str(model) for model in available_models) + "`")
531
+ for name, nested in nested_checks:
532
+ if not isinstance(nested, dict):
533
+ continue
534
+ details.extend(
535
+ [
536
+ f"- {name}: {_status_label(nested.get('status'))}, {_latency_text(nested.get('response_time_ms'))}",
537
+ ]
538
+ )
539
+ if nested.get("message"):
540
+ details.extend(_markdown_code_block(nested.get("message")))
541
+ if not details:
542
+ return []
543
+ return ["", f"## {title}", *details]
544
+
545
+
546
+ def _format_smoke_markdown(data: dict[str, Any]) -> str:
547
+ cases = data.get("cases") or []
548
+ failed = data.get("failed_cases") or []
549
+ degraded = data.get("degraded_cases") or []
550
+ lines = [
551
+ "# Smart Search Smoke",
552
+ "",
553
+ f"Mode: `{data.get('mode', '')}`",
554
+ f"Overall: {_status_label(data.get('ok'))}",
555
+ f"Cases: {len(cases)} total, {len(failed)} failed, {len(degraded)} degraded",
556
+ ]
557
+ if cases:
558
+ rows = []
559
+ for case in cases:
560
+ rows.append(
561
+ [
562
+ case.get("name", ""),
563
+ _status_label(case.get("ok")),
564
+ case.get("severity", ""),
565
+ case.get("error") or case.get("error_type") or case.get("skipped", ""),
566
+ ]
567
+ )
568
+ lines.extend(["", "## Cases"])
569
+ lines.extend(_markdown_table(["Case", "Status", "Severity", "Details"], rows))
570
+ lines.extend(_error_lines(data))
571
+ return "\n".join(lines).strip() + "\n"
572
+
573
+
574
+ def _format_diagnose_markdown(data: dict[str, Any]) -> str:
575
+ lines = [
576
+ "# Smart Search Diagnose",
577
+ "",
578
+ f"Provider: `{data.get('provider', '')}`",
579
+ f"Status: {_status_label(data.get('ok'))}",
580
+ f"Summary: {data.get('summary', '-')}",
581
+ f"Recommendation: {data.get('recommendation', '-')}",
582
+ f"Config file: `{data.get('config_file', '')}`",
583
+ f"Config dir source: `{data.get('config_dir_source', '-')}`",
584
+ f"API URL: `{data.get('api_url', '')}`",
585
+ f"API key: `{data.get('api_key', '')}`",
586
+ f"Model: `{data.get('model', '')}`",
587
+ f"Configured stream: {_yes_no(data.get('configured_stream'))}",
588
+ f"Timeout: {_format_seconds(float(data.get('timeout_seconds', 0) or 0))} seconds",
589
+ ]
590
+ checks = data.get("checks") or []
591
+ if checks:
592
+ rows = []
593
+ for check in checks:
594
+ rows.append(
595
+ [
596
+ check.get("name", ""),
597
+ _status_label(check.get("status")),
598
+ _latency_text(check.get("response_time_ms")),
599
+ check.get("http_status", "-"),
600
+ check.get("content_type", "-"),
601
+ _yes_no(check.get("has_content")),
602
+ check.get("message", ""),
603
+ ]
604
+ )
605
+ lines.extend(["", "## Checks"])
606
+ lines.extend(_markdown_table(["Check", "Status", "Latency", "HTTP", "Content-Type", "Has content", "Message"], rows))
607
+ if data.get("next_command"):
608
+ lines.extend(["", "## Next Command"])
609
+ lines.extend(_markdown_code_block(data.get("next_command")))
610
+ lines.extend(_error_lines(data))
611
+ return "\n".join(lines).strip() + "\n"
612
+
613
+
614
+ def _format_route_markdown(data: dict[str, Any]) -> str:
615
+ lines = [
616
+ "# Intent Route",
617
+ "",
618
+ f"Status: {_status_label(data.get('ok'))}",
619
+ f"Query: `{data.get('query', '')}`",
620
+ f"Mode: `{data.get('intent_router_mode', '')}`",
621
+ f"Executed search: {_yes_no(data.get('executed_search'))}",
622
+ f"Required capabilities: `{', '.join(data.get('required_capabilities') or [])}`",
623
+ f"Confidence: `{data.get('confidence', '')}`",
624
+ f"Engines: `{', '.join(data.get('router_engines_used') or [])}`",
625
+ f"Embedding model: `{data.get('embedding_model', '')}`",
626
+ f"Embedding threshold: `{data.get('embedding_threshold', '')}` ({data.get('embedding_threshold_source', '')})",
627
+ f"Embedding margin: `{data.get('embedding_margin', '')}` ({data.get('embedding_margin_source', '')})",
628
+ f"Degraded: {_yes_no(data.get('degraded'))}",
629
+ ]
630
+ if data.get("embedding_preset_recommendation"):
631
+ lines.extend(["", "## Embedding Preset Recommendation", "", data.get("embedding_preset_recommendation")])
632
+ commands = data.get("embedding_preset_commands") or []
633
+ if commands:
634
+ lines.extend(_markdown_code_block("\n".join(str(command) for command in commands)))
635
+ if data.get("degraded_reason"):
636
+ lines.append(f"Degraded reason: {data.get('degraded_reason')}")
637
+ reasons = data.get("reasons") or []
638
+ if reasons:
639
+ lines.extend(["", "## Reasons"])
640
+ for reason in reasons:
641
+ lines.append(f"- {reason}")
642
+ signals = data.get("intent_signals") or {}
643
+ if signals:
644
+ rows = [[key, value] for key, value in sorted(signals.items())]
645
+ lines.extend(["", "## Signals"])
646
+ lines.extend(_markdown_table(["Signal", "Value"], rows))
647
+ lines.extend(_error_lines(data))
648
+ return "\n".join(lines).strip() + "\n"
649
+
650
+
651
+ def _format_route_calibrate_markdown(data: dict[str, Any]) -> str:
652
+ lines = [
653
+ "# Route Calibration",
654
+ "",
655
+ f"Status: {_status_label(data.get('ok'))}",
656
+ f"Primary metric: `{data.get('primary_metric', data.get('metric', ''))}`",
657
+ f"Dataset size: `{data.get('dataset_size', '')}`",
658
+ f"Recommended model: `{data.get('recommended_model') or '-'}`",
659
+ f"Recommended threshold: `{data.get('recommended_threshold') if data.get('recommended_threshold') is not None else '-'}`",
660
+ f"Recommended margin: `{data.get('recommended_margin') if data.get('recommended_margin') is not None else '-'}`",
661
+ ]
662
+ results = data.get("model_results") or []
663
+ if results:
664
+ rows = []
665
+ for item in results:
666
+ rows.append(
667
+ [
668
+ item.get("model", ""),
669
+ _status_label(item.get("ok")),
670
+ item.get("dimension", ""),
671
+ _latency_text(item.get("latency_ms")),
672
+ item.get("semantic_macro_f1", ""),
673
+ item.get("full_route_macro_f1", ""),
674
+ item.get("recommended_threshold", ""),
675
+ item.get("recommended_margin", ""),
676
+ item.get("error", ""),
677
+ ]
678
+ )
679
+ lines.extend(["", "## Models"])
680
+ lines.extend(
681
+ _markdown_table(
682
+ ["Model", "Status", "Dim", "Latency", "Semantic F1", "Full-route F1", "Threshold", "Margin", "Error"],
683
+ rows,
684
+ )
685
+ )
686
+ failed = data.get("failed_models") or []
687
+ if failed:
688
+ lines.extend(["", "## Failed Models"])
689
+ for model in failed:
690
+ lines.append(f"- `{model}`")
691
+ best = next((item for item in results if item.get("model") == data.get("recommended_model")), None)
692
+ if isinstance(best, dict):
693
+ failures = best.get("semantic_failures") or []
694
+ if failures:
695
+ lines.extend(["", "## Representative Semantic Failures"])
696
+ rows = []
697
+ for failure in failures[:8]:
698
+ rows.append(
699
+ [
700
+ failure.get("id", ""),
701
+ failure.get("expected", ""),
702
+ failure.get("predicted", ""),
703
+ failure.get("top_capability", ""),
704
+ failure.get("top_score", ""),
705
+ failure.get("margin", ""),
706
+ failure.get("query", ""),
707
+ ]
708
+ )
709
+ lines.extend(_markdown_table(["Case", "Expected", "Predicted", "Top", "Score", "Margin", "Query"], rows))
710
+ lines.extend(_error_lines(data))
711
+ return "\n".join(lines).strip() + "\n"
712
+
713
+
714
+ def _format_config_markdown(data: dict[str, Any]) -> str:
715
+ lines = ["# Smart Search Config", "", f"Status: {_status_label(data.get('ok'))}"]
716
+ if data.get("config_file"):
717
+ lines.append(f"Config file: `{data.get('config_file')}`")
718
+ if data.get("config_dir"):
719
+ lines.append(f"Config dir: `{data.get('config_dir')}`")
720
+ if data.get("config_dir_source"):
721
+ lines.append(f"Config dir source: `{data.get('config_dir_source')}`")
722
+ if data.get("default_config_file"):
723
+ lines.append(f"Default config file: `{data.get('default_config_file')}`")
724
+ if data.get("legacy_windows_config_file"):
725
+ lines.append(f"Legacy Windows config file: `{data.get('legacy_windows_config_file')}`")
726
+ lines.append(f"Legacy Windows config exists: {_status_label(data.get('legacy_windows_config_exists'))}")
727
+ if data.get("config_dir_override_value"):
728
+ lines.append(f"SMART_SEARCH_CONFIG_DIR: `{data.get('config_dir_override_value')}`")
729
+ lines.append(f"Override matches default: {_yes_no(data.get('config_dir_override_matches_default'))}")
730
+ if "exists" in data:
731
+ lines.append(f"Exists: {_status_label(bool(data.get('exists')))}")
732
+ if data.get("key"):
733
+ lines.append(f"Key: `{data.get('key')}`")
734
+ if data.get("value"):
735
+ lines.append(f"Value: `{data.get('value')}`")
736
+ values = data.get("values") or {}
737
+ if values:
738
+ lines.extend(["", "## Values"])
739
+ lines.extend(_markdown_table(["Key", "Value"], [[key, value] for key, value in values.items()]))
740
+ lines.extend(_error_lines(data))
741
+ return "\n".join(lines).strip() + "\n"
742
+
743
+
744
+ def _format_model_markdown(data: dict[str, Any]) -> str:
745
+ lines = ["# Smart Search Model", "", f"Status: {_status_label(data.get('ok'))}"]
746
+ rows = []
747
+ if data.get("xai_model"):
748
+ rows.append(["xai-responses", data.get("xai_model")])
749
+ if data.get("openai_compatible_model"):
750
+ rows.append(["openai-compatible", data.get("openai_compatible_model")])
751
+ fallback_models = data.get("openai_compatible_fallback_models") or []
752
+ if fallback_models:
753
+ rows.append(["openai-compatible fallback", ", ".join(fallback_models)])
754
+ if data.get("current_model"):
755
+ rows.append(["current", data.get("current_model")])
756
+ if rows:
757
+ lines.extend(["", "## Models"])
758
+ lines.extend(_markdown_table(["Provider", "Model"], rows))
759
+ if data.get("config_file"):
760
+ lines.extend(["", f"Config file: `{data.get('config_file')}`"])
761
+ lines.extend(_error_lines(data))
762
+ return "\n".join(lines).strip() + "\n"
763
+
764
+
765
+ def _format_setup_markdown(data: dict[str, Any]) -> str:
766
+ lines = ["# Smart Search Setup", "", f"Status: {_status_label(data.get('ok'))}"]
767
+ if data.get("config_file"):
768
+ lines.append(f"Config file: `{data.get('config_file')}`")
769
+ saved = data.get("saved") or data.get("values") or {}
770
+ if saved:
771
+ lines.extend(["", "## Saved Values"])
772
+ lines.extend(_markdown_table(["Key", "Value"], [[key, value] for key, value in saved.items()]))
773
+ skills = data.get("skills") or {}
774
+ if isinstance(skills, dict) and skills:
775
+ installed = skills.get("installed") or []
776
+ failed = skills.get("failed") or []
777
+ lines.extend(["", "## Skills", f"Installed: {len(installed)}", f"Failed: {len(failed)}"])
778
+ lines.extend(_error_lines(data))
779
+ return "\n".join(lines).strip() + "\n"
780
+
781
+
782
+ def _format_skills_markdown(data: dict[str, Any]) -> str:
783
+ lines = ["# Smart Search Skills", "", f"Status: {_status_label(data.get('ok'))}"]
784
+ if data.get("root"):
785
+ lines.append(f"Root: `{data.get('root')}`")
786
+ if data.get("skill"):
787
+ lines.append(f"Skill: `{data.get('skill')}`")
788
+ if data.get("bundled_files") is not None:
789
+ lines.append(f"Bundled files: {data.get('bundled_files')}")
790
+
791
+ targets = data.get("targets") or data.get("installed") or []
792
+ if targets:
793
+ rows = []
794
+ for item in targets:
795
+ rows.append(
796
+ [
797
+ item.get("target", ""),
798
+ item.get("status", "installed"),
799
+ item.get("files", item.get("installed_files", "")),
800
+ item.get("installed_files", ""),
801
+ _yes_no(item.get("hash_match")),
802
+ len(item.get("extra_files") or []),
803
+ item.get("path", ""),
804
+ ]
805
+ )
806
+ lines.extend(["", "## Targets"])
807
+ lines.extend(_markdown_table(["Target", "Status", "Files", "Installed", "Hash match", "Extra", "Path"], rows))
808
+ if data.get("failed"):
809
+ lines.extend(["", "## Failed"])
810
+ lines.extend(_markdown_table(["Target", "Path", "Error"], [[item.get("target"), item.get("path"), item.get("error")] for item in data.get("failed", [])]))
811
+ lines.extend(_error_lines(data))
812
+ return "\n".join(lines).strip() + "\n"
813
+
814
+
815
+ def _format_markdown(command: str, data: dict[str, Any]) -> str:
816
+ if command == "search":
817
+ if not data.get("ok", False) and (data.get("error") or data.get("error_type")):
818
+ lines = ["# Smart Search Search", ""]
819
+ if data.get("query"):
820
+ lines.append(f"Query: `{data.get('query')}`")
821
+ if data.get("provider") is not None:
822
+ lines.append(f"Provider: `{data.get('provider')}`")
823
+ if data.get("model") is not None:
824
+ lines.append(f"Model: `{data.get('model')}`")
825
+ if data.get("stream") is not None:
826
+ lines.append(f"Stream: {_yes_no(data.get('stream'))}")
827
+ if data.get("recommendation"):
828
+ lines.extend(["", "## Recommendation", str(data.get("recommendation"))])
829
+ if data.get("diagnose_command"):
830
+ lines.extend(["", "## Next Command"])
831
+ lines.extend(_markdown_code_block(data.get("diagnose_command")))
832
+ lines.extend(_error_lines(data))
833
+ return "\n".join(lines).strip() + "\n"
834
+ lines = [data.get("content", "")]
835
+ primary_sources = data.get("primary_sources") or []
836
+ extra_sources = data.get("extra_sources") or []
837
+ if primary_sources or extra_sources:
838
+ warning = data.get("source_warning") or ""
839
+ if warning:
840
+ lines.append(f"\n> {warning}")
841
+ if primary_sources:
842
+ lines.append("\n## Primary Sources")
843
+ for item in primary_sources:
844
+ url = item.get("url", "")
845
+ title = item.get("title") or item.get("provider") or url
846
+ lines.append(f"- [{title}]({url})")
847
+ if extra_sources:
848
+ lines.append("\n## Extra Sources")
849
+ for item in extra_sources:
850
+ url = item.get("url", "")
851
+ title = item.get("title") or item.get("provider") or url
852
+ lines.append(f"- [{title}]({url})")
853
+ return "\n".join(lines).strip() + "\n"
854
+
855
+ sources = data.get("sources") or []
856
+ if sources:
857
+ lines.append("\n## Sources")
858
+ for item in sources:
859
+ url = item.get("url", "")
860
+ title = item.get("title") or item.get("provider") or url
861
+ lines.append(f"- [{title}]({url})")
862
+ return "\n".join(lines).strip() + "\n"
863
+ if command == "fetch":
864
+ return (data.get("content") or "") + ("\n" if data.get("content") else "")
865
+ if command == "context7-docs":
866
+ content = data.get("content") or ""
867
+ lines = [
868
+ "# Context7 Docs",
869
+ "",
870
+ f"Status: {_status_label(data.get('ok'))}",
871
+ f"Library: `{data.get('library_id', '')}`",
872
+ f"Query: `{data.get('query', '')}`",
873
+ ]
874
+ if content:
875
+ lines.extend(["", content])
876
+ lines.extend(_error_lines(data))
877
+ return "\n".join(lines).strip() + "\n"
878
+ if command == "route":
879
+ return _format_route_markdown(data)
880
+ if command == "route-calibrate":
881
+ return _format_route_calibrate_markdown(data)
882
+ if command == "doctor":
883
+ return _format_doctor_markdown(data)
884
+ if command == "diagnose":
885
+ return _format_diagnose_markdown(data)
886
+ if command == "smoke":
887
+ return _format_smoke_markdown(data)
888
+ if command == "config":
889
+ return _format_config_markdown(data)
890
+ if command == "model":
891
+ return _format_model_markdown(data)
892
+ if command == "setup":
893
+ return _format_setup_markdown(data)
894
+ if command == "skills":
895
+ return _format_skills_markdown(data)
896
+ titles = {
897
+ "map": "Site Map",
898
+ "exa-search": "Exa Search",
899
+ "exa-similar": "Exa Similar Pages",
900
+ "zhipu-search": "Zhipu Search",
901
+ "zhipu-mcp-search": "Zhipu Coding Plan MCP Search",
902
+ "zhipu-mcp-reader": "Zhipu Coding Plan MCP Reader",
903
+ "zhipu-mcp-search-doc": "Zhipu Coding Plan MCP Search Doc",
904
+ "zhipu-mcp-repo-structure": "Zhipu Coding Plan MCP Repo Structure",
905
+ "zhipu-mcp-read-file": "Zhipu Coding Plan MCP Read File",
906
+ "context7-library": "Context7 Library Search",
907
+ }
908
+ if command in titles:
909
+ return _format_result_markdown(command, data, titles[command])
910
+ return _format_config_markdown(data)
911
+
912
+
913
+ def _plain_result_lines(data: dict[str, Any]) -> list[str]:
914
+ results = data.get("results") or []
915
+ if not results:
916
+ return ["No results."] if data.get("ok") else []
917
+ lines = []
918
+ for index, item in enumerate(results, 1):
919
+ title = _result_title(item, index)
920
+ target = _result_target(item)
921
+ summary = _one_line(_result_summary(item), 120)
922
+ line = f"{index}. {title}"
923
+ if target:
924
+ line += f" - {target}"
925
+ if summary:
926
+ line += f" - {summary}"
927
+ lines.append(line)
928
+ return lines
929
+
930
+
931
+ def _format_content(command: str, data: dict[str, Any]) -> str:
932
+ if command in {"search", "docs", "fetch", "context7-docs"}:
933
+ content = data.get("content")
934
+ if content:
935
+ return str(content) + "\n"
936
+ if data.get("ok"):
937
+ return ""
938
+ error = _error_summary(data)
939
+ if error:
940
+ return f"{_status_label(data.get('ok'))}: {error}\n"
941
+ return ""
942
+ if command == "route":
943
+ capabilities = ", ".join(data.get("required_capabilities") or []) or "none"
944
+ lines = [
945
+ f"Intent route {_status_label(data.get('ok'))}: capabilities={capabilities}",
946
+ f"mode={data.get('intent_router_mode', '')}; confidence={data.get('confidence', '')}; engines={','.join(data.get('router_engines_used') or [])}",
947
+ f"embedding_model={data.get('embedding_model', '')}; threshold={data.get('embedding_threshold', '')}({data.get('embedding_threshold_source', '')}); margin={data.get('embedding_margin', '')}({data.get('embedding_margin_source', '')})",
948
+ ]
949
+ if data.get("embedding_preset_recommendation"):
950
+ lines.append(
951
+ "embedding_preset_recommendation="
952
+ f"threshold={data.get('embedding_preset_threshold')} "
953
+ f"margin={data.get('embedding_preset_margin')}"
954
+ )
955
+ if data.get("degraded_reason"):
956
+ lines.append(f"degraded={data.get('degraded_reason')}")
957
+ if data.get("error"):
958
+ lines.append(f"Error: {_error_summary(data)}")
959
+ return "\n".join(lines).strip() + "\n"
960
+ if command == "route-calibrate":
961
+ results = data.get("model_results") or []
962
+ ok_count = sum(1 for item in results if item.get("ok"))
963
+ lines = [
964
+ f"Route calibration {_status_label(data.get('ok'))}: {ok_count}/{len(results)} models calibrated",
965
+ f"primary_metric={data.get('primary_metric', data.get('metric', ''))}; dataset={data.get('dataset_size', '')}",
966
+ ]
967
+ if data.get("recommended_model"):
968
+ lines.append(
969
+ "recommended="
970
+ f"{data.get('recommended_model')} "
971
+ f"threshold={data.get('recommended_threshold')} "
972
+ f"margin={data.get('recommended_margin')}"
973
+ )
974
+ if data.get("failed_models"):
975
+ lines.append("failed=" + ",".join(str(item) for item in data.get("failed_models") or []))
976
+ if data.get("error"):
977
+ lines.append(f"Error: {_error_summary(data)}")
978
+ return "\n".join(lines).strip() + "\n"
979
+ if command == "doctor":
980
+ configured = data.get("capability_status", {})
981
+ capability_bits = []
982
+ for name, status in configured.items():
983
+ if isinstance(status, dict):
984
+ capability_bits.append(f"{name}={_status_label(status.get('ok'))}")
985
+ lines = [
986
+ f"Doctor {_status_label(data.get('ok'))}: {data.get('config_status', '')}".strip(),
987
+ f"Minimum profile: {_status_label(data.get('minimum_profile_ok'))}",
988
+ ]
989
+ if capability_bits:
990
+ lines.append("Capabilities: " + ", ".join(capability_bits))
991
+ router = data.get("intent_router_status") or {}
992
+ if router.get("embedding_preset_recommendation"):
993
+ lines.append(
994
+ "Embedding preset recommendation: "
995
+ f"threshold={router.get('embedding_preset_threshold')} "
996
+ f"margin={router.get('embedding_preset_margin')}"
997
+ )
998
+ if data.get("error"):
999
+ lines.append(f"Error: {_error_summary(data)}")
1000
+ return "\n".join(lines).strip() + "\n"
1001
+ if command == "diagnose":
1002
+ lines = [
1003
+ f"Diagnose {data.get('provider', '')} {_status_label(data.get('ok'))}: {data.get('summary', '')}".strip(),
1004
+ ]
1005
+ if data.get("recommendation"):
1006
+ lines.append(f"Recommendation: {data.get('recommendation')}")
1007
+ if data.get("error"):
1008
+ lines.append(f"Error: {_error_summary(data)}")
1009
+ return "\n".join(lines).strip() + "\n"
1010
+ if command == "smoke":
1011
+ cases = data.get("cases") or []
1012
+ failed = data.get("failed_cases") or []
1013
+ degraded = data.get("degraded_cases") or []
1014
+ return f"Smoke {data.get('mode', '')} {_status_label(data.get('ok'))}: {len(cases)} cases, {len(failed)} failed, {len(degraded)} degraded\n"
1015
+ if command == "config":
1016
+ parts = [f"Config {_status_label(data.get('ok'))}"]
1017
+ if data.get("config_file"):
1018
+ parts.append(f"file={data.get('config_file')}")
1019
+ if data.get("config_dir_source"):
1020
+ parts.append(f"source={data.get('config_dir_source')}")
1021
+ if data.get("config_dir_override_value"):
1022
+ parts.append(f"override={data.get('config_dir_override_value')}")
1023
+ if data.get("key"):
1024
+ parts.append(f"key={data.get('key')}")
1025
+ if data.get("value"):
1026
+ parts.append(f"value={data.get('value')}")
1027
+ values = data.get("values") or {}
1028
+ if values:
1029
+ parts.append(f"values={len(values)}")
1030
+ if data.get("error"):
1031
+ parts.append(f"error={_error_summary(data)}")
1032
+ return "; ".join(parts) + "\n"
1033
+ if command == "model":
1034
+ if data.get("error"):
1035
+ return f"Model {_status_label(data.get('ok'))}: {_error_summary(data)}\n"
1036
+ rows = []
1037
+ if data.get("xai_model"):
1038
+ rows.append(f"xai-responses={data.get('xai_model')}")
1039
+ if data.get("openai_compatible_model"):
1040
+ rows.append(f"openai-compatible={data.get('openai_compatible_model')}")
1041
+ if data.get("current_model"):
1042
+ rows.append(f"current={data.get('current_model')}")
1043
+ return ("Models: " + ", ".join(rows) if rows else f"Model {_status_label(data.get('ok'))}") + "\n"
1044
+ if command == "setup":
1045
+ if data.get("error"):
1046
+ return f"Setup {_status_label(data.get('ok'))}: {_error_summary(data)}\n"
1047
+ saved = data.get("saved") or data.get("values") or {}
1048
+ return f"Setup {_status_label(data.get('ok'))}: {len(saved)} values saved\n"
1049
+ if command == "skills":
1050
+ if data.get("error"):
1051
+ return f"Skills {_status_label(data.get('ok'))}: {_error_summary(data)}\n"
1052
+ targets = data.get("targets") or data.get("installed") or []
1053
+ counts = data.get("status_counts") or {}
1054
+ if counts:
1055
+ summary = ", ".join(f"{key}={value}" for key, value in sorted(counts.items()))
1056
+ return f"Skills {_status_label(data.get('ok'))}: {summary}\n"
1057
+ return f"Skills {_status_label(data.get('ok'))}: {len(targets)} targets\n"
1058
+ if command in {
1059
+ "map",
1060
+ "exa-search",
1061
+ "exa-similar",
1062
+ "zhipu-search",
1063
+ "zhipu-mcp-search",
1064
+ "zhipu-mcp-reader",
1065
+ "zhipu-mcp-search-doc",
1066
+ "zhipu-mcp-repo-structure",
1067
+ "zhipu-mcp-read-file",
1068
+ "context7-library",
1069
+ }:
1070
+ lines = _plain_result_lines(data)
1071
+ if data.get("error"):
1072
+ lines.append(f"Error: {_error_summary(data)}")
1073
+ return "\n".join(lines).strip() + "\n"
1074
+ if data.get("error"):
1075
+ return f"{_status_label(data.get('ok'))}: {_error_summary(data)}\n"
1076
+ return f"{command}: {_status_label(data.get('ok'))}\n"
1077
+
1078
+
1079
+ def _render(command: str, data: dict[str, Any], fmt: str) -> str:
1080
+ if fmt == "content":
1081
+ return _format_content(command, data)
1082
+ if fmt == "markdown":
1083
+ return _format_markdown(command, data)
1084
+ return _json(data)
1085
+
1086
+
1087
+ def _stdout_safe(text: str) -> str:
1088
+ return _stream_safe(sys.stdout, text)
1089
+
1090
+
1091
+ def _stream_safe(stream: Any, text: str) -> str:
1092
+ encoding = getattr(stream, "encoding", None) or "utf-8"
1093
+ errors = getattr(stream, "errors", None) or "strict"
1094
+ try:
1095
+ text.encode(encoding, errors=errors)
1096
+ return text
1097
+ except UnicodeEncodeError:
1098
+ return text.encode(encoding, errors="backslashreplace").decode(encoding)
1099
+
1100
+
1101
+ def _write_stdout(text: str) -> None:
1102
+ sys.stdout.write(_stdout_safe(text))
1103
+
1104
+
1105
+ def _write_stderr(text: str) -> None:
1106
+ sys.stderr.write(_stream_safe(sys.stderr, text))
1107
+
1108
+
1109
+ def _smart_search_banner_text() -> str:
1110
+ try:
1111
+ import pyfiglet
1112
+
1113
+ banner = pyfiglet.figlet_format("Smart Search", font="slant")
1114
+ return banner.rstrip()
1115
+ except Exception:
1116
+ return _STATIC_SMART_SEARCH_BANNER
1117
+
1118
+
1119
+ def _write_setup_banner(lang: str) -> None:
1120
+ banner = _smart_search_banner_text()
1121
+ tagline = _t(lang, "CLI-first multi-source search for AI agents", "CLI-first multi-source search for AI agents")
1122
+ _write_stderr(f"\n{banner}\n\n Smart Search\n {tagline}\n")
1123
+
1124
+
1125
+ def _write_panel(text: str, lang: str) -> None:
1126
+ if not _is_interactive_setup_stream():
1127
+ _write_stderr(text)
1128
+ return
1129
+ try:
1130
+ from rich.console import Console
1131
+ from rich.panel import Panel
1132
+ except Exception:
1133
+ _write_stderr(text)
1134
+ return
1135
+ console = Console(file=sys.stderr, force_terminal=True)
1136
+ title = _t(lang, "Smart Search 配置", "Smart Search Setup")
1137
+ console.print(Panel(text.strip(), title=title, expand=False, safe_box=True))
1138
+
1139
+
1140
+ def _exit_code(data: dict[str, Any]) -> int:
1141
+ if data.get("ok", False):
1142
+ return EXIT_OK
1143
+ error_type = data.get("error_type")
1144
+ if error_type == "config_error":
1145
+ return EXIT_CONFIG_ERROR
1146
+ if error_type == "parameter_error":
1147
+ return EXIT_PARAMETER_ERROR
1148
+ if error_type in {"network_error", "capability_error"}:
1149
+ return EXIT_NETWORK_ERROR
1150
+ if error_type == "provider_error":
1151
+ return EXIT_NETWORK_ERROR
1152
+ if error_type == "evidence_error":
1153
+ return EXIT_NETWORK_ERROR
1154
+ return EXIT_RUNTIME_ERROR
1155
+
1156
+
1157
+ def _print_result(command: str, data: dict[str, Any], fmt: str, output: str = "") -> int:
1158
+ rendered = _render(command, data, fmt)
1159
+ if output:
1160
+ service.write_output(output, rendered)
1161
+ if fmt == "json":
1162
+ rendered = _json_stdout_safe(data)
1163
+ _write_stdout(rendered)
1164
+ if rendered and not rendered.endswith("\n"):
1165
+ _write_stdout("\n")
1166
+ return _exit_code(data)
1167
+
1168
+
1169
+ def _add_format_args(parser: argparse.ArgumentParser) -> None:
1170
+ parser.add_argument("--format", choices=["json", "markdown", "content"], default="json")
1171
+ parser.add_argument("--output", default="", help="Write rendered output to a file.")
1172
+
1173
+
1174
+ def _is_secret_key(key: str) -> bool:
1175
+ upper_key = key.upper()
1176
+ return "KEY" in upper_key or "TOKEN" in upper_key or "SECRET" in upper_key
1177
+
1178
+
1179
+ def _is_private_display_key(key: str) -> bool:
1180
+ return key.upper().endswith("_URL") or key.upper().endswith("_BASE_URL")
1181
+
1182
+
1183
+ def _t(lang: str, zh: str, en: str) -> str:
1184
+ return zh if lang == "zh" else en
1185
+
1186
+
1187
+ def _display_provider(provider: str, lang: str) -> str:
1188
+ names = {
1189
+ "xai-responses": "xAI Responses",
1190
+ "openai-compatible": "OpenAI-compatible",
1191
+ "zhipu": _t(lang, "智谱", "Zhipu"),
1192
+ "zhipu-mcp": _t(lang, "智谱 Coding Plan MCP", "Zhipu Coding Plan MCP"),
1193
+ "zhipu-mcp-reader": _t(lang, "智谱 MCP Reader", "Zhipu MCP Reader"),
1194
+ "exa": "Exa",
1195
+ "context7": "Context7",
1196
+ "jina": "Jina Reader",
1197
+ "tavily": "Tavily",
1198
+ "firecrawl": "Firecrawl",
1199
+ }
1200
+ return names.get(provider, provider)
1201
+
1202
+
1203
+ def _with_scheme(url: str) -> str:
1204
+ value = url.strip()
1205
+ if not value:
1206
+ return ""
1207
+ if "://" not in value:
1208
+ return f"https://{value}"
1209
+ return value
1210
+
1211
+
1212
+ def _normalize_custom_base_url(url: str) -> str:
1213
+ value = _with_scheme(url).strip()
1214
+ return value.rstrip("/") if value else ""
1215
+
1216
+
1217
+ def _normalize_tavily_api_url(url: str, *, hikari: bool = True) -> str:
1218
+ value = _normalize_custom_base_url(url)
1219
+ if not value:
1220
+ return ""
1221
+ parsed = urlsplit(value)
1222
+ host = parsed.netloc.lower()
1223
+ path = parsed.path.rstrip("/")
1224
+ if host == "api.tavily.com":
1225
+ return urlunsplit((parsed.scheme, parsed.netloc, path or "", "", ""))
1226
+ if hikari and path in {"", "/mcp"}:
1227
+ return urlunsplit((parsed.scheme, parsed.netloc, "/api/tavily", "", ""))
1228
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
1229
+
1230
+
1231
+ def _normalize_tavily_flag_api_url(url: str, api_key: str = "") -> str:
1232
+ value = _normalize_custom_base_url(url)
1233
+ if not value:
1234
+ return ""
1235
+ parsed = urlsplit(value)
1236
+ path = parsed.path.rstrip("/")
1237
+ if path == "/mcp" or _is_tavily_hikari_key(api_key):
1238
+ return _normalize_tavily_api_url(value)
1239
+ return _normalize_tavily_api_url(value, hikari=False)
1240
+
1241
+
1242
+ def _normalize_firecrawl_api_url(url: str) -> str:
1243
+ return _normalize_custom_base_url(url)
1244
+
1245
+
1246
+ def _normalize_zhipu_api_url(url: str) -> str:
1247
+ return _normalize_custom_base_url(url)
1248
+
1249
+
1250
+ def _normalize_jina_reader_api_url(url: str) -> str:
1251
+ return _normalize_custom_base_url(url)
1252
+
1253
+
1254
+ def _config_value_source(key: str) -> str:
1255
+ getter = getattr(service.config, "get_config_source", None)
1256
+ if callable(getter):
1257
+ return str(getter(key))
1258
+ return "default"
1259
+
1260
+
1261
+ def _current_config_value(key: str, current: dict[str, str]) -> str:
1262
+ value = str(current.get(key, "") or "")
1263
+ if value:
1264
+ return value
1265
+ if key == "INTENT_EMBEDDING_API_URL":
1266
+ return str(getattr(service.config, "intent_embedding_api_url", "") or "")
1267
+ if key == "INTENT_EMBEDDING_THRESHOLD":
1268
+ try:
1269
+ return str(getattr(service.config, "intent_embedding_threshold", "") or "")
1270
+ except ValueError:
1271
+ return ""
1272
+ if key == "INTENT_EMBEDDING_MARGIN":
1273
+ try:
1274
+ return str(getattr(service.config, "intent_embedding_margin", "") or "")
1275
+ except ValueError:
1276
+ return ""
1277
+ return ""
1278
+
1279
+
1280
+ def _matches_float_text(value: str, expected: str) -> bool:
1281
+ try:
1282
+ return abs(float(str(value).strip()) - float(expected)) < 0.0005
1283
+ except (TypeError, ValueError):
1284
+ return False
1285
+
1286
+
1287
+ def _apply_embedding_setup_preset(
1288
+ values: dict[str, str],
1289
+ current: dict[str, str],
1290
+ *,
1291
+ interactive: bool,
1292
+ lang: str,
1293
+ ) -> list[str]:
1294
+ warnings: list[str] = []
1295
+ merged = _merge_setup_values(current, values)
1296
+ model = merged.get("INTENT_EMBEDDING_MODEL", "")
1297
+ preset = embedding_preset_for_model(model)
1298
+ if not preset:
1299
+ return warnings
1300
+
1301
+ for key, preset_value in (
1302
+ ("INTENT_EMBEDDING_API_URL", preset.api_url),
1303
+ ("INTENT_EMBEDDING_THRESHOLD", preset.threshold),
1304
+ ("INTENT_EMBEDDING_MARGIN", preset.margin),
1305
+ ):
1306
+ if values.get(key):
1307
+ continue
1308
+ source = _config_value_source(key)
1309
+ current_value = _current_config_value(key, current)
1310
+ if source == "default" or not current_value:
1311
+ values[key] = preset_value
1312
+ continue
1313
+ if key == "INTENT_EMBEDDING_API_URL":
1314
+ matches = current_value.rstrip("/") == preset_value
1315
+ else:
1316
+ matches = _matches_float_text(current_value, preset_value)
1317
+ if not matches:
1318
+ warning = (
1319
+ f"{key} is currently {current_value}; recommended for {preset.model} is {preset_value}."
1320
+ )
1321
+ warnings.append(warning)
1322
+ if interactive:
1323
+ _write_stderr(
1324
+ _t(
1325
+ lang,
1326
+ f"提示: {warning}\n",
1327
+ f"Note: {warning}\n",
1328
+ )
1329
+ )
1330
+ return warnings
1331
+
1332
+
1333
+ def _has_embedding_setup_values(values: dict[str, str]) -> bool:
1334
+ return any(
1335
+ bool(values.get(key))
1336
+ for key in (
1337
+ "INTENT_EMBEDDING_API_URL",
1338
+ "INTENT_EMBEDDING_API_KEY",
1339
+ "INTENT_EMBEDDING_MODEL",
1340
+ "INTENT_EMBEDDING_THRESHOLD",
1341
+ "INTENT_EMBEDDING_MARGIN",
1342
+ )
1343
+ )
1344
+
1345
+
1346
+ def _is_tavily_hikari_key(api_key: str) -> bool:
1347
+ return api_key.strip().lower().startswith("th-")
1348
+
1349
+
1350
+ def _is_interactive_setup_stream() -> bool:
1351
+ return bool(getattr(sys.stdin, "isatty", lambda: False)() and getattr(sys.stderr, "isatty", lambda: False)())
1352
+
1353
+
1354
+ def _setup_status_from_values(values: dict[str, str]) -> dict[str, Any]:
1355
+ def has(key: str) -> bool:
1356
+ return bool(values.get(key))
1357
+
1358
+ main_configured: set[str] = set()
1359
+ if has("XAI_API_KEY"):
1360
+ main_configured.add("xai-responses")
1361
+ if has("OPENAI_COMPATIBLE_API_URL") and has("OPENAI_COMPATIBLE_API_KEY"):
1362
+ main_configured.add("openai-compatible")
1363
+
1364
+ status = {
1365
+ "main_search": {
1366
+ "configured": [provider for provider in ("xai-responses", "openai-compatible") if provider in main_configured],
1367
+ "fallback_chain": ["xai-responses", "openai-compatible"],
1368
+ },
1369
+ "web_search": {
1370
+ "configured": [
1371
+ provider
1372
+ for provider, configured in [
1373
+ ("zhipu", has("ZHIPU_API_KEY")),
1374
+ ("zhipu-mcp", has("ZHIPU_MCP_API_KEY")),
1375
+ ("tavily", has("TAVILY_API_KEY")),
1376
+ ("firecrawl", has("FIRECRAWL_API_KEY")),
1377
+ ]
1378
+ if configured
1379
+ ],
1380
+ "fallback_chain": ["zhipu", "zhipu-mcp", "tavily", "firecrawl"],
1381
+ },
1382
+ "docs_search": {
1383
+ "configured": [
1384
+ provider
1385
+ for provider, configured in [
1386
+ ("context7", has("CONTEXT7_API_KEY")),
1387
+ ("exa", has("EXA_API_KEY")),
1388
+ ]
1389
+ if configured
1390
+ ],
1391
+ "fallback_chain": ["context7", "exa"],
1392
+ },
1393
+ "web_fetch": {
1394
+ "configured": [
1395
+ provider
1396
+ for provider, configured in [
1397
+ ("tavily", has("TAVILY_API_KEY")),
1398
+ ("jina", has("JINA_API_KEY")),
1399
+ ("zhipu-mcp-reader", has("ZHIPU_MCP_API_KEY")),
1400
+ ("firecrawl", has("FIRECRAWL_API_KEY")),
1401
+ ]
1402
+ if configured
1403
+ ],
1404
+ "fallback_chain": ["tavily", "jina", "zhipu-mcp-reader", "firecrawl"],
1405
+ },
1406
+ }
1407
+ for item in status.values():
1408
+ item["ok"] = bool(item["configured"])
1409
+ return status
1410
+
1411
+
1412
+ def _merge_setup_values(current: dict[str, str], values: dict[str, str]) -> dict[str, str]:
1413
+ merged = dict(current)
1414
+ merged.update({key: value for key, value in values.items() if value})
1415
+ return merged
1416
+
1417
+
1418
+ def _write_setup_status(status: dict[str, Any], lang: str, *, final: bool = False) -> None:
1419
+ title = _t(lang, "最低配置检查", "Minimum profile check") if final else _t(lang, "当前状态", "Current status")
1420
+ _write_stderr(f"\n{title}:\n")
1421
+ required = {"main_search", "docs_search", "web_fetch"}
1422
+ labels = {
1423
+ "main_search": _t(lang, "main_search 主搜索", "main_search primary search"),
1424
+ "docs_search": _t(lang, "docs_search 文档搜索", "docs_search documentation search"),
1425
+ "web_fetch": _t(lang, "web_fetch 网页抓取", "web_fetch page fetch"),
1426
+ "web_search": _t(lang, "web_search 网页补强", "web_search web reinforcement"),
1427
+ }
1428
+ for capability in ("main_search", "docs_search", "web_fetch", "web_search"):
1429
+ item = status.get(capability, {})
1430
+ configured = item.get("configured") or []
1431
+ configured_text = ", ".join(_display_provider(provider, lang) for provider in configured)
1432
+ if item.get("ok"):
1433
+ marker = "OK"
1434
+ value = configured_text
1435
+ elif capability in required:
1436
+ marker = "MISSING"
1437
+ value = _t(lang, "需要至少配置一个 provider", "at least one provider is required")
1438
+ else:
1439
+ marker = "OPTIONAL"
1440
+ value = _t(lang, "未配置", "not configured")
1441
+ _write_stderr(f" [{marker}] {labels[capability]}: {value}\n")
1442
+
1443
+
1444
+ def _prompt_choice(prompt: str, default: str = "") -> str:
1445
+ _write_stderr(prompt)
1446
+ value = input("").strip()
1447
+ return value or default
1448
+
1449
+
1450
+ def _prompt_yes_no(prompt: str, default: bool = False) -> bool:
1451
+ default_text = "Y/n" if default else "y/N"
1452
+ answer = _prompt_choice(f"{prompt} [{default_text}]: ", "y" if default else "n").strip().lower()
1453
+ return answer in {"y", "yes", "是", "好", "1", "true"}
1454
+
1455
+
1456
+ def _prompt_value(key: str, label: str, current: str = "", optional: bool = False, lang: str = "en") -> str:
1457
+ suffix = _t(lang, " 可选", " optional") if optional else _t(lang, " 必填", " required")
1458
+ current_display = (
1459
+ _t(lang, "已配置,回车保留", "configured, press Enter to keep")
1460
+ if current and (_is_secret_key(key) or _is_private_display_key(key))
1461
+ else current
1462
+ )
1463
+ if current:
1464
+ prompt = f"{label}{suffix} [{current_display}]: "
1465
+ else:
1466
+ prompt = f"{label}{suffix}: "
1467
+ if _is_secret_key(key):
1468
+ value = getpass.getpass(_stream_safe(sys.stderr, prompt)).strip()
1469
+ else:
1470
+ _write_stderr(prompt)
1471
+ value = input("").strip()
1472
+ return value or current
1473
+
1474
+
1475
+ def _ascii_choice_values(choices: list[dict[str, Any]]) -> list[dict[str, Any]]:
1476
+ return [
1477
+ {**choice, "name": _stream_safe(sys.stderr, str(choice.get("name", "")))}
1478
+ for choice in choices
1479
+ ]
1480
+
1481
+
1482
+ def _select_with_tui(message: str, choices: list[dict[str, Any]], default: Any = None) -> Any:
1483
+ if not _is_interactive_setup_stream():
1484
+ return None
1485
+ try:
1486
+ from InquirerPy import inquirer
1487
+ except Exception:
1488
+ return None
1489
+ try:
1490
+ with contextlib.redirect_stdout(sys.stderr):
1491
+ return inquirer.select(
1492
+ message=_stream_safe(sys.stderr, message),
1493
+ choices=_ascii_choice_values(choices),
1494
+ default=default,
1495
+ qmark="",
1496
+ pointer=">",
1497
+ marker=">",
1498
+ ).execute()
1499
+ except (KeyboardInterrupt, EOFError):
1500
+ raise
1501
+ except Exception:
1502
+ return None
1503
+
1504
+
1505
+ def _checkbox_with_tui(message: str, choices: list[dict[str, Any]]) -> list[str] | None:
1506
+ if not _is_interactive_setup_stream():
1507
+ return None
1508
+ try:
1509
+ from InquirerPy import inquirer
1510
+ except Exception:
1511
+ return None
1512
+ try:
1513
+ with contextlib.redirect_stdout(sys.stderr):
1514
+ result = inquirer.checkbox(
1515
+ message=_stream_safe(sys.stderr, message),
1516
+ choices=_ascii_choice_values(choices),
1517
+ instruction="(Up/Down move, Space select, Enter confirm)",
1518
+ qmark="",
1519
+ pointer=">",
1520
+ enabled_symbol="[x]",
1521
+ disabled_symbol="[ ]",
1522
+ ).execute()
1523
+ return [str(item) for item in result]
1524
+ except (KeyboardInterrupt, EOFError):
1525
+ raise
1526
+ except Exception:
1527
+ return None
1528
+
1529
+
1530
+ def _provider_choices(providers: list[str], selected: list[str], lang: str) -> list[dict[str, Any]]:
1531
+ selected_set = set(selected)
1532
+ return [
1533
+ {"name": _display_provider(provider, lang), "value": provider, "enabled": provider in selected_set}
1534
+ for provider in providers
1535
+ ]
1536
+
1537
+
1538
+ def _prompt_provider_multi_select(
1539
+ message: str,
1540
+ providers: list[str],
1541
+ default_selected: list[str],
1542
+ lang: str,
1543
+ ) -> list[str]:
1544
+ tui_value = _checkbox_with_tui(message, _provider_choices(providers, default_selected, lang))
1545
+ if tui_value is not None:
1546
+ return [provider for provider in providers if provider in set(tui_value)]
1547
+
1548
+ default_text = ",".join(default_selected) if default_selected else "skip"
1549
+ _write_stderr(f"{message} [{'/'.join(providers)}/skip] ({default_text}): ")
1550
+ raw = input("").strip().lower()
1551
+ if not raw:
1552
+ return [provider for provider in providers if provider in set(default_selected)]
1553
+ aliases = {
1554
+ "跳过": "skip",
1555
+ "无": "skip",
1556
+ "n": "skip",
1557
+ "no": "skip",
1558
+ "否": "skip",
1559
+ "都配": "all",
1560
+ "全部": "all",
1561
+ "两个": "all",
1562
+ "both": "all",
1563
+ "all": "all",
1564
+ "xai": "xai-responses",
1565
+ "openai": "openai-compatible",
1566
+ "ctx7": "context7",
1567
+ "context": "context7",
1568
+ }
1569
+ tokens = [aliases.get(part.strip(), part.strip()) for part in raw.replace("+", ",").replace(";", ",").split(",")]
1570
+ if len(tokens) == 1 and " " in tokens[0]:
1571
+ tokens = [aliases.get(part.strip(), part.strip()) for part in tokens[0].split()]
1572
+ if "skip" in tokens or "none" in tokens:
1573
+ return []
1574
+ if "all" in tokens:
1575
+ return providers
1576
+ selected = [provider for provider in providers if provider in tokens]
1577
+ return selected if selected else [provider for provider in providers if provider in set(default_selected)]
1578
+
1579
+
1580
+ def _prompt_select(message: str, choices: list[dict[str, Any]], default: str) -> str:
1581
+ tui_value = _select_with_tui(message, choices, default)
1582
+ if tui_value is not None:
1583
+ return str(tui_value)
1584
+ choice_values = [str(choice["value"]) for choice in choices]
1585
+ _write_stderr(f"{message} [{'/'.join(choice_values)}] ({default}): ")
1586
+ value = input("").strip().lower()
1587
+ return value if value in set(choice_values) else default
1588
+
1589
+
1590
+ def _select_setup_language(lang: str = "") -> str:
1591
+ if lang in {"zh", "en"}:
1592
+ return lang
1593
+ choices = [
1594
+ {"name": "中文", "value": "zh"},
1595
+ {"name": "English", "value": "en"},
1596
+ ]
1597
+ answer = _prompt_select("Language / 语言", choices, "zh").strip().lower()
1598
+ if answer in {"en", "english"}:
1599
+ return "en"
1600
+ return "zh"
1601
+
1602
+
1603
+ def _skill_target_choices(selected: list[str], lang: str) -> list[dict[str, Any]]:
1604
+ selected_set = set(selected)
1605
+ choices: list[dict[str, Any]] = []
1606
+ for target in SKILL_TARGETS:
1607
+ label = target.label
1608
+ name = f"{label} (~/{target.relative_root})"
1609
+ choices.append({"name": name, "value": target.target_id, "enabled": target.target_id in selected_set})
1610
+ return choices
1611
+
1612
+
1613
+ def _prompt_skill_targets(lang: str) -> list[str]:
1614
+ _write_stderr(
1615
+ _t(
1616
+ lang,
1617
+ "\n[可选] 安装 smart-search-cli skill\n用途: 让本机全局 AI 工具知道优先调用 smart-search CLI。\n提示: 只安装 Smart Search skill;不会初始化 Trellis,也不会生成 hooks、agents 或 commands。\n",
1618
+ "\n[Optional] Install the smart-search-cli skill\nPurpose: teach user-level AI tools on this machine to call the smart-search CLI first.\nNote: this only installs the Smart Search skill; it does not initialize Trellis or generate hooks, agents, or commands.\n",
1619
+ )
1620
+ )
1621
+ tui_value = _checkbox_with_tui(
1622
+ _t(lang, "安装给哪些 AI 工具使用?", "Install for which AI tools?"),
1623
+ _skill_target_choices(DEFAULT_SKILL_TARGET_IDS, lang),
1624
+ )
1625
+ if tui_value is not None:
1626
+ return [target.target_id for target in SKILL_TARGETS if target.target_id in set(tui_value)]
1627
+
1628
+ default_text = ",".join(DEFAULT_SKILL_TARGET_IDS)
1629
+ _write_stderr(
1630
+ _t(
1631
+ lang,
1632
+ f"安装 skill 目标 [codex,claude,cursor,.../all/skip] ({default_text}): ",
1633
+ f"Skill install targets [codex,claude,cursor,.../all/skip] ({default_text}): ",
1634
+ )
1635
+ )
1636
+ raw = input("").strip()
1637
+ if not raw:
1638
+ return list(DEFAULT_SKILL_TARGET_IDS)
1639
+ try:
1640
+ return parse_skill_targets(raw)
1641
+ except SkillInstallError as e:
1642
+ _write_stderr(f"{e}\n")
1643
+ return list(DEFAULT_SKILL_TARGET_IDS)
1644
+
1645
+
1646
+ def _setup_choice(prompt: str, choices: set[str], default: str) -> str:
1647
+ value = _prompt_choice(prompt, default).strip().lower()
1648
+ aliases = {
1649
+ "保持": "keep",
1650
+ "跳过": "skip",
1651
+ "都配": "both",
1652
+ "两个": "both",
1653
+ "是": "yes",
1654
+ "否": "no",
1655
+ }
1656
+ value = aliases.get(value, value)
1657
+ return value if value in choices else default
1658
+
1659
+
1660
+ def _prompt_main_search(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1661
+ status = _setup_status_from_values(_merge_setup_values(current, values))
1662
+ configured = status["main_search"]["configured"]
1663
+ default_selected = configured or ["xai-responses"]
1664
+ _write_stderr(
1665
+ _t(
1666
+ lang,
1667
+ "\n[1/3 必选] main_search 主搜索\n用途: 负责综合搜索回答和最终合成。\n推荐: 有 xAI key 选 xai;有中转服务选 openai;两者都配可以同能力兜底。\n",
1668
+ "\n[1/3 Required] main_search primary search\nPurpose: broad search answers and final synthesis.\nRecommended: choose xai for an xAI key, openai for a relay, or both for same-capability fallback.\n",
1669
+ )
1670
+ )
1671
+ selected = _prompt_provider_multi_select(
1672
+ _t(
1673
+ lang,
1674
+ "选择 main_search provider",
1675
+ "Choose main_search providers",
1676
+ ),
1677
+ ["xai-responses", "openai-compatible"],
1678
+ default_selected,
1679
+ lang,
1680
+ )
1681
+ if "xai-responses" in selected:
1682
+ values["XAI_API_KEY"] = _prompt_value("XAI_API_KEY", "xAI API key", current.get("XAI_API_KEY", ""), lang=lang)
1683
+ values["XAI_MODEL"] = _prompt_value(
1684
+ "XAI_MODEL",
1685
+ _t(lang, "xAI Responses 模型", "xAI Responses model"),
1686
+ current.get("XAI_MODEL", ""),
1687
+ optional=True,
1688
+ lang=lang,
1689
+ )
1690
+ if "openai-compatible" in selected:
1691
+ values["OPENAI_COMPATIBLE_API_URL"] = _prompt_value(
1692
+ "OPENAI_COMPATIBLE_API_URL",
1693
+ _t(
1694
+ lang,
1695
+ "OpenAI-compatible API 地址(示例: https://api.openai.com/v1)",
1696
+ "OpenAI-compatible API URL (example: https://api.openai.com/v1)",
1697
+ ),
1698
+ current.get("OPENAI_COMPATIBLE_API_URL", ""),
1699
+ lang=lang,
1700
+ )
1701
+ values["OPENAI_COMPATIBLE_API_KEY"] = _prompt_value(
1702
+ "OPENAI_COMPATIBLE_API_KEY",
1703
+ "OpenAI-compatible API key",
1704
+ current.get("OPENAI_COMPATIBLE_API_KEY", ""),
1705
+ lang=lang,
1706
+ )
1707
+ values["OPENAI_COMPATIBLE_MODEL"] = _prompt_value(
1708
+ "OPENAI_COMPATIBLE_MODEL",
1709
+ _t(lang, "OpenAI-compatible 模型", "OpenAI-compatible model"),
1710
+ current.get("OPENAI_COMPATIBLE_MODEL", ""),
1711
+ optional=True,
1712
+ lang=lang,
1713
+ )
1714
+ values["OPENAI_COMPATIBLE_FALLBACK_MODELS"] = _prompt_value(
1715
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS",
1716
+ _t(lang, "OpenAI-compatible 备用模型(逗号分隔,可留空)", "OpenAI-compatible fallback models (comma-separated, optional)"),
1717
+ current.get("OPENAI_COMPATIBLE_FALLBACK_MODELS", ""),
1718
+ optional=True,
1719
+ lang=lang,
1720
+ )
1721
+ stream_default = current.get("OPENAI_COMPATIBLE_STREAM", "")
1722
+ if _prompt_yes_no(
1723
+ _t(
1724
+ lang,
1725
+ f"是否启用 OpenAI-compatible stream=true?用于部分中转长请求兼容 [{stream_default or 'false'}]: ",
1726
+ f"Enable OpenAI-compatible stream=true for relay long-request compatibility [{stream_default or 'false'}]: ",
1727
+ ),
1728
+ default=(str(stream_default).lower() in {"true", "1", "yes"}),
1729
+ ):
1730
+ values["OPENAI_COMPATIBLE_STREAM"] = "true"
1731
+ elif stream_default:
1732
+ values["OPENAI_COMPATIBLE_STREAM"] = "false"
1733
+
1734
+
1735
+ def _prompt_docs_search(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1736
+ status = _setup_status_from_values(_merge_setup_values(current, values))
1737
+ default_selected = status["docs_search"]["configured"] or ["context7"]
1738
+ _write_stderr(
1739
+ _t(
1740
+ lang,
1741
+ "\n[2/3 必选] docs_search 文档搜索\n用途: 查官方文档、SDK、API、框架和库说明。\n推荐: 文档/API/库优先 Context7;官方域名、论文和低噪声发现再配 Exa。\n",
1742
+ "\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",
1743
+ )
1744
+ )
1745
+ selected = _prompt_provider_multi_select(
1746
+ _t(
1747
+ lang,
1748
+ "选择 docs_search provider",
1749
+ "Choose docs_search providers",
1750
+ ),
1751
+ ["exa", "context7"],
1752
+ default_selected,
1753
+ lang,
1754
+ )
1755
+ if "exa" in selected:
1756
+ values["EXA_API_KEY"] = _prompt_value("EXA_API_KEY", "Exa API key", current.get("EXA_API_KEY", ""), lang=lang)
1757
+ if "context7" in selected:
1758
+ values["CONTEXT7_API_KEY"] = _prompt_value(
1759
+ "CONTEXT7_API_KEY",
1760
+ "Context7 API key",
1761
+ current.get("CONTEXT7_API_KEY", ""),
1762
+ lang=lang,
1763
+ )
1764
+
1765
+
1766
+ def _prompt_tavily_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1767
+ current_url = current.get("TAVILY_API_URL", "")
1768
+ tavily_key = values.get("TAVILY_API_KEY") or current.get("TAVILY_API_KEY", "")
1769
+ if current_url:
1770
+ default_choice = "current"
1771
+ elif _is_tavily_hikari_key(tavily_key):
1772
+ default_choice = "hikari"
1773
+ else:
1774
+ default_choice = "official"
1775
+ choices = []
1776
+ if current_url:
1777
+ choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1778
+ choices.extend([
1779
+ {"name": _t(lang, "官方 Tavily (https://api.tavily.com)", "Official Tavily (https://api.tavily.com)"), "value": "official"},
1780
+ {"name": _t(lang, "Tavily Hikari / 号池", "Tavily Hikari / pooled endpoint"), "value": "hikari"},
1781
+ {"name": _t(lang, "自定义 Tavily REST base", "Custom Tavily REST base"), "value": "custom"},
1782
+ ])
1783
+ choice = _prompt_select(_t(lang, "选择 Tavily endpoint", "Choose Tavily endpoint"), choices, default_choice)
1784
+ if choice == "current":
1785
+ return
1786
+ if choice == "official":
1787
+ values["TAVILY_API_URL"] = TAVILY_DEFAULT_API_URL
1788
+ return
1789
+ if choice == "hikari":
1790
+ _write_stderr(
1791
+ _t(
1792
+ lang,
1793
+ "号池地址填服务商给你的域名或 URL,例如 https://pool.example.com 或 https://pool.example.com/mcp;setup 会保存为 https://pool.example.com/api/tavily。\n",
1794
+ "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",
1795
+ )
1796
+ )
1797
+ label = _t(
1798
+ lang,
1799
+ "Tavily REST 地址",
1800
+ "Tavily REST URL",
1801
+ )
1802
+ raw = _prompt_value("TAVILY_API_URL", label, current_url, optional=False, lang=lang)
1803
+ normalized = _normalize_tavily_api_url(raw) if choice == "hikari" else _normalize_tavily_api_url(raw, hikari=False)
1804
+ if normalized:
1805
+ values["TAVILY_API_URL"] = normalized
1806
+ if normalized != raw.rstrip("/"):
1807
+ _write_stderr(_t(lang, f"已规范化 Tavily REST base: {normalized}\n", f"Normalized Tavily REST base: {normalized}\n"))
1808
+
1809
+
1810
+ def _prompt_firecrawl_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1811
+ current_url = current.get("FIRECRAWL_API_URL", "")
1812
+ choices = []
1813
+ if current_url:
1814
+ choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1815
+ choices.extend([
1816
+ {
1817
+ "name": _t(
1818
+ lang,
1819
+ "官方 Firecrawl (https://api.firecrawl.dev/v2)",
1820
+ "Official Firecrawl (https://api.firecrawl.dev/v2)",
1821
+ ),
1822
+ "value": "official",
1823
+ },
1824
+ {"name": _t(lang, "自定义 Firecrawl REST base", "Custom Firecrawl REST base"), "value": "custom"},
1825
+ ])
1826
+ default_choice = "current" if current_url else "official"
1827
+ choice = _prompt_select(_t(lang, "选择 Firecrawl endpoint", "Choose Firecrawl endpoint"), choices, default_choice)
1828
+ if choice == "current":
1829
+ return
1830
+ if choice == "official":
1831
+ values["FIRECRAWL_API_URL"] = FIRECRAWL_DEFAULT_API_URL
1832
+ return
1833
+ raw = _prompt_value(
1834
+ "FIRECRAWL_API_URL",
1835
+ _t(lang, "Firecrawl 自定义 REST base", "Firecrawl custom REST base"),
1836
+ current_url,
1837
+ optional=False,
1838
+ lang=lang,
1839
+ )
1840
+ normalized = _normalize_firecrawl_api_url(raw)
1841
+ if normalized:
1842
+ values["FIRECRAWL_API_URL"] = normalized
1843
+
1844
+
1845
+ def _prompt_zhipu_api_url(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1846
+ current_url = current.get("ZHIPU_API_URL", "")
1847
+ choices = []
1848
+ if current_url:
1849
+ choices.append({"name": _t(lang, "保留当前地址(已配置)", "Keep current URL (configured)"), "value": "current"})
1850
+ choices.extend([
1851
+ {
1852
+ "name": _t(
1853
+ lang,
1854
+ "官方智谱 Web Search API (https://open.bigmodel.cn/api)",
1855
+ "Official Zhipu Web Search API (https://open.bigmodel.cn/api)",
1856
+ ),
1857
+ "value": "official",
1858
+ },
1859
+ {
1860
+ "name": _t(
1861
+ lang,
1862
+ "自定义智谱 API 地址",
1863
+ "Custom Zhipu API URL",
1864
+ ),
1865
+ "value": "custom",
1866
+ },
1867
+ ])
1868
+ default_choice = "current" if current_url else "official"
1869
+ choice = _prompt_select(_t(lang, "选择智谱 API 地址", "Choose Zhipu API URL"), choices, default_choice)
1870
+ if choice == "current":
1871
+ return
1872
+ if choice == "official":
1873
+ values["ZHIPU_API_URL"] = ZHIPU_DEFAULT_API_URL
1874
+ return
1875
+ raw = _prompt_value(
1876
+ "ZHIPU_API_URL",
1877
+ _t(lang, "智谱 API 地址", "Zhipu API URL"),
1878
+ current_url,
1879
+ optional=False,
1880
+ lang=lang,
1881
+ )
1882
+ normalized = _normalize_zhipu_api_url(raw)
1883
+ if normalized:
1884
+ values["ZHIPU_API_URL"] = normalized
1885
+
1886
+
1887
+ def _prompt_zhipu_search_engine(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1888
+ current_engine = current.get("ZHIPU_SEARCH_ENGINE", "")
1889
+ choices = []
1890
+ if current_engine:
1891
+ choices.append(
1892
+ {
1893
+ "name": _t(
1894
+ lang,
1895
+ f"保留当前搜索服务({current_engine})",
1896
+ f"Keep current search service ({current_engine})",
1897
+ ),
1898
+ "value": "current",
1899
+ }
1900
+ )
1901
+ choices.extend(
1902
+ {"name": engine, "value": engine}
1903
+ for engine in ZHIPU_SEARCH_ENGINE_CHOICES
1904
+ )
1905
+ choices.append({"name": _t(lang, "自定义搜索服务", "Custom search service"), "value": "custom"})
1906
+ default_choice = "current" if current_engine else "search_std"
1907
+ choice = _prompt_select(_t(lang, "选择智谱搜索服务", "Choose Zhipu search service"), choices, default_choice)
1908
+ if choice == "current":
1909
+ return
1910
+ if choice == "custom":
1911
+ raw = _prompt_value(
1912
+ "ZHIPU_SEARCH_ENGINE",
1913
+ _t(lang, "智谱搜索服务", "Zhipu search service"),
1914
+ current_engine,
1915
+ optional=False,
1916
+ lang=lang,
1917
+ )
1918
+ if raw:
1919
+ values["ZHIPU_SEARCH_ENGINE"] = raw.strip()
1920
+ return
1921
+ values["ZHIPU_SEARCH_ENGINE"] = choice
1922
+
1923
+
1924
+ def _prompt_web_fetch(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1925
+ status = _setup_status_from_values(_merge_setup_values(current, values))
1926
+ default_selected = status["web_fetch"]["configured"] or ["tavily"]
1927
+ _write_stderr(
1928
+ _t(
1929
+ lang,
1930
+ "\n[3/3 必选] web_fetch 网页抓取\n用途: 已知 URL 抓正文;高风险事实核验必须用。\n推荐: Tavily 优先;Jina 需要 key 才算标准配置;Firecrawl 可作为抓取兜底。\n",
1931
+ "\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",
1932
+ )
1933
+ )
1934
+ selected = _prompt_provider_multi_select(
1935
+ _t(
1936
+ lang,
1937
+ "选择 web_fetch provider",
1938
+ "Choose web_fetch providers",
1939
+ ),
1940
+ ["tavily", "jina", "firecrawl"],
1941
+ default_selected,
1942
+ lang,
1943
+ )
1944
+ if "tavily" in selected:
1945
+ values["TAVILY_API_KEY"] = _prompt_value("TAVILY_API_KEY", "Tavily API key", current.get("TAVILY_API_KEY", ""), lang=lang)
1946
+ _prompt_tavily_api_url(values, current, lang)
1947
+ if "jina" in selected:
1948
+ values["JINA_API_KEY"] = _prompt_value("JINA_API_KEY", "Jina API key", current.get("JINA_API_KEY", ""), lang=lang)
1949
+ raw_url = _prompt_value(
1950
+ "JINA_READER_API_URL",
1951
+ "Jina Reader API URL",
1952
+ current.get("JINA_READER_API_URL", "https://r.jina.ai"),
1953
+ optional=True,
1954
+ lang=lang,
1955
+ )
1956
+ values["JINA_READER_API_URL"] = _normalize_jina_reader_api_url(raw_url)
1957
+ if "firecrawl" in selected:
1958
+ values["FIRECRAWL_API_KEY"] = _prompt_value(
1959
+ "FIRECRAWL_API_KEY",
1960
+ "Firecrawl API key",
1961
+ current.get("FIRECRAWL_API_KEY", ""),
1962
+ lang=lang,
1963
+ )
1964
+ _prompt_firecrawl_api_url(values, current, lang)
1965
+
1966
+
1967
+ def _prompt_optional_enhancements(values: dict[str, str], current: dict[str, str], lang: str) -> None:
1968
+ _write_stderr(
1969
+ _t(
1970
+ lang,
1971
+ "\n[可选增强] web_search 网页补强\n用途: 中文、国内、时效、域名过滤类来源检索。\n推荐: 中文场景建议配置 Zhipu。\n",
1972
+ "\n[Optional] web_search web reinforcement\nPurpose: Chinese, domestic, current, or domain-filtered source discovery.\nRecommended: configure Zhipu for Chinese/current scenarios.\n",
1973
+ )
1974
+ )
1975
+ default_selected = ["zhipu"] if current.get("ZHIPU_API_KEY") else []
1976
+ selected = _prompt_provider_multi_select(
1977
+ _t(lang, "选择可选 web_search 增强", "Choose optional web_search reinforcement"),
1978
+ ["zhipu"],
1979
+ default_selected,
1980
+ lang,
1981
+ )
1982
+ if "zhipu" in selected:
1983
+ values["ZHIPU_API_KEY"] = _prompt_value("ZHIPU_API_KEY", "Zhipu API key", current.get("ZHIPU_API_KEY", ""), lang=lang)
1984
+ _prompt_zhipu_api_url(values, current, lang)
1985
+ _prompt_zhipu_search_engine(values, current, lang)
1986
+ if _prompt_yes_no(_t(lang, "是否调整验证/兜底默认值?", "Adjust validation/fallback defaults?"), default=False):
1987
+ values["SMART_SEARCH_VALIDATION_LEVEL"] = _prompt_value(
1988
+ "SMART_SEARCH_VALIDATION_LEVEL",
1989
+ _t(lang, "验证强度 (fast/balanced/strict)", "Validation level (fast/balanced/strict)"),
1990
+ current.get("SMART_SEARCH_VALIDATION_LEVEL", ""),
1991
+ optional=True,
1992
+ lang=lang,
1993
+ )
1994
+ values["SMART_SEARCH_FALLBACK_MODE"] = _prompt_value(
1995
+ "SMART_SEARCH_FALLBACK_MODE",
1996
+ _t(lang, "兜底模式 (auto/off)", "Fallback mode (auto/off)"),
1997
+ current.get("SMART_SEARCH_FALLBACK_MODE", ""),
1998
+ optional=True,
1999
+ lang=lang,
2000
+ )
2001
+ values["SMART_SEARCH_MINIMUM_PROFILE"] = _prompt_value(
2002
+ "SMART_SEARCH_MINIMUM_PROFILE",
2003
+ _t(lang, "最低配置门槛 (standard/off)", "Minimum profile (standard/off)"),
2004
+ current.get("SMART_SEARCH_MINIMUM_PROFILE", ""),
2005
+ optional=True,
2006
+ lang=lang,
2007
+ )
2008
+
2009
+
2010
+ def _has_intent_router_config(values: dict[str, str]) -> bool:
2011
+ keys = {
2012
+ "SMART_SEARCH_INTENT_ROUTER",
2013
+ "INTENT_EMBEDDING_API_URL",
2014
+ "INTENT_EMBEDDING_API_KEY",
2015
+ "INTENT_EMBEDDING_MODEL",
2016
+ "INTENT_EMBEDDING_THRESHOLD",
2017
+ "INTENT_EMBEDDING_MARGIN",
2018
+ "INTENT_CLASSIFIER_API_URL",
2019
+ "INTENT_CLASSIFIER_API_KEY",
2020
+ "INTENT_CLASSIFIER_MODEL",
2021
+ "INTENT_ROUTER_TIMEOUT_SECONDS",
2022
+ }
2023
+ return any(bool(values.get(key)) for key in keys)
2024
+
2025
+
2026
+ def _prompt_intent_router(values: dict[str, str], current: dict[str, str], lang: str) -> None:
2027
+ merged = _merge_setup_values(current, values)
2028
+ _write_stderr(
2029
+ _t(
2030
+ lang,
2031
+ "\n[可选增强] 智能意图路由\n用途: 诊断问题与 docs_search、web_search、web_fetch 的匹配关系。\n说明: Agent 查询命令已明确 operation,路由器只保留为诊断能力。\n",
2032
+ "\n[Optional] smart intent routing\nPurpose: diagnose how a query maps to docs_search, web_search, or web_fetch.\nNote: Agent commands already declare their operation; the router is diagnostic only.\n",
2033
+ )
2034
+ )
2035
+ default_configure = _has_intent_router_config(merged)
2036
+ if not _prompt_yes_no(
2037
+ _t(lang, "是否配置/更新智能意图路由?", "Configure or update smart intent routing?"),
2038
+ default=default_configure,
2039
+ ):
2040
+ return
2041
+
2042
+ mode_default = values.get("SMART_SEARCH_INTENT_ROUTER") or current.get("SMART_SEARCH_INTENT_ROUTER") or "hybrid"
2043
+ if mode_default not in {"hybrid", "rules", "off"}:
2044
+ mode_default = "hybrid"
2045
+ mode = _prompt_select(
2046
+ _t(lang, "选择 intent router 模式", "Choose intent router mode"),
2047
+ [
2048
+ {"name": _t(lang, "hybrid: 规则 + embeddings + classifier,缺配置自动降级 rules", "hybrid: rules + embeddings + classifier, degrading to rules when optional config is missing"), "value": "hybrid"},
2049
+ {"name": _t(lang, "rules: 只用本地规则", "rules: local rules only"), "value": "rules"},
2050
+ {"name": _t(lang, "off: 关闭额外意图路由", "off: disable additional intent routing"), "value": "off"},
2051
+ ],
2052
+ mode_default,
2053
+ )
2054
+ values["SMART_SEARCH_INTENT_ROUTER"] = mode
2055
+ if mode != "hybrid":
2056
+ return
2057
+
2058
+ if _prompt_yes_no(
2059
+ _t(lang, "配置 embeddings 语义路由?", "Configure embeddings semantic routing?"),
2060
+ default=bool(merged.get("INTENT_EMBEDDING_API_URL") or merged.get("INTENT_EMBEDDING_API_KEY") or merged.get("INTENT_EMBEDDING_MODEL")),
2061
+ ):
2062
+ _write_stderr(
2063
+ _t(
2064
+ lang,
2065
+ "推荐 preset: SiliconFlow + Qwen/Qwen3-Embedding-8B;setup 会自动使用 threshold=0.475、margin=0.053。\n",
2066
+ "Recommended preset: SiliconFlow + Qwen/Qwen3-Embedding-8B; setup will use threshold=0.475 and margin=0.053 automatically.\n",
2067
+ )
2068
+ )
2069
+ raw_url = _prompt_value(
2070
+ "INTENT_EMBEDDING_API_URL",
2071
+ _t(
2072
+ lang,
2073
+ f"Embeddings API 地址(推荐: {QWEN3_EMBEDDING_8B_PRESET.api_url})",
2074
+ f"Embeddings API URL (recommended: {QWEN3_EMBEDDING_8B_PRESET.api_url})",
2075
+ ),
2076
+ current.get("INTENT_EMBEDDING_API_URL", ""),
2077
+ optional=True,
2078
+ lang=lang,
2079
+ )
2080
+ values["INTENT_EMBEDDING_API_URL"] = _normalize_custom_base_url(raw_url)
2081
+ values["INTENT_EMBEDDING_API_KEY"] = _prompt_value(
2082
+ "INTENT_EMBEDDING_API_KEY",
2083
+ "Embeddings API key",
2084
+ current.get("INTENT_EMBEDDING_API_KEY", ""),
2085
+ optional=True,
2086
+ lang=lang,
2087
+ )
2088
+ values["INTENT_EMBEDDING_MODEL"] = _prompt_value(
2089
+ "INTENT_EMBEDDING_MODEL",
2090
+ _t(lang, "Embeddings 模型(推荐: Qwen/Qwen3-Embedding-8B)", "Embeddings model (recommended: Qwen/Qwen3-Embedding-8B)"),
2091
+ current.get("INTENT_EMBEDDING_MODEL", "") or QWEN3_EMBEDDING_8B_PRESET.model,
2092
+ optional=True,
2093
+ lang=lang,
2094
+ )
2095
+
2096
+ if _prompt_yes_no(
2097
+ _t(lang, "配置 classifier 模型路由?", "Configure classifier model routing?"),
2098
+ default=bool(merged.get("INTENT_CLASSIFIER_API_URL") or merged.get("INTENT_CLASSIFIER_API_KEY") or merged.get("INTENT_CLASSIFIER_MODEL")),
2099
+ ):
2100
+ raw_url = _prompt_value(
2101
+ "INTENT_CLASSIFIER_API_URL",
2102
+ _t(
2103
+ lang,
2104
+ "Classifier API 地址(示例: https://api.openai.com/v1/chat/completions)",
2105
+ "Classifier API URL (example: https://api.openai.com/v1/chat/completions)",
2106
+ ),
2107
+ current.get("INTENT_CLASSIFIER_API_URL", ""),
2108
+ optional=True,
2109
+ lang=lang,
2110
+ )
2111
+ values["INTENT_CLASSIFIER_API_URL"] = _normalize_custom_base_url(raw_url)
2112
+ values["INTENT_CLASSIFIER_API_KEY"] = _prompt_value(
2113
+ "INTENT_CLASSIFIER_API_KEY",
2114
+ "Classifier API key",
2115
+ current.get("INTENT_CLASSIFIER_API_KEY", ""),
2116
+ optional=True,
2117
+ lang=lang,
2118
+ )
2119
+ values["INTENT_CLASSIFIER_MODEL"] = _prompt_value(
2120
+ "INTENT_CLASSIFIER_MODEL",
2121
+ _t(lang, "Classifier 模型", "Classifier model"),
2122
+ current.get("INTENT_CLASSIFIER_MODEL", ""),
2123
+ optional=True,
2124
+ lang=lang,
2125
+ )
2126
+
2127
+ values["INTENT_ROUTER_TIMEOUT_SECONDS"] = _prompt_value(
2128
+ "INTENT_ROUTER_TIMEOUT_SECONDS",
2129
+ _t(lang, "Intent router 超时秒数", "Intent router timeout seconds"),
2130
+ current.get("INTENT_ROUTER_TIMEOUT_SECONDS", ""),
2131
+ optional=True,
2132
+ lang=lang,
2133
+ )
2134
+
2135
+
2136
+ def _write_setup_keep_note(lang: str) -> None:
2137
+ _write_stderr(
2138
+ _t(
2139
+ lang,
2140
+ "\n提示: setup 不会删除旧配置;删除请运行 `smart-search config unset KEY`。\n",
2141
+ "\nNote: setup does not delete saved values; use `smart-search config unset KEY` to remove one.\n",
2142
+ )
2143
+ )
2144
+
2145
+
2146
+ def _write_setup_examples(lang: str) -> None:
2147
+ _write_stderr(
2148
+ _t(
2149
+ lang,
2150
+ "\n不知道怎么填: 先配齐 main_search + docs_search + web_fetch。\n"
2151
+ " main_search: xAI Responses,或 OpenAI-compatible(示例: https://api.openai.com/v1)\n"
2152
+ " docs_search: 文档/API 优先 Context7;官方域名、论文和低噪声发现再配 Exa。\n"
2153
+ " web_fetch: Tavily 官方地址是 https://api.tavily.com;号池填 https://<host>/api/tavily。\n"
2154
+ " intent embeddings: 推荐 SiliconFlow + Qwen/Qwen3-Embedding-8B,setup 会自动补 threshold=0.475、margin=0.053。\n"
2155
+ " key 都填你自己控制台里的;Zhipu / Firecrawl 可以之后再补。\n",
2156
+ "\nIf unsure: first configure main_search + docs_search + web_fetch.\n"
2157
+ " main_search: xAI Responses, or OpenAI-compatible (example: https://api.openai.com/v1)\n"
2158
+ " docs_search: Context7 for docs/API first; add Exa for official domains, papers, and low-noise discovery.\n"
2159
+ " web_fetch: official Tavily endpoint is https://api.tavily.com; pooled endpoints use https://<host>/api/tavily.\n"
2160
+ " intent embeddings: recommended SiliconFlow + Qwen/Qwen3-Embedding-8B; setup auto-fills threshold=0.475 and margin=0.053.\n"
2161
+ " Use keys from your own provider consoles. Zhipu / Firecrawl can be added later.\n",
2162
+ )
2163
+ )
2164
+
2165
+
2166
+ def _run_guided_setup_prompts(
2167
+ values: dict[str, str],
2168
+ current: dict[str, str],
2169
+ lang: str,
2170
+ *,
2171
+ skill_targets: list[str] | None = None,
2172
+ show_banner: bool = True,
2173
+ ) -> None:
2174
+ config_file = service.config_path()["config_file"]
2175
+ if show_banner:
2176
+ _write_setup_banner(lang)
2177
+ _write_panel(
2178
+ _t(
2179
+ lang,
2180
+ f"\nSmart Search 配置向导\n配置文件: {config_file}\n\n目标: standard 最低可用配置\n操作: 方向键移动,空格勾选,回车确认;API key 输入不显示。\n最低要求: main_search + docs_search + web_fetch 各至少一个 provider。\n",
2181
+ 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",
2182
+ ),
2183
+ lang,
2184
+ )
2185
+ _write_setup_keep_note(lang)
2186
+ _write_setup_examples(lang)
2187
+ _write_setup_status(_setup_status_from_values(_merge_setup_values(current, values)), lang)
2188
+ if skill_targets is not None:
2189
+ skill_targets[:] = _prompt_skill_targets(lang)
2190
+ _prompt_main_search(values, current, lang)
2191
+ _prompt_docs_search(values, current, lang)
2192
+ _prompt_web_fetch(values, current, lang)
2193
+ _prompt_optional_enhancements(values, current, lang)
2194
+ _prompt_intent_router(values, current, lang)
2195
+
2196
+
2197
+ def _write_skill_install_summary(result: dict[str, Any], lang: str) -> None:
2198
+ if not result.get("selected"):
2199
+ _write_stderr(_t(lang, "\nSkill 安装: 已跳过。\n", "\nSkill install: skipped.\n"))
2200
+ return
2201
+ _write_stderr(
2202
+ _t(
2203
+ lang,
2204
+ f"\nSkill 安装结果: installed {result.get('installed_count', 0)}, skipped {result.get('skipped_count', 0)}, failed {result.get('failed_count', 0)}\n",
2205
+ f"\nSkill install result: installed {result.get('installed_count', 0)}, skipped {result.get('skipped_count', 0)}, failed {result.get('failed_count', 0)}\n",
2206
+ )
2207
+ )
2208
+ for item in result.get("installed", []):
2209
+ _write_stderr(f" [OK] {item.get('label')} -> {item.get('path')}\n")
2210
+ for item in result.get("failed", []):
2211
+ _write_stderr(f" [FAILED] {item.get('label')} -> {item.get('path')}: {item.get('error')}\n")
2212
+
2213
+
2214
+ def _run_advanced_setup_prompts(values: dict[str, str], current: dict[str, str], lang: str) -> None:
2215
+ _write_stderr(
2216
+ _t(
2217
+ lang,
2218
+ "\n高级模式: 逐项配置底层键。一般用户建议直接使用默认分组向导。\n",
2219
+ "\nAdvanced mode: configure low-level keys one by one. Most users should use the grouped wizard.\n",
2220
+ )
2221
+ )
2222
+ prompts = [
2223
+ ("XAI_API_URL", "xAI Responses API URL", True),
2224
+ ("XAI_API_KEY", "xAI API key", True),
2225
+ ("XAI_MODEL", "xAI Responses model", True),
2226
+ ("XAI_TOOLS", "xAI Responses tools (web_search,x_search)", True),
2227
+ ("OPENAI_COMPATIBLE_API_URL", "OpenAI-compatible API URL", True),
2228
+ ("OPENAI_COMPATIBLE_API_KEY", "OpenAI-compatible API key", True),
2229
+ ("OPENAI_COMPATIBLE_MODEL", "OpenAI-compatible model", True),
2230
+ ("OPENAI_COMPATIBLE_FALLBACK_MODELS", "OpenAI-compatible fallback models (comma-separated)", True),
2231
+ ("OPENAI_COMPATIBLE_STREAM", "OpenAI-compatible stream mode (true/false)", True),
2232
+ ("SMART_SEARCH_VALIDATION_LEVEL", "Validation level (fast/balanced/strict)", True),
2233
+ ("SMART_SEARCH_FALLBACK_MODE", "Fallback mode (auto/off)", True),
2234
+ ("SMART_SEARCH_MINIMUM_PROFILE", "Minimum profile (standard/off)", True),
2235
+ ("SMART_SEARCH_OPERATION_CONFIG", "Operation routing JSON", True),
2236
+ ("SMART_SEARCH_INTENT_ROUTER", "Intent router mode (hybrid/rules/off)", True),
2237
+ ("INTENT_EMBEDDING_API_URL", "Intent embedding API URL", True),
2238
+ ("INTENT_EMBEDDING_API_KEY", "Intent embedding API key", True),
2239
+ ("INTENT_EMBEDDING_MODEL", "Intent embedding model", True),
2240
+ ("INTENT_EMBEDDING_THRESHOLD", "Intent embedding threshold (0-1)", True),
2241
+ ("INTENT_EMBEDDING_MARGIN", "Intent embedding margin (0-1)", True),
2242
+ ("INTENT_CLASSIFIER_API_URL", "Intent classifier API URL", True),
2243
+ ("INTENT_CLASSIFIER_API_KEY", "Intent classifier API key", True),
2244
+ ("INTENT_CLASSIFIER_MODEL", "Intent classifier model", True),
2245
+ ("INTENT_ROUTER_TIMEOUT_SECONDS", "Intent router timeout seconds", True),
2246
+ ("EXA_API_KEY", "Exa API key", True),
2247
+ ("CONTEXT7_API_KEY", "Context7 API key", True),
2248
+ ("ZHIPU_API_KEY", "Zhipu API key", True),
2249
+ ("ZHIPU_API_URL", "Zhipu Web Search API URL", True),
2250
+ ("ZHIPU_SEARCH_ENGINE", "Zhipu search service (search_std/search_pro/search_pro_sogou/search_pro_quark/custom)", True),
2251
+ ("ZHIPU_MCP_API_KEY", "Zhipu Coding Plan MCP API key", True),
2252
+ ("ZHIPU_MCP_SEARCH_API_URL", "Zhipu Coding Plan search MCP URL", True),
2253
+ ("ZHIPU_MCP_READER_API_URL", "Zhipu Coding Plan reader MCP URL", True),
2254
+ ("ZHIPU_MCP_ZREAD_API_URL", "Zhipu Coding Plan zread MCP URL", True),
2255
+ ("ZHIPU_MCP_TIMEOUT_SECONDS", "Zhipu Coding Plan MCP timeout seconds", True),
2256
+ ("JINA_API_KEY", "Jina API key", True),
2257
+ ("JINA_READER_API_URL", "Jina Reader API URL", True),
2258
+ ("JINA_RESPOND_WITH", "Jina respond-with mode (optional, e.g. readerlm-v2)", True),
2259
+ ("JINA_TIMEOUT_SECONDS", "Jina timeout seconds", True),
2260
+ ("TAVILY_API_URL", "Tavily API URL", True),
2261
+ ("TAVILY_API_KEY", "Tavily API key", True),
2262
+ ("FIRECRAWL_API_URL", "Firecrawl API URL", True),
2263
+ ("FIRECRAWL_API_KEY", "Firecrawl API key", True),
2264
+ ]
2265
+ for key, label, optional in prompts:
2266
+ if values[key]:
2267
+ continue
2268
+ value = _prompt_value(key, label, current.get(key, ""), optional=optional, lang=lang)
2269
+ if key == "TAVILY_API_URL":
2270
+ value = _normalize_tavily_api_url(value)
2271
+ elif key == "FIRECRAWL_API_URL":
2272
+ value = _normalize_firecrawl_api_url(value)
2273
+ elif key == "ZHIPU_API_URL":
2274
+ value = _normalize_zhipu_api_url(value)
2275
+ elif key == "JINA_READER_API_URL":
2276
+ value = _normalize_jina_reader_api_url(value)
2277
+ elif key in {"ZHIPU_MCP_SEARCH_API_URL", "ZHIPU_MCP_READER_API_URL", "ZHIPU_MCP_ZREAD_API_URL"}:
2278
+ value = _normalize_custom_base_url(value)
2279
+ values[key] = value
2280
+
2281
+
2282
+ async def _run_async(args: argparse.Namespace) -> int:
2283
+ if args.command == "search":
2284
+ if args.operation == "answer":
2285
+ data = await service.search_answer(args.query, stream=args.stream, timeout_seconds=args.timeout, debug=args.debug)
2286
+ elif args.operation == "sources":
2287
+ data = await service.search_sources(
2288
+ args.query,
2289
+ limit=args.limit,
2290
+ mode=args.mode,
2291
+ start_published_date=args.start_published_date,
2292
+ include_domains=args.include_domains,
2293
+ exclude_domains=args.exclude_domains,
2294
+ category=args.category,
2295
+ include_text=args.include_text,
2296
+ include_highlights=args.include_highlights,
2297
+ debug=args.debug,
2298
+ )
2299
+ else:
2300
+ data = await service.search_similar(args.url, limit=args.limit, debug=args.debug)
2301
+ return _print_result("search", data, args.format, args.output)
2302
+ if args.command == "docs":
2303
+ if args.operation == "resolve":
2304
+ data = await service.docs_resolve(args.name, args.query, debug=args.debug)
2305
+ elif args.operation == "search":
2306
+ data = await service.docs_search(args.query, source=args.source, debug=args.debug)
2307
+ elif args.operation == "tree":
2308
+ data = await service.docs_tree(args.repo, path=args.path, ref=args.ref, debug=args.debug)
2309
+ else:
2310
+ data = await service.docs_read(args.repo, args.path, ref=args.ref, debug=args.debug)
2311
+ return _print_result("docs", data, args.format, args.output)
2312
+ if args.command == "route":
2313
+ data = await service.route(args.query, validation=args.validation, mode=args.router_mode)
2314
+ return _print_result("route", data, args.format, args.output)
2315
+ if args.command == "route-calibrate":
2316
+ data = await service.route_calibrate(models=args.models)
2317
+ return _print_result("route-calibrate", data, args.format, args.output)
2318
+ if args.command == "fetch":
2319
+ if args.operation == "content":
2320
+ data = await service.fetch_content(args.url, debug=args.debug)
2321
+ else:
2322
+ schema = json.loads(args.schema) if args.schema else None
2323
+ data = await service.fetch_extract(args.url, max_length=args.max_length, schema=schema, debug=args.debug)
2324
+ return _print_result("fetch", data, args.format, args.output)
2325
+ if args.command == "map":
2326
+ data = await service.map_site_operation(
2327
+ args.url,
2328
+ instructions=args.instructions,
2329
+ max_depth=args.max_depth,
2330
+ max_breadth=args.max_breadth,
2331
+ limit=args.limit,
2332
+ timeout=args.timeout,
2333
+ debug=args.debug,
2334
+ )
2335
+ return _print_result("map", data, args.format, args.output)
2336
+ if args.command == "exa-search":
2337
+ data = await service.search_sources(
2338
+ args.query,
2339
+ limit=args.num_results,
2340
+ mode="semantic" if args.search_type == "neural" else args.search_type,
2341
+ include_text=args.include_text,
2342
+ include_highlights=args.include_highlights,
2343
+ start_published_date=args.start_published_date,
2344
+ include_domains=args.include_domains,
2345
+ exclude_domains=args.exclude_domains,
2346
+ category=args.category,
2347
+ )
2348
+ return _print_result("search", data, args.format, args.output)
2349
+ if args.command == "exa-similar":
2350
+ data = await service.search_similar(args.url, limit=args.num_results)
2351
+ return _print_result("search", data, args.format, args.output)
2352
+ if args.command == "zhipu-search":
2353
+ data = await service.search_sources(args.query, limit=args.count, start_published_date=args.search_recency_filter if args.search_recency_filter != "noLimit" else "", include_domains=[args.search_domain_filter] if args.search_domain_filter else [])
2354
+ return _print_result("search", data, args.format, args.output)
2355
+ if args.command == "zhipu-mcp-search":
2356
+ data = await service.search_sources(args.query, limit=args.count)
2357
+ return _print_result("search", data, args.format, args.output)
2358
+ if args.command == "zhipu-mcp-reader":
2359
+ data = await service.fetch_content(args.url)
2360
+ return _print_result("fetch", data, args.format, args.output)
2361
+ if args.command == "zhipu-mcp-search-doc":
2362
+ data = await service.docs_search(args.query, source=args.repo)
2363
+ return _print_result("docs", data, args.format, args.output)
2364
+ if args.command == "zhipu-mcp-repo-structure":
2365
+ data = await service.docs_tree(args.repo, ref=args.ref)
2366
+ return _print_result("docs", data, args.format, args.output)
2367
+ if args.command == "zhipu-mcp-read-file":
2368
+ data = await service.docs_read(args.repo, args.path, ref=args.ref)
2369
+ return _print_result("docs", data, args.format, args.output)
2370
+ if args.command == "context7-library":
2371
+ data = await service.docs_resolve(args.name, args.query)
2372
+ return _print_result("docs", data, args.format, args.output)
2373
+ if args.command == "context7-docs":
2374
+ data = await service.docs_search(args.query, source=args.library_id)
2375
+ return _print_result("docs", data, args.format, args.output)
2376
+ if args.command == "smoke":
2377
+ data = await service.smoke(args.mode)
2378
+ return _print_result("smoke", data, args.format, args.output)
2379
+ if args.command == "doctor":
2380
+ data = await service.doctor()
2381
+ return _print_result("doctor", data, args.format, args.output)
2382
+ if args.command == "diagnose":
2383
+ if args.diagnose_command in {"search", "docs", "fetch", "map"}:
2384
+ data = await service.diagnose_operation(args.diagnose_command, args.operation)
2385
+ return _print_result("diagnose", data, args.format, args.output)
2386
+ if args.diagnose_command == "provider":
2387
+ data = await service.diagnose_openai_compatible(timeout_seconds=args.timeout)
2388
+ return _print_result("diagnose", data, args.format, args.output)
2389
+ if args.diagnose_command == "route":
2390
+ data = await service.route(args.query, validation=args.validation, mode=args.router_mode)
2391
+ return _print_result("diagnose", data, args.format, args.output)
2392
+ if args.diagnose_command == "route-calibrate":
2393
+ data = await service.route_calibrate(models=args.models)
2394
+ return _print_result("diagnose", data, args.format, args.output)
2395
+ data = await service.smoke(args.mode)
2396
+ return _print_result("diagnose", data, args.format, args.output)
2397
+ return EXIT_PARAMETER_ERROR
2398
+
2399
+
2400
+ def _run_model(args: argparse.Namespace) -> int:
2401
+ if args.model_command == "set":
2402
+ data = service.set_model(args.model)
2403
+ elif args.model_command == "current":
2404
+ data = service.current_model()
2405
+ else:
2406
+ data = {"ok": False, "error_type": "parameter_error", "error": "Unknown model command"}
2407
+ return _print_result("model", data, args.format, args.output)
2408
+
2409
+
2410
+ def _run_config(args: argparse.Namespace) -> int:
2411
+ if args.config_command == "path":
2412
+ data = service.config_path()
2413
+ elif args.config_command == "list":
2414
+ data = service.config_list(show_secrets=False)
2415
+ elif args.config_command == "set":
2416
+ data = service.config_set(args.key, args.value)
2417
+ elif args.config_command == "unset":
2418
+ data = service.config_unset(args.key)
2419
+ else:
2420
+ data = {"ok": False, "error_type": "parameter_error", "error": "Unknown config command"}
2421
+ return _print_result("config", data, args.format, args.output)
2422
+
2423
+
2424
+ def _skill_targets_from_args(args: argparse.Namespace) -> list[str]:
2425
+ if getattr(args, "all", False):
2426
+ return [target.target_id for target in SKILL_TARGETS]
2427
+ raw = getattr(args, "targets", "") or ""
2428
+ if raw:
2429
+ return parse_skill_targets(raw)
2430
+ return list(DEFAULT_SKILL_TARGET_IDS)
2431
+
2432
+
2433
+ def _run_skills(args: argparse.Namespace) -> int:
2434
+ try:
2435
+ target_ids = _skill_targets_from_args(args)
2436
+ except SkillInstallError as e:
2437
+ data = {"ok": False, "error_type": "parameter_error", "error": str(e), "selected": []}
2438
+ return _print_result("skills", data, args.format, args.output)
2439
+
2440
+ if args.skills_command == "status":
2441
+ try:
2442
+ data = status_skill_targets(target_ids, project_root=args.skills_root)
2443
+ except SkillInstallError as e:
2444
+ data = {"ok": False, "error_type": "runtime_error", "error": str(e), "selected": target_ids}
2445
+ return _print_result("skills", data, args.format, args.output)
2446
+
2447
+ if args.skills_command == "update":
2448
+ try:
2449
+ data = install_skill_targets(target_ids, project_root=args.skills_root)
2450
+ except SkillInstallError as e:
2451
+ data = {"ok": False, "error_type": "runtime_error", "error": str(e), "selected": target_ids}
2452
+ return _print_result("skills", data, args.format, args.output)
2453
+
2454
+ data = {"ok": False, "error_type": "parameter_error", "error": "Unknown skills command", "selected": target_ids}
2455
+ return _print_result("skills", data, args.format, args.output)
2456
+
2457
+
2458
+ def _run_setup(args: argparse.Namespace) -> int:
2459
+ try:
2460
+ explicit_skill_targets = parse_skill_targets(args.install_skills) if args.install_skills else []
2461
+ except SkillInstallError as e:
2462
+ data = {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": service.config_path()["config_file"]}
2463
+ return _print_result("setup", data, args.format, args.output)
2464
+
2465
+ values = {
2466
+ "XAI_API_URL": args.xai_api_url,
2467
+ "XAI_API_KEY": args.xai_api_key,
2468
+ "XAI_MODEL": args.xai_model,
2469
+ "XAI_TOOLS": args.xai_tools_explicit,
2470
+ "OPENAI_COMPATIBLE_API_URL": args.openai_compatible_api_url,
2471
+ "OPENAI_COMPATIBLE_API_KEY": args.openai_compatible_api_key,
2472
+ "OPENAI_COMPATIBLE_MODEL": args.openai_compatible_model,
2473
+ "OPENAI_COMPATIBLE_FALLBACK_MODELS": args.openai_compatible_fallback_models,
2474
+ "OPENAI_COMPATIBLE_STREAM": args.openai_compatible_stream,
2475
+ "SMART_SEARCH_VALIDATION_LEVEL": args.validation_level,
2476
+ "SMART_SEARCH_FALLBACK_MODE": args.fallback_mode,
2477
+ "SMART_SEARCH_MINIMUM_PROFILE": args.minimum_profile,
2478
+ "SMART_SEARCH_OPERATION_CONFIG": args.operation_config,
2479
+ "SMART_SEARCH_INTENT_ROUTER": args.intent_router,
2480
+ "INTENT_EMBEDDING_API_URL": _normalize_custom_base_url(args.intent_embedding_api_url),
2481
+ "INTENT_EMBEDDING_API_KEY": args.intent_embedding_api_key,
2482
+ "INTENT_EMBEDDING_MODEL": args.intent_embedding_model,
2483
+ "INTENT_EMBEDDING_THRESHOLD": args.intent_embedding_threshold,
2484
+ "INTENT_EMBEDDING_MARGIN": args.intent_embedding_margin,
2485
+ "INTENT_CLASSIFIER_API_URL": _normalize_custom_base_url(args.intent_classifier_api_url),
2486
+ "INTENT_CLASSIFIER_API_KEY": args.intent_classifier_api_key,
2487
+ "INTENT_CLASSIFIER_MODEL": args.intent_classifier_model,
2488
+ "INTENT_ROUTER_TIMEOUT_SECONDS": args.intent_router_timeout,
2489
+ "EXA_API_KEY": args.exa_key,
2490
+ "CONTEXT7_API_KEY": args.context7_key,
2491
+ "ZHIPU_API_KEY": args.zhipu_key,
2492
+ "ZHIPU_API_URL": _normalize_zhipu_api_url(args.zhipu_api_url),
2493
+ "ZHIPU_SEARCH_ENGINE": args.zhipu_search_engine,
2494
+ "ZHIPU_MCP_API_KEY": args.zhipu_mcp_key,
2495
+ "ZHIPU_MCP_SEARCH_API_URL": _normalize_custom_base_url(args.zhipu_mcp_search_api_url),
2496
+ "ZHIPU_MCP_READER_API_URL": _normalize_custom_base_url(args.zhipu_mcp_reader_api_url),
2497
+ "ZHIPU_MCP_ZREAD_API_URL": _normalize_custom_base_url(args.zhipu_mcp_zread_api_url),
2498
+ "ZHIPU_MCP_TIMEOUT_SECONDS": args.zhipu_mcp_timeout,
2499
+ "JINA_API_KEY": args.jina_key,
2500
+ "JINA_READER_API_URL": _normalize_jina_reader_api_url(args.jina_reader_api_url),
2501
+ "JINA_RESPOND_WITH": args.jina_respond_with,
2502
+ "JINA_TIMEOUT_SECONDS": args.jina_timeout,
2503
+ "TAVILY_API_URL": _normalize_tavily_flag_api_url(args.tavily_api_url, args.tavily_key),
2504
+ "TAVILY_API_KEY": args.tavily_key,
2505
+ "FIRECRAWL_API_URL": _normalize_firecrawl_api_url(args.firecrawl_api_url),
2506
+ "FIRECRAWL_API_KEY": args.firecrawl_key,
2507
+ }
2508
+
2509
+ lang = args.lang if args.lang in {"zh", "en"} else "zh"
2510
+ selected_skill_targets: list[str] = list(explicit_skill_targets)
2511
+ setup_warnings: list[str] = []
2512
+ current_for_setup: dict[str, str] = {}
2513
+
2514
+ if not args.non_interactive:
2515
+ current_for_setup = service.config_list(show_secrets=True)["values"]
2516
+ _write_setup_banner(args.lang if args.lang in {"zh", "en"} else "zh")
2517
+ lang = _select_setup_language(args.lang)
2518
+ if args.advanced:
2519
+ _run_advanced_setup_prompts(values, current_for_setup, lang)
2520
+ else:
2521
+ skill_targets_for_prompt = selected_skill_targets if not args.skip_skills and not selected_skill_targets else None
2522
+ _run_guided_setup_prompts(values, current_for_setup, lang, skill_targets=skill_targets_for_prompt, show_banner=False)
2523
+ if _has_embedding_setup_values(values):
2524
+ setup_warnings.extend(_apply_embedding_setup_preset(values, current_for_setup, interactive=True, lang=lang))
2525
+ else:
2526
+ current_for_setup = service.config_list(show_secrets=True)["values"]
2527
+ if _has_embedding_setup_values(values):
2528
+ setup_warnings.extend(_apply_embedding_setup_preset(values, current_for_setup, interactive=False, lang=lang))
2529
+
2530
+ saved: dict[str, str] = {}
2531
+ for key, value in values.items():
2532
+ if value:
2533
+ result = service.config_set(key, value)
2534
+ saved[key] = result.get("value", "")
2535
+
2536
+ skill_result = None
2537
+ if not args.skip_skills and selected_skill_targets:
2538
+ skill_result = install_skill_targets(selected_skill_targets, project_root=args.skills_root)
2539
+
2540
+ ok = True if skill_result is None else bool(skill_result.get("ok", False))
2541
+ data = {"ok": ok, "config_file": service.config_path()["config_file"], "saved": saved}
2542
+ if setup_warnings:
2543
+ data["warnings"] = setup_warnings
2544
+ if skill_result is not None:
2545
+ data["skills"] = skill_result
2546
+ if not skill_result.get("ok", False):
2547
+ data["error_type"] = "runtime_error"
2548
+ data["error"] = "One or more skill targets failed to install."
2549
+ if not args.non_interactive:
2550
+ current_after = service.config_list(show_secrets=True)["values"]
2551
+ final_values = _merge_setup_values(current_after, values)
2552
+ final_status = _setup_status_from_values(final_values)
2553
+ _write_stderr(_t(lang, "\n保存完成。\n", "\nSaved.\n"))
2554
+ if skill_result is not None:
2555
+ _write_skill_install_summary(skill_result, lang)
2556
+ _write_setup_status(final_status, lang, final=True)
2557
+ missing = [capability for capability in ("main_search", "docs_search", "web_fetch") if not final_status[capability]["ok"]]
2558
+ if missing:
2559
+ _write_stderr(
2560
+ _t(
2561
+ lang,
2562
+ "\n当前配置尚未满足 standard 最低配置。\nsearch / doctor 会 fail closed,不会假装可用。\n",
2563
+ "\nThe current config does not satisfy the standard minimum profile.\nsearch / doctor will fail closed instead of pretending to work.\n",
2564
+ )
2565
+ )
2566
+ else:
2567
+ _write_stderr(
2568
+ _t(
2569
+ lang,
2570
+ "\n下一步建议:\n smart-search doctor --format json\n smart-search smoke --mock --format json\n",
2571
+ "\nNext steps:\n smart-search doctor --format json\n smart-search smoke --mock --format json\n",
2572
+ )
2573
+ )
2574
+ data["minimum_profile_ok"] = not missing
2575
+ data["minimum_profile_missing"] = missing
2576
+ data["capability_status"] = final_status
2577
+ return _print_result("setup", data, args.format, args.output)
2578
+
2579
+
2580
+ def _run_regression() -> int:
2581
+ root = Path(__file__).resolve().parents[2]
2582
+ patterns = [
2583
+ "tests/test_cli.py",
2584
+ "tests/test_service.py",
2585
+ "tests/test_providers_new.py",
2586
+ "tests/test_jina_provider.py",
2587
+ "tests/test_zhipu_mcp_provider.py",
2588
+ "tests/test_smoke.py",
2589
+ "tests/test_intent_router.py",
2590
+ "tests/test_regression.py",
2591
+ "tests/test_release_workflow.py",
2592
+ ]
2593
+ if not all((root / pattern).exists() for pattern in patterns):
2594
+ print("Packaged install has no test files; running mock smoke regression instead.", file=sys.stderr)
2595
+ return asyncio.run(_run_regression_smoke_fallback())
2596
+ cmd = [sys.executable, "-m", "pytest", *patterns]
2597
+ return subprocess.call(cmd, cwd=str(root))
2598
+
2599
+
2600
+ async def _run_regression_smoke_fallback() -> int:
2601
+ data = await service.smoke("mock")
2602
+ return _print_result("smoke", data, "json")
2603
+
2604
+
2605
+ def build_parser() -> argparse.ArgumentParser:
2606
+ parser = SmartSearchArgumentParser(
2607
+ prog="smart-search",
2608
+ description="Smart Search CLI for AI-agent web retrieval.",
2609
+ )
2610
+ parser.add_argument("-v", "--v", "--version", action="version", version=f"%(prog)s {_get_version()}")
2611
+ sub = parser.add_subparsers(
2612
+ dest="command",
2613
+ required=True,
2614
+ parser_class=SmartSearchArgumentParser,
2615
+ metavar="{search,docs,fetch,map,doctor,diagnose,skills,setup,config,dev}",
2616
+ )
2617
+
2618
+ search_parser = sub.add_parser("search", aliases=COMMAND_ALIASES["search"], help="Answer questions, discover sources, or find similar pages.")
2619
+ search_parser.set_defaults(command="search")
2620
+ search_sub = search_parser.add_subparsers(dest="operation", required=True, parser_class=SmartSearchArgumentParser)
2621
+ search_answer = search_sub.add_parser("answer", help="Generate a web-backed answer.")
2622
+ search_answer.set_defaults(operation="answer")
2623
+ search_answer.add_argument("query")
2624
+ stream_group = search_answer.add_mutually_exclusive_group()
2625
+ stream_group.add_argument("--stream", dest="stream", action="store_true", default=None, help="Use stream=true for OpenAI-compatible main search.")
2626
+ stream_group.add_argument("--no-stream", dest="stream", action="store_false", help="Force stream=false for OpenAI-compatible main search.")
2627
+ search_answer.add_argument("--timeout", type=float, default=90, metavar="SECONDS")
2628
+ search_answer.add_argument("--debug", action="store_true")
2629
+ _add_format_args(search_answer)
2630
+ search_sources = search_sub.add_parser("sources", help="Return source-first web results.")
2631
+ search_sources.set_defaults(operation="sources")
2632
+ search_sources.add_argument("query")
2633
+ search_sources.add_argument("--limit", type=int, default=5)
2634
+ search_sources.add_argument("--mode", choices=["semantic", "keyword", "auto"], default="auto")
2635
+ search_sources.add_argument("--start-published-date", default="")
2636
+ search_sources.add_argument("--include-domains", nargs="+", default=[])
2637
+ search_sources.add_argument("--exclude-domains", nargs="+", default=[])
2638
+ search_sources.add_argument("--category", default="")
2639
+ search_sources.add_argument("--include-text", action="store_true")
2640
+ search_sources.add_argument("--include-highlights", action="store_true")
2641
+ search_sources.add_argument("--debug", action="store_true")
2642
+ _add_format_args(search_sources)
2643
+ search_similar = search_sub.add_parser("similar", help="Find pages similar to a URL.")
2644
+ search_similar.set_defaults(operation="similar")
2645
+ search_similar.add_argument("url")
2646
+ search_similar.add_argument("--limit", type=int, default=5)
2647
+ search_similar.add_argument("--debug", action="store_true")
2648
+ _add_format_args(search_similar)
2649
+
2650
+ docs_parser = sub.add_parser("docs", help="Resolve, search, inspect, or read technical documentation and repositories.")
2651
+ docs_parser.set_defaults(command="docs")
2652
+ docs_sub = docs_parser.add_subparsers(dest="operation", required=True, parser_class=SmartSearchArgumentParser)
2653
+ docs_resolve = docs_sub.add_parser("resolve")
2654
+ docs_resolve.set_defaults(operation="resolve")
2655
+ docs_resolve.add_argument("name")
2656
+ docs_resolve.add_argument("query", nargs="?", default="")
2657
+ docs_resolve.add_argument("--debug", action="store_true")
2658
+ _add_format_args(docs_resolve)
2659
+ docs_search = docs_sub.add_parser("search")
2660
+ docs_search.set_defaults(operation="search")
2661
+ docs_search.add_argument("query")
2662
+ docs_search.add_argument("--source", default="")
2663
+ docs_search.add_argument("--debug", action="store_true")
2664
+ _add_format_args(docs_search)
2665
+ docs_tree = docs_sub.add_parser("tree")
2666
+ docs_tree.set_defaults(operation="tree")
2667
+ docs_tree.add_argument("repo")
2668
+ docs_tree.add_argument("--path", default="")
2669
+ docs_tree.add_argument("--ref", default="")
2670
+ docs_tree.add_argument("--debug", action="store_true")
2671
+ _add_format_args(docs_tree)
2672
+ docs_read = docs_sub.add_parser("read")
2673
+ docs_read.set_defaults(operation="read")
2674
+ docs_read.add_argument("repo")
2675
+ docs_read.add_argument("path")
2676
+ docs_read.add_argument("--ref", default="")
2677
+ docs_read.add_argument("--debug", action="store_true")
2678
+ _add_format_args(docs_read)
2679
+
2680
+ route_parser = sub.add_parser(
2681
+ "route", aliases=COMMAND_ALIASES["route"], help=argparse.SUPPRESS
2682
+ )
2683
+ route_parser.set_defaults(command="route")
2684
+ route_parser.add_argument("query")
2685
+ route_parser.add_argument("--validation", choices=["fast", "balanced", "strict"], default="")
2686
+ route_parser.add_argument(
2687
+ "--router-mode",
2688
+ choices=["hybrid", "rules", "off"],
2689
+ default="",
2690
+ help="Override SMART_SEARCH_INTENT_ROUTER for this diagnostic call.",
2691
+ )
2692
+ _add_format_args(route_parser)
2693
+
2694
+ route_calibrate_parser = sub.add_parser(
2695
+ "route-calibrate",
2696
+ aliases=COMMAND_ALIASES["route-calibrate"],
2697
+ help=argparse.SUPPRESS,
2698
+ )
2699
+ route_calibrate_parser.set_defaults(command="route-calibrate")
2700
+ route_calibrate_parser.add_argument(
2701
+ "--models",
2702
+ default="",
2703
+ help="Comma-separated embedding model names. Defaults to known candidates plus the configured model.",
2704
+ )
2705
+ _add_format_args(route_calibrate_parser)
2706
+
2707
+ fetch_parser = sub.add_parser("fetch", aliases=COMMAND_ALIASES["fetch"], help="Read or structurally extract a known URL.")
2708
+ fetch_parser.set_defaults(command="fetch")
2709
+ fetch_sub = fetch_parser.add_subparsers(dest="operation", required=True, parser_class=SmartSearchArgumentParser)
2710
+ fetch_content = fetch_sub.add_parser("content", help="Return readable content or Markdown.")
2711
+ fetch_content.set_defaults(operation="content")
2712
+ fetch_content.add_argument("url")
2713
+ fetch_content.add_argument("--debug", action="store_true")
2714
+ _add_format_args(fetch_content)
2715
+ fetch_extract = fetch_sub.add_parser("extract", help="Return structured data and raw evidence.")
2716
+ fetch_extract.set_defaults(operation="extract")
2717
+ fetch_extract.add_argument("url")
2718
+ fetch_extract.add_argument("--max-length", type=int, default=20000)
2719
+ fetch_extract.add_argument("--schema", default="", help="JSON schema object.")
2720
+ fetch_extract.add_argument("--debug", action="store_true")
2721
+ _add_format_args(fetch_extract)
2722
+
2723
+ map_parser = sub.add_parser("map", aliases=COMMAND_ALIASES["map"], help="Explore website URL and link structure.")
2724
+ map_parser.set_defaults(command="map")
2725
+ map_sub = map_parser.add_subparsers(dest="operation", required=True, parser_class=SmartSearchArgumentParser)
2726
+ map_site = map_sub.add_parser("site")
2727
+ map_site.set_defaults(operation="site")
2728
+ map_site.add_argument("url")
2729
+ map_site.add_argument("--instructions", default="")
2730
+ map_site.add_argument("--max-depth", type=int, default=1)
2731
+ map_site.add_argument("--max-breadth", type=int, default=20)
2732
+ map_site.add_argument("--limit", type=int, default=50)
2733
+ map_site.add_argument("--timeout", type=int, default=150)
2734
+ map_site.add_argument("--debug", action="store_true")
2735
+ _add_format_args(map_site)
2736
+
2737
+ exa_parser = sub.add_parser(
2738
+ "exa-search", aliases=COMMAND_ALIASES["exa-search"], help=argparse.SUPPRESS
2739
+ )
2740
+ exa_parser.set_defaults(command="exa-search")
2741
+ exa_parser.add_argument("query")
2742
+ exa_parser.add_argument("--num-results", type=int, default=5)
2743
+ exa_parser.add_argument("--search-type", choices=["neural", "keyword", "auto"], default="neural")
2744
+ exa_parser.add_argument("--include-text", action="store_true")
2745
+ exa_parser.add_argument("--include-highlights", action="store_true")
2746
+ exa_parser.add_argument("--start-published-date", default="")
2747
+ exa_parser.add_argument("--include-domains", nargs="+", default="")
2748
+ exa_parser.add_argument("--exclude-domains", nargs="+", default="")
2749
+ exa_parser.add_argument("--category", default="")
2750
+ _add_format_args(exa_parser)
2751
+
2752
+ similar_parser = sub.add_parser(
2753
+ "exa-similar", aliases=COMMAND_ALIASES["exa-similar"], help=argparse.SUPPRESS
2754
+ )
2755
+ similar_parser.set_defaults(command="exa-similar")
2756
+ similar_parser.add_argument("url")
2757
+ similar_parser.add_argument("--num-results", type=int, default=5)
2758
+ _add_format_args(similar_parser)
2759
+
2760
+ zhipu_parser = sub.add_parser(
2761
+ "zhipu-search", aliases=COMMAND_ALIASES["zhipu-search"], help=argparse.SUPPRESS
2762
+ )
2763
+ zhipu_parser.set_defaults(command="zhipu-search")
2764
+ zhipu_parser.add_argument("query")
2765
+ zhipu_parser.add_argument("--count", type=int, default=10)
2766
+ zhipu_parser.add_argument("--search-engine", default="")
2767
+ zhipu_parser.add_argument("--search-recency-filter", default="noLimit")
2768
+ zhipu_parser.add_argument("--search-domain-filter", default="")
2769
+ zhipu_parser.add_argument("--content-size", choices=["medium", "high"], default="medium")
2770
+ _add_format_args(zhipu_parser)
2771
+
2772
+ zhipu_mcp_search_parser = sub.add_parser(
2773
+ "zhipu-mcp-search",
2774
+ aliases=COMMAND_ALIASES["zhipu-mcp-search"],
2775
+ help=argparse.SUPPRESS,
2776
+ )
2777
+ zhipu_mcp_search_parser.set_defaults(command="zhipu-mcp-search")
2778
+ zhipu_mcp_search_parser.add_argument("query")
2779
+ zhipu_mcp_search_parser.add_argument("--count", type=int, default=5)
2780
+ _add_format_args(zhipu_mcp_search_parser)
2781
+
2782
+ zhipu_mcp_reader_parser = sub.add_parser(
2783
+ "zhipu-mcp-reader",
2784
+ aliases=COMMAND_ALIASES["zhipu-mcp-reader"],
2785
+ help=argparse.SUPPRESS,
2786
+ )
2787
+ zhipu_mcp_reader_parser.set_defaults(command="zhipu-mcp-reader")
2788
+ zhipu_mcp_reader_parser.add_argument("url")
2789
+ _add_format_args(zhipu_mcp_reader_parser)
2790
+
2791
+ zhipu_mcp_search_doc_parser = sub.add_parser(
2792
+ "zhipu-mcp-search-doc",
2793
+ aliases=COMMAND_ALIASES["zhipu-mcp-search-doc"],
2794
+ help=argparse.SUPPRESS,
2795
+ )
2796
+ zhipu_mcp_search_doc_parser.set_defaults(command="zhipu-mcp-search-doc")
2797
+ zhipu_mcp_search_doc_parser.add_argument("repo")
2798
+ zhipu_mcp_search_doc_parser.add_argument("query")
2799
+ zhipu_mcp_search_doc_parser.add_argument("--max-results", type=int, default=5)
2800
+ _add_format_args(zhipu_mcp_search_doc_parser)
2801
+
2802
+ zhipu_mcp_repo_structure_parser = sub.add_parser(
2803
+ "zhipu-mcp-repo-structure",
2804
+ aliases=COMMAND_ALIASES["zhipu-mcp-repo-structure"],
2805
+ help=argparse.SUPPRESS,
2806
+ )
2807
+ zhipu_mcp_repo_structure_parser.set_defaults(command="zhipu-mcp-repo-structure")
2808
+ zhipu_mcp_repo_structure_parser.add_argument("repo")
2809
+ zhipu_mcp_repo_structure_parser.add_argument("--ref", default="")
2810
+ _add_format_args(zhipu_mcp_repo_structure_parser)
2811
+
2812
+ zhipu_mcp_read_file_parser = sub.add_parser(
2813
+ "zhipu-mcp-read-file",
2814
+ aliases=COMMAND_ALIASES["zhipu-mcp-read-file"],
2815
+ help=argparse.SUPPRESS,
2816
+ )
2817
+ zhipu_mcp_read_file_parser.set_defaults(command="zhipu-mcp-read-file")
2818
+ zhipu_mcp_read_file_parser.add_argument("repo")
2819
+ zhipu_mcp_read_file_parser.add_argument("path")
2820
+ zhipu_mcp_read_file_parser.add_argument("--ref", default="")
2821
+ _add_format_args(zhipu_mcp_read_file_parser)
2822
+
2823
+
2824
+ context7_library_parser = sub.add_parser(
2825
+ "context7-library",
2826
+ aliases=COMMAND_ALIASES["context7-library"],
2827
+ help=argparse.SUPPRESS,
2828
+ )
2829
+ context7_library_parser.set_defaults(command="context7-library")
2830
+ context7_library_parser.add_argument("name")
2831
+ context7_library_parser.add_argument("query", nargs="?", default="")
2832
+ _add_format_args(context7_library_parser)
2833
+
2834
+ context7_docs_parser = sub.add_parser(
2835
+ "context7-docs",
2836
+ aliases=COMMAND_ALIASES["context7-docs"],
2837
+ help=argparse.SUPPRESS,
2838
+ )
2839
+ context7_docs_parser.set_defaults(command="context7-docs")
2840
+ context7_docs_parser.add_argument("library_id")
2841
+ context7_docs_parser.add_argument("query")
2842
+ _add_format_args(context7_docs_parser)
2843
+
2844
+
2845
+ smoke_parser = sub.add_parser(
2846
+ "smoke", aliases=COMMAND_ALIASES["smoke"], help=argparse.SUPPRESS
2847
+ )
2848
+ smoke_parser.set_defaults(command="smoke")
2849
+ smoke_mode = smoke_parser.add_mutually_exclusive_group()
2850
+ smoke_mode.add_argument("--mode", choices=["mock", "live"], default=None)
2851
+ smoke_mode.add_argument("--mock", dest="mode", action="store_const", const="mock", help="Run offline mock smoke checks.")
2852
+ smoke_mode.add_argument("--live", dest="mode", action="store_const", const="live", help="Run live provider smoke checks.")
2853
+ smoke_parser.set_defaults(mode="mock")
2854
+ _add_format_args(smoke_parser)
2855
+
2856
+ doctor_parser = sub.add_parser(
2857
+ "doctor", aliases=COMMAND_ALIASES["doctor"], help="Show masked configuration and connection checks."
2858
+ )
2859
+ doctor_parser.set_defaults(command="doctor")
2860
+ _add_format_args(doctor_parser)
2861
+
2862
+ diagnose_parser = sub.add_parser(
2863
+ "diagnose",
2864
+ aliases=COMMAND_ALIASES["diagnose"],
2865
+ help="Troubleshoot operations, providers, routing, and smoke checks.",
2866
+ )
2867
+ diagnose_parser.set_defaults(command="diagnose")
2868
+ diagnose_sub = diagnose_parser.add_subparsers(dest="diagnose_command", required=True, parser_class=SmartSearchArgumentParser)
2869
+ for capability, operations in {
2870
+ "search": ["answer", "sources", "similar"],
2871
+ "docs": ["resolve", "search", "tree", "read"],
2872
+ "fetch": ["content", "extract"],
2873
+ "map": ["site"],
2874
+ }.items():
2875
+ item = diagnose_sub.add_parser(capability)
2876
+ item.set_defaults(diagnose_command=capability)
2877
+ item.add_argument("operation", nargs="?", choices=operations, default="")
2878
+ _add_format_args(item)
2879
+ diagnose_provider = diagnose_sub.add_parser("provider")
2880
+ diagnose_provider.set_defaults(diagnose_command="provider")
2881
+ diagnose_provider.add_argument("provider", choices=["openai-compatible"])
2882
+ diagnose_provider.add_argument("--timeout", type=float, default=30)
2883
+ diagnose_provider.add_argument("--format", choices=["json", "markdown"], default="markdown")
2884
+ diagnose_provider.add_argument("--output", default="")
2885
+ diagnose_route = diagnose_sub.add_parser("route")
2886
+ diagnose_route.set_defaults(diagnose_command="route")
2887
+ diagnose_route.add_argument("query")
2888
+ diagnose_route.add_argument("--validation", choices=["fast", "balanced", "strict"], default="")
2889
+ diagnose_route.add_argument("--router-mode", choices=["hybrid", "rules", "off"], default="")
2890
+ _add_format_args(diagnose_route)
2891
+ diagnose_calibrate = diagnose_sub.add_parser("route-calibrate")
2892
+ diagnose_calibrate.set_defaults(diagnose_command="route-calibrate")
2893
+ diagnose_calibrate.add_argument("--models", default="")
2894
+ _add_format_args(diagnose_calibrate)
2895
+ diagnose_smoke = diagnose_sub.add_parser("smoke")
2896
+ diagnose_smoke.set_defaults(diagnose_command="smoke")
2897
+ diagnose_smoke.add_argument("--mode", choices=["mock", "live"], default="mock")
2898
+ _add_format_args(diagnose_smoke)
2899
+
2900
+ model_parser = sub.add_parser(
2901
+ "model",
2902
+ aliases=COMMAND_ALIASES["model"],
2903
+ help=argparse.SUPPRESS,
2904
+ )
2905
+ model_parser.set_defaults(command="model")
2906
+ model_sub = model_parser.add_subparsers(dest="model_command", required=True, parser_class=SmartSearchArgumentParser)
2907
+ model_set = model_sub.add_parser("set", aliases=MODEL_COMMAND_ALIASES["set"])
2908
+ model_set.set_defaults(model_command="set")
2909
+ model_set.add_argument("model")
2910
+ _add_format_args(model_set)
2911
+ model_current = model_sub.add_parser("current", aliases=MODEL_COMMAND_ALIASES["current"])
2912
+ model_current.set_defaults(model_command="current")
2913
+ _add_format_args(model_current)
2914
+
2915
+ skills_parser = sub.add_parser(
2916
+ "skills",
2917
+ aliases=COMMAND_ALIASES["skills"],
2918
+ help="Inspect or update installed smart-search-cli skills.",
2919
+ )
2920
+ skills_parser.set_defaults(command="skills")
2921
+ skills_sub = skills_parser.add_subparsers(dest="skills_command", required=True, parser_class=SmartSearchArgumentParser)
2922
+ skills_status = skills_sub.add_parser("status", aliases=SKILLS_COMMAND_ALIASES["status"], help="Compare bundled and installed skill files.")
2923
+ skills_status.set_defaults(skills_command="status")
2924
+ skills_status.add_argument(
2925
+ "--targets",
2926
+ default=",".join(DEFAULT_SKILL_TARGET_IDS),
2927
+ help="Comma-separated AI tool targets, e.g. codex,claude,cursor,hermes.",
2928
+ )
2929
+ skills_status.add_argument("--all", action="store_true", help="Check every known skill target.")
2930
+ skills_status.add_argument(
2931
+ "--skills-root",
2932
+ default="",
2933
+ help="Advanced override for the user-level skill root; defaults to the current user's home directory.",
2934
+ )
2935
+ _add_format_args(skills_status)
2936
+ skills_update = skills_sub.add_parser("update", aliases=SKILLS_COMMAND_ALIASES["update"], help="Overwrite selected installed skill files with bundled assets.")
2937
+ skills_update.set_defaults(skills_command="update")
2938
+ skills_update.add_argument(
2939
+ "--targets",
2940
+ default=",".join(DEFAULT_SKILL_TARGET_IDS),
2941
+ help="Comma-separated AI tool targets, e.g. codex,claude,cursor,hermes.",
2942
+ )
2943
+ skills_update.add_argument("--all", action="store_true", help="Update every known skill target.")
2944
+ skills_update.add_argument(
2945
+ "--skills-root",
2946
+ default="",
2947
+ help="Advanced override for the user-level skill root; defaults to the current user's home directory.",
2948
+ )
2949
+ _add_format_args(skills_update)
2950
+
2951
+ setup_parser = sub.add_parser(
2952
+ "setup", aliases=COMMAND_ALIASES["setup"], help="Interactively save local provider configuration."
2953
+ )
2954
+ setup_parser.set_defaults(command="setup")
2955
+ setup_parser.add_argument("--non-interactive", action="store_true", help="Only save values passed as flags.")
2956
+ setup_parser.add_argument("--lang", choices=["zh", "en"], default="", help="Interactive setup language.")
2957
+ setup_parser.add_argument("--advanced", action="store_true", help="Show every low-level config key in interactive setup.")
2958
+ setup_parser.add_argument("--skip-skills", action="store_true", help="Skip user-level smart-search-cli skill installation.")
2959
+ setup_parser.add_argument(
2960
+ "--install-skills",
2961
+ default="",
2962
+ help="Comma-separated AI tool targets for smart-search-cli skill installation, e.g. codex,claude,cursor,hermes.",
2963
+ )
2964
+ setup_parser.add_argument(
2965
+ "--skills-root",
2966
+ default="",
2967
+ help="Advanced override for the user-level skill root; defaults to the current user's home directory.",
2968
+ )
2969
+ setup_parser.add_argument("--xai-api-url", default="", help="Save XAI_API_URL.")
2970
+ setup_parser.add_argument("--xai-api-key", default="", help="Save XAI_API_KEY.")
2971
+ setup_parser.add_argument("--xai-model", default="", help="Save XAI_MODEL.")
2972
+ setup_parser.add_argument("--xai-tools-explicit", default="", help="Save XAI_TOOLS.")
2973
+ setup_parser.add_argument("--openai-compatible-api-url", default="", help="Save OPENAI_COMPATIBLE_API_URL.")
2974
+ setup_parser.add_argument("--openai-compatible-api-key", default="", help="Save OPENAI_COMPATIBLE_API_KEY.")
2975
+ setup_parser.add_argument("--openai-compatible-model", default="", help="Save OPENAI_COMPATIBLE_MODEL.")
2976
+ setup_parser.add_argument("--openai-compatible-fallback-models", default="", help="Save OPENAI_COMPATIBLE_FALLBACK_MODELS.")
2977
+ setup_parser.add_argument("--openai-compatible-stream", default="", help="Save OPENAI_COMPATIBLE_STREAM.")
2978
+ setup_parser.add_argument("--validation-level", default="", help="Save SMART_SEARCH_VALIDATION_LEVEL.")
2979
+ setup_parser.add_argument("--fallback-mode", default="", help="Save SMART_SEARCH_FALLBACK_MODE.")
2980
+ setup_parser.add_argument("--minimum-profile", default="", help="Save SMART_SEARCH_MINIMUM_PROFILE.")
2981
+ setup_parser.add_argument("--operation-config", default="", help="Save SMART_SEARCH_OPERATION_CONFIG JSON.")
2982
+ setup_parser.add_argument("--intent-router", default="", help="Save SMART_SEARCH_INTENT_ROUTER.")
2983
+ setup_parser.add_argument("--intent-embedding-api-url", default="", help="Save INTENT_EMBEDDING_API_URL.")
2984
+ setup_parser.add_argument("--intent-embedding-api-key", default="", help="Save INTENT_EMBEDDING_API_KEY.")
2985
+ setup_parser.add_argument("--intent-embedding-model", default="", help="Save INTENT_EMBEDDING_MODEL.")
2986
+ setup_parser.add_argument("--intent-embedding-threshold", default="", help="Save INTENT_EMBEDDING_THRESHOLD.")
2987
+ setup_parser.add_argument("--intent-embedding-margin", default="", help="Save INTENT_EMBEDDING_MARGIN.")
2988
+ setup_parser.add_argument("--intent-classifier-api-url", default="", help="Save INTENT_CLASSIFIER_API_URL.")
2989
+ setup_parser.add_argument("--intent-classifier-api-key", default="", help="Save INTENT_CLASSIFIER_API_KEY.")
2990
+ setup_parser.add_argument("--intent-classifier-model", default="", help="Save INTENT_CLASSIFIER_MODEL.")
2991
+ setup_parser.add_argument("--intent-router-timeout", default="", help="Save INTENT_ROUTER_TIMEOUT_SECONDS.")
2992
+ setup_parser.add_argument("--exa-key", default="", help="Save EXA_API_KEY.")
2993
+ setup_parser.add_argument("--context7-key", default="", help="Save CONTEXT7_API_KEY.")
2994
+ setup_parser.add_argument("--zhipu-key", default="", help="Save ZHIPU_API_KEY.")
2995
+ setup_parser.add_argument("--zhipu-api-url", default="", help="Save ZHIPU_API_URL.")
2996
+ setup_parser.add_argument("--zhipu-search-engine", default="", help="Save ZHIPU_SEARCH_ENGINE.")
2997
+ setup_parser.add_argument("--zhipu-mcp-key", default="", help="Save ZHIPU_MCP_API_KEY.")
2998
+ setup_parser.add_argument("--zhipu-mcp-search-api-url", default="", help="Save ZHIPU_MCP_SEARCH_API_URL.")
2999
+ setup_parser.add_argument("--zhipu-mcp-reader-api-url", default="", help="Save ZHIPU_MCP_READER_API_URL.")
3000
+ setup_parser.add_argument("--zhipu-mcp-zread-api-url", default="", help="Save ZHIPU_MCP_ZREAD_API_URL.")
3001
+ setup_parser.add_argument("--zhipu-mcp-timeout", default="", help="Save ZHIPU_MCP_TIMEOUT_SECONDS.")
3002
+ setup_parser.add_argument("--jina-key", default="", help="Save JINA_API_KEY.")
3003
+ setup_parser.add_argument("--jina-reader-api-url", default="", help="Save JINA_READER_API_URL.")
3004
+ setup_parser.add_argument("--jina-respond-with", default="", help="Save JINA_RESPOND_WITH, e.g. readerlm-v2.")
3005
+ setup_parser.add_argument("--jina-timeout", default="", help="Save JINA_TIMEOUT_SECONDS.")
3006
+ setup_parser.add_argument("--tavily-api-url", default="", help="Save TAVILY_API_URL.")
3007
+ setup_parser.add_argument("--tavily-key", default="", help="Save TAVILY_API_KEY.")
3008
+ setup_parser.add_argument("--firecrawl-api-url", default="", help="Save FIRECRAWL_API_URL.")
3009
+ setup_parser.add_argument("--firecrawl-key", default="", help="Save FIRECRAWL_API_KEY.")
3010
+ _add_format_args(setup_parser)
3011
+
3012
+ config_parser = sub.add_parser(
3013
+ "config", aliases=COMMAND_ALIASES["config"], help="Read or edit the local Smart Search config file."
3014
+ )
3015
+ config_parser.set_defaults(command="config")
3016
+ config_sub = config_parser.add_subparsers(dest="config_command", required=True, parser_class=SmartSearchArgumentParser)
3017
+ config_path = config_sub.add_parser("path", aliases=CONFIG_COMMAND_ALIASES["path"])
3018
+ config_path.set_defaults(config_command="path")
3019
+ _add_format_args(config_path)
3020
+ config_list = config_sub.add_parser("list", aliases=CONFIG_COMMAND_ALIASES["list"])
3021
+ config_list.set_defaults(config_command="list")
3022
+ _add_format_args(config_list)
3023
+ config_set = config_sub.add_parser("set", aliases=CONFIG_COMMAND_ALIASES["set"])
3024
+ config_set.set_defaults(config_command="set")
3025
+ config_set.add_argument("key")
3026
+ config_set.add_argument("value")
3027
+ _add_format_args(config_set)
3028
+ config_unset = config_sub.add_parser("unset", aliases=CONFIG_COMMAND_ALIASES["unset"])
3029
+ config_unset.set_defaults(config_command="unset")
3030
+ config_unset.add_argument("key")
3031
+ _add_format_args(config_unset)
3032
+
3033
+ dev_parser = sub.add_parser("dev", help="Developer-only commands.")
3034
+ dev_parser.set_defaults(command="dev")
3035
+ dev_sub = dev_parser.add_subparsers(dest="dev_command", required=True, parser_class=SmartSearchArgumentParser)
3036
+ dev_regression = dev_sub.add_parser("regression")
3037
+ dev_regression.set_defaults(dev_command="regression")
3038
+
3039
+ regression_parser = sub.add_parser(
3040
+ "regression", aliases=COMMAND_ALIASES["regression"], help=argparse.SUPPRESS
3041
+ )
3042
+ regression_parser.set_defaults(command="regression")
3043
+ for action in parser._actions:
3044
+ if isinstance(action, argparse._SubParsersAction):
3045
+ action._choices_actions = [choice for choice in action._choices_actions if choice.help != argparse.SUPPRESS]
3046
+ return parser
3047
+
3048
+
3049
+ def _normalize_legacy_argv(argv: list[str]) -> list[str]:
3050
+ if len(argv) >= 2 and argv[0] == "search" and argv[1] not in {"answer", "sources", "similar", "-h", "--help"}:
3051
+ return ["search", "answer", *argv[1:]]
3052
+ if len(argv) >= 2 and argv[0] == "fetch" and argv[1] not in {"content", "extract", "-h", "--help"}:
3053
+ return ["fetch", "content", *argv[1:]]
3054
+ if len(argv) >= 2 and argv[0] == "map" and argv[1] not in {"site", "-h", "--help"}:
3055
+ return ["map", "site", *argv[1:]]
3056
+ if len(argv) >= 2 and argv[0] == "diagnose" and argv[1] == "openai-compatible":
3057
+ return ["diagnose", "provider", *argv[1:]]
3058
+ return argv
3059
+
3060
+
3061
+ def _legacy_migration_hint(argv: list[str]) -> str:
3062
+ if not argv:
3063
+ return ""
3064
+ command = argv[0]
3065
+ if command == "search" and len(argv) >= 2 and argv[1] not in {"answer", "sources", "similar", "-h", "--help"}:
3066
+ return "smart-search search answer QUERY"
3067
+ if command == "fetch" and len(argv) >= 2 and argv[1] not in {"content", "extract", "-h", "--help"}:
3068
+ return "smart-search fetch content URL"
3069
+ if command == "map" and len(argv) >= 2 and argv[1] not in {"site", "-h", "--help"}:
3070
+ return "smart-search map site URL"
3071
+ if command == "diagnose" and len(argv) >= 2 and argv[1] == "openai-compatible":
3072
+ return "smart-search diagnose provider openai-compatible"
3073
+ migrations = {
3074
+ "exa-search": "smart-search search sources",
3075
+ "exa-similar": "smart-search search similar",
3076
+ "zhipu-search": "smart-search search sources",
3077
+ "zhipu-mcp-search": "smart-search search sources",
3078
+ "zhipu-mcp-reader": "smart-search fetch content",
3079
+ "zhipu-mcp-search-doc": "smart-search docs search",
3080
+ "zhipu-mcp-repo-structure": "smart-search docs tree",
3081
+ "zhipu-mcp-read-file": "smart-search docs read",
3082
+ "context7-library": "smart-search docs resolve",
3083
+ "context7-docs": "smart-search docs search",
3084
+ "route": "smart-search diagnose route",
3085
+ "route-calibrate": "smart-search diagnose route-calibrate",
3086
+ "smoke": "smart-search diagnose smoke",
3087
+ "regression": "smart-search dev regression",
3088
+ "model": "smart-search config",
3089
+ }
3090
+ return migrations.get(command, "")
3091
+
3092
+
3093
+ def main(argv: list[str] | None = None) -> int:
3094
+ parser = build_parser()
3095
+ original_argv = list(sys.argv[1:] if argv is None else argv)
3096
+ migration = _legacy_migration_hint(original_argv)
3097
+ if migration:
3098
+ print(f"Deprecated command; migrate to: {migration}", file=sys.stderr)
3099
+ argv = _normalize_legacy_argv(original_argv)
3100
+ args = parser.parse_args(argv)
3101
+ try:
3102
+ if args.command == "regression" or (args.command == "dev" and args.dev_command == "regression"):
3103
+ return _run_regression()
3104
+ if args.command == "setup":
3105
+ return _run_setup(args)
3106
+ if args.command == "skills":
3107
+ return _run_skills(args)
3108
+ if args.command == "config":
3109
+ return _run_config(args)
3110
+ if args.command == "model":
3111
+ return _run_model(args)
3112
+ return asyncio.run(_run_async(args))
3113
+ except KeyboardInterrupt:
3114
+ return EXIT_RUNTIME_ERROR
3115
+
3116
+
3117
+ if __name__ == "__main__":
3118
+ raise SystemExit(main())