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/cli/help.py ADDED
@@ -0,0 +1,185 @@
1
+ """Formatting helpers for the shared slash-command help."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from xg.cli.commands import SLASH_COMMANDS, SlashCommandSpec, SlashSubcommandSpec
8
+
9
+
10
+ CATEGORY_LABELS: dict[str, str] = {
11
+ "workflow": "工作流",
12
+ "config": "配置与能力",
13
+ "session": "会话",
14
+ "memory": "记忆",
15
+ "safety": "安全",
16
+ "control": "控制",
17
+ "general": "通用",
18
+ }
19
+
20
+ CATEGORY_ORDER = (
21
+ "workflow",
22
+ "config",
23
+ "session",
24
+ "memory",
25
+ "safety",
26
+ "control",
27
+ "general",
28
+ )
29
+
30
+ SHORTCUTS = (
31
+ ("Enter", "发送输入"),
32
+ ("↑ / ↓", "浏览输入历史;输入 / 时浏览命令建议"),
33
+ ("Ctrl+C", "取消当前任务"),
34
+ ("Ctrl+L", "清屏"),
35
+ ("Ctrl+R", "显示或隐藏侧栏"),
36
+ ("Ctrl+T", "打开配置面板(Provider 与 SmartRouter)"),
37
+ ("Ctrl+1..4", "切换 Inspector 视图(Session/Plan/Memory/Safety)"),
38
+ ("Ctrl+Tab", "Inspector 视图循环切换(Ctrl+Shift+Tab 反向)"),
39
+ ("Esc", "关闭弹窗、取消当前交互或清除输入"),
40
+ )
41
+
42
+
43
+ def _command_key(value: str) -> str:
44
+ """Normalize a command name or alias for exact help lookup."""
45
+
46
+ return value.strip().lower().lstrip("/")
47
+
48
+
49
+ def parse_help_command(raw: str) -> str | None:
50
+ """Return the optional help query, or ``None`` for a non-help input."""
51
+
52
+ parts = raw.strip().split(maxsplit=1)
53
+ if not parts or parts[0].lower() not in ("/help", "/?"):
54
+ return None
55
+ return parts[1].strip() if len(parts) > 1 else ""
56
+
57
+
58
+ def _aliases_text(spec: SlashCommandSpec) -> str:
59
+ if not spec.aliases:
60
+ return ""
61
+ return f"(别名:{'、'.join(spec.aliases)})"
62
+
63
+
64
+ def _command_lines(commands: Sequence[SlashCommandSpec]) -> list[str]:
65
+ visible = [spec for spec in commands if spec.usage and spec.description]
66
+ if not visible:
67
+ return []
68
+ usage_width = max(len(spec.usage) for spec in visible)
69
+ lines: list[str] = []
70
+ for category in CATEGORY_ORDER:
71
+ grouped = [spec for spec in visible if spec.category == category]
72
+ if not grouped:
73
+ continue
74
+ lines.append(CATEGORY_LABELS.get(category, category))
75
+ for spec in grouped:
76
+ usage = spec.usage.ljust(usage_width)
77
+ lines.append(f" {usage} {spec.description}{_aliases_text(spec)}")
78
+ lines.append("")
79
+
80
+ # Keep custom categories visible even if a future command adds one that
81
+ # is not yet part of the standard presentation order.
82
+ known = set(CATEGORY_ORDER)
83
+ for category in dict.fromkeys(spec.category for spec in visible if spec.category not in known):
84
+ lines.append(CATEGORY_LABELS.get(category, category))
85
+ for spec in visible:
86
+ if spec.category == category:
87
+ usage = spec.usage.ljust(usage_width)
88
+ lines.append(f" {usage} {spec.description}{_aliases_text(spec)}")
89
+ lines.append("")
90
+ return lines
91
+
92
+
93
+ def format_help(
94
+ commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
95
+ *,
96
+ include_shortcuts: bool = True,
97
+ ) -> str:
98
+ """Return the complete, renderer-neutral slash-command help text."""
99
+
100
+ lines = ["XG 命令帮助", ""]
101
+ lines.extend(_command_lines(commands))
102
+
103
+ aliases = [
104
+ f"{alias} = {spec.name}"
105
+ for spec in commands
106
+ for alias in spec.aliases
107
+ ]
108
+ if aliases:
109
+ lines.extend(["命令别名", f" {' '.join(aliases)}", ""])
110
+
111
+ if include_shortcuts:
112
+ lines.append("TUI 快捷键")
113
+ shortcut_width = max(len(key) for key, _ in SHORTCUTS)
114
+ lines.extend(
115
+ f" {key.ljust(shortcut_width)} {description}"
116
+ for key, description in SHORTCUTS
117
+ )
118
+ lines.append("")
119
+
120
+ lines.extend(
121
+ [
122
+ "提示:输入 / 后可以使用 ↑ / ↓ 选择命令建议。",
123
+ "输入 /help <命令> 可查看某条命令的详细用法。",
124
+ ]
125
+ )
126
+ return "\n".join(lines).rstrip()
127
+
128
+
129
+ def format_command_help(
130
+ query: str,
131
+ commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
132
+ ) -> str:
133
+ """Return help for one command or an actionable unknown-command hint."""
134
+
135
+ token = query.strip().split(maxsplit=1)[0] if query.strip() else ""
136
+ normalized = _command_key(token)
137
+ if not normalized:
138
+ return format_help(commands)
139
+
140
+ spec = find_command(token, commands)
141
+ if spec is None:
142
+ return f"未找到命令帮助:{token}。输入 /help 查看全部命令。"
143
+
144
+ lines = [f"{spec.name} — {spec.description}", f"用法:{spec.usage}"]
145
+ if spec.aliases:
146
+ lines.append(f"别名:{'、'.join(spec.aliases)}")
147
+ if spec.details:
148
+ lines.extend(["", *spec.details])
149
+ if spec.subcommands:
150
+ lines.extend(["", "子命令"])
151
+ lines.extend(_subcommand_lines(spec.subcommands))
152
+ if spec.examples:
153
+ lines.extend(["", "示例"])
154
+ lines.extend(f" {example}" for example in spec.examples)
155
+ return "\n".join(lines)
156
+
157
+
158
+ def _subcommand_lines(subcommands: Sequence[SlashSubcommandSpec]) -> list[str]:
159
+ """Format detailed command modes with stable aligned columns."""
160
+
161
+ usage_width = max(len(item.usage) for item in subcommands)
162
+ return [
163
+ f" {item.usage.ljust(usage_width)} {item.description}"
164
+ for item in subcommands
165
+ ]
166
+
167
+
168
+ def find_command(
169
+ query: str,
170
+ commands: Sequence[SlashCommandSpec] = SLASH_COMMANDS,
171
+ ) -> SlashCommandSpec | None:
172
+ """Find a command by its name or alias."""
173
+
174
+ normalized = _command_key(query)
175
+ if not normalized:
176
+ return None
177
+ return next(
178
+ (
179
+ item
180
+ for item in commands
181
+ if _command_key(item.name) == normalized
182
+ or any(_command_key(alias) == normalized for alias in item.aliases)
183
+ ),
184
+ None,
185
+ )
xg/cli/train.py ADDED
@@ -0,0 +1,200 @@
1
+ """训练 ML 模型命令的执行层:/train 的参数解析、确认提示与子进程流式执行。
2
+
3
+ 设计口径(对齐 05 文档与现有 /provider 红线):
4
+ - 训练仍是显式手动触发:不带 --yes 只返回确认提示,绝不静默跑。
5
+ - 训练跑在独立子进程 :file:`tools/train_router.py`,本层只「spawn + 收日志」,
6
+ 不 import 训练逻辑,规避 torch 等重依赖进主进程。
7
+ - inline 走同步版 :func:`run_training_sync`;TUI 走异步版 :func:`run_training_async`,
8
+ 两者共用参数解析与 argv 构造,日志都逐行流式上报。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ _TRAIN_SCRIPT = Path(__file__).resolve().parents[2] / "tools" / "train_router.py"
21
+ _TRAIN_DEPS = ("lightgbm", "sklearn", "joblib", "numpy")
22
+
23
+
24
+ @dataclass
25
+ class TrainPlan:
26
+ dataset: str | None = None
27
+ output: str | None = None
28
+ overwrite: bool = False
29
+ feedback_only: bool = False
30
+ semantic: bool = True # 默认带语义列:自动探测语义编码器;--no-semantic 关闭
31
+
32
+
33
+ def _default_output() -> Path:
34
+ """默认产物路径:与 feedback.log 同目录的 router.lgb(对齐 train_router)。"""
35
+ from xg.adaptive.store import data_dir # noqa: PLC0415
36
+
37
+ return data_dir() / "router.lgb"
38
+
39
+
40
+ def _default_semantic_onnx() -> Path | None:
41
+ """自动探测默认语义编码器产物:数据目录 → 随包 assets 兜底,找到即返回路径。"""
42
+ from xg.adaptive.store import data_dir # noqa: PLC0415
43
+
44
+ primary = data_dir() / "router_semantics.onnx"
45
+ if primary.exists():
46
+ return primary
47
+ bundled = Path(__file__).resolve().parents[1] / "assets" / "router_semantics.onnx"
48
+ return bundled if bundled.exists() else None
49
+
50
+
51
+ def check_train_deps() -> str | None:
52
+ """缺训练依赖时返回安装提示;全部可用返回 None。
53
+
54
+ 注意:语义编码器(bge)非必需——缺失时 train_router 自动回退 TF-IDF 特征。
55
+ """
56
+ missing = [m for m in _TRAIN_DEPS if _import_safe(m) is False]
57
+ if missing:
58
+ return (
59
+ "缺少训练依赖:"
60
+ + ", ".join(missing)
61
+ + "。请先 `pip install -e .` 安装核心依赖后再试。"
62
+ )
63
+ return None
64
+
65
+
66
+ def _import_safe(mod: str) -> bool:
67
+ try:
68
+ __import__(mod)
69
+ return True
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def parse_train_command(raw: str) -> tuple[TrainPlan, str | None]:
75
+ """解析 `/train [...]` 参数,返回 (plan, error)。错误时 plan 不保证完整。"""
76
+ plan = TrainPlan()
77
+ tokens = raw.split(maxsplit=1)[1].split() if len(raw.split(maxsplit=1)) > 1 else []
78
+ i = 0
79
+ while i < len(tokens):
80
+ t = tokens[i]
81
+ if t in ("--yes", "-y"):
82
+ plan.overwrite = True
83
+ elif t == "--feedback-only":
84
+ plan.feedback_only = True
85
+ elif t == "--no-semantic":
86
+ plan.semantic = False
87
+ elif t in ("--output", "--out", "-o"):
88
+ i += 1
89
+ if i >= len(tokens):
90
+ return plan, "--output 需要一个路径参数"
91
+ plan.output = tokens[i]
92
+ elif t.startswith("-"):
93
+ return plan, f"未知参数: {t}"
94
+ else:
95
+ if plan.dataset is not None:
96
+ return plan, f"多余参数: {t}"
97
+ plan.dataset = t
98
+ i += 1
99
+ if plan.dataset and plan.feedback_only:
100
+ return plan, "不能同时指定数据集与 --feedback-only"
101
+ if not plan.dataset and not plan.feedback_only:
102
+ plan.feedback_only = True # 缺省:仅用 feedback.log
103
+ if plan.output is not None:
104
+ plan.output = plan.output.strip('"\'')
105
+ return plan, None
106
+
107
+
108
+ def confirmation_message(plan: TrainPlan) -> str:
109
+ """未带 --yes 时返回的确认提示:告知将如何训练、需加 --yes 才执行。"""
110
+ src = "反馈日志 feedback.log" if plan.feedback_only else f"数据集 {plan.dataset}"
111
+ out = plan.output or str(_default_output())
112
+ sem_note = "带语义列(默认,自动探测语义编码器)" if plan.semantic else "纯 TF-IDF(--no-semantic)"
113
+ return (
114
+ "确认将运行 SmartRouter 训练:\n"
115
+ f" 样本来源: {src}\n"
116
+ f" 产物路径: {out}\n"
117
+ f" 语义特征: {sem_note}\n"
118
+ "完整数据集 / feedback 样本会被去噪后用于训练;训练为手动触发,"
119
+ "不会自动运行。确认执行请在命令末尾加 --yes。"
120
+ )
121
+
122
+
123
+ def build_argv(plan: TrainPlan) -> list[str]:
124
+ """构造 tools/train_router.py 的 argv(output 缺省时不传,交给脚本默认)。
125
+
126
+ 语义列默认启用:plan.semantic 为真时自动探测默认语义编码器产物并传入
127
+ --semantic-onnx;未探测到或 --no-semantic 则不传,训练回退纯 TF-IDF 特征。
128
+ """
129
+ argv = [sys.executable, str(_TRAIN_SCRIPT)]
130
+ if plan.output:
131
+ argv += ["--out", plan.output]
132
+ if plan.feedback_only:
133
+ argv += ["--feedback-only"]
134
+ elif plan.dataset:
135
+ argv += [plan.dataset]
136
+ if plan.semantic:
137
+ onnx = _default_semantic_onnx()
138
+ if onnx is not None:
139
+ argv += ["--semantic-onnx", str(onnx)]
140
+ return argv
141
+
142
+
143
+ def _build_env() -> dict[str, str]:
144
+ """让子进程以 UTF-8 输出(Windows 默认 GBK 会与读取端 utf-8 解码冲突导致乱码)。"""
145
+ env = dict(os.environ)
146
+ env["PYTHONUTF8"] = "1"
147
+ env.setdefault("PYTHONIOENCODING", "utf-8")
148
+ return env
149
+
150
+
151
+ def run_training_sync(
152
+ plan: TrainPlan, on_line: callable | None = None
153
+ ) -> tuple[bool, list[str]]:
154
+ """同步执行训练(inline 用)。逐行流式读子进程输出交给 on_line,返回 (ok, lines)。"""
155
+ argv = build_argv(plan)
156
+ proc = subprocess.Popen(
157
+ argv,
158
+ stdout=subprocess.PIPE,
159
+ stderr=subprocess.STDOUT,
160
+ text=True,
161
+ encoding="utf-8",
162
+ errors="replace",
163
+ env=_build_env(),
164
+ )
165
+ lines: list[str] = []
166
+ try:
167
+ assert proc.stdout is not None
168
+ for line in proc.stdout:
169
+ line = line.rstrip("\n")
170
+ lines.append(line)
171
+ if on_line is not None:
172
+ on_line(line)
173
+ finally:
174
+ rc = proc.wait()
175
+ return rc == 0, lines
176
+
177
+
178
+ async def run_training_async(
179
+ plan: TrainPlan, on_line: callable | None = None
180
+ ) -> tuple[bool, list[str]]:
181
+ """异步执行训练(TUI / CommandService 用)。逐行流式上报,返回 (ok, lines)。"""
182
+ argv = build_argv(plan)
183
+ proc = await asyncio.create_subprocess_exec(
184
+ *argv,
185
+ stdout=asyncio.subprocess.PIPE,
186
+ stderr=asyncio.subprocess.STDOUT,
187
+ env=_build_env(),
188
+ )
189
+ lines: list[str] = []
190
+ assert proc.stdout is not None
191
+ while True:
192
+ raw = await proc.stdout.readline()
193
+ if not raw:
194
+ break
195
+ line = raw.decode("utf-8", errors="replace").rstrip("\n")
196
+ lines.append(line)
197
+ if on_line is not None:
198
+ on_line(line)
199
+ await proc.wait()
200
+ return (proc.returncode or 0) == 0, lines
xg/config/__init__.py ADDED
File without changes
@@ -0,0 +1,121 @@
1
+ """幂等读写本地 :file:`.env` 文件(用于交互式写入 API Key)。
2
+
3
+ 设计口径(对齐 02 方案 F2 / J2):
4
+ - config.json 永不落 key;key 只经 :file:`.env` 的 ``XG_<NAME>_API_KEY`` 提供。
5
+ - 本模块只负责「定位、增改、去重、原子写」某一行,不做其他配置管理。
6
+ - 写入对原文幂等:已存在 -> 原位保留注释风格并替换值;不存在 -> 追加。
7
+ - 用临时文件 + ``os.replace`` 原子落盘,失败不破坏原文件。
8
+
9
+ 定位规则(J2):从工作目录向上查找命中现有的 :file:`.env`(最多 3 层);
10
+ 找不到时回退到 ``fallback_dir / ".env"``。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+
20
+ def find_env_file(start_dir: str | Path | None = None, max_up: int = 3) -> Path | None:
21
+ """从 ``start_dir`` 向上查找已存在的 :file:`.env`(最多 ``max_up`` 层)。"""
22
+ cur = Path(start_dir) if start_dir else Path.cwd()
23
+ for _ in range(max_up):
24
+ candidate = cur / ".env"
25
+ if candidate.is_file():
26
+ return candidate
27
+ if cur == cur.parent:
28
+ break
29
+ cur = cur.parent
30
+ return None
31
+
32
+
33
+ def decide_env_path(start_dir: str | Path | None = None, fallback_dir: str | Path | None = None) -> Path:
34
+ """确定要写入的 .env 路径:优先命中现有文件,否则落到 fallback 目录。"""
35
+ existing = find_env_file(start_dir)
36
+ if existing is not None:
37
+ return existing
38
+ base = Path(fallback_dir) if fallback_dir else Path.cwd()
39
+ return base / ".env"
40
+
41
+
42
+ def env_value(lines: list[str], key: str) -> str | None:
43
+ """取 ``key`` 当前生效值(容忍 ``KEY=v`` / ``KEY = v``,返回去引号值)。"""
44
+ prefix = f"{key}="
45
+ for raw in lines:
46
+ line = raw.strip()
47
+ if line.startswith(prefix):
48
+ return line[len(prefix):].strip().strip('"').strip("'")
49
+ if line.startswith(f"{key} ="):
50
+ return line[len(f"{key} ="):].strip().strip('"').strip("'")
51
+ return None
52
+
53
+
54
+ def upsert_env_key(lines: list[str], key: str, value: str) -> list[str]:
55
+ """返回把 ``key=value`` 写入后的新行列表(已存在则替换,否则追加)。
56
+
57
+ - 只匹配精确 ``KEY=...`` 行;注释行、导出行不变。
58
+ - 存在重复行时保留第一处位置并去重后续重复。
59
+ """
60
+ prefix = f"{key}="
61
+ key_line = f"{key}={value}"
62
+ out: list[str] = []
63
+ replaced = False
64
+ for raw in lines:
65
+ line = raw.strip()
66
+ if not replaced and line.startswith(prefix):
67
+ # 保留该行缩进/前缀风格:追加新赋值行
68
+ spaces = raw[: len(raw) - len(raw.lstrip())]
69
+ out.append(f"{spaces}{key_line}")
70
+ replaced = True
71
+ continue
72
+ if line.startswith(prefix):
73
+ # 已存在 -> 去重后续重复行
74
+ continue
75
+ out.append(raw)
76
+ if not replaced:
77
+ out.append(key_line)
78
+ return out
79
+
80
+
81
+ def write_env_atomic(path: str | Path, lines: list[str]) -> None:
82
+ """把 ``lines`` 原子写入 ``path``(临时文件 + reinstated替换)。"""
83
+ target = Path(path)
84
+ target.parent.mkdir(parents=True, exist_ok=True)
85
+ fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".env.", suffix=".tmp")
86
+ try:
87
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
88
+ for line in lines:
89
+ handle.write(line if line.endswith("\n") else line + "\n")
90
+ os.replace(tmp_name, str(target))
91
+ except BaseException:
92
+ try:
93
+ os.remove(tmp_name)
94
+ except OSError:
95
+ pass
96
+ raise
97
+
98
+
99
+ def set_env_key(
100
+ path: str | Path,
101
+ key: str,
102
+ value: str,
103
+ *,
104
+ overwrite: bool = False,
105
+ ) -> tuple[bool, str | None]:
106
+ """写入 ``key=value`` 到 ``path``。
107
+
108
+ 返回 ``(changed, previous)``:
109
+ - ``key`` 不存在 -> 追加,返回 ``(True, None)``;
110
+ - ``key`` 已存在且 ``overwrite=True`` -> 替换,返回 ``(True, 旧值)``;
111
+ - ``key`` 已存在且 ``overwrite=False`` -> 不做修改,返回 ``(False, 旧值)``。
112
+ 由调用方负责占位值拦截与确认,本函数只做机械写入(幂等原则)。
113
+ """
114
+ target = Path(path)
115
+ original = target.read_text(encoding="utf-8").splitlines() if target.is_file() else []
116
+ previous = env_value(original, key)
117
+ if previous is not None and not overwrite:
118
+ return False, previous
119
+ next_lines = upsert_env_key(original, key, value)
120
+ write_env_atomic(target, next_lines)
121
+ return True, previous