xg-cli 1.0__py3-none-any.whl

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 (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/config/manager.py ADDED
@@ -0,0 +1,446 @@
1
+ """配置合并与持久化。
2
+
3
+ 优先级(从低到高):默认值 < 用户级 ~/.xg/config.json < 项目级 .xg/config.json < 环境变量/.env。
4
+ API Key 只从环境变量 / .env 读取,绝不写入配置文件。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ from xg.config.providers import Provider, ProviderRegistry
18
+
19
+ USER_CONFIG = "config.json"
20
+ DEFAULT_CONTEXT_WINDOW = 128_000
21
+
22
+
23
+ class ProviderNotConfigured(Exception):
24
+ """未配置可用的 base provider 时抛出,提示用户先配置。"""
25
+
26
+
27
+ def _api_key_env_for(name: str) -> str:
28
+ """由 provider 名称自动推导对应的 API Key 环境变量名。"""
29
+ return f"XG_{name.upper()}_API_KEY"
30
+
31
+ # SmartRouter 档位固定名称(顺序即惯例展示顺序)
32
+ _SMART_ROUTER_TIERS = ("Basic", "Enhanced", "Superior", "Ultimate")
33
+
34
+
35
+ @dataclass
36
+ class ActiveConfig:
37
+ """解析后的当前生效配置快照。"""
38
+
39
+ provider_name: str
40
+ model: str
41
+ api_base: str
42
+ api_key: str
43
+ context_window: int
44
+ supports_cache: bool = False
45
+ supports_vision: bool = False
46
+
47
+
48
+ def mask_key(key: str) -> str:
49
+ """API Key 脱敏:前 4 位 + ****。"""
50
+ if not key:
51
+ return "(未配置)"
52
+ return f"{key[:4]}****" if len(key) > 4 else "****"
53
+
54
+
55
+ _PLACEHOLDER_EXACT = {"sk-xxx", "xxx", "your-api-key", "api-key", "changeme", "placeholder", "sk-xxx-xxx"}
56
+
57
+
58
+ def _is_placeholder(value: str) -> bool:
59
+ """判断是否为占位值(如 sk-xxx / xxx)。占位值视为未配置。"""
60
+ v = value.strip().lower()
61
+ if not v:
62
+ return True
63
+ if v in _PLACEHOLDER_EXACT:
64
+ return True
65
+ return v.startswith("sk-xxx") or v.startswith("xxx")
66
+
67
+
68
+ def _deep_merge(base: dict, override: dict) -> dict:
69
+ """递归合并两个 dict,override 按名覆盖 base。"""
70
+ result = dict(base)
71
+ for key, value in override.items():
72
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
73
+ result[key] = _deep_merge(result[key], value)
74
+ else:
75
+ result[key] = value
76
+ return result
77
+
78
+
79
+ def _find_env_file() -> Path | None:
80
+ """从当前工作目录向上查找 .env(最多 3 层)。"""
81
+ cur = Path.cwd()
82
+ for _ in range(3):
83
+ candidate = cur / ".env"
84
+ if candidate.is_file():
85
+ return candidate
86
+ if cur == cur.parent:
87
+ break
88
+ cur = cur.parent
89
+ return None
90
+
91
+
92
+ class ConfigManager:
93
+ def __init__(
94
+ self,
95
+ user_dir: str | Path | None = None,
96
+ project_dir: str | Path | None = None,
97
+ env: dict[str, str] | None = None,
98
+ env_file: str | Path | None = None,
99
+ load_env: bool = True,
100
+ ) -> None:
101
+ self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
102
+ self.project_dir = Path(project_dir) if project_dir else Path.cwd() / ".xg"
103
+ self.user_config_path = self.user_dir / USER_CONFIG
104
+ self.project_config_path = self.project_dir / USER_CONFIG
105
+ self.env: dict[str, str] = env if env is not None else os.environ
106
+ self.registry = ProviderRegistry()
107
+ if load_env:
108
+ self._load_env_file(env_file)
109
+
110
+ # ---------- 文件读写 ----------
111
+
112
+ def _load_env_file(self, env_file: str | Path | None) -> None:
113
+ if env_file is None:
114
+ env_file = _find_env_file()
115
+ if env_file and Path(env_file).is_file():
116
+ load_dotenv(env_file, override=False)
117
+
118
+ def _read_config(self, path: Path) -> dict:
119
+ if not path.is_file():
120
+ return {}
121
+ try:
122
+ data = json.loads(path.read_text(encoding="utf-8"))
123
+ return data if isinstance(data, dict) else {}
124
+ except (OSError, json.JSONDecodeError):
125
+ return {}
126
+
127
+ def _write_config(self, path: Path, data: dict) -> None:
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
130
+
131
+ def _merged_config(self) -> dict:
132
+ """项目级按名覆盖用户级。"""
133
+ return _deep_merge(
134
+ self._read_config(self.user_config_path),
135
+ self._read_config(self.project_config_path),
136
+ )
137
+
138
+ # ---------- provider 解析 ----------
139
+
140
+ def provider_names(self) -> list[str]:
141
+ """内置 + 配置文件中定义的自定义 provider。"""
142
+ names = set(self.registry.names())
143
+ names.update((self._merged_config().get("providers") or {}).keys())
144
+ return sorted(names)
145
+
146
+ def resolve_provider(self, name: str) -> Provider | None:
147
+ """返回合并配置后的 provider;未定义返回 None。
148
+
149
+ API Key: 优先 providers.<name>.api_key;若缺失且占位值则视为未配置。
150
+ """
151
+ cfg = (self._merged_config().get("providers") or {}).get(name)
152
+ if not isinstance(cfg, dict):
153
+ cfg = None
154
+ base = self.registry.get(name)
155
+
156
+ if base is None and cfg is None:
157
+ return None
158
+
159
+ def _extract_api_key(cfg_dict: dict | None) -> str:
160
+ if not cfg_dict:
161
+ return ""
162
+ key = str(cfg_dict.get("api_key", "")).strip()
163
+ if _is_placeholder(key):
164
+ return ""
165
+ return key
166
+
167
+ def _extract_models(cfg_dict: dict | None) -> tuple[str, ...]:
168
+ if not cfg_dict:
169
+ return ()
170
+ raw = cfg_dict.get("models")
171
+ if not isinstance(raw, list):
172
+ return ()
173
+ models = [str(m).strip() for m in raw if str(m).strip()]
174
+ return tuple(dict.fromkeys(models)) # 去重保序
175
+
176
+ if base is None:
177
+ required = ("api_base", "default_model")
178
+ if not cfg or not all(cfg.get(k) for k in required):
179
+ return None
180
+ api_key = _extract_api_key(cfg)
181
+ return Provider(
182
+ name=name,
183
+ display_name=str(cfg.get("display_name", name)),
184
+ api_base=str(cfg["api_base"]),
185
+ default_model=str(cfg["default_model"]),
186
+ models=_extract_models(cfg),
187
+ api_key=api_key,
188
+ context_window=int(cfg.get("context_window", DEFAULT_CONTEXT_WINDOW)),
189
+ supports_cache=bool(cfg.get("supports_cache", False)),
190
+ supports_vision=bool(cfg.get("supports_vision", False)),
191
+ )
192
+
193
+ api_key = _extract_api_key(cfg) or base.api_key
194
+ return Provider(
195
+ name=base.name,
196
+ display_name=str((cfg or {}).get("display_name", base.display_name)),
197
+ api_base=str((cfg or {}).get("api_base", base.api_base)),
198
+ default_model=str((cfg or {}).get("default_model", base.default_model)),
199
+ models=_extract_models(cfg) or base.models,
200
+ api_key=api_key,
201
+ context_window=int((cfg or {}).get("context_window", base.context_window)),
202
+ supports_cache=base.supports_cache,
203
+ supports_vision=base.supports_vision,
204
+ )
205
+
206
+ def list_providers(self) -> list[Provider]:
207
+ return [p for name in self.provider_names() if (p := self.resolve_provider(name))]
208
+
209
+ def resolve_api_base(self, provider: Provider) -> str:
210
+ """URL 读取:provider 来自 config.json 的 providers.<name>.api_base(不走 .env)。"""
211
+ return provider.api_base
212
+
213
+ def resolve_api_key(self, provider: Provider) -> str:
214
+ """API Key:读取 config.json 的 providers.<name>.api_key(不走 .env)。
215
+
216
+ 占位值(如 sk-xxx / xxx)视为未配置,避免占位符被当真实 key 使用。
217
+ """
218
+ return provider.api_key if not _is_placeholder(provider.api_key) else ""
219
+
220
+ def resolve_window(self, provider: Provider) -> int:
221
+ """上下文窗口:XG_CONTEXT_WINDOW 环境变量 > provider 能力。"""
222
+ raw = self.env.get("XG_CONTEXT_WINDOW", "")
223
+ try:
224
+ return int(raw) if raw else provider.context_window
225
+ except ValueError:
226
+ return provider.context_window
227
+
228
+ # ---------- 生效配置 ----------
229
+
230
+ def active(self) -> ActiveConfig:
231
+ merged = self._merged_config()
232
+ # 激活的 base provider 只认 config.json 的 active_provider(不再读 XG_PROVIDER)。
233
+ # 未配置可用的 provider 时 fail-fast 提示。
234
+ provider_name = str(merged.get("active_provider", "") or "")
235
+ provider = self.resolve_provider(provider_name) if provider_name else None
236
+ if provider is None:
237
+ raise ProviderNotConfigured(
238
+ "未配置可用的 base provider。请在 config.json 的 providers 中定义 "
239
+ f"{provider_name or '<name>'}(name / api_base / default_model / api_key),"
240
+ "并设置 active_provider 选中 base;配置可全程用 /provider 命令或 TUI 面板完成。"
241
+ )
242
+
243
+ # 模型:配置 active_model > provider 默认(不再读 XG_MODEL)
244
+ model = (
245
+ str(merged.get("active_model", "") or "")
246
+ or provider.default_model
247
+ )
248
+ # base url:来自 config.json 的 api_base
249
+ api_base = provider.api_base
250
+
251
+ return ActiveConfig(
252
+ provider_name=provider.name,
253
+ model=model,
254
+ api_base=api_base,
255
+ api_key=self.resolve_api_key(provider),
256
+ context_window=self.resolve_window(provider),
257
+ supports_cache=provider.supports_cache,
258
+ supports_vision=provider.supports_vision,
259
+ )
260
+
261
+ # ---------- 持久化 ----------
262
+
263
+ def set_active(self, provider: str, model: str) -> None:
264
+ user = self._read_config(self.user_config_path)
265
+ user["active_provider"] = provider
266
+ user["active_model"] = model
267
+ self._write_config(self.user_config_path, user)
268
+
269
+ def set_config_value(self, dotted_key: str, value: str) -> None:
270
+ """按点路径写入用户配置,如 providers.deepseek.default_model。"""
271
+ user = self._read_config(self.user_config_path)
272
+ keys = dotted_key.split(".")
273
+ node: Any = user
274
+ for key in keys[:-1]:
275
+ nxt = node.get(key)
276
+ if not isinstance(nxt, dict):
277
+ nxt = {}
278
+ node[key] = nxt
279
+ node = nxt
280
+ node[keys[-1]] = value
281
+ self._write_config(self.user_config_path, user)
282
+
283
+ # ---------- provider 分层读写 ----------
284
+
285
+ def provider_layer(self, name: str) -> str:
286
+ """返回定义该 provider 的配置层:project > user > ''(项目级优先)。"""
287
+ project = (self._read_config(self.project_config_path).get("providers") or {})
288
+ user = (self._read_config(self.user_config_path).get("providers") or {})
289
+ if name in project:
290
+ return "project"
291
+ if name in user:
292
+ return "user"
293
+ return ""
294
+
295
+ def upsert_provider(self, name: str, fields: dict) -> str:
296
+ """写入/更新 provider 字段到其**生效层**(项目级优先,缺省 user)。
297
+
298
+ 返回实际写入的层名(``user`` / ``project``)。只有 ``None`` 值的字段会被忽略,
299
+ 以便做部分更新(如只改 api_base 时保留 display_name)。
300
+ """
301
+ layer = self.provider_layer(name) or "user"
302
+ path = self.project_config_path if layer == "project" else self.user_config_path
303
+ data = self._read_config(path)
304
+ providers = data.setdefault("providers", {})
305
+ existing = providers.get(name)
306
+ updates = {k: v for k, v in fields.items() if v is not None}
307
+ if isinstance(existing, dict):
308
+ existing.update(updates)
309
+ else:
310
+ providers[name] = updates
311
+ self._write_config(path, data)
312
+ return layer
313
+
314
+ def delete_provider(self, name: str) -> bool:
315
+ """从用户级与项目级配置中删除该 provider;返回是否实际删除(清理悬空节)。"""
316
+ changed = False
317
+ for path in (self.user_config_path, self.project_config_path):
318
+ data = self._read_config(path)
319
+ providers = data.get("providers")
320
+ if isinstance(providers, dict) and name in providers:
321
+ del providers[name]
322
+ if not providers:
323
+ data.pop("providers", None)
324
+ self._write_config(path, data)
325
+ changed = True
326
+ return changed
327
+
328
+ def get_config_value(self, dotted_key: str) -> str | None:
329
+ """从合并配置读取值;非标量时返回 JSON 字符串。"""
330
+ node: Any = self._merged_config()
331
+ for key in dotted_key.split("."):
332
+ if isinstance(node, dict) and key in node:
333
+ node = node[key]
334
+ else:
335
+ return None
336
+ if isinstance(node, (str, int, float, bool)):
337
+ return str(node)
338
+ return json.dumps(node, ensure_ascii=False)
339
+
340
+ # ---------- SmartRouter ----------
341
+
342
+ def smart_router_config(self) -> dict[str, Any]:
343
+ """读取合并配置中的 smart_router 节(用户级 < 项目级深合并)。
344
+
345
+ 返回结构(缺省时):
346
+ {"enabled": False, "tiers": {}}
347
+ tiers 中每个档位(Basic/Enhanced/Superior/Ultimate)形如:
348
+ {"provider": "deepseek", "model": "deepseek-chat"}
349
+ 校验规则:非法结构/非法档位名/非法 provider-model 类型一律丢弃该条目,
350
+ 绝不抛错(配置损坏时静默回退默认行为)。
351
+ """
352
+ merged = self._merged_config()
353
+ raw = merged.get("smart_router") or {}
354
+ if not isinstance(raw, dict):
355
+ return {"enabled": False, "tiers": {}}
356
+
357
+ enabled_raw = raw.get("enabled", False)
358
+ enabled = enabled_raw if isinstance(enabled_raw, bool) else str(enabled_raw).lower() in ("on", "1", "true")
359
+
360
+ tiers_raw = raw.get("tiers") or {}
361
+ tiers: dict[str, dict[str, str]] = {}
362
+ if isinstance(tiers_raw, dict):
363
+ for tier_name, tier_cfg in tiers_raw.items():
364
+ if not isinstance(tier_cfg, dict):
365
+ continue
366
+ entry: dict[str, str] = {}
367
+ for key in ("provider", "model"):
368
+ value = tier_cfg.get(key)
369
+ if isinstance(value, str) and value.strip():
370
+ entry[key] = value.strip()
371
+ if entry:
372
+ tiers[str(tier_name)] = entry
373
+
374
+ return {"enabled": enabled, "tiers": tiers}
375
+
376
+ def set_smart_router_enabled(self, enabled: bool) -> None:
377
+ """持久化开关位到用户级配置(/smartRouter on|off 时调用)。"""
378
+ user = self._read_config(self.user_config_path)
379
+ node = user.get("smart_router")
380
+ if not isinstance(node, dict):
381
+ node = {}
382
+ node["enabled"] = bool(enabled)
383
+ user["smart_router"] = node
384
+ self._write_config(self.user_config_path, user)
385
+
386
+ def set_smart_router_tier(self, tier_name: str, provider: str, model: str) -> None:
387
+ """持久化一个档位的 provider/model 到用户级配置(/tier set 时调用)。"""
388
+ user = self._read_config(self.user_config_path)
389
+ node = user.get("smart_router")
390
+ if not isinstance(node, dict):
391
+ node = {}
392
+ tiers = node.get("tiers")
393
+ if not isinstance(tiers, dict):
394
+ tiers = {}
395
+ tiers[tier_name] = {"provider": provider, "model": model}
396
+ node["tiers"] = tiers
397
+ user["smart_router"] = node
398
+ self._write_config(self.user_config_path, user)
399
+
400
+ def remove_smart_router_tier(self, tier_name: str) -> bool:
401
+ """清空一个档位(删除其配置,回落到手动 active);返回是否有实际配置被删。"""
402
+ user = self._read_config(self.user_config_path)
403
+ node = user.get("smart_router")
404
+ if not isinstance(node, dict):
405
+ return False
406
+ tiers = node.get("tiers")
407
+ if not isinstance(tiers, dict) or tier_name not in tiers:
408
+ return False
409
+ del tiers[tier_name]
410
+ if not tiers:
411
+ node.pop("tiers", None)
412
+ user["smart_router"] = node
413
+ self._write_config(self.user_config_path, user)
414
+ return True
415
+
416
+ # ---------- UI 偏好 ----------
417
+
418
+ def get_ui_language(self) -> str:
419
+ """读取 Inspector language;非法值回退到 English。"""
420
+ value = self.get_config_value("ui_language")
421
+ return value.strip().lower() if value and value.strip().lower() in {"en", "zh"} else "en"
422
+
423
+ def ui_language_source(self) -> str:
424
+ """返回 Inspector language 的配置来源,便于 /lang 展示。"""
425
+ project = self._read_config(self.project_config_path)
426
+ if str(project.get("ui_language", "")).strip().lower() in {"en", "zh"}:
427
+ return "project config"
428
+ user = self._read_config(self.user_config_path)
429
+ if str(user.get("ui_language", "")).strip().lower() in {"en", "zh"}:
430
+ return "user config"
431
+ return "default"
432
+
433
+ def set_ui_language(self, language: str) -> None:
434
+ """Persist a validated Inspector language in the user config."""
435
+ normalized = language.strip().lower()
436
+ if normalized not in {"en", "zh"}:
437
+ raise ValueError("ui_language must be en or zh")
438
+ self.set_config_value("ui_language", normalized)
439
+
440
+ def reset_ui_language(self) -> None:
441
+ """Remove the user override for Inspector language."""
442
+ user = self._read_config(self.user_config_path)
443
+ if "ui_language" not in user:
444
+ return
445
+ user.pop("ui_language")
446
+ self._write_config(self.user_config_path, user)
xg/config/mcp.py ADDED
@@ -0,0 +1,229 @@
1
+ """MCP server configuration loading, merging and environment expansion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ from xg.mcp.models import McpServerConfig
14
+
15
+
16
+ MCP_CONFIG_FILE = "mcp.json"
17
+ _SERVER_RE = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
18
+ _ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
19
+ _LEVELS = {"default", "never", "confirm", "always"}
20
+
21
+
22
+ def _deep_merge(base: dict, override: dict) -> dict:
23
+ result = dict(base)
24
+ for key, value in override.items():
25
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
26
+ result[key] = _deep_merge(result[key], value)
27
+ else:
28
+ result[key] = value
29
+ return result
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class McpConfigLoadResult:
34
+ servers: dict[str, McpServerConfig]
35
+ errors: tuple[str, ...] = ()
36
+
37
+
38
+ class McpConfigManager:
39
+ def __init__(
40
+ self,
41
+ *,
42
+ user_dir: str | Path | None = None,
43
+ project_root: str | Path | None = None,
44
+ env: dict[str, str] | None = None,
45
+ defaults: dict[str, Any] | None = None,
46
+ ) -> None:
47
+ self.user_dir = Path(user_dir) if user_dir else Path.home() / ".xg"
48
+ self.project_root = (Path(project_root) if project_root else Path.cwd()).resolve()
49
+ self.user_config_path = self.user_dir / MCP_CONFIG_FILE
50
+ self.project_config_path = self.project_root / ".xg" / MCP_CONFIG_FILE
51
+ self.env = env if env is not None else os.environ
52
+ self.defaults = {
53
+ "startup_timeout": 15.0,
54
+ "request_timeout": 120.0,
55
+ "shutdown_timeout": 5.0,
56
+ "max_output_chars": 20_000,
57
+ "max_tools": 256,
58
+ "max_resources": 512,
59
+ "max_message_bytes": 2_097_152,
60
+ "resource_max_chars": 32_000,
61
+ "log_lines": 200,
62
+ **(defaults or {}),
63
+ }
64
+
65
+ def _read(self, path: Path) -> tuple[dict, str | None]:
66
+ if not path.is_file():
67
+ return {}, None
68
+ try:
69
+ data = json.loads(path.read_text(encoding="utf-8"))
70
+ except json.JSONDecodeError as exc:
71
+ return {}, f"{path}: JSON 格式错误({exc.lineno}:{exc.colno})"
72
+ except OSError as exc:
73
+ return {}, f"{path}: 读取失败({exc})"
74
+ if not isinstance(data, dict):
75
+ return {}, f"{path}: 顶层必须是 JSON 对象"
76
+ return data, None
77
+
78
+ def _expand(self, value: Any, missing: set[str]) -> Any:
79
+ if isinstance(value, str):
80
+ def replace(match: re.Match[str]) -> str:
81
+ name = match.group(1)
82
+ if name not in self.env:
83
+ missing.add(name)
84
+ return ""
85
+ return str(self.env[name])
86
+ return _ENV_RE.sub(replace, value)
87
+ if isinstance(value, list):
88
+ return [self._expand(item, missing) for item in value]
89
+ if isinstance(value, dict):
90
+ return {str(key): self._expand(item, missing) for key, item in value.items()}
91
+ return value
92
+
93
+ @staticmethod
94
+ def _number(raw: dict, name: str, default: float, minimum: float = 0.1) -> float:
95
+ try:
96
+ return max(minimum, float(raw.get(name, default)))
97
+ except (TypeError, ValueError):
98
+ return float(default)
99
+
100
+ @staticmethod
101
+ def _integer(raw: dict, name: str, default: int, minimum: int = 1) -> int:
102
+ try:
103
+ return max(minimum, int(raw.get(name, default)))
104
+ except (TypeError, ValueError):
105
+ return int(default)
106
+
107
+ def load(self) -> McpConfigLoadResult:
108
+ user, user_error = self._read(self.user_config_path)
109
+ project, project_error = self._read(self.project_config_path)
110
+ errors = [error for error in (user_error, project_error) if error]
111
+ merged = _deep_merge(user, project)
112
+ raw_servers = merged.get("servers", {})
113
+ if not isinstance(raw_servers, dict):
114
+ return McpConfigLoadResult({}, tuple([*errors, "mcp.json 的 servers 必须是对象"]))
115
+
116
+ servers: dict[str, McpServerConfig] = {}
117
+ enabled_overrides = user.get("enabled_overrides", {})
118
+ if not isinstance(enabled_overrides, dict):
119
+ enabled_overrides = {}
120
+ for name, original in raw_servers.items():
121
+ if not _SERVER_RE.fullmatch(str(name)):
122
+ errors.append(f"MCP Server 名称无效: {name}")
123
+ continue
124
+ if not isinstance(original, dict):
125
+ errors.append(f"MCP Server {name} 配置必须是对象")
126
+ continue
127
+ missing: set[str] = set()
128
+ source = dict(original)
129
+ if name in enabled_overrides:
130
+ source["enabled"] = bool(enabled_overrides[name])
131
+ raw = self._expand(source, missing)
132
+ if missing:
133
+ errors.append(f"MCP Server {name} 缺少环境变量: {', '.join(sorted(missing))}")
134
+ continue
135
+ try:
136
+ config = self._parse_server(str(name), raw)
137
+ except ValueError as exc:
138
+ errors.append(f"MCP Server {name}: {exc}")
139
+ continue
140
+ servers[name] = config
141
+ return McpConfigLoadResult(servers, tuple(errors))
142
+
143
+ def _parse_server(self, name: str, raw: dict) -> McpServerConfig:
144
+ transport = str(raw.get("transport", "stdio")).lower().replace("-", "_")
145
+ if transport == "http":
146
+ transport = "streamable_http"
147
+ if transport not in {"stdio", "streamable_http"}:
148
+ raise ValueError("transport 必须是 stdio 或 streamable_http")
149
+ command = str(raw.get("command", "")).strip() or None
150
+ url = str(raw.get("url", "")).strip() or None
151
+ if transport == "stdio" and not command:
152
+ raise ValueError("stdio transport 缺少 command")
153
+ if transport == "streamable_http":
154
+ if not url:
155
+ raise ValueError("streamable_http transport 缺少 url")
156
+ parsed_url = urlparse(url)
157
+ if parsed_url.scheme.lower() not in {"http", "https"}:
158
+ raise ValueError("url 只允许 http/https")
159
+ if parsed_url.username or parsed_url.password:
160
+ raise ValueError("url 禁止内嵌用户名或密码,请使用 headers + ${VAR}")
161
+
162
+ args = raw.get("args", [])
163
+ env = raw.get("env", {})
164
+ headers = raw.get("headers", {})
165
+ overrides = raw.get("tool_overrides", {})
166
+ if not isinstance(args, list) or not all(isinstance(item, (str, int, float)) for item in args):
167
+ raise ValueError("args 必须是字符串数组")
168
+ if not isinstance(env, dict) or not isinstance(headers, dict):
169
+ raise ValueError("env/headers 必须是对象")
170
+ if not isinstance(overrides, dict):
171
+ raise ValueError("tool_overrides 必须是对象")
172
+ hitl = str(raw.get("hitl", "default")).lower()
173
+ if hitl not in _LEVELS:
174
+ raise ValueError("hitl 必须是 default/never/confirm/always")
175
+ clean_overrides: dict[str, str] = {}
176
+ for tool_name, level in overrides.items():
177
+ level = str(level).lower()
178
+ if level not in _LEVELS - {"default"}:
179
+ raise ValueError(f"工具 {tool_name} 的审批级别无效")
180
+ clean_overrides[str(tool_name)] = level
181
+
182
+ cwd = str(raw.get("cwd", "")).strip() or None
183
+ if cwd:
184
+ cwd_path = Path(cwd)
185
+ if not cwd_path.is_absolute():
186
+ cwd_path = self.project_root / cwd_path
187
+ cwd_path = cwd_path.resolve()
188
+ if not cwd_path.is_relative_to(self.project_root):
189
+ raise ValueError("cwd 必须位于项目根目录内")
190
+ cwd = str(cwd_path)
191
+ elif transport == "stdio":
192
+ cwd = str(self.project_root)
193
+
194
+ return McpServerConfig(
195
+ name=name,
196
+ transport=transport, # type: ignore[arg-type]
197
+ enabled=bool(raw.get("enabled", True)),
198
+ command=command,
199
+ args=tuple(str(item) for item in args),
200
+ cwd=cwd,
201
+ env={str(key): str(value) for key, value in env.items()},
202
+ url=url,
203
+ headers={str(key): str(value) for key, value in headers.items()},
204
+ startup_timeout=self._number(raw, "startup_timeout", self.defaults["startup_timeout"]),
205
+ request_timeout=self._number(raw, "request_timeout", self.defaults["request_timeout"]),
206
+ shutdown_timeout=self._number(raw, "shutdown_timeout", self.defaults["shutdown_timeout"]),
207
+ max_output_chars=self._integer(raw, "max_output_chars", self.defaults["max_output_chars"]),
208
+ hitl=hitl, # type: ignore[arg-type]
209
+ tool_overrides=clean_overrides,
210
+ max_tools=self._integer(raw, "max_tools", self.defaults["max_tools"]),
211
+ max_resources=self._integer(raw, "max_resources", self.defaults["max_resources"]),
212
+ max_message_bytes=self._integer(raw, "max_message_bytes", self.defaults["max_message_bytes"], 1024),
213
+ resource_max_chars=self._integer(raw, "resource_max_chars", self.defaults["resource_max_chars"]),
214
+ log_lines=self._integer(raw, "log_lines", self.defaults["log_lines"]),
215
+ )
216
+
217
+ def set_enabled(self, name: str, enabled: bool) -> None:
218
+ data, error = self._read(self.user_config_path)
219
+ if error:
220
+ raise ValueError(error)
221
+ overrides = data.setdefault("enabled_overrides", {})
222
+ if not isinstance(overrides, dict):
223
+ overrides = {}
224
+ data["enabled_overrides"] = overrides
225
+ overrides[name] = enabled
226
+ self.user_config_path.parent.mkdir(parents=True, exist_ok=True)
227
+ self.user_config_path.write_text(
228
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
229
+ )