abelworkflow 1.1.2 → 1.2.0

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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -29
  3. package/extensions/gpt-responses-compat.ts +213 -0
  4. package/extensions/k2think-compat.ts +40 -0
  5. package/lib/cli/args.mjs +5 -19
  6. package/lib/cli/main.mjs +105 -72
  7. package/lib/cli/prompts.mjs +9 -2
  8. package/lib/config/dotenv.mjs +1 -1
  9. package/lib/config/jsonc.mjs +1 -1
  10. package/lib/config/store.mjs +197 -45
  11. package/lib/config/toml.mjs +147 -453
  12. package/lib/installer/assets.mjs +67 -45
  13. package/lib/installer/install.mjs +202 -45
  14. package/lib/installer/links.mjs +124 -101
  15. package/lib/installer/lock.mjs +82 -0
  16. package/lib/installer/state.mjs +14 -21
  17. package/lib/paths.mjs +9 -9
  18. package/lib/providers/claude.mjs +58 -114
  19. package/lib/providers/codex.mjs +101 -198
  20. package/lib/providers/pi.mjs +332 -439
  21. package/lib/providers/skills.mjs +88 -68
  22. package/lib/providers/url.mjs +35 -0
  23. package/lib/templates/workflow/commands/abel-design.md +23 -161
  24. package/lib/templates/workflow/commands/abel-diagnose.md +30 -56
  25. package/lib/templates/workflow/commands/abel-implement.md +30 -150
  26. package/lib/templates/workflow/commands/abel-init.md +1 -1
  27. package/lib/tools/cli-installer.mjs +29 -190
  28. package/package.json +7 -9
  29. package/skills/context7-auto-research/context7-api.cjs +75 -17
  30. package/skills/dev-browser/SKILL.md +15 -1
  31. package/skills/dev-browser/dist/scripts/start.d.ts +0 -1
  32. package/skills/dev-browser/dist/scripts/start.js +8 -36
  33. package/skills/dev-browser/dist/src/client.d.ts +3 -4
  34. package/skills/dev-browser/dist/src/client.js +117 -117
  35. package/skills/dev-browser/dist/src/entrypoint.d.ts +1 -1
  36. package/skills/dev-browser/dist/src/entrypoint.js +38 -31
  37. package/skills/dev-browser/dist/src/index.d.ts +0 -1
  38. package/skills/dev-browser/dist/src/index.js +0 -1
  39. package/skills/dev-browser/dist/src/page-api.d.ts +0 -1
  40. package/skills/dev-browser/dist/src/page-api.js +0 -1
  41. package/skills/dev-browser/dist/src/relay.d.ts +0 -1
  42. package/skills/dev-browser/dist/src/relay.js +53 -7
  43. package/skills/dev-browser/dist/src/runtime.d.ts +3 -14
  44. package/skills/dev-browser/dist/src/runtime.js +5 -15
  45. package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts +0 -1
  46. package/skills/dev-browser/dist/src/snapshot/browser-script.js +0 -1
  47. package/skills/dev-browser/dist/src/snapshot/index.d.ts +0 -1
  48. package/skills/dev-browser/dist/src/snapshot/index.js +0 -1
  49. package/skills/dev-browser/dist/src/snapshot/inject.d.ts +0 -1
  50. package/skills/dev-browser/dist/src/snapshot/inject.js +0 -1
  51. package/skills/dev-browser/dist/src/standalone.d.ts +0 -1
  52. package/skills/dev-browser/dist/src/standalone.js +2 -3
  53. package/skills/dev-browser/dist/src/startup.d.ts +3 -12
  54. package/skills/dev-browser/dist/src/startup.js +6 -10
  55. package/skills/dev-browser/dist/src/target-registry.d.ts +0 -1
  56. package/skills/dev-browser/dist/src/target-registry.js +0 -1
  57. package/skills/dev-browser/dist/src/types.d.ts +0 -1
  58. package/skills/dev-browser/dist/src/types.js +0 -1
  59. package/skills/dev-browser/package-lock.json +1 -1
  60. package/skills/dev-browser/package.json +2 -2
  61. package/skills/grok-search/.env.example +1 -1
  62. package/skills/grok-search/SKILL.md +13 -16
  63. package/skills/grok-search/scripts/_dotenv.py +1 -1
  64. package/skills/grok-search/scripts/groksearch_cli.py +271 -195
  65. package/skills/time/SKILL.md +5 -2
  66. package/skills/time/scripts/time_cli.py +61 -22
  67. package/extensions/pi-gpt-responses-compat/index.ts +0 -25
  68. package/lib/templates/codex/config-base.toml +0 -103
  69. package/skills/dev-browser/dist/scripts/start.d.ts.map +0 -1
  70. package/skills/dev-browser/dist/scripts/start.js.map +0 -1
  71. package/skills/dev-browser/dist/src/client.d.ts.map +0 -1
  72. package/skills/dev-browser/dist/src/client.js.map +0 -1
  73. package/skills/dev-browser/dist/src/entrypoint.d.ts.map +0 -1
  74. package/skills/dev-browser/dist/src/entrypoint.js.map +0 -1
  75. package/skills/dev-browser/dist/src/index.d.ts.map +0 -1
  76. package/skills/dev-browser/dist/src/index.js.map +0 -1
  77. package/skills/dev-browser/dist/src/page-api.d.ts.map +0 -1
  78. package/skills/dev-browser/dist/src/page-api.js.map +0 -1
  79. package/skills/dev-browser/dist/src/relay.d.ts.map +0 -1
  80. package/skills/dev-browser/dist/src/relay.js.map +0 -1
  81. package/skills/dev-browser/dist/src/runtime.d.ts.map +0 -1
  82. package/skills/dev-browser/dist/src/runtime.js.map +0 -1
  83. package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts.map +0 -1
  84. package/skills/dev-browser/dist/src/snapshot/browser-script.js.map +0 -1
  85. package/skills/dev-browser/dist/src/snapshot/index.d.ts.map +0 -1
  86. package/skills/dev-browser/dist/src/snapshot/index.js.map +0 -1
  87. package/skills/dev-browser/dist/src/snapshot/inject.d.ts.map +0 -1
  88. package/skills/dev-browser/dist/src/snapshot/inject.js.map +0 -1
  89. package/skills/dev-browser/dist/src/standalone.d.ts.map +0 -1
  90. package/skills/dev-browser/dist/src/standalone.js.map +0 -1
  91. package/skills/dev-browser/dist/src/startup.d.ts.map +0 -1
  92. package/skills/dev-browser/dist/src/startup.js.map +0 -1
  93. package/skills/dev-browser/dist/src/target-registry.d.ts.map +0 -1
  94. package/skills/dev-browser/dist/src/target-registry.js.map +0 -1
  95. package/skills/dev-browser/dist/src/types.d.ts.map +0 -1
  96. package/skills/dev-browser/dist/src/types.js.map +0 -1
  97. package/skills/grok-search/gitignore.template +0 -4
  98. package/skills/grok-search/scripts/groksearch_entry.py +0 -131
@@ -15,6 +15,8 @@ from pathlib import Path
15
15
  from typing import Optional
16
16
  from urllib.parse import urlsplit, urlunsplit
17
17
 
18
+ sys.dont_write_bytecode = True
19
+
18
20
  from _dotenv import load_dotenv
19
21
 
20
22
  try:
@@ -22,13 +24,10 @@ try:
22
24
  from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
23
25
  from tenacity.wait import wait_base
24
26
  except ImportError:
25
- print("Error: 所需包未安装。请运行: python scripts/groksearch_entry.py --help 或 pip install httpx tenacity", file=sys.stderr)
27
+ print("Error: 所需包未安装。请运行: python -m pip install -r requirements.txt", file=sys.stderr)
26
28
  sys.exit(1)
27
29
 
28
30
 
29
- load_dotenv()
30
-
31
-
32
31
  # ============================================================================
33
32
  # 配置
34
33
  # ============================================================================
@@ -119,19 +118,9 @@ class Config:
119
118
  def tavily_api_key(self) -> Optional[str]:
120
119
  return os.getenv("TAVILY_API_KEY") or None
121
120
 
122
- def _apply_model_suffix(self, model: str) -> str:
123
- try:
124
- url = self.grok_api_url
125
- except ValueError:
126
- return model
127
- if "openrouter" in url and ":online" not in model:
128
- return f"{model}:online"
129
- return model
130
-
131
121
  @property
132
122
  def grok_model(self) -> str:
133
- model = os.getenv("GROK_MODEL") or self._DEFAULT_MODEL
134
- return self._apply_model_suffix(model)
123
+ return os.getenv("GROK_MODEL") or self._DEFAULT_MODEL
135
124
 
136
125
  @staticmethod
137
126
  def _mask_api_key(key: str) -> str:
@@ -214,6 +203,7 @@ def _emit_tavily_warning(message: str) -> None:
214
203
  # ============================================================================
215
204
 
216
205
  RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
206
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024
217
207
 
218
208
 
219
209
  class StreamEmbeddedError(Exception):
@@ -224,6 +214,44 @@ class EmptyStreamError(Exception):
224
214
  """流式响应解析完成但内容为空。"""
225
215
 
226
216
 
217
+ class ResponseTooLargeError(ValueError):
218
+ """Response exceeded the configured in-memory byte limit."""
219
+
220
+
221
+ async def _iter_response_bytes(response, max_bytes: Optional[int] = None):
222
+ limit = MAX_RESPONSE_BYTES if max_bytes is None else max_bytes
223
+ total = 0
224
+ async for chunk in response.aiter_bytes():
225
+ total += len(chunk)
226
+ if total > limit:
227
+ raise ResponseTooLargeError(f"API 响应超过 {limit} 字节限制")
228
+ yield chunk
229
+
230
+
231
+ async def _read_response_bytes(response, max_bytes: Optional[int] = None) -> bytes:
232
+ body = bytearray()
233
+ async for chunk in _iter_response_bytes(response, max_bytes):
234
+ body.extend(chunk)
235
+ return bytes(body)
236
+
237
+
238
+ async def _read_json_response(response):
239
+ return json.loads(await _read_response_bytes(response))
240
+
241
+
242
+ def _extract_chat_content(data, container: str) -> str:
243
+ if not isinstance(data, dict):
244
+ return ""
245
+ choices = data.get("choices")
246
+ if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
247
+ return ""
248
+ payload = choices[0].get(container)
249
+ if not isinstance(payload, dict):
250
+ return ""
251
+ content = payload.get("content")
252
+ return content if isinstance(content, str) else ""
253
+
254
+
227
255
  def _is_retryable_exception(exc) -> bool:
228
256
  if isinstance(exc, (StreamEmbeddedError, EmptyStreamError)):
229
257
  return True
@@ -237,6 +265,7 @@ def _is_retryable_exception(exc) -> bool:
237
265
  class _WaitWithRetryAfter(wait_base):
238
266
  def __init__(self, multiplier: float, max_wait: int):
239
267
  self._base_wait = wait_random_exponential(multiplier=multiplier, max=max_wait)
268
+ self._max_wait = max_wait
240
269
  self._protocol_error_base = 3.0
241
270
 
242
271
  def __call__(self, retry_state):
@@ -256,13 +285,13 @@ class _WaitWithRetryAfter(wait_base):
256
285
  return None
257
286
  header = header.strip()
258
287
  if header.isdigit():
259
- return float(header)
288
+ return min(float(header), self._max_wait)
260
289
  try:
261
290
  retry_dt = parsedate_to_datetime(header)
262
291
  if retry_dt.tzinfo is None:
263
292
  retry_dt = retry_dt.replace(tzinfo=timezone.utc)
264
293
  delay = (retry_dt - datetime.now(timezone.utc)).total_seconds()
265
- return max(0.0, delay)
294
+ return min(max(0.0, delay), self._max_wait)
266
295
  except (TypeError, ValueError):
267
296
  return None
268
297
 
@@ -404,6 +433,23 @@ def _bracket_ipv6_authority(authority: str) -> str:
404
433
  return f"{prefix}[{target}]"
405
434
 
406
435
 
436
+ def _validate_http_url(url: str) -> str:
437
+ parts = urlsplit(url)
438
+ try:
439
+ hostname = parts.hostname
440
+ parts.port
441
+ except ValueError as error:
442
+ raise ValueError(f"URL 端口无效: {url!r}") from error
443
+ if not parts.netloc or not hostname:
444
+ raise ValueError(f"URL 缺少主机名: {url!r}")
445
+ hostport = parts.netloc.rsplit("@", 1)[-1]
446
+ if hostport.endswith(":"):
447
+ raise ValueError(f"URL 端口无效: {url!r}")
448
+ if parts.username is not None or parts.password is not None:
449
+ raise ValueError("URL 不允许包含用户信息")
450
+ return url
451
+
452
+
407
453
  def normalize_url(url: str) -> str:
408
454
  s = (url or "").strip()
409
455
  while True:
@@ -431,7 +477,9 @@ def normalize_url(url: str) -> str:
431
477
  if parts.netloc:
432
478
  fixed_netloc = _bracket_ipv6_authority(parts.netloc)
433
479
  if fixed_netloc != parts.netloc:
434
- return urlunsplit((parts.scheme, fixed_netloc, parts.path, parts.query, parts.fragment))
480
+ return _validate_http_url(
481
+ urlunsplit((parts.scheme, fixed_netloc, parts.path, parts.query, parts.fragment))
482
+ )
435
483
  # Fix common invalid form like: http://::1 (IPv6 without brackets)
436
484
  if not parts.netloc and parts.path and ":" in parts.path and not parts.path.startswith("/"):
437
485
  rest = s[len(scheme_match.group(0)) :]
@@ -439,30 +487,22 @@ def normalize_url(url: str) -> str:
439
487
  if authority:
440
488
  fixed_authority = _bracket_ipv6_authority(authority)
441
489
  if fixed_authority != authority:
442
- return f"{scheme}://{fixed_authority}{remainder}"
443
- return s
490
+ return _validate_http_url(f"{scheme}://{fixed_authority}{remainder}")
491
+ return _validate_http_url(s)
444
492
 
445
493
  # Protocol-relative URL.
446
494
  if s.startswith("//"):
447
495
  authority, remainder = _split_authority_and_remainder(s)
448
- if not authority:
449
- raise ValueError(f"URL 缺少主机名: {url!r}")
450
496
  host = _extract_host_from_authority(authority)
451
- if not host:
452
- raise ValueError(f"URL 缺少主机名: {url!r}")
453
497
  scheme = "http" if _is_local_host(host) else "https"
454
498
  authority = _bracket_ipv6_authority(authority)
455
- return f"{scheme}://{authority}{remainder}"
499
+ return _validate_http_url(f"{scheme}://{authority}{remainder}")
456
500
 
457
501
  authority, remainder = _split_authority_and_remainder(s)
458
- if not authority:
459
- raise ValueError(f"URL 缺少主机名: {url!r}")
460
502
  host = _extract_host_from_authority(authority)
461
- if not host:
462
- raise ValueError(f"URL 缺少主机名: {url!r}")
463
503
  scheme = "http" if _is_local_host(host) else "https"
464
504
  authority = _bracket_ipv6_authority(authority)
465
- return f"{scheme}://{authority}{remainder}"
505
+ return _validate_http_url(f"{scheme}://{authority}{remainder}")
466
506
 
467
507
 
468
508
  def _materialize_docsify_markdown_url(url: str) -> Optional[str]:
@@ -624,18 +664,16 @@ class GrokSearchProvider:
624
664
  reraise=True,
625
665
  ):
626
666
  with attempt:
627
- response = await client.post(
667
+ async with client.stream(
668
+ "POST",
628
669
  f"{self.api_url}/chat/completions",
629
670
  headers=self._headers,
630
671
  json=payload_copy,
631
672
  timeout=_NON_STREAM_TIMEOUT,
632
- )
633
- response.raise_for_status()
634
- data = response.json()
635
- choices = data.get("choices", [])
636
- if choices:
637
- return choices[0].get("message", {}).get("content", "")
638
- return ""
673
+ ) as response:
674
+ response.raise_for_status()
675
+ data = await _read_json_response(response)
676
+ return _extract_chat_content(data, "message")
639
677
 
640
678
  async def _execute_stream(self, payload: dict) -> str:
641
679
  """流式请求(首选,chunk 保活避免网关超时)。"""
@@ -659,46 +697,75 @@ class GrokSearchProvider:
659
697
  return await self._parse_streaming_response(response)
660
698
 
661
699
  async def _parse_streaming_response(self, response) -> str:
662
- content = ""
663
- full_body_buffer = []
664
-
665
- async for line in response.aiter_lines():
666
- line = line.strip()
700
+ content_parts = []
701
+ fallback_body = bytearray()
702
+ pending = bytearray()
703
+ scan_index = 0
704
+ saw_sse = False
705
+ cr, lf = b"\r\n"
706
+
707
+ def process_line(raw_line: bytes) -> None:
708
+ nonlocal saw_sse
709
+ line = raw_line.strip()
667
710
  if not line:
668
- continue
669
- full_body_buffer.append(line)
670
-
671
- if line.startswith("data:"):
672
- if line in ("data: [DONE]", "data:[DONE]"):
673
- continue
674
- try:
675
- json_str = line[5:].lstrip()
676
- data = json.loads(json_str)
677
- except (json.JSONDecodeError, IndexError):
711
+ return
712
+ if not line.startswith(b"data:"):
713
+ if not saw_sse:
714
+ fallback_body.extend(raw_line)
715
+ fallback_body.extend(b"\n")
716
+ return
717
+ if not saw_sse:
718
+ saw_sse = True
719
+ fallback_body.clear()
720
+ if line in (b"data: [DONE]", b"data:[DONE]"):
721
+ return
722
+ try:
723
+ data = json.loads(line[5:].lstrip())
724
+ except (json.JSONDecodeError, IndexError):
725
+ return
726
+ if isinstance(data, dict) and "error" in data:
727
+ raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
728
+ value = _extract_chat_content(data, "delta")
729
+ if value:
730
+ content_parts.append(value)
731
+
732
+ async for chunk in _iter_response_bytes(response):
733
+ pending.extend(chunk)
734
+ line_start = 0
735
+ while scan_index < len(pending):
736
+ separator = pending[scan_index]
737
+ if separator not in (cr, lf):
738
+ scan_index += 1
678
739
  continue
679
- if isinstance(data, dict) and "error" in data:
680
- raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
681
- choices = data.get("choices", [])
682
- if choices:
683
- delta = choices[0].get("delta", {})
684
- if "content" in delta:
685
- content += delta["content"]
686
-
687
- if not content and full_body_buffer:
740
+ if separator == cr and scan_index + 1 == len(pending):
741
+ break
742
+ process_line(bytes(pending[line_start:scan_index]))
743
+ scan_index += 1
744
+ if separator == cr and scan_index < len(pending) and pending[scan_index] == lf:
745
+ scan_index += 1
746
+ line_start = scan_index
747
+ if line_start:
748
+ del pending[:line_start]
749
+ scan_index -= line_start
750
+
751
+ if pending:
752
+ process_line(bytes(pending[:-1] if pending[-1] == cr else pending))
753
+
754
+ if content_parts:
755
+ return "".join(content_parts)
756
+
757
+ if fallback_body:
688
758
  try:
689
- full_text = "".join(full_body_buffer)
690
- data = json.loads(full_text)
759
+ data = json.loads(fallback_body)
691
760
  if isinstance(data, dict) and "error" in data:
692
761
  raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
693
- if "choices" in data and data["choices"]:
694
- message = data["choices"][0].get("message", {})
695
- content = message.get("content", "")
762
+ content = _extract_chat_content(data, "message")
763
+ if content.strip():
764
+ return content
696
765
  except json.JSONDecodeError:
697
766
  pass
698
767
 
699
- if not content.strip():
700
- raise EmptyStreamError("流式响应内容为空")
701
- return content
768
+ raise EmptyStreamError("流式响应内容为空")
702
769
 
703
770
 
704
771
  # ============================================================================
@@ -716,9 +783,9 @@ async def _call_tavily_extract(url: str) -> tuple[Optional[str], Optional[str]]:
716
783
  body = {"urls": [url], "format": "markdown"}
717
784
  try:
718
785
  async with httpx.AsyncClient(timeout=60.0) as client:
719
- response = await client.post(endpoint, headers=headers, json=body)
720
- response.raise_for_status()
721
- data = response.json()
786
+ async with client.stream("POST", endpoint, headers=headers, json=body) as response:
787
+ response.raise_for_status()
788
+ data = await _read_json_response(response)
722
789
  except httpx.TimeoutException:
723
790
  return None, "Tavily extract 超时"
724
791
  except httpx.HTTPStatusError as e:
@@ -792,9 +859,9 @@ async def _call_tavily_search(query: str, max_results: int = 6) -> tuple[list[di
792
859
  }
793
860
  try:
794
861
  async with httpx.AsyncClient(timeout=90.0) as client:
795
- response = await client.post(endpoint, headers=headers, json=body)
796
- response.raise_for_status()
797
- data = response.json()
862
+ async with client.stream("POST", endpoint, headers=headers, json=body) as response:
863
+ response.raise_for_status()
864
+ data = await _read_json_response(response)
798
865
  except httpx.TimeoutException:
799
866
  return [], "Tavily search 超时"
800
867
  except httpx.HTTPStatusError as e:
@@ -802,17 +869,29 @@ async def _call_tavily_search(query: str, max_results: int = 6) -> tuple[list[di
802
869
  except Exception as e:
803
870
  return [], f"Tavily search 错误: {str(e)}"
804
871
 
805
- results = (data or {}).get("results") or []
806
- if not isinstance(results, list) or not results:
807
- return [], None
808
- return [
809
- {
810
- "title": (r or {}).get("title", "") or "",
811
- "url": (r or {}).get("url", "") or "",
812
- "description": (r or {}).get("content", "") or "",
872
+ if not isinstance(data, dict):
873
+ return [], "Tavily search 返回无效响应"
874
+ results = data.get("results") or []
875
+ if not isinstance(results, list):
876
+ return [], "Tavily search 返回无效结果"
877
+
878
+ normalized = []
879
+ ignored = 0
880
+ for result in results:
881
+ if not isinstance(result, dict):
882
+ ignored += 1
883
+ continue
884
+ item = {
885
+ "title": result.get("title", "") or "",
886
+ "url": result.get("url", "") or "",
887
+ "description": result.get("content", "") or "",
813
888
  }
814
- for r in results
815
- ], None
889
+ if _is_valid_search_result(item):
890
+ normalized.append(item)
891
+ else:
892
+ ignored += 1
893
+ warning = f"Tavily search 忽略 {ignored} 条无效结果" if ignored else None
894
+ return normalized, warning
816
895
 
817
896
 
818
897
  async def _call_tavily_map(
@@ -841,18 +920,33 @@ async def _call_tavily_map(
841
920
 
842
921
  try:
843
922
  async with httpx.AsyncClient(timeout=float(timeout + 10)) as client:
844
- response = await client.post(endpoint, headers=headers, json=body)
845
- response.raise_for_status()
846
- data = response.json()
923
+ async with client.stream("POST", endpoint, headers=headers, json=body) as response:
924
+ response.raise_for_status()
925
+ data = await _read_json_response(response)
926
+ if not isinstance(data, dict):
927
+ return {"error": "Tavily map 返回无效响应"}
928
+ if "error" in data:
929
+ error = data["error"]
930
+ return {"error": error if error else "Tavily map 返回错误响应"}
931
+
932
+ base_url = data.get("base_url")
933
+ results = data.get("results")
934
+ response_time = data.get("response_time")
935
+ if not isinstance(base_url, str) or not base_url.strip():
936
+ return {"error": "Tavily map 返回无效 base_url"}
937
+ if not isinstance(results, list) or any(not isinstance(item, str) for item in results):
938
+ return {"error": "Tavily map 返回无效 results"}
939
+ if isinstance(response_time, bool) or not isinstance(response_time, (int, float)):
940
+ return {"error": "Tavily map 返回无效 response_time"}
847
941
  return {
848
- "base_url": data.get("base_url", ""),
849
- "results": data.get("results", []),
850
- "response_time": data.get("response_time", 0),
942
+ "base_url": base_url,
943
+ "results": results,
944
+ "response_time": response_time,
851
945
  }
852
946
  except httpx.TimeoutException:
853
947
  return {"error": f"映射超时: 请求超过{timeout}秒"}
854
948
  except httpx.HTTPStatusError as e:
855
- return {"error": f"HTTP错误: {e.response.status_code}", "status_code": e.response.status_code, "detail": e.response.text[:200]}
949
+ return {"error": f"HTTP错误: {e.response.status_code}", "status_code": e.response.status_code}
856
950
  except Exception as e:
857
951
  return {"error": f"映射错误: {str(e)}"}
858
952
 
@@ -882,20 +976,46 @@ def extract_json(text: str) -> str:
882
976
  # 尝试解析为 JSON
883
977
  try:
884
978
  data = json.loads(text)
885
- # 标准化字段名
886
- if isinstance(data, list):
887
- standardized = []
888
- for item in data:
889
- if isinstance(item, dict):
890
- standardized.append({
891
- "title": item.get("title", ""),
892
- "url": item.get("url", item.get("link", "")),
893
- "description": item.get("description", item.get("content", item.get("snippet", item.get("summary", ""))))
894
- })
895
- return json.dumps(standardized, ensure_ascii=False, indent=2)
896
979
  return json.dumps(data, ensure_ascii=False, indent=2)
897
- except json.JSONDecodeError:
898
- return json.dumps({"error": "解析 JSON 失败", "raw": text[:500]}, ensure_ascii=False, indent=2)
980
+ except json.JSONDecodeError as error:
981
+ raise ValueError("解析 JSON 失败") from error
982
+
983
+
984
+ def _validate_search_results(results: list) -> None:
985
+ for index, item in enumerate(results):
986
+ _validate_search_result(item, index + 1)
987
+
988
+
989
+ def _validate_search_result(item, index: int = 1) -> None:
990
+ if not isinstance(item, dict):
991
+ raise ValueError(f"搜索结果 {index} 必须是对象")
992
+ for field in ("title", "url", "description"):
993
+ value = item.get(field)
994
+ if not isinstance(value, str) or not value.strip():
995
+ raise ValueError(f"搜索结果 {index} 缺少有效 {field}")
996
+ url = item["url"]
997
+ try:
998
+ parts = urlsplit(url)
999
+ valid_url = (
1000
+ not re.search(r"\s", url)
1001
+ and parts.scheme.lower() in ("http", "https")
1002
+ and bool(parts.netloc and parts.hostname)
1003
+ and parts.username is None
1004
+ and parts.password is None
1005
+ )
1006
+ parts.port
1007
+ except ValueError:
1008
+ valid_url = False
1009
+ if not valid_url:
1010
+ raise ValueError(f"搜索结果 {index} 包含无效 URL")
1011
+
1012
+
1013
+ def _is_valid_search_result(item) -> bool:
1014
+ try:
1015
+ _validate_search_result(item)
1016
+ return True
1017
+ except ValueError:
1018
+ return False
899
1019
 
900
1020
 
901
1021
  # ============================================================================
@@ -904,7 +1024,7 @@ def extract_json(text: str) -> str:
904
1024
 
905
1025
  async def cmd_web_search(args):
906
1026
  try:
907
- effective_model = config._apply_model_suffix(args.model) if args.model else config.grok_model
1027
+ effective_model = args.model if args.model else config.grok_model
908
1028
  provider = GrokSearchProvider(config.grok_api_url, config.grok_api_key, effective_model)
909
1029
  result = await provider.search(args.query, args.platform, args.min_results, args.max_results)
910
1030
  if args.raw:
@@ -912,8 +1032,8 @@ async def cmd_web_search(args):
912
1032
  else:
913
1033
  parsed = json.loads(extract_json(result))
914
1034
  if not isinstance(parsed, list):
915
- print(json.dumps(parsed, ensure_ascii=False, indent=2))
916
- return
1035
+ raise ValueError("搜索响应必须是 JSON 数组")
1036
+ _validate_search_results(parsed)
917
1037
 
918
1038
  merged: list[dict] = parsed
919
1039
 
@@ -929,10 +1049,11 @@ async def cmd_web_search(args):
929
1049
  else:
930
1050
  extras, tavily_warning = await _call_tavily_search(args.query, extra_sources)
931
1051
  if tavily_warning:
932
- _emit_tavily_warning(f"{tavily_warning};仅附加 Grok 搜索结果")
1052
+ _emit_tavily_warning(f"{tavily_warning};保留有效搜索结果")
933
1053
 
934
1054
  seen: set[str] = set()
935
1055
  out: list[dict] = []
1056
+ ignored_tavily = 0
936
1057
  for item in merged:
937
1058
  url = (item or {}).get("url", "")
938
1059
  if isinstance(url, str) and url:
@@ -940,9 +1061,10 @@ async def cmd_web_search(args):
940
1061
  out.append(item)
941
1062
 
942
1063
  for item in extras:
943
- url = (item or {}).get("url", "")
944
- if not isinstance(url, str) or not url.startswith(("http://", "https://")):
1064
+ if not _is_valid_search_result(item):
1065
+ ignored_tavily += 1
945
1066
  continue
1067
+ url = item["url"]
946
1068
  if url in seen:
947
1069
  continue
948
1070
  seen.add(url)
@@ -954,8 +1076,12 @@ async def cmd_web_search(args):
954
1076
  }
955
1077
  )
956
1078
 
1079
+ if ignored_tavily:
1080
+ _emit_tavily_warning(f"忽略 {ignored_tavily} 条无效 Tavily 结果")
1081
+
957
1082
  merged = out
958
1083
 
1084
+ _validate_search_results(merged)
959
1085
  print(json.dumps(merged, ensure_ascii=False, indent=2))
960
1086
  except ValueError as e:
961
1087
  print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
@@ -1037,43 +1163,54 @@ async def cmd_web_map(args):
1037
1163
  args.limit,
1038
1164
  args.timeout,
1039
1165
  )
1040
- print(json.dumps(result, ensure_ascii=False, indent=2))
1041
1166
  if (result or {}).get("error"):
1167
+ print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
1042
1168
  sys.exit(1)
1169
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1043
1170
 
1044
1171
 
1045
1172
  async def cmd_get_config_info(args):
1046
1173
  config_info = config.get_config_info()
1047
1174
 
1175
+ try:
1176
+ api_url = config.grok_api_url
1177
+ api_key = config.grok_api_key
1178
+ except ValueError:
1179
+ print(json.dumps(config_info, ensure_ascii=False, indent=2), file=sys.stderr)
1180
+ sys.exit(1)
1181
+
1048
1182
  if not args.no_test:
1049
1183
  test_result = {"status": "未测试", "message": "", "response_time_ms": 0}
1184
+ connection_succeeded = False
1050
1185
  try:
1051
- api_url = config.grok_api_url
1052
- api_key = config.grok_api_key
1053
1186
  models_url = f"{api_url}/models"
1054
1187
 
1055
1188
  start_time = time.time()
1056
1189
  async with httpx.AsyncClient(timeout=10.0) as client:
1057
- response = await client.get(
1190
+ async with client.stream(
1191
+ "GET",
1058
1192
  models_url,
1059
1193
  headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1060
- )
1061
- response_time = (time.time() - start_time) * 1000
1062
-
1063
- if response.status_code == 200:
1064
- test_result["status"] = "✅ 连接成功"
1065
- test_result["response_time_ms"] = round(response_time, 2)
1066
- try:
1067
- models_data = response.json()
1068
- if "data" in models_data:
1194
+ ) as response:
1195
+ response_time = (time.time() - start_time) * 1000
1196
+
1197
+ if response.status_code == 200:
1198
+ try:
1199
+ models_data = await _read_json_response(response)
1200
+ except ResponseTooLargeError:
1201
+ raise
1202
+ except (json.JSONDecodeError, TypeError):
1203
+ models_data = None
1204
+ connection_succeeded = True
1205
+ test_result["status"] = "✅ 连接成功"
1206
+ test_result["response_time_ms"] = round(response_time, 2)
1207
+ if isinstance(models_data, dict) and isinstance(models_data.get("data"), list):
1069
1208
  model_count = len(models_data["data"])
1070
1209
  test_result["message"] = f"已获取 {model_count} 个模型"
1071
1210
  test_result["available_models"] = [m.get("id") for m in models_data["data"] if isinstance(m, dict)]
1072
- except:
1073
- pass
1074
- else:
1075
- test_result["status"] = "⚠️ 连接问题"
1076
- test_result["message"] = f"HTTP {response.status_code}"
1211
+ else:
1212
+ test_result["status"] = "⚠️ 连接问题"
1213
+ test_result["message"] = f"HTTP {response.status_code}"
1077
1214
 
1078
1215
  except httpx.TimeoutException:
1079
1216
  test_result["status"] = "❌ 连接超时"
@@ -1084,67 +1221,11 @@ async def cmd_get_config_info(args):
1084
1221
 
1085
1222
  config_info["connection_test"] = test_result
1086
1223
 
1087
- print(json.dumps(config_info, ensure_ascii=False, indent=2))
1088
-
1089
-
1090
- async def cmd_toggle_builtin_tools(args):
1091
- # 查找项目根目录
1092
- if args.root:
1093
- root = Path(args.root)
1094
- if not root.exists():
1095
- print(json.dumps({"error": f"指定的根目录不存在: {args.root}"}, ensure_ascii=False), file=sys.stderr)
1224
+ if not connection_succeeded:
1225
+ print(json.dumps(config_info, ensure_ascii=False, indent=2), file=sys.stderr)
1096
1226
  sys.exit(1)
1097
- else:
1098
- root = Path.cwd()
1099
- while root != root.parent and not (root / ".git").exists():
1100
- root = root.parent
1101
- if not (root / ".git").exists():
1102
- print(json.dumps({
1103
- "error": "未找到 .git 目录。使用 --root 指定项目根目录。",
1104
- "hint": "从 git 仓库中运行此命令,或指定 --root PATH"
1105
- }, ensure_ascii=False), file=sys.stderr)
1106
- sys.exit(1)
1107
-
1108
- settings_path = root / ".agent" / "settings.json"
1109
- tools = ["WebFetch", "WebSearch"]
1110
1227
 
1111
- # 加载或初始化
1112
- if settings_path.exists():
1113
- with open(settings_path, 'r', encoding='utf-8') as f:
1114
- settings = json.load(f)
1115
- else:
1116
- settings = {"permissions": {"deny": []}}
1117
-
1118
- deny = settings.setdefault("permissions", {}).setdefault("deny", [])
1119
- blocked = all(t in deny for t in tools)
1120
-
1121
- # 执行操作
1122
- action = args.action.lower()
1123
- if action in ["on", "enable"]:
1124
- for t in tools:
1125
- if t not in deny:
1126
- deny.append(t)
1127
- settings_path.parent.mkdir(parents=True, exist_ok=True)
1128
- with open(settings_path, 'w', encoding='utf-8') as f:
1129
- json.dump(settings, f, ensure_ascii=False, indent=2)
1130
- msg = "内置工具已禁用"
1131
- blocked = True
1132
- elif action in ["off", "disable"]:
1133
- deny[:] = [t for t in deny if t not in tools]
1134
- settings_path.parent.mkdir(parents=True, exist_ok=True)
1135
- with open(settings_path, 'w', encoding='utf-8') as f:
1136
- json.dump(settings, f, ensure_ascii=False, indent=2)
1137
- msg = "内置工具已启用"
1138
- blocked = False
1139
- else:
1140
- msg = f"内置工具当前{'已禁用' if blocked else '已启用'}"
1141
-
1142
- print(json.dumps({
1143
- "blocked": blocked,
1144
- "deny_list": deny,
1145
- "file": str(settings_path),
1146
- "message": msg
1147
- }, ensure_ascii=False, indent=2))
1228
+ print(json.dumps(config_info, ensure_ascii=False, indent=2))
1148
1229
 
1149
1230
 
1150
1231
  # ============================================================================
@@ -1158,7 +1239,6 @@ async def _run_command(args):
1158
1239
  "web_fetch": cmd_web_fetch,
1159
1240
  "web_map": cmd_web_map,
1160
1241
  "get_config_info": cmd_get_config_info,
1161
- "toggle_builtin_tools": cmd_toggle_builtin_tools,
1162
1242
  }
1163
1243
  try:
1164
1244
  await commands[args.command](args)
@@ -1167,6 +1247,7 @@ async def _run_command(args):
1167
1247
 
1168
1248
 
1169
1249
  def main():
1250
+ load_dotenv()
1170
1251
  parser = argparse.ArgumentParser(
1171
1252
  prog="groksearch_cli",
1172
1253
  description="GrokSearch CLI - 通过 Grok/Tavily 进行独立的网页搜索/获取/映射"
@@ -1205,11 +1286,6 @@ def main():
1205
1286
  p_config = subparsers.add_parser("get_config_info", help="显示配置并测试连接")
1206
1287
  p_config.add_argument("--no-test", action="store_true", help="跳过连接测试")
1207
1288
 
1208
- # toggle_builtin_tools
1209
- p_toggle = subparsers.add_parser("toggle_builtin_tools", help="切换内置 WebSearch/WebFetch")
1210
- p_toggle.add_argument("--action", "-a", default="status", help="操作: on/off/status")
1211
- p_toggle.add_argument("--root", "-r", help="项目根路径 (默认: 通过 .git 自动检测)")
1212
-
1213
1289
  args = parser.parse_args()
1214
1290
 
1215
1291
  # 应用覆盖