codebee 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1415 +1,1511 @@
1
- # -*- coding: utf-8 -*-
2
- """智能体管理器:安装检测、版本、安装/升级、模型配置读写。
3
-
4
- 安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
5
- 防止相对路径穿越到预期之外的系统位置。
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- import os
11
- import re
12
- import shutil
13
- import socket
14
- import subprocess
15
- import sys
16
- import threading
17
- import time
18
- import webbrowser
19
- import zlib
20
- from pathlib import Path
21
-
22
- from . import catalog, paths, runner
23
-
24
- CREATE_NO_WINDOW = 0x08000000
25
- VERSION_TTL = 300 # 版本缓存 5 分钟
26
-
27
- _LOCK = threading.RLock()
28
- _STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
29
-
30
- # 能自动写入默认模型的 config.format(其余格式只能手动编辑)
31
- _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "yaml-line")
32
-
33
-
34
- def _expand(p):
35
- return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
36
-
37
-
38
- def _safe_config_path(raw):
39
- """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
40
- if not raw:
41
- return None
42
- full = _expand(raw)
43
- home = os.path.abspath(os.path.expanduser("~"))
44
- try:
45
- if os.path.commonpath([full, home]) != home:
46
- return None
47
- except ValueError:
48
- return None
49
- return full
50
-
51
-
52
- # ---------------------------------------------------------------- 检测
53
-
54
- def detect_entry(entry):
55
- d = entry.get("detect") or {}
56
- if d.get("cli"):
57
- path = shutil.which(d["cli"])
58
- return {"installed": bool(path), "detail": path or ""}
59
- if d.get("exe"):
60
- full = _expand(d["exe"])
61
- return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
62
- if d.get("uwp"):
63
- base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
64
- return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
65
- if d.get("dir"):
66
- full = _expand(d["dir"])
67
- return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
68
- return {"installed": False, "detail": ""}
69
-
70
-
71
- def detect_all(force=False):
72
- """检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
73
- Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
74
- (曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
75
- """
76
- with _LOCK:
77
- if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
78
- return _STATE["detected"]
79
- ev = _STATE["detect_ev"]
80
- lead = ev is None # 我是本次检测的执行者
81
- if lead:
82
- ev = _STATE["detect_ev"] = threading.Event()
83
- if not lead:
84
- ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
85
- with _LOCK:
86
- return dict(_STATE["detected"])
87
- try:
88
- detected = {}
89
- for entry in catalog.load():
90
- try:
91
- detected[entry["id"]] = detect_entry(entry)
92
- except Exception as e:
93
- detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
94
- if detected:
95
- with _LOCK:
96
- _STATE["detected"] = detected
97
- finally:
98
- with _LOCK:
99
- _STATE["detect_ts"] = time.time()
100
- _STATE["detect_ev"] = None
101
- ev.set()
102
- return detected
103
-
104
-
105
- def _uwp_version(package_dir):
106
- import xml.etree.ElementTree as ET
107
- mf = os.path.join(package_dir, "AppxManifest.xml")
108
- if not os.path.isfile(mf):
109
- return None
110
- try:
111
- tree = ET.parse(mf)
112
- for el in tree.iter():
113
- if el.tag.endswith("}Identity") or el.tag == "Identity":
114
- return el.get("Version")
115
- except Exception:
116
- pass
117
- return None
118
-
119
-
120
- def _exe_version(path):
121
- try:
122
- r = subprocess.run(
123
- ["powershell", "-NoProfile", "-Command",
124
- "(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
125
- capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
126
- out = r.stdout.decode("utf-8", "replace").strip()
127
- return out or None
128
- except Exception:
129
- return None
130
-
131
-
132
- def version_of(entry):
133
- """版本探测:CLI 走 --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
134
- eid = entry["id"]
135
- with _LOCK:
136
- cached = _STATE["versions"].get(eid)
137
- if cached and time.time() - cached[0] < VERSION_TTL:
138
- return cached[1]
139
- det = (detect_all() or {}).get(eid) or {}
140
- version = None
141
- cli = (entry.get("detect") or {}).get("cli")
142
- if cli and det.get("installed"):
143
- try:
144
- r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
145
- creationflags=CREATE_NO_WINDOW, timeout=20)
146
- out = (r.stdout or b"").decode("utf-8", "replace").strip()
147
- if not out:
148
- out = (r.stderr or b"").decode("utf-8", "replace").strip()
149
- version = out.splitlines()[0][:60] if out else None
150
- except Exception:
151
- version = None
152
- elif det.get("detail") and os.path.isdir(det["detail"]):
153
- version = _uwp_version(det["detail"])
154
- elif det.get("detail") and os.path.isfile(det["detail"]):
155
- version = _exe_version(det["detail"])
156
- with _LOCK:
157
- _STATE["versions"][eid] = (time.time(), version or "-")
158
- return version or "-"
159
-
160
-
161
- # ---------------------------------------------------------------- TOML 表内键
162
-
163
- def _toml_span(lines, table):
164
- """定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
165
- 只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
166
- 子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
167
- header = re.compile(r"^\[([^\[\]]+)\]\s*$")
168
- start = None
169
- for i, ln in enumerate(lines):
170
- if ln.lstrip().startswith("["):
171
- m = header.match(ln.strip())
172
- if not m:
173
- continue
174
- if start is not None:
175
- return start, i
176
- if m.group(1).strip() == table:
177
- start = i
178
- if start is None:
179
- return None, None
180
- return start, len(lines)
181
-
182
-
183
- def _toml_read_value(text, table, leaf):
184
- m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
185
- if table is None else None
186
- if table is None:
187
- return m.group(1) if m else None
188
- start, end = _toml_span(text.splitlines(), table)
189
- if start is None:
190
- return None
191
- for ln in text.splitlines()[start + 1:end]:
192
- m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
193
- if m:
194
- return m.group(1)
195
- return None
196
-
197
-
198
- def _toml_write_value(text, table, leaf, value):
199
- """就地写入 TOML 的 [table] leaf(双引号标量),保留其余内容与换行风格。"""
200
- eol = "\r\n" if "\r\n" in text else "\n"
201
- lines = text.splitlines()
202
- new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
203
- # 换值不换行:保留行尾注释等其余内容
204
- pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
205
- if table is None:
206
- for i, ln in enumerate(lines):
207
- m = pat.match(ln)
208
- if m:
209
- lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
210
- break
211
- else:
212
- lines.append(new_line)
213
- return eol.join(lines).rstrip("\r\n") + eol
214
- start, end = _toml_span(lines, table)
215
- if start is None: # 表不存在:整段追加
216
- if lines and lines[-1].strip():
217
- lines.append("")
218
- lines.append("[%s]" % table)
219
- lines.append(new_line)
220
- return eol.join(lines).rstrip("\r\n") + eol
221
- for i in range(start + 1, end):
222
- m = pat.match(lines[i])
223
- if m:
224
- lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
225
- return eol.join(lines).rstrip("\r\n") + eol
226
- lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
227
- return eol.join(lines).rstrip("\r\n") + eol
228
-
229
-
230
- # ---------------------------------------------------------------- 模型配置
231
-
232
- def _config_path(entry):
233
- cfg = entry.get("config") or {}
234
- return _safe_config_path(cfg.get("path"))
235
-
236
-
237
- def _yaml_model_path(cfg):
238
- """把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
239
- return _dotted_key(cfg)
240
-
241
-
242
- def _dotted_key(cfg):
243
- """model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
244
- key = (cfg.get("model_key") or "model").strip()
245
- if "." in key:
246
- section, leaf = key.split(".", 1)
247
- return section.strip(), leaf.strip()
248
- return None, key
249
-
250
-
251
- def _yaml_quote(value):
252
- """YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
253
- return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
254
-
255
-
256
- def _yaml_unquote(raw):
257
- """取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
258
- s = (raw or "").strip()
259
- if not s:
260
- return ""
261
- if s[0] in ("'", '"'):
262
- q = s[0]
263
- i = 1
264
- buf = []
265
- while i < len(s):
266
- ch = s[i]
267
- if q == '"' and ch == "\\" and i + 1 < len(s):
268
- nxt = s[i + 1]
269
- buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
270
- i += 2
271
- continue
272
- if ch == q:
273
- if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
274
- buf.append("'")
275
- i += 2
276
- continue
277
- break
278
- buf.append(ch)
279
- i += 1
280
- return "".join(buf).strip()
281
- # 无引号:截断行尾注释(# 前的空白才算注释起始)
282
- return re.split(r"\s+#", s, 1)[0].strip()
283
-
284
-
285
- def _yaml_span(lines, section):
286
- """定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
287
- start = None
288
- for i, ln in enumerate(lines):
289
- m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
290
- if not m:
291
- continue
292
- if m.group(1).strip() == section:
293
- start = i
294
- continue
295
- if start is not None:
296
- return start, i
297
- if start is None:
298
- return None, None
299
- return start, len(lines)
300
-
301
-
302
- def _yaml_read_value(text, section, leaf):
303
- lines = text.splitlines()
304
- if section is None:
305
- m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
306
- return _yaml_unquote(m.group(1)) or None if m else None
307
- start, end = _yaml_span(lines, section)
308
- if start is None:
309
- return None
310
- for ln in lines[start + 1:end]:
311
- m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
312
- if m:
313
- return _yaml_unquote(m.group(1)) or None
314
- return None
315
-
316
-
317
- def _yaml_write_value(text, section, leaf, value):
318
- """就地写入 YAML section.leaf,保留其余内容、缩进与换行风格。"""
319
- eol = "\r\n" if "\r\n" in text else "\n"
320
- lines = text.splitlines()
321
- quoted = _yaml_quote(value)
322
- if section is None:
323
- for i, ln in enumerate(lines):
324
- if re.match(r"^%s\s*:" % re.escape(leaf), ln):
325
- lines[i] = "%s: %s" % (leaf, quoted)
326
- break
327
- else:
328
- lines.append("%s: %s" % (leaf, quoted))
329
- return eol.join(lines).rstrip("\r\n") + eol
330
- start, end = _yaml_span(lines, section)
331
- if start is None: # 段不存在:整段追加
332
- if lines and lines[-1].strip():
333
- lines.append("")
334
- lines.append("%s:" % section)
335
- lines.append(" %s: %s" % (leaf, quoted))
336
- return eol.join(lines).rstrip("\r\n") + eol
337
- for i in range(start + 1, end):
338
- m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
339
- if m: # 键已存在:只换值,保留原缩进
340
- lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
341
- return eol.join(lines).rstrip("\r\n") + eol
342
- indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
343
- for i in range(start + 1, end):
344
- if not lines[i].strip():
345
- continue
346
- if indent == " ":
347
- m = re.match(r"^(\s+)\S", lines[i])
348
- if m:
349
- indent = m.group(1)
350
- last = i
351
- lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
352
- return eol.join(lines).rstrip("\r\n") + eol
353
-
354
-
355
- def read_model(entry):
356
- path = _config_path(entry)
357
- cfg = entry.get("config") or {}
358
- if not path or not os.path.isfile(path) or not cfg.get("format"):
359
- return None
360
- try:
361
- text = open(path, encoding="utf-8", errors="replace").read()
362
- except Exception:
363
- return None
364
- if cfg["format"] == "toml-line":
365
- m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
366
- return m.group(1) if m else None
367
- if cfg["format"] == "toml-section":
368
- table, leaf = _dotted_key(cfg)
369
- return _toml_read_value(text, table, leaf)
370
- if cfg["format"] == "json":
371
- try:
372
- v = json.loads(text).get("model")
373
- if v:
374
- return v
375
- except Exception:
376
- pass
377
- m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
378
- return m.group(1) if m else None
379
- if cfg["format"] == "jsonc":
380
- m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
381
- return m.group(1) if m else None
382
- if cfg["format"] == "yaml-line":
383
- return _yaml_read_value(text, *_yaml_model_path(cfg))
384
- return None
385
-
386
-
387
- def write_model(entry, model):
388
- """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
389
- json / yaml-line。"""
390
- path = _config_path(entry)
391
- cfg = entry.get("config") or {}
392
- fmt = cfg.get("format")
393
- if not path:
394
- return {"ok": False,
395
- "error": "配置路径无效或不在用户主目录内,已拒绝写入"}
396
- if fmt not in _WRITABLE_FORMATS:
397
- return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
398
- model = (model or "").strip()
399
- if not model:
400
- return {"ok": False, "error": "模型名不能为空"}
401
- # 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
402
- # 校验通过后统一改用解析路径写入
403
- home = os.path.abspath(os.path.expanduser("~"))
404
- path = os.path.realpath(path)
405
- try:
406
- if os.path.commonpath([path, home]) != home:
407
- return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
408
- except ValueError:
409
- return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
410
- try:
411
- if os.path.isfile(path):
412
- shutil.copyfile(path, path + ".bak")
413
- elif fmt in ("yaml-line", "toml-section"):
414
- # dsh/grok 首次运行都可能不建自己的主配置文件(dsh 不建 settings.yaml、
415
- # grok 不建 config.toml——官方文档明确「missing 时用内置默认」);
416
- # 它们正是用户层覆盖的落点,缺了就按需创建(否则模型永远写不进去)
417
- Path(path).parent.mkdir(parents=True, exist_ok=True)
418
- Path(path).write_bytes(b"")
419
- if fmt == "toml-line":
420
- text = open(path, encoding="utf-8", errors="replace").read()
421
- new_line = 'model = "%s"' % model
422
- if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
423
- text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
424
- else:
425
- text = text.rstrip("\n") + "\n" + new_line + "\n"
426
- Path(path).write_bytes(text.encode("utf-8"))
427
- elif fmt == "toml-section":
428
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
429
- text = _toml_write_value(text, *_dotted_key(cfg), value=model)
430
- Path(path).write_bytes(text.encode("utf-8"))
431
- elif fmt == "yaml-line":
432
- # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
433
- # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
434
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
435
- text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
436
- Path(path).write_bytes(text.encode("utf-8"))
437
- else:
438
- try:
439
- data = json.loads(open(path, encoding="utf-8", errors="replace").read())
440
- except FileNotFoundError:
441
- return {"ok": False,
442
- "error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
443
- except Exception:
444
- return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
445
- data["model"] = model
446
- Path(path).write_bytes(
447
- json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
448
- return {"ok": True, "model": read_model(entry)}
449
- except Exception as e:
450
- return {"ok": False, "error": repr(e)}
451
-
452
-
453
- # ---------------------------------------------------------------- 一键打开
454
-
455
- def _port_open(port, timeout=0.5):
456
- try:
457
- with socket.create_connection(("127.0.0.1", port), timeout=timeout):
458
- return True
459
- except OSError:
460
- return False
461
-
462
-
463
- def launch_env(entry):
464
- """打开交互/网页版时注入的子进程环境变量:与编排同源的绑定凭据
465
- (dsh=DEEPSEEK_*,claude=ANTHROPIC_*,codex=ORCH_API_KEY)。交互进程
466
- 脱离了编排链路,没有这层注入就拿不到 API key。无绑定时返回 {}。"""
467
- try:
468
- from . import modelhub # 惰性导入:modelhub 体量大且避免潜在环
469
- b = modelhub.resolve_binding(entry["id"]) or {}
470
- except Exception:
471
- return {}
472
- return dict(b.get("env") or {})
473
-
474
-
475
- def _launch_log_path(entry):
476
- """web 类启动日志的落点:id 白名单化后仅作文件名成分,最终路径必须仍围栏
477
- 在数据目录内(catalog.json 用户可编辑,id 不可信;非白名单 id 用 crc32
478
- 稳定代称——跨进程重启不变,「已在运行」回读上次日志才找得到)。"""
479
- raw_id = str(entry.get("id") or "")
480
- if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw_id):
481
- stem = raw_id
482
- else:
483
- stem = "agent-%d" % (zlib.crc32(raw_id.encode("utf-8")) & 0xFFFFFFFF)
484
- data_root = os.path.abspath(str(paths.DATA_DIR))
485
- p = Path(data_root, "launch", stem + ".log")
486
- try:
487
- if os.path.commonpath([os.path.abspath(str(p)), data_root]) != data_root:
488
- return None
489
- except ValueError:
490
- return None
491
- return p
492
-
493
-
494
- def _best_url(log_path, port):
495
- """从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
496
- try:
497
- text = open(str(log_path), encoding="utf-8", errors="replace").read()
498
- except Exception:
499
- return None
500
- urls = re.findall(r"https?://[^\s\"'<>]+", text)
501
- same = [u for u in urls if ":%d" % port in u]
502
- if not same:
503
- return None
504
- tokened = [u for u in same if "token=" in u]
505
- return (tokened or same)[0]
506
-
507
-
508
- def _yaml_model_ids(text, section):
509
- """收集 section.models 序列里的全部模型 id(保持顺序)。段/键缺失返回 []。"""
510
- lines = text.splitlines()
511
- start, end = _yaml_span(lines, section)
512
- if start is None:
513
- return []
514
- m_indent = None
515
- for i in range(start + 1, end):
516
- m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
517
- if m:
518
- m_indent = len(m.group(1))
519
- start = i
520
- break
521
- if m_indent is None:
522
- return []
523
- ids = []
524
- for ln in lines[start + 1:end]:
525
- if not ln.strip():
526
- continue
527
- if len(ln) - len(ln.lstrip(" ")) <= m_indent:
528
- break # models 列表结束(遇到同级或更浅缩进的键)
529
- m = re.match(r"^\s*-\s+id\s*:\s*(.+?)\s*$", ln)
530
- if m:
531
- ids.append(_yaml_unquote(m.group(1)))
532
- return ids
533
-
534
-
535
- def _yaml_ensure_model_entry(text, section, model, context_window=1000000):
536
- """确保 section.models 序列里有 id==model 的条目,缺则按现有条目形状追加
537
- (id/name/contextWindow——dsh 的 catalog 校验要求 id 与 name 非空)。
538
- 已存在或段/列表结构不完整时原文返回。返回 (new_text, added)。"""
539
- eol = "\r\n" if "\r\n" in text else "\n"
540
- lines = text.splitlines()
541
- start, end = _yaml_span(lines, section)
542
- if start is None:
543
- return text, False
544
- m_idx = m_indent = None
545
- for i in range(start + 1, end):
546
- m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
547
- if m:
548
- m_idx, m_indent = i, len(m.group(1))
549
- break
550
- if m_idx is None:
551
- return text, False # 段内没有 models 键:不凭空造结构(保守)
552
- item_indent = None
553
- last_item = m_idx
554
- i = m_idx + 1
555
- while i < end:
556
- ln = lines[i]
557
- if not ln.strip():
558
- i += 1
559
- continue
560
- if len(ln) - len(ln.lstrip(" ")) <= m_indent:
561
- break # models 列表结束
562
- m = re.match(r"^(\s*)-\s+id\s*:\s*(.+?)\s*$", ln)
563
- if m:
564
- item_indent = m.group(1)
565
- last_item = i
566
- if _yaml_unquote(m.group(2)) == model:
567
- return text, False
568
- elif re.match(r"^\s+\S", ln):
569
- last_item = i # 条目的续属性行(name/contextWindow…)
570
- i += 1
571
- item_indent = item_indent or (" " * (m_indent + 2))
572
- block = ["%s- id: %s" % (item_indent, _yaml_quote(model)),
573
- "%s name: %s" % (item_indent, _yaml_quote(model)),
574
- "%s contextWindow: %d" % (item_indent, context_window)]
575
- lines[last_item + 1:last_item + 1] = block
576
- return eol.join(lines).rstrip("\r\n") + eol, True
577
-
578
-
579
- def _sync_dsh_settings(entry, model, base_url):
580
- """dsh 专属:把绑定模型的端点与模型目录写进 ~/.dsh/settings.yaml
581
- llm-deepseek 段(agent-default-model.model 由 write_model 负责)。
582
- 端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
583
- models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
584
- 返回错误串或 None。"""
585
- path = _config_path(entry)
586
- if not path:
587
- return "dsh 配置路径无效"
588
- path = os.path.realpath(path)
589
- home = os.path.abspath(os.path.expanduser("~"))
590
- try:
591
- if os.path.commonpath([path, home]) != home:
592
- return "dsh 配置路径越出用户主目录,已拒绝"
593
- except ValueError:
594
- return "dsh 配置路径越出用户主目录,已拒绝"
595
- try:
596
- if os.path.isfile(path):
597
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
598
- else:
599
- Path(path).parent.mkdir(parents=True, exist_ok=True)
600
- text = ""
601
- text2 = _yaml_write_value(text, "llm-deepseek", "baseURL", base_url)
602
- text2, _added = _yaml_ensure_model_entry(text2, "llm-deepseek", model)
603
- if text2 != text:
604
- if os.path.isfile(path):
605
- shutil.copyfile(path, path + ".bak")
606
- Path(path).write_bytes(text2.encode("utf-8"))
607
- return None
608
- except Exception as e:
609
- return repr(e)
610
-
611
-
612
- def _dsh_selfcheck_model(entry):
613
- """dsh 无绑定时自检:agent-default-model.model 必须在端点 models 列表内,
614
- 否则 web UI 打开就是一个选不中的模型(glm-5.3-flash vs V4 端点的实况)。
615
- 不在列表则改选列表第一个并写回。返回 (生效模型, note)。"""
616
- path = _config_path(entry)
617
- if not path or not os.path.isfile(path):
618
- return None, ""
619
- try:
620
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
621
- except Exception:
622
- return None, ""
623
- cur = _yaml_read_value(text, "agent-default-model", "model")
624
- ids = _yaml_model_ids(text, "llm-deepseek")
625
- if not ids:
626
- return cur, "dsh 端点未登记任何模型,请先在 dsh 侧配置模型目录"
627
- if cur in ids:
628
- return cur, ""
629
- first = ids[0]
630
- w = write_model(entry, first)
631
- fixed = w.get("model") if w.get("ok") else None
632
- note = "dsh 默认模型 %s 不在端点模型列表,已改选 %s" % (cur or "(空)", first)
633
- if not fixed:
634
- note += "(写入失败:%s)" % w.get("error")
635
- return fixed, note
636
-
637
-
638
- def _dsh_key_present():
639
- """dsh 的密钥是否有着落:进程 env 或它自己的 ~/.dsh/.env(credentials-local)。"""
640
- if os.environ.get("DEEPSEEK_API_KEY"):
641
- return True
642
- try:
643
- envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
644
- return "DEEPSEEK_API_KEY" in open(envfile, encoding="utf-8", errors="replace").read()
645
- except Exception:
646
- return False
647
-
648
-
649
- def _toml_section_set(text, section, pairs):
650
- """就地写 TOML 段([section] 下多键)。段存在则逐键替换,缺则整段追加在文末。
651
- 只处理 codex config.toml 这种顶层简单段;返回 (new_text, changed)。"""
652
- eol = "\r\n" if "\r\n" in text else "\n"
653
- lines = text.splitlines()
654
- start = end = None
655
- header = "[%s]" % section
656
- for i, ln in enumerate(lines):
657
- if ln.strip() == header:
658
- start = i
659
- elif start is not None and ln.startswith("[") and ln.rstrip().endswith("]"):
660
- end = i
661
- break
662
- if start is None:
663
- block = [header] + ["%s = %s" % (k, v) for k, v in pairs]
664
- if lines and lines[-1].strip():
665
- lines.append("")
666
- lines.extend(block)
667
- return eol.join(lines).rstrip("\r\n") + eol, True
668
- end = end if end is not None else len(lines)
669
- changed = False
670
- todo = dict(pairs)
671
- for i in range(start + 1, end):
672
- m = re.match(r"^(\s*)([A-Za-z0-9_.-]+)\s*=", lines[i])
673
- if m and m.group(2) in todo:
674
- lines[i] = "%s%s = %s" % (m.group(1), m.group(2), todo.pop(m.group(2)))
675
- changed = True
676
- if todo:
677
- ins = start + 1
678
- while ins < end and not lines[ins].strip():
679
- ins += 1
680
- for k, v in list(todo.items())[::-1]:
681
- lines.insert(ins, "%s = %s" % (k, v))
682
- changed = True
683
- return eol.join(lines).rstrip("\r\n") + eol, changed
684
-
685
-
686
- def _toml_top_set(text, key, value):
687
- """写 TOML 顶层键(第一个 [段] 之前的区域)。返回 (new_text, changed)。"""
688
- eol = "\r\n" if "\r\n" in text else "\n"
689
- lines = text.splitlines()
690
- pat = re.compile(r"^(\s*)%s\s*=\s*.+$" % re.escape(key))
691
- for i, ln in enumerate(lines):
692
- if pat.match(ln):
693
- lines[i] = "%s%s = %s" % (pat.match(ln).group(1), key, value)
694
- return eol.join(lines).rstrip("\r\n") + eol, True
695
- first_section = next((i for i, ln in enumerate(lines)
696
- if ln.startswith("[") and ln.rstrip().endswith("]")), len(lines))
697
- lines.insert(first_section, "%s = %s" % (key, value))
698
- return eol.join(lines).rstrip("\r\n") + eol, True
699
-
700
-
701
- def _sync_codex_settings(entry, model, cp):
702
- """codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
703
- ([model_providers.orch] + 顶层 model_provider/model)。
704
-
705
- codex 交互 TUI 不认编排的 -c 一次性覆盖,也不认 ORCH_API_KEY env——
706
- 没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
707
- 不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
708
- _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
709
- path = _config_path(entry)
710
- if not path:
711
- return "codex 配置路径无效"
712
- path = os.path.realpath(path)
713
- home = os.path.abspath(os.path.expanduser("~"))
714
- try:
715
- if os.path.commonpath([path, home]) != home:
716
- return "codex 配置路径越出用户主目录,已拒绝"
717
- except ValueError:
718
- return "codex 配置路径越出用户主目录,已拒绝"
719
- name = cp.get("name", "orch")
720
- def q(v):
721
- return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
722
- pairs = [("name", q(cp.get("name", name))),
723
- ("base_url", q(cp.get("base_url", ""))),
724
- ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
725
- ("wire_api", q(cp.get("wire_api", "responses")))]
726
- try:
727
- if os.path.isfile(path):
728
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
729
- else:
730
- Path(path).parent.mkdir(parents=True, exist_ok=True)
731
- text = ""
732
- text2, _ = _toml_section_set(text, "model_providers.%s" % name, pairs)
733
- text2, _ = _toml_top_set(text2, "model_provider", q(name))
734
- if model:
735
- text2, _ = _toml_top_set(text2, "model", q(model))
736
- if text2 != text:
737
- if os.path.isfile(path):
738
- shutil.copyfile(path, path + ".bak")
739
- Path(path).write_bytes(text2.encode("utf-8"))
740
- return None
741
- except Exception as e:
742
- return repr(e)
743
-
744
-
745
- # ---------------------------------------------------------------- 打开前凭据注入
746
-
747
- def _jsonc_scan_object(text, start):
748
- """扫描 text[start](须为 '{')起的 JSON(C) 对象:返回 (闭合偏移, 键表)。
749
- 键表为 [key, key_start, key_end, value_start, value_end](偏移相对整个 text,
750
- value_end 指向值结束后一格)。跳过字符串转义、// /* */ 注释、嵌套括号,
751
- 未闭合时容错返回文末。"""
752
- n = len(text)
753
- i = start + 1
754
- keys = []
755
- state = "key"
756
- depth = 0
757
-
758
- def skip_string(j):
759
- while j < n:
760
- if text[j] == "\\":
761
- j += 2
762
- continue
763
- if text[j] == '"':
764
- return j + 1
765
- j += 1
766
- return n
767
-
768
- def skip_comment(j):
769
- if j + 1 < n and text[j + 1] == "/":
770
- e = text.find("\n", j)
771
- return n if e < 0 else e
772
- if j + 1 < n and text[j + 1] == "*":
773
- e = text.find("*/", j + 2)
774
- return n if e < 0 else e + 2
775
- return j + 1 # 非注释的孤立斜杠:当普通字符
776
-
777
- while i < n:
778
- ch = text[i]
779
- if ch in " \t\r\n":
780
- i += 1
781
- continue
782
- if ch == "/":
783
- i = skip_comment(i)
784
- continue
785
- if state == "key":
786
- if ch == "}":
787
- return i, keys
788
- if ch == ",":
789
- i += 1
790
- continue
791
- if ch == '"':
792
- j = skip_string(i + 1)
793
- keys.append([text[i + 1:j - 1], i, j, 0, 0])
794
- state = "colon"
795
- i = j
796
- continue
797
- i += 1
798
- continue
799
- if state == "colon":
800
- if ch == ":":
801
- state = "value"
802
- i += 1
803
- continue
804
- # state == value
805
- if not keys:
806
- return n, keys # 结构异常:放弃扫描
807
- keys[-1][3] = i
808
- if ch in "{[":
809
- depth = 0
810
- j = i
811
- while j < n:
812
- c = text[j]
813
- if c == '"':
814
- j = skip_string(j + 1)
815
- continue
816
- if c == "/":
817
- j = skip_comment(j)
818
- continue
819
- if c in "{[":
820
- depth += 1
821
- elif c in "}]":
822
- depth -= 1
823
- if depth == 0:
824
- break
825
- j += 1
826
- keys[-1][4] = j + 1
827
- i = j + 1
828
- elif ch == '"':
829
- j = skip_string(i + 1)
830
- keys[-1][4] = j
831
- i = j
832
- else: # 数字 / true / false / null
833
- j = i
834
- while j < n and text[j] not in ",}\r\n":
835
- j += 1
836
- keys[-1][4] = j
837
- i = j
838
- state = "key"
839
- return n, keys
840
-
841
-
842
- def _jsonc_set(text, path, value_json):
843
- """JSON(C) 顶层就地写键:path=("provider","orch") 或 ("model",)。
844
- 只动目标片段,其余文本(含注释与原格式)原样保留。返回 (new_text, ok)。"""
845
- brace = text.find("{")
846
- if brace < 0:
847
- return text, False
848
- end, keys = _jsonc_scan_object(text, brace)
849
- head = path[0]
850
- if len(path) == 1:
851
- for _k, _ks, _ke, vs, ve in keys:
852
- if _k == head:
853
- return text[:vs] + value_json + text[ve:], True
854
- return _jsonc_insert_entry(text, brace, end, head, value_json), True
855
- # 二级路径:先定位一级键的值对象
856
- tgt = None
857
- for k, _ks, _ke, vs, ve in keys:
858
- if k == head:
859
- tgt = (vs, ve)
860
- break
861
- if tgt is None: # 一级键不存在:整体插入
862
- block = json.dumps({path[1]: json.loads(value_json)}, ensure_ascii=False)
863
- return _jsonc_insert_entry(text, brace, end, head, block), True
864
- vs, ve = tgt
865
- if text[vs:ve].lstrip()[0:1] != "{":
866
- return text, False # 一级值不是对象:保守放弃(不覆盖用户的非标结构)
867
- end2, keys2 = _jsonc_scan_object(text, vs)
868
- for k, _ks, _ke, v2s, v2e in keys2:
869
- if k == path[1]:
870
- return text[:v2s] + value_json + text[v2e:], True
871
- seg = text[vs:end2 + 1]
872
- new_seg = _jsonc_insert_entry(seg, 0, end2 - vs, path[1], value_json)
873
- if new_seg is None:
874
- return text, False
875
- return text[:vs] + new_seg + text[end2 + 1:], True
876
-
877
-
878
- def _jsonc_insert_entry(text, brace, end, key, value_json):
879
- """在 {brace..end} 对象的开头插入 "key": value(带尾逗号,不依赖原文件的
880
- 逗号风格);对象为空时去掉多余逗号。"""
881
- m = re.match(r"\{([ \t\r\n]*)", text[brace:end + 1])
882
- first = brace + (m.end(1) if m else 1) # m 从 brace 起 match:end(1) 已含 '{'
883
- if first >= end: # 空对象 {}
884
- return text[:brace + 1] + ' "%s": %s ' % (key, value_json) + text[end:]
885
- return text[:first] + '"%s": %s,\n ' % (key, value_json) + text[first:]
886
-
887
-
888
- def _sync_settings_env(path, updates, remove_keys=()):
889
- """把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
890
- 启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
891
- 不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
892
- try:
893
- if os.path.isfile(path):
894
- text = open(path, encoding="utf-8", errors="replace").read()
895
- try:
896
- data = json.loads(text)
897
- except Exception:
898
- return "settings.json 不是合法 JSON,已中止(避免覆盖)"
899
- else:
900
- Path(path).parent.mkdir(parents=True, exist_ok=True)
901
- data = {}
902
- if not isinstance(data, dict):
903
- return "settings.json 结构异常(顶层不是对象),已中止"
904
- env = data.get("env")
905
- if not isinstance(env, dict):
906
- env = {}
907
- for k in remove_keys:
908
- env.pop(k, None)
909
- env.update(updates)
910
- data["env"] = env
911
- if os.path.isfile(path):
912
- shutil.copyfile(path, path + ".bak")
913
- Path(path).write_bytes(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
914
- return None
915
- except Exception as e:
916
- return repr(e)
917
-
918
-
919
- def _guard_home(path):
920
- """展开并校验配置路径必须在用户主目录内;通过则返回 realpath,否则 None。"""
921
- if not path:
922
- return None
923
- path = os.path.realpath(path)
924
- home = os.path.abspath(os.path.expanduser("~"))
925
- try:
926
- if os.path.commonpath([path, home]) != home:
927
- return None
928
- except ValueError:
929
- return None
930
- return path
931
-
932
-
933
- def _sync_claude_settings(entry, model, prov):
934
- """claude-code 专属:把 anthropic 供应商的端点+密钥+模型写进
935
- ~/.claude/settings.json env 段(交互 TUI 与无头共用该文件,只认
936
- ANTHROPIC_*;编排降级链给的 ORCH_API_KEY 对它等于没 key)。
937
- 返回错误串或 None"""
938
- path = _guard_home(_config_path(entry))
939
- if not path:
940
- return "claude 配置路径无效或越出用户主目录,已拒绝"
941
- updates = {"ANTHROPIC_BASE_URL": prov.get("base_url") or "",
942
- # AUTH_TOKEN Bearer 头(Z.ai 等原生 anthropic 网关的用法);
943
- # x-api-key 互斥,清掉可能残留的 ANTHROPIC_API_KEY 防止带错头
944
- "ANTHROPIC_AUTH_TOKEN": prov.get("api_key") or ""}
945
- if model:
946
- updates["ANTHROPIC_MODEL"] = model
947
- return _sync_settings_env(path, updates, remove_keys=("ANTHROPIC_API_KEY",))
948
-
949
-
950
- def _sync_qwen_settings(entry, model, prov):
951
- """qwencode 专属:openai 兼容供应商写进 ~/.qwen/settings.json 的 env 段
952
- (qwen-code 实测认 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL,存在即
953
- 优先走 openai 兼容通道——2026-09-15 真机对维云端点实测请求到达并鉴权)。
954
- anthropic 协议未实证,不开(协议不匹配时宁可提示)。返回错误串或 None。"""
955
- path = _guard_home(_config_path(entry))
956
- if not path:
957
- return "qwen 配置路径无效或越出用户主目录,已拒绝"
958
- updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
959
- "OPENAI_BASE_URL": prov.get("base_url") or ""}
960
- if model:
961
- updates["OPENAI_MODEL"] = model
962
- return _sync_settings_env(path, updates)
963
-
964
-
965
- def _opencode_config_candidates(entry):
966
- """opencode 配置的候选路径:catalog 登记的 jsonc 优先,其次同目录的
967
- opencode.json(opencode 两种文件名都认,用户现有安装多用 json)。"""
968
- out = []
969
- primary = _config_path(entry)
970
- if primary:
971
- out.append(primary)
972
- alt = os.path.join(os.path.abspath(os.path.expanduser("~/.config/opencode")),
973
- "opencode.json")
974
- if alt not in out:
975
- out.append(alt)
976
- return out
977
-
978
-
979
- def _sync_opencode_settings(entry, model, prov):
980
- """opencode 专属:把绑定供应商写进其配置的 provider.orch + 顶层 model
981
- (opencode 交互 TUI 只认自家配置文件里的凭据,ORCH_API_KEY env 对它等于
982
- 没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
983
- 全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
984
- 文本级就地 patch(保留注释)。返回错误串或 None。"""
985
- npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
986
- block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
987
- "options": {"baseURL": prov.get("base_url") or "",
988
- "apiKey": prov.get("api_key") or ""},
989
- "models": {model: {"name": model}} if model else {}}
990
- top_model = ("orch/" + model) if model else ""
991
- targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
992
- or [_opencode_config_candidates(entry)[0]]
993
- errs = []
994
- for path in targets:
995
- try:
996
- path = os.path.realpath(path)
997
- home = os.path.abspath(os.path.expanduser("~"))
998
- try:
999
- if os.path.commonpath([path, home]) != home:
1000
- errs.append("路径越出主目录:%s" % path)
1001
- continue
1002
- except ValueError:
1003
- errs.append("路径越出主目录:%s" % path)
1004
- continue
1005
- if os.path.isfile(path):
1006
- text = open(path, encoding="utf-8", errors="replace", newline="").read()
1007
- else:
1008
- Path(path).parent.mkdir(parents=True, exist_ok=True)
1009
- text = ""
1010
- try:
1011
- data = json.loads(text) if text.strip() else {}
1012
- except Exception:
1013
- data = None
1014
- if data is not None: # 纯 JSON:整体读改写(保序)
1015
- if not isinstance(data, dict):
1016
- errs.append("结构异常(顶层不是对象):%s" % path)
1017
- continue
1018
- provs = data.get("provider")
1019
- if not isinstance(provs, dict):
1020
- provs = {}
1021
- provs["orch"] = block
1022
- data["provider"] = provs
1023
- if top_model:
1024
- data["model"] = top_model
1025
- new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1026
- else: # JSONC(带注释):文本级 patch
1027
- block_json = json.dumps(block, ensure_ascii=False)
1028
- new_text, ok = _jsonc_set(text, ("provider", "orch"), block_json)
1029
- if not ok:
1030
- errs.append("JSONC 就地改写失败:%s" % path)
1031
- continue
1032
- if top_model:
1033
- new_text, _ = _jsonc_set(new_text, ("model",),
1034
- json.dumps(top_model))
1035
- if os.path.isfile(path):
1036
- shutil.copyfile(path, path + ".bak")
1037
- Path(path).write_bytes(new_text.encode("utf-8"))
1038
- except Exception as e:
1039
- errs.append("%s: %r" % (path, e))
1040
- return "".join(errs) or None
1041
-
1042
-
1043
- # 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
1044
- # 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
1045
- _AGENT_INJECTORS = {
1046
- "claude-code": (("anthropic",), _sync_claude_settings),
1047
- "opencode": (("anthropic", "openai"), _sync_opencode_settings),
1048
- "qwencode": (("openai",), _sync_qwen_settings),
1049
- }
1050
-
1051
- # 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
1052
- # (mimo 系 opencode 衍生但配置路径未实证,先按提示类;grok 吃 XAI_API_KEY 但
1053
- # 无端点 env 可指中转,openai 协议供应商也用不上)
1054
- _NO_CHANNEL_HINT = ("grok-build", "pi", "mimo-code")
1055
-
1056
-
1057
- def _sync_agent_injection(entry, binding):
1058
- """打开前把绑定链里第一个可注入供应商落进 CLI 自家配置。与编排降级链解耦:
1059
- 协议不匹配不降级(claude 拿 openai 的 key 等于没 key)。返回给用户看的提示
1060
- (成功注入 / 不可用原因),None=该 CLI 无需处理。"""
1061
- from . import modelhub
1062
- spec = _AGENT_INJECTORS.get(entry["id"])
1063
- if spec:
1064
- protocols, injector = spec
1065
- pick, note = modelhub.launch_pick(entry["id"], protocols)
1066
- if pick:
1067
- prov = pick["provider"]
1068
- err = injector(entry, pick["model"], prov)
1069
- if err:
1070
- return "%s 凭据同步失败:%s(打开后可能需在其自带界面登录)" % (
1071
- entry.get("name", entry["id"]), err)
1072
- return "已注入 %s(%s · %s)" % (prov.get("name") or "供应商",
1073
- prov.get("protocol"), prov.get("base_url", ""))
1074
- return note
1075
- if entry["id"] in _NO_CHANNEL_HINT:
1076
- if binding.get("env"):
1077
- return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
1078
- return "未绑定可用供应商:打开后需在其自带界面登录;要打开即用请到「CLI 绑定」页绑定"
1079
- return None
1080
-
1081
-
1082
- def _sync_launch_model(entry, binding):
1083
- """打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
1084
- 交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
1085
- 「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
1086
- codex 额外落 provider 段(交互 TUI 只认 config.toml,不认编排的 -c 覆盖
1087
- ORCH_API_KEY env)。返回给用户看的同步笔记列表。"""
1088
- notes = []
1089
- model = (binding.get("model") or "").strip()
1090
- prov = binding.get("provider") or {}
1091
- fmt = (entry.get("config") or {}).get("format")
1092
- is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1093
- is_codex = entry["id"] in ("codex-cli", "codex")
1094
- cp = binding.get("codex_provider")
1095
- if model and fmt in _WRITABLE_FORMATS:
1096
- w = write_model(entry, model)
1097
- notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
1098
- else "模型同步失败:%s" % w.get("error"))
1099
- elif model and not is_dsh and entry["id"] not in _AGENT_INJECTORS:
1100
- # 有专属注入通道的 CLI 由 _sync_agent_injection 负责落模型(含 jsonc),
1101
- # 不再报「只读配置」以免与注入成功的提示互相矛盾
1102
- notes.append("该工具模型为只读配置,按其现有配置打开(绑定模型 %s 未自动写入)" % model)
1103
- if is_dsh and model and prov.get("base_url"):
1104
- err = _sync_dsh_settings(entry, model, prov["base_url"])
1105
- notes.append("dsh 端点已同步为 %s" % prov["base_url"] if not err
1106
- else "dsh 端点同步失败:%s" % err)
1107
- elif is_dsh and not model:
1108
- _fixed, note = _dsh_selfcheck_model(entry)
1109
- if note:
1110
- notes.append(note)
1111
- if is_codex and cp:
1112
- err = _sync_codex_settings(entry, model, cp)
1113
- notes.append("codex 供应商已同步为 %s" % cp.get("base_url", "") if not err
1114
- else "codex 供应商同步失败:%s" % err)
1115
- inj = _sync_agent_injection(entry, binding)
1116
- if inj:
1117
- notes.append(inj)
1118
- return notes
1119
-
1120
-
1121
- def launch(entry, open_browser=True):
1122
- """一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
1123
-
1124
- 打开前把「CLI 绑定」页的模型落盘到该 CLI 配置文件(dsh 连端点与 models
1125
- 目录一起同步),并注入绑定密钥 env——保证打开即选中可用模型。"""
1126
- if not detect_entry(entry).get("installed"):
1127
- return {"ok": False, "error": "未安装,无法打开"}
1128
- launch = entry.get("launch") or {}
1129
- cmd = (launch.get("command") or "").strip()
1130
- if not cmd:
1131
- return {"ok": False, "error": "未配置打开命令(可在 data/catalog.json 补 launch 字段)"}
1132
- is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1133
- try:
1134
- from . import modelhub
1135
- binding = modelhub.resolve_binding(entry["id"]) or {}
1136
- except Exception:
1137
- binding = {}
1138
- env = os.environ.copy()
1139
- env.update(binding.get("env") or {})
1140
- notes = _sync_launch_model(entry, binding)
1141
- if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
1142
- notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
1143
- "要打开即用,请到「CLI 绑定」页给 DeepSeek Harness 绑定 openai 协议供应商")
1144
- name = entry.get("name", entry["id"])
1145
- kind = (launch.get("kind") or "console").lower()
1146
- extra = (";".join(notes)) if notes else ""
1147
-
1148
- if kind == "web":
1149
- try:
1150
- port = int(launch.get("port") or 0)
1151
- except (TypeError, ValueError):
1152
- port = 0
1153
- if port <= 0:
1154
- return {"ok": False, "error": "web 类打开必须配置固定端口(launch.port)"}
1155
- bare = "http://127.0.0.1:%d" % port
1156
- log_path = _launch_log_path(entry)
1157
- if not log_path:
1158
- return {"ok": False, "error": "启动日志路径不可信,已拒绝打开"}
1159
- if _port_open(port):
1160
- # 已在运行:新实例抢不到端口、新 token 也拿不到——从上次启动日志
1161
- # 恢复带 token 的信任 URL(dsh web 有 /?token=... 围栏,裸开是 401)。
1162
- # 模型同步照做:正在跑的实例不重启,改的是它下次生效的配置
1163
- url = _best_url(log_path, port) or bare
1164
- if open_browser:
1165
- webbrowser.open(url)
1166
- msg = "服务已在运行,已打开 " + url
1167
- if extra:
1168
- msg += "(" + extra + ""
1169
- return {"ok": True, "kind": "web", "url": url, "message": msg}
1170
- # 后台起服务:无窗口、不阻塞请求;子进程输出重定向到启动日志(cmd 层
1171
- # 重定向,路径含空格才加引号)——web 类普遍会在启动行打印带 token 的
1172
- # 信任 URL(如 dsh web),就绪线程从日志提取后再开浏览器
1173
- ls = str(log_path)
1174
- spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1175
- try:
1176
- log_path.parent.mkdir(parents=True, exist_ok=True)
1177
- subprocess.Popen(["cmd", "/c", spawn_cmd], cwd=str(paths.ROOT), env=env,
1178
- stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1179
- stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW)
1180
- except Exception as e:
1181
- return {"ok": False, "error": "无法启动服务: %r" % e}
1182
- if open_browser:
1183
- threading.Thread(target=_open_when_ready, args=(port, bare, log_path),
1184
- name="launch-wait-%d" % port, daemon=True).start()
1185
- return {"ok": True, "kind": "web", "url": bare,
1186
- "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1187
- % (name, bare, ("" + extra) if extra else "")}
1188
-
1189
- if sys.platform != "win32":
1190
- return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
1191
- # start 为目标命令新开一个可见终端窗口;cmd /k CLI 退出后窗口保留,
1192
- # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1193
- argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
1194
- "cmd", "/k", cmd]
1195
- try:
1196
- subprocess.Popen(argv, cwd=str(paths.ROOT), env=env,
1197
- creationflags=CREATE_NO_WINDOW)
1198
- except Exception as e:
1199
- return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1200
- return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1201
- % (name, ("(" + extra + "") if extra else "")}
1202
-
1203
-
1204
- def _open_when_ready(port, url, log_path, timeout=30):
1205
- """等 web 服务端口就绪后开浏览器。优先从启动日志提取带 token 的信任 URL
1206
- (端口通了 token 行可能还差几十毫秒才落盘,故就绪后最多再等 6 秒);
1207
- 超时兜底也开——服务可能只是慢,用户手动刷新即可。"""
1208
- deadline = time.time() + timeout
1209
- while time.time() < deadline:
1210
- if _port_open(port):
1211
- break
1212
- time.sleep(0.5)
1213
- token_deadline = time.time() + 6
1214
- best = None
1215
- while time.time() < token_deadline:
1216
- best = _best_url(log_path, port)
1217
- if best and "token=" in best:
1218
- break
1219
- time.sleep(0.5)
1220
- try:
1221
- webbrowser.open(best or url)
1222
- except Exception:
1223
- pass
1224
-
1225
-
1226
- # ---------------------------------------------------------------- 安装/升级
1227
-
1228
- def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
1229
- """执行 install/upgrade/uninstall 命令(在任务队列里跑,日志实时落盘)。"""
1230
- if op == "uninstall":
1231
- cmd = catalog.uninstall_command(entry)
1232
- if not cmd:
1233
- return {"ok": False,
1234
- "error": "无法推导卸载命令:请在 data/catalog.json 的 \"%s\" 里配置 uninstall 字段"
1235
- % entry["id"]}
1236
- else:
1237
- cmd = entry.get(op)
1238
- if not cmd:
1239
- return {"ok": False,
1240
- "error": "未配置 %s 命令:请在 data/catalog.json 的 \"%s\" 里补充,或用官方渠道安装"
1241
- % (op, entry["id"])}
1242
- res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
1243
- timeout=1800, cancel_event=cancel_event, log_path=log_path)
1244
- detect_all(force=True)
1245
- with _LOCK:
1246
- _STATE["versions"].pop(entry["id"], None)
1247
- return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
1248
- "error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
1249
-
1250
-
1251
- # ---------------------------------------------------------------- 版本检查
1252
-
1253
- _UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
1254
- UPDATE_TTL = 600
1255
-
1256
-
1257
- def _npm_pkg_name(cmd):
1258
- """从 npm 安装命令里取包名(实现已统一到 catalog.npm_pkg_name)。"""
1259
- return catalog.npm_pkg_name(cmd)
1260
-
1261
-
1262
- def _ver_tuple(s):
1263
- return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
1264
-
1265
-
1266
- def check_update(entry, force=False):
1267
- """检查是否有新版本可用。npm npm view;winget 走 winget upgrade 列表;
1268
- 其他渠道标记为不支持。结果缓存 10 分钟。"""
1269
- eid = entry["id"]
1270
- if not force:
1271
- cached = _UPDATE_CACHE.get(eid)
1272
- if cached and time.time() - cached[0] < UPDATE_TTL:
1273
- return cached[1]
1274
- current = version_of(entry)
1275
- cur_num = ".".join(str(x) for x in _ver_tuple(current)) or "-"
1276
- result = {"current": current, "latest": None, "updatable": None, "note": ""}
1277
-
1278
- cmd = entry.get("install") or entry.get("upgrade") or ""
1279
- pkg = _npm_pkg_name(cmd)
1280
- if pkg:
1281
- r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
1282
- latest = ""
1283
- if r["ok"]:
1284
- for line in (r["stdout"] or "").splitlines():
1285
- line = line.strip()
1286
- if line and re.match(r"^\d", line):
1287
- latest = line
1288
- if not latest:
1289
- result["note"] = "查询 npm 失败(网络或 registry 问题):" + (r["stderr"] or "")[-200:]
1290
- else:
1291
- result["latest"] = latest
1292
- result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
1293
- if not result["updatable"]:
1294
- result["note"] = "已是最新版本"
1295
- elif "winget" in cmd:
1296
- m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
1297
- wid = m.group(1) if m else None
1298
- if not wid:
1299
- result["note"] = "无法从命令中解析 winget 包 ID"
1300
- else:
1301
- r = runner.run_process(argv=["cmd", "/c", "winget", "upgrade"], timeout=180)
1302
- if not r["ok"]:
1303
- result["note"] = "winget upgrade 查询失败:" + (r["stderr"] or "")[-200:]
1304
- else:
1305
- hit = [l for l in (r["stdout"] or "").splitlines() if wid.lower() in l.lower()]
1306
- result["updatable"] = bool(hit)
1307
- if hit:
1308
- result["latest"] = "(winget 有可用更新)"
1309
- else:
1310
- result["latest"] = cur_num
1311
- result["note"] = "已是最新版本"
1312
- else:
1313
- result["note"] = "该渠道暂不支持自动检查更新,可直接点升级尝试"
1314
-
1315
- with _LOCK:
1316
- _UPDATE_CACHE[eid] = (time.time(), result)
1317
- return result
1318
-
1319
-
1320
- # 「进入智能体目录页自动检查更新」的后台任务状态
1321
- _UPDATE_CHECK = {"running": False, "total": 0, "done": 0}
1322
-
1323
-
1324
- def updates_checking():
1325
- with _LOCK:
1326
- return bool(_UPDATE_CHECK["running"])
1327
-
1328
-
1329
- def update_info(entry):
1330
- """单个 CLI 的更新检查结果(供管理页卡片展示)。没查过时为 unknown。"""
1331
- with _LOCK:
1332
- cached = _UPDATE_CACHE.get(entry["id"])
1333
- if not cached:
1334
- return {"status": "unknown", "latest": None, "updatable": None,
1335
- "note": "", "checked_at": ""}
1336
- res = cached[1] or {}
1337
- up = res.get("updatable")
1338
- status = "updatable" if up is True else ("current" if up is False else "unsupported")
1339
- return {"status": status, "latest": res.get("latest"), "updatable": up,
1340
- "note": res.get("note") or "",
1341
- "checked_at": time.strftime("%H:%M", time.localtime(cached[0]))}
1342
-
1343
-
1344
- def _checkable_entries():
1345
- """已安装且配了 install/upgrade 的条目——只有这些才谈得上「是否有新版本」。"""
1346
- detected = detect_all()
1347
- out = []
1348
- for e in catalog.load():
1349
- det = (detected or {}).get(e["id"]) or {}
1350
- if det.get("installed") and (e.get("upgrade") or e.get("install")):
1351
- out.append(e)
1352
- return out
1353
-
1354
-
1355
- def check_updates_async(force=False):
1356
- """后台逐个检查已安装 CLI 的远端最新版本,返回本次要检查的条目数。
1357
-
1358
- 进入「智能体目录」页时自动触发。单条结果复用 check_update 的 10 分钟缓存,
1359
- 所以反复进出页面几乎不产生额外子进程/网络开销;已在跑时不重复起线程。
1360
- """
1361
- with _LOCK:
1362
- if _UPDATE_CHECK["running"]:
1363
- return 0
1364
- entries = _checkable_entries()
1365
- _UPDATE_CHECK.update(running=True, total=len(entries), done=0)
1366
-
1367
- def _worker():
1368
- try:
1369
- for e in entries:
1370
- try:
1371
- check_update(e, force=force)
1372
- except Exception:
1373
- pass
1374
- finally:
1375
- with _LOCK:
1376
- _UPDATE_CHECK["done"] += 1
1377
- finally:
1378
- with _LOCK:
1379
- _UPDATE_CHECK["running"] = False
1380
-
1381
- threading.Thread(target=_worker, name="catalog-update-check", daemon=True).start()
1382
- return len(entries)
1383
-
1384
-
1385
- def catalog_view():
1386
- """管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
1387
- from . import registry
1388
- entries = catalog.load()
1389
- detected = detect_all(force=False)
1390
- enabled = registry.load_enabled()
1391
- view = []
1392
- for e in entries:
1393
- det = detected.get(e["id"]) or {}
1394
- pref = enabled.get(e["id"]) or {}
1395
- orch_enabled = bool(pref.get("enabled", e.get("default_enabled", False))) if e.get("orch") else False
1396
- view.append({
1397
- "id": e["id"], "name": e.get("name", e["id"]), "note": e.get("note", ""),
1398
- "group": "installed" if det.get("installed") else "installable",
1399
- "installed": det.get("installed", False),
1400
- "detail": det.get("detail", ""),
1401
- "version": version_of(e),
1402
- "config_path": _config_path(e),
1403
- "config_writable": (e.get("config") or {}).get("format") in _WRITABLE_FORMATS,
1404
- "model": read_model(e),
1405
- "orch_kind": (e.get("orch") or {}).get("kind"),
1406
- "orch_enabled": orch_enabled,
1407
- "update": update_info(e),
1408
- "has_install": bool(e.get("install")),
1409
- "has_upgrade": bool(e.get("upgrade")),
1410
- # 卸载命令由 install/upgrade 推导(或 catalog 显式配置),供 UI 确认框展示
1411
- "uninstall_cmd": catalog.uninstall_command(e) if det.get("installed") else None,
1412
- # 一键打开配置(kind=web/console + command);没配的条目 UI 不出「打开」按钮
1413
- "launch": e.get("launch"),
1414
- })
1415
- return view
1
+ # -*- coding: utf-8 -*-
2
+ """智能体管理器:安装检测、版本、安装/升级、模型配置读写。
3
+
4
+ 安全约束:catalog 中的配置文件路径展开后必须落在用户主目录内,
5
+ 防止相对路径穿越到预期之外的系统位置。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+ import webbrowser
19
+ import zlib
20
+ from pathlib import Path
21
+
22
+ from . import catalog, paths, runner
23
+
24
+ CREATE_NO_WINDOW = 0x08000000
25
+ VERSION_TTL = 300 # 版本缓存 5 分钟
26
+
27
+ _LOCK = threading.RLock()
28
+ _STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
29
+
30
+ # 能自动写入默认模型的 config.format(其余格式只能手动编辑)
31
+ _WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
32
+ "yaml-line")
33
+
34
+
35
+ def _expand(p):
36
+ return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
37
+
38
+
39
+ def _safe_config_path(raw):
40
+ """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
41
+ if not raw:
42
+ return None
43
+ full = _expand(raw)
44
+ home = os.path.abspath(os.path.expanduser("~"))
45
+ try:
46
+ if os.path.commonpath([full, home]) != home:
47
+ return None
48
+ except ValueError:
49
+ return None
50
+ return full
51
+
52
+
53
+ # ---------------------------------------------------------------- 检测
54
+
55
+ def detect_entry(entry):
56
+ d = entry.get("detect") or {}
57
+ if d.get("cli"):
58
+ path = shutil.which(d["cli"])
59
+ return {"installed": bool(path), "detail": path or ""}
60
+ if d.get("exe"):
61
+ full = _expand(d["exe"])
62
+ return {"installed": os.path.isfile(full), "detail": full if os.path.isfile(full) else ""}
63
+ if d.get("uwp"):
64
+ base = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Packages", d["uwp"])
65
+ return {"installed": os.path.isdir(base), "detail": base if os.path.isdir(base) else ""}
66
+ if d.get("dir"):
67
+ full = _expand(d["dir"])
68
+ return {"installed": os.path.isdir(full), "detail": full if os.path.isdir(full) else ""}
69
+ return {"installed": False, "detail": ""}
70
+
71
+
72
+ def detect_all(force=False):
73
+ """检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile
74
+ Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
75
+ (曾导致 SSE 多连接时服务假死)。等待方有界等待 30s 后拿旧结果。
76
+ """
77
+ with _LOCK:
78
+ if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
79
+ return _STATE["detected"]
80
+ ev = _STATE["detect_ev"]
81
+ lead = ev is None # 我是本次检测的执行者
82
+ if lead:
83
+ ev = _STATE["detect_ev"] = threading.Event()
84
+ if not lead:
85
+ ev.wait(30) # 检测完成或超时;两种情况都拿当前最新快照
86
+ with _LOCK:
87
+ return dict(_STATE["detected"])
88
+ try:
89
+ detected = {}
90
+ for entry in catalog.load():
91
+ try:
92
+ detected[entry["id"]] = detect_entry(entry)
93
+ except Exception as e:
94
+ detected[entry["id"]] = {"installed": False, "detail": "检测出错: %r" % e}
95
+ if detected:
96
+ with _LOCK:
97
+ _STATE["detected"] = detected
98
+ finally:
99
+ with _LOCK:
100
+ _STATE["detect_ts"] = time.time()
101
+ _STATE["detect_ev"] = None
102
+ ev.set()
103
+ return detected
104
+
105
+
106
+ def _uwp_version(package_dir):
107
+ import xml.etree.ElementTree as ET
108
+ mf = os.path.join(package_dir, "AppxManifest.xml")
109
+ if not os.path.isfile(mf):
110
+ return None
111
+ try:
112
+ tree = ET.parse(mf)
113
+ for el in tree.iter():
114
+ if el.tag.endswith("}Identity") or el.tag == "Identity":
115
+ return el.get("Version")
116
+ except Exception:
117
+ pass
118
+ return None
119
+
120
+
121
+ def _exe_version(path):
122
+ try:
123
+ r = subprocess.run(
124
+ ["powershell", "-NoProfile", "-Command",
125
+ "(Get-Item -LiteralPath '%s').VersionInfo.ProductVersion" % path.replace("'", "''")],
126
+ capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=25)
127
+ out = r.stdout.decode("utf-8", "replace").strip()
128
+ return out or None
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ def version_of(entry):
134
+ """版本探测:CLI --version;UWP 读 AppxManifest;exe 走 PowerShell(惰性缓存)。"""
135
+ eid = entry["id"]
136
+ with _LOCK:
137
+ cached = _STATE["versions"].get(eid)
138
+ if cached and time.time() - cached[0] < VERSION_TTL:
139
+ return cached[1]
140
+ det = (detect_all() or {}).get(eid) or {}
141
+ version = None
142
+ cli = (entry.get("detect") or {}).get("cli")
143
+ if cli and det.get("installed"):
144
+ try:
145
+ r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
146
+ creationflags=CREATE_NO_WINDOW, timeout=20)
147
+ out = (r.stdout or b"").decode("utf-8", "replace").strip()
148
+ if not out:
149
+ out = (r.stderr or b"").decode("utf-8", "replace").strip()
150
+ version = out.splitlines()[0][:60] if out else None
151
+ except Exception:
152
+ version = None
153
+ elif det.get("detail") and os.path.isdir(det["detail"]):
154
+ version = _uwp_version(det["detail"])
155
+ elif det.get("detail") and os.path.isfile(det["detail"]):
156
+ version = _exe_version(det["detail"])
157
+ with _LOCK:
158
+ _STATE["versions"][eid] = (time.time(), version or "-")
159
+ return version or "-"
160
+
161
+
162
+ # ---------------------------------------------------------------- TOML 表内键
163
+
164
+ def _toml_span(lines, table):
165
+ """定位顶层表 `[table]` 的行区间 [start, end);start 为表头行。
166
+ 只认顶层表头(行首无空白),不误吞嵌套 `[[array]]` 之外的子表——
167
+ 子表在 TOML 里也是 `[a.b]` 顶层写法,同样按表头截断。"""
168
+ header = re.compile(r"^\[([^\[\]]+)\]\s*$")
169
+ start = None
170
+ for i, ln in enumerate(lines):
171
+ if ln.lstrip().startswith("["):
172
+ m = header.match(ln.strip())
173
+ if not m:
174
+ continue
175
+ if start is not None:
176
+ return start, i
177
+ if m.group(1).strip() == table:
178
+ start = i
179
+ if start is None:
180
+ return None, None
181
+ return start, len(lines)
182
+
183
+
184
+ def _toml_read_value(text, table, leaf):
185
+ m = re.search(r'(?m)^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), text) \
186
+ if table is None else None
187
+ if table is None:
188
+ return m.group(1) if m else None
189
+ start, end = _toml_span(text.splitlines(), table)
190
+ if start is None:
191
+ return None
192
+ for ln in text.splitlines()[start + 1:end]:
193
+ m = re.match(r'^\s*%s\s*=\s*"([^"]*)"\s*(#.*)?$' % re.escape(leaf), ln)
194
+ if m:
195
+ return m.group(1)
196
+ return None
197
+
198
+
199
+ def _toml_write_value(text, table, leaf, value):
200
+ """就地写入 TOML [table] leaf(双引号标量),保留其余内容与换行风格。"""
201
+ eol = "\r\n" if "\r\n" in text else "\n"
202
+ lines = text.splitlines()
203
+ new_line = '%s = "%s"' % (leaf, value.replace("\\", "\\\\").replace('"', '\\"'))
204
+ # 换值不换行:保留行尾注释等其余内容
205
+ pat = re.compile(r'^(\s*%s\s*=\s*)"(?:[^"\\]|\\.)*"(.*)$' % re.escape(leaf))
206
+ if table is None:
207
+ for i, ln in enumerate(lines):
208
+ m = pat.match(ln)
209
+ if m:
210
+ lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
211
+ break
212
+ else:
213
+ lines.append(new_line)
214
+ return eol.join(lines).rstrip("\r\n") + eol
215
+ start, end = _toml_span(lines, table)
216
+ if start is None: # 表不存在:整段追加
217
+ if lines and lines[-1].strip():
218
+ lines.append("")
219
+ lines.append("[%s]" % table)
220
+ lines.append(new_line)
221
+ return eol.join(lines).rstrip("\r\n") + eol
222
+ for i in range(start + 1, end):
223
+ m = pat.match(lines[i])
224
+ if m:
225
+ lines[i] = "%s\"%s\"%s" % (m.group(1), value.replace("\\", "\\\\").replace('"', '\\"'), m.group(2))
226
+ return eol.join(lines).rstrip("\r\n") + eol
227
+ lines.insert(end, new_line) # 表内末尾追加(表头区间终点即下一表头前)
228
+ return eol.join(lines).rstrip("\r\n") + eol
229
+
230
+
231
+ # ---------------------------------------------------------------- 模型配置
232
+
233
+ def _config_path(entry):
234
+ cfg = entry.get("config") or {}
235
+ return _safe_config_path(cfg.get("path"))
236
+
237
+
238
+ def _yaml_model_path(cfg):
239
+ """把 config.model_key 的点号路径拆成 (段, 键);无点号时段为 None(顶层键)。"""
240
+ return _dotted_key(cfg)
241
+
242
+
243
+ def _dotted_key(cfg):
244
+ """model_key 点号路径拆 (表/段, 键);无点号时第一元为 None(顶层键)。"""
245
+ key = (cfg.get("model_key") or "model").strip()
246
+ if "." in key:
247
+ section, leaf = key.split(".", 1)
248
+ return section.strip(), leaf.strip()
249
+ return None, key
250
+
251
+
252
+ def _yaml_quote(value):
253
+ """YAML 双引号标量。必须加引号:模型名可能以 [ 开头(YAML 流序列)或含 #。"""
254
+ return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"')
255
+
256
+
257
+ def _yaml_unquote(raw):
258
+ """取 YAML 标量的值:剥引号、丢行尾注释。引号内的 # 不算注释。"""
259
+ s = (raw or "").strip()
260
+ if not s:
261
+ return ""
262
+ if s[0] in ("'", '"'):
263
+ q = s[0]
264
+ i = 1
265
+ buf = []
266
+ while i < len(s):
267
+ ch = s[i]
268
+ if q == '"' and ch == "\\" and i + 1 < len(s):
269
+ nxt = s[i + 1]
270
+ buf.append('"' if nxt == '"' else ("\\" if nxt == "\\" else nxt))
271
+ i += 2
272
+ continue
273
+ if ch == q:
274
+ if q == "'" and i + 1 < len(s) and s[i + 1] == "'": # '' 转义
275
+ buf.append("'")
276
+ i += 2
277
+ continue
278
+ break
279
+ buf.append(ch)
280
+ i += 1
281
+ return "".join(buf).strip()
282
+ # 无引号:截断行尾注释(# 前的空白才算注释起始)
283
+ return re.split(r"\s+#", s, 1)[0].strip()
284
+
285
+
286
+ def _yaml_span(lines, section):
287
+ """定位顶层段的行区间 [start, end);start 为段名行,其子键在 start+1..end。"""
288
+ start = None
289
+ for i, ln in enumerate(lines):
290
+ m = re.match(r"^([^\s#][^:]*):\s*(.*)$", ln)
291
+ if not m:
292
+ continue
293
+ if m.group(1).strip() == section:
294
+ start = i
295
+ continue
296
+ if start is not None:
297
+ return start, i
298
+ if start is None:
299
+ return None, None
300
+ return start, len(lines)
301
+
302
+
303
+ def _yaml_read_value(text, section, leaf):
304
+ lines = text.splitlines()
305
+ if section is None:
306
+ m = re.search(r"(?m)^%s\s*:\s*(.+?)\s*$" % re.escape(leaf), text)
307
+ return _yaml_unquote(m.group(1)) or None if m else None
308
+ start, end = _yaml_span(lines, section)
309
+ if start is None:
310
+ return None
311
+ for ln in lines[start + 1:end]:
312
+ m = re.match(r"^\s+%s\s*:\s*(.+?)\s*$" % re.escape(leaf), ln)
313
+ if m:
314
+ return _yaml_unquote(m.group(1)) or None
315
+ return None
316
+
317
+
318
+ def _yaml_write_value(text, section, leaf, value):
319
+ """就地写入 YAML section.leaf,保留其余内容、缩进与换行风格。"""
320
+ eol = "\r\n" if "\r\n" in text else "\n"
321
+ lines = text.splitlines()
322
+ quoted = _yaml_quote(value)
323
+ if section is None:
324
+ for i, ln in enumerate(lines):
325
+ if re.match(r"^%s\s*:" % re.escape(leaf), ln):
326
+ lines[i] = "%s: %s" % (leaf, quoted)
327
+ break
328
+ else:
329
+ lines.append("%s: %s" % (leaf, quoted))
330
+ return eol.join(lines).rstrip("\r\n") + eol
331
+ start, end = _yaml_span(lines, section)
332
+ if start is None: # 段不存在:整段追加
333
+ if lines and lines[-1].strip():
334
+ lines.append("")
335
+ lines.append("%s:" % section)
336
+ lines.append(" %s: %s" % (leaf, quoted))
337
+ return eol.join(lines).rstrip("\r\n") + eol
338
+ for i in range(start + 1, end):
339
+ m = re.match(r"^(\s+)%s\s*:" % re.escape(leaf), lines[i])
340
+ if m: # 键已存在:只换值,保留原缩进
341
+ lines[i] = "%s%s: %s" % (m.group(1), leaf, quoted)
342
+ return eol.join(lines).rstrip("\r\n") + eol
343
+ indent, last = " ", start # 段存在但无该键:跟随段内缩进、追加到段尾
344
+ for i in range(start + 1, end):
345
+ if not lines[i].strip():
346
+ continue
347
+ if indent == " ":
348
+ m = re.match(r"^(\s+)\S", lines[i])
349
+ if m:
350
+ indent = m.group(1)
351
+ last = i
352
+ lines.insert(last + 1, "%s%s: %s" % (indent, leaf, quoted))
353
+ return eol.join(lines).rstrip("\r\n") + eol
354
+
355
+
356
+ def _json_path_get(data, keys):
357
+ """沿点号路径下钻 JSON 嵌套;终点必须是字符串。"""
358
+ cur = data
359
+ for k in keys:
360
+ if isinstance(cur, dict) and k in cur:
361
+ cur = cur[k]
362
+ else:
363
+ return None
364
+ return cur if isinstance(cur, str) else None
365
+
366
+
367
+ def _json_path_set(data, keys, value):
368
+ """沿点号路径写入 JSON 嵌套,缺中间对象就地创建;
369
+ 中途遇到非 dict(如 string 简写形式)升级为对象。"""
370
+ cur = data
371
+ for k in keys[:-1]:
372
+ if not isinstance(cur.get(k), dict):
373
+ cur[k] = {}
374
+ cur = cur[k]
375
+ cur[keys[-1]] = value
376
+
377
+
378
+ def read_model(entry):
379
+ path = _config_path(entry)
380
+ cfg = entry.get("config") or {}
381
+ if not path or not os.path.isfile(path) or not cfg.get("format"):
382
+ return None
383
+ try:
384
+ text = open(path, encoding="utf-8", errors="replace").read()
385
+ except Exception:
386
+ return None
387
+ if cfg["format"] == "toml-line":
388
+ m = re.search(r'(?m)^\s*model\s*=\s*"([^"]+)"', text)
389
+ return m.group(1) if m else None
390
+ if cfg["format"] == "toml-section":
391
+ table, leaf = _dotted_key(cfg)
392
+ return _toml_read_value(text, table, leaf)
393
+ if cfg["format"] == "json":
394
+ try:
395
+ v = json.loads(text).get("model")
396
+ if v:
397
+ return v
398
+ except Exception:
399
+ pass
400
+ m = re.search(r'"model"\s*:\s*"([^"]+)"', text)
401
+ return m.group(1) if m else None
402
+ if cfg["format"] == "jsonc":
403
+ keys = (cfg.get("model_key") or "model").split(".")
404
+ try:
405
+ data = json.loads(_jsonc_strip_comments(text) or "{}")
406
+ except Exception:
407
+ return None
408
+ return _json_path_get(data, keys)
409
+ if cfg["format"] == "json-path":
410
+ try:
411
+ data = json.loads(text or "{}")
412
+ except Exception:
413
+ return None
414
+ return _json_path_get(data, (cfg.get("model_key") or "model").split("."))
415
+ if cfg["format"] == "yaml-line":
416
+ return _yaml_read_value(text, *_yaml_model_path(cfg))
417
+ return None
418
+
419
+
420
+ def write_model(entry, model):
421
+ """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
422
+ json / json-path / jsonc / yaml-line。"""
423
+ path = _config_path(entry)
424
+ cfg = entry.get("config") or {}
425
+ fmt = cfg.get("format")
426
+ if not path:
427
+ return {"ok": False,
428
+ "error": "配置路径无效或不在用户主目录内,已拒绝写入"}
429
+ if fmt not in _WRITABLE_FORMATS:
430
+ return {"ok": False, "error": "该工具的模型配置格式暂不支持自动写入,请手动编辑 %s" % path}
431
+ model = (model or "").strip()
432
+ if not model:
433
+ return {"ok": False, "error": "模型名不能为空"}
434
+ # 写入前二次校验:解析真实路径后必须仍在用户主目录内(防穿越/符号链接),
435
+ # 校验通过后统一改用解析路径写入
436
+ home = os.path.abspath(os.path.expanduser("~"))
437
+ path = os.path.realpath(path)
438
+ try:
439
+ if os.path.commonpath([path, home]) != home:
440
+ return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
441
+ except ValueError:
442
+ return {"ok": False, "error": "配置路径越出用户主目录,已拒绝写入"}
443
+ try:
444
+ if os.path.isfile(path):
445
+ shutil.copyfile(path, path + ".bak")
446
+ else:
447
+ # CLI 首次运行都未必建主配置(grok 不建 config.toml、pi 不建
448
+ # settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
449
+ # 内置默认」);它正是用户层覆盖的落点,缺了按需创建
450
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
451
+ Path(path).write_bytes(b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
452
+ if fmt == "jsonc":
453
+ # mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
454
+ # _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
455
+ keys = (cfg.get("model_key") or "model").split(".")
456
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
457
+ new_text, ok = _jsonc_set(text, tuple(keys),
458
+ json.dumps(model, ensure_ascii=False))
459
+ if not ok:
460
+ return {"ok": False,
461
+ "error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
462
+ Path(path).write_bytes(new_text.encode("utf-8"))
463
+ elif fmt == "toml-line":
464
+ text = open(path, encoding="utf-8", errors="replace").read()
465
+ new_line = 'model = "%s"' % model
466
+ if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
467
+ text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
468
+ else:
469
+ text = text.rstrip("\n") + "\n" + new_line + "\n"
470
+ Path(path).write_bytes(text.encode("utf-8"))
471
+ elif fmt == "toml-section":
472
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
473
+ text = _toml_write_value(text, *_dotted_key(cfg), value=model)
474
+ Path(path).write_bytes(text.encode("utf-8"))
475
+ elif fmt == "yaml-line":
476
+ # newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
477
+ # 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
478
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
479
+ text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
480
+ Path(path).write_bytes(text.encode("utf-8"))
481
+ elif fmt == "json-path":
482
+ # openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
483
+ # openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
484
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
485
+ try:
486
+ data = json.loads(text) if text.strip() else {}
487
+ except Exception:
488
+ return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
489
+ _json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
490
+ # pi 的 settings.json:defaultModel 必须配 defaultProvider 才能解析出
491
+ # (provider, model) 二元组;catalog 里声明了的伴随键一并落盘
492
+ for k, v in (cfg.get("model_extra_keys") or {}).items():
493
+ _json_path_set(data, k.split("."), v)
494
+ Path(path).write_bytes(
495
+ json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
496
+ else:
497
+ try:
498
+ data = json.loads(open(path, encoding="utf-8", errors="replace").read())
499
+ except FileNotFoundError:
500
+ return {"ok": False,
501
+ "error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
502
+ except Exception:
503
+ return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
504
+ data["model"] = model
505
+ Path(path).write_bytes(
506
+ json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
507
+ return {"ok": True, "model": read_model(entry)}
508
+ except Exception as e:
509
+ return {"ok": False, "error": repr(e)}
510
+
511
+
512
+ # ---------------------------------------------------------------- 一键打开
513
+
514
+ def _port_open(port, timeout=0.5):
515
+ try:
516
+ with socket.create_connection(("127.0.0.1", port), timeout=timeout):
517
+ return True
518
+ except OSError:
519
+ return False
520
+
521
+
522
+ def launch_env(entry):
523
+ """打开交互/网页版时注入的子进程环境变量:与编排同源的绑定凭据
524
+ (dsh=DEEPSEEK_*,claude=ANTHROPIC_*,codex=ORCH_API_KEY)。交互进程
525
+ 脱离了编排链路,没有这层注入就拿不到 API key。无绑定时返回 {}。"""
526
+ try:
527
+ from . import modelhub # 惰性导入:modelhub 体量大且避免潜在环
528
+ b = modelhub.resolve_binding(entry["id"]) or {}
529
+ except Exception:
530
+ return {}
531
+ return dict(b.get("env") or {})
532
+
533
+
534
+ def _launch_log_path(entry):
535
+ """web 类启动日志的落点:id 白名单化后仅作文件名成分,最终路径必须仍围栏
536
+ 在数据目录内(catalog.json 用户可编辑,id 不可信;非白名单 id 用 crc32
537
+ 稳定代称——跨进程重启不变,「已在运行」回读上次日志才找得到)。"""
538
+ raw_id = str(entry.get("id") or "")
539
+ if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw_id):
540
+ stem = raw_id
541
+ else:
542
+ stem = "agent-%d" % (zlib.crc32(raw_id.encode("utf-8")) & 0xFFFFFFFF)
543
+ data_root = os.path.abspath(str(paths.DATA_DIR))
544
+ p = Path(data_root, "launch", stem + ".log")
545
+ try:
546
+ if os.path.commonpath([os.path.abspath(str(p)), data_root]) != data_root:
547
+ return None
548
+ except ValueError:
549
+ return None
550
+ return p
551
+
552
+
553
+ def _best_url(log_path, port):
554
+ """从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
555
+ try:
556
+ text = open(str(log_path), encoding="utf-8", errors="replace").read()
557
+ except Exception:
558
+ return None
559
+ urls = re.findall(r"https?://[^\s\"'<>]+", text)
560
+ same = [u for u in urls if ":%d" % port in u]
561
+ if not same:
562
+ return None
563
+ tokened = [u for u in same if "token=" in u]
564
+ return (tokened or same)[0]
565
+
566
+
567
+ def _yaml_model_ids(text, section):
568
+ """收集 section.models 序列里的全部模型 id(保持顺序)。段/键缺失返回 []。"""
569
+ lines = text.splitlines()
570
+ start, end = _yaml_span(lines, section)
571
+ if start is None:
572
+ return []
573
+ m_indent = None
574
+ for i in range(start + 1, end):
575
+ m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
576
+ if m:
577
+ m_indent = len(m.group(1))
578
+ start = i
579
+ break
580
+ if m_indent is None:
581
+ return []
582
+ ids = []
583
+ for ln in lines[start + 1:end]:
584
+ if not ln.strip():
585
+ continue
586
+ if len(ln) - len(ln.lstrip(" ")) <= m_indent:
587
+ break # models 列表结束(遇到同级或更浅缩进的键)
588
+ m = re.match(r"^\s*-\s+id\s*:\s*(.+?)\s*$", ln)
589
+ if m:
590
+ ids.append(_yaml_unquote(m.group(1)))
591
+ return ids
592
+
593
+
594
+ def _yaml_ensure_model_entry(text, section, model, context_window=1000000):
595
+ """确保 section.models 序列里有 id==model 的条目,缺则按现有条目形状追加
596
+ (id/name/contextWindow——dsh 的 catalog 校验要求 id 与 name 非空)。
597
+ 已存在或段/列表结构不完整时原文返回。返回 (new_text, added)。"""
598
+ eol = "\r\n" if "\r\n" in text else "\n"
599
+ lines = text.splitlines()
600
+ start, end = _yaml_span(lines, section)
601
+ if start is None:
602
+ return text, False
603
+ m_idx = m_indent = None
604
+ for i in range(start + 1, end):
605
+ m = re.match(r"^(\s+)models\s*:\s*(?:#.*)?$", lines[i])
606
+ if m:
607
+ m_idx, m_indent = i, len(m.group(1))
608
+ break
609
+ if m_idx is None:
610
+ return text, False # 段内没有 models 键:不凭空造结构(保守)
611
+ item_indent = None
612
+ last_item = m_idx
613
+ i = m_idx + 1
614
+ while i < end:
615
+ ln = lines[i]
616
+ if not ln.strip():
617
+ i += 1
618
+ continue
619
+ if len(ln) - len(ln.lstrip(" ")) <= m_indent:
620
+ break # models 列表结束
621
+ m = re.match(r"^(\s*)-\s+id\s*:\s*(.+?)\s*$", ln)
622
+ if m:
623
+ item_indent = m.group(1)
624
+ last_item = i
625
+ if _yaml_unquote(m.group(2)) == model:
626
+ return text, False
627
+ elif re.match(r"^\s+\S", ln):
628
+ last_item = i # 条目的续属性行(name/contextWindow…)
629
+ i += 1
630
+ item_indent = item_indent or (" " * (m_indent + 2))
631
+ block = ["%s- id: %s" % (item_indent, _yaml_quote(model)),
632
+ "%s name: %s" % (item_indent, _yaml_quote(model)),
633
+ "%s contextWindow: %d" % (item_indent, context_window)]
634
+ lines[last_item + 1:last_item + 1] = block
635
+ return eol.join(lines).rstrip("\r\n") + eol, True
636
+
637
+
638
+ def _sync_dsh_settings(entry, model, base_url):
639
+ """dsh 专属:把绑定模型的端点与模型目录写进 ~/.dsh/settings.yaml 的
640
+ llm-deepseek 段(agent-default-model.model 由 write_model 负责)。
641
+ 端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
642
+ models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
643
+ 返回错误串或 None。"""
644
+ path = _config_path(entry)
645
+ if not path:
646
+ return "dsh 配置路径无效"
647
+ path = os.path.realpath(path)
648
+ home = os.path.abspath(os.path.expanduser("~"))
649
+ try:
650
+ if os.path.commonpath([path, home]) != home:
651
+ return "dsh 配置路径越出用户主目录,已拒绝"
652
+ except ValueError:
653
+ return "dsh 配置路径越出用户主目录,已拒绝"
654
+ try:
655
+ if os.path.isfile(path):
656
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
657
+ else:
658
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
659
+ text = ""
660
+ text2 = _yaml_write_value(text, "llm-deepseek", "baseURL", base_url)
661
+ text2, _added = _yaml_ensure_model_entry(text2, "llm-deepseek", model)
662
+ if text2 != text:
663
+ if os.path.isfile(path):
664
+ shutil.copyfile(path, path + ".bak")
665
+ Path(path).write_bytes(text2.encode("utf-8"))
666
+ return None
667
+ except Exception as e:
668
+ return repr(e)
669
+
670
+
671
+ def _dsh_selfcheck_model(entry):
672
+ """dsh 无绑定时自检:agent-default-model.model 必须在端点 models 列表内,
673
+ 否则 web UI 打开就是一个选不中的模型(glm-5.3-flash vs V4 端点的实况)。
674
+ 不在列表则改选列表第一个并写回。返回 (生效模型, note)。"""
675
+ path = _config_path(entry)
676
+ if not path or not os.path.isfile(path):
677
+ return None, ""
678
+ try:
679
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
680
+ except Exception:
681
+ return None, ""
682
+ cur = _yaml_read_value(text, "agent-default-model", "model")
683
+ ids = _yaml_model_ids(text, "llm-deepseek")
684
+ if not ids:
685
+ return cur, "dsh 端点未登记任何模型,请先在 dsh 侧配置模型目录"
686
+ if cur in ids:
687
+ return cur, ""
688
+ first = ids[0]
689
+ w = write_model(entry, first)
690
+ fixed = w.get("model") if w.get("ok") else None
691
+ note = "dsh 默认模型 %s 不在端点模型列表,已改选 %s" % (cur or "(空)", first)
692
+ if not fixed:
693
+ note += "(写入失败:%s" % w.get("error")
694
+ return fixed, note
695
+
696
+
697
+ def _dsh_key_present():
698
+ """dsh 的密钥是否有着落:进程 env 或它自己的 ~/.dsh/.env(credentials-local)。"""
699
+ if os.environ.get("DEEPSEEK_API_KEY"):
700
+ return True
701
+ try:
702
+ envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
703
+ return "DEEPSEEK_API_KEY" in open(envfile, encoding="utf-8", errors="replace").read()
704
+ except Exception:
705
+ return False
706
+
707
+
708
+ def _toml_section_set(text, section, pairs):
709
+ """就地写 TOML 段([section] 下多键)。段存在则逐键替换,缺则整段追加在文末。
710
+ 只处理 codex config.toml 这种顶层简单段;返回 (new_text, changed)。"""
711
+ eol = "\r\n" if "\r\n" in text else "\n"
712
+ lines = text.splitlines()
713
+ start = end = None
714
+ header = "[%s]" % section
715
+ for i, ln in enumerate(lines):
716
+ if ln.strip() == header:
717
+ start = i
718
+ elif start is not None and ln.startswith("[") and ln.rstrip().endswith("]"):
719
+ end = i
720
+ break
721
+ if start is None:
722
+ block = [header] + ["%s = %s" % (k, v) for k, v in pairs]
723
+ if lines and lines[-1].strip():
724
+ lines.append("")
725
+ lines.extend(block)
726
+ return eol.join(lines).rstrip("\r\n") + eol, True
727
+ end = end if end is not None else len(lines)
728
+ changed = False
729
+ todo = dict(pairs)
730
+ for i in range(start + 1, end):
731
+ m = re.match(r"^(\s*)([A-Za-z0-9_.-]+)\s*=", lines[i])
732
+ if m and m.group(2) in todo:
733
+ lines[i] = "%s%s = %s" % (m.group(1), m.group(2), todo.pop(m.group(2)))
734
+ changed = True
735
+ if todo:
736
+ ins = start + 1
737
+ while ins < end and not lines[ins].strip():
738
+ ins += 1
739
+ for k, v in list(todo.items())[::-1]:
740
+ lines.insert(ins, "%s = %s" % (k, v))
741
+ changed = True
742
+ return eol.join(lines).rstrip("\r\n") + eol, changed
743
+
744
+
745
+ def _toml_top_set(text, key, value):
746
+ """写 TOML 顶层键(第一个 [段] 之前的区域)。返回 (new_text, changed)。"""
747
+ eol = "\r\n" if "\r\n" in text else "\n"
748
+ lines = text.splitlines()
749
+ pat = re.compile(r"^(\s*)%s\s*=\s*.+$" % re.escape(key))
750
+ for i, ln in enumerate(lines):
751
+ if pat.match(ln):
752
+ lines[i] = "%s%s = %s" % (pat.match(ln).group(1), key, value)
753
+ return eol.join(lines).rstrip("\r\n") + eol, True
754
+ first_section = next((i for i, ln in enumerate(lines)
755
+ if ln.startswith("[") and ln.rstrip().endswith("]")), len(lines))
756
+ lines.insert(first_section, "%s = %s" % (key, value))
757
+ return eol.join(lines).rstrip("\r\n") + eol, True
758
+
759
+
760
+ def _sync_codex_settings(entry, model, cp):
761
+ """codex 专属:把绑定供应商与模型写进 ~/.codex/config.toml
762
+ ([model_providers.orch] 段 + 顶层 model_provider/model)。
763
+
764
+ codex 交互 TUI 不认编排的 -c 一次性覆盖,也不认 ORCH_API_KEY env——
765
+ 没有 config.toml 里的 provider 段,绑定模型根本无处可用;而 model 单写
766
+ 不写 provider 会指到 codex 自带 openai 官方端点上(401)。与编排的
767
+ _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
768
+ path = _config_path(entry)
769
+ if not path:
770
+ return "codex 配置路径无效"
771
+ path = os.path.realpath(path)
772
+ home = os.path.abspath(os.path.expanduser("~"))
773
+ try:
774
+ if os.path.commonpath([path, home]) != home:
775
+ return "codex 配置路径越出用户主目录,已拒绝"
776
+ except ValueError:
777
+ return "codex 配置路径越出用户主目录,已拒绝"
778
+ name = cp.get("name", "orch")
779
+ def q(v):
780
+ return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
781
+ pairs = [("name", q(cp.get("name", name))),
782
+ ("base_url", q(cp.get("base_url", ""))),
783
+ ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
784
+ ("wire_api", q(cp.get("wire_api", "responses")))]
785
+ try:
786
+ if os.path.isfile(path):
787
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
788
+ else:
789
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
790
+ text = ""
791
+ text2, _ = _toml_section_set(text, "model_providers.%s" % name, pairs)
792
+ text2, _ = _toml_top_set(text2, "model_provider", q(name))
793
+ if model:
794
+ text2, _ = _toml_top_set(text2, "model", q(model))
795
+ if text2 != text:
796
+ if os.path.isfile(path):
797
+ shutil.copyfile(path, path + ".bak")
798
+ Path(path).write_bytes(text2.encode("utf-8"))
799
+ return None
800
+ except Exception as e:
801
+ return repr(e)
802
+
803
+
804
+ # ---------------------------------------------------------------- 打开前凭据注入
805
+
806
+ def _jsonc_scan_object(text, start):
807
+ """扫描 text[start](须为 '{')起的 JSON(C) 对象:返回 (闭合偏移, 键表)。
808
+ 键表为 [key, key_start, key_end, value_start, value_end](偏移相对整个 text,
809
+ value_end 指向值结束后一格)。跳过字符串转义、// 与 /* */ 注释、嵌套括号,
810
+ 未闭合时容错返回文末。"""
811
+ n = len(text)
812
+ i = start + 1
813
+ keys = []
814
+ state = "key"
815
+ depth = 0
816
+
817
+ def skip_string(j):
818
+ while j < n:
819
+ if text[j] == "\\":
820
+ j += 2
821
+ continue
822
+ if text[j] == '"':
823
+ return j + 1
824
+ j += 1
825
+ return n
826
+
827
+ def skip_comment(j):
828
+ if j + 1 < n and text[j + 1] == "/":
829
+ e = text.find("\n", j)
830
+ return n if e < 0 else e
831
+ if j + 1 < n and text[j + 1] == "*":
832
+ e = text.find("*/", j + 2)
833
+ return n if e < 0 else e + 2
834
+ return j + 1 # 非注释的孤立斜杠:当普通字符
835
+
836
+ while i < n:
837
+ ch = text[i]
838
+ if ch in " \t\r\n":
839
+ i += 1
840
+ continue
841
+ if ch == "/":
842
+ i = skip_comment(i)
843
+ continue
844
+ if state == "key":
845
+ if ch == "}":
846
+ return i, keys
847
+ if ch == ",":
848
+ i += 1
849
+ continue
850
+ if ch == '"':
851
+ j = skip_string(i + 1)
852
+ keys.append([text[i + 1:j - 1], i, j, 0, 0])
853
+ state = "colon"
854
+ i = j
855
+ continue
856
+ i += 1
857
+ continue
858
+ if state == "colon":
859
+ if ch == ":":
860
+ state = "value"
861
+ i += 1
862
+ continue
863
+ # state == value
864
+ if not keys:
865
+ return n, keys # 结构异常:放弃扫描
866
+ keys[-1][3] = i
867
+ if ch in "{[":
868
+ depth = 0
869
+ j = i
870
+ while j < n:
871
+ c = text[j]
872
+ if c == '"':
873
+ j = skip_string(j + 1)
874
+ continue
875
+ if c == "/":
876
+ j = skip_comment(j)
877
+ continue
878
+ if c in "{[":
879
+ depth += 1
880
+ elif c in "}]":
881
+ depth -= 1
882
+ if depth == 0:
883
+ break
884
+ j += 1
885
+ keys[-1][4] = j + 1
886
+ i = j + 1
887
+ elif ch == '"':
888
+ j = skip_string(i + 1)
889
+ keys[-1][4] = j
890
+ i = j
891
+ else: # 数字 / true / false / null
892
+ j = i
893
+ while j < n and text[j] not in ",}\r\n":
894
+ j += 1
895
+ keys[-1][4] = j
896
+ i = j
897
+ state = "key"
898
+ return n, keys
899
+
900
+
901
+ def _jsonc_strip_comments(text):
902
+ """剥掉 jsonc // 行注释与 /* */ 块注释,供 json.loads 解析。
903
+ 字符串字面量内部的 //($schema 的 https:// 等)不剥——逐字符扫描;
904
+ 简单的 re.sub(r"//[^\\n]*") 会把 URL 截断导致解析失败。"""
905
+ out = []
906
+ i, n = 0, len(text)
907
+ while i < n:
908
+ ch = text[i]
909
+ if ch == '"':
910
+ j = i + 1
911
+ while j < n:
912
+ if text[j] == "\\":
913
+ j += 2
914
+ continue
915
+ if text[j] == '"':
916
+ break
917
+ j += 1
918
+ out.append(text[i:min(j + 1, n)])
919
+ i = j + 1
920
+ continue
921
+ if ch == "/" and i + 1 < n and text[i + 1] == "/":
922
+ e = text.find("\n", i)
923
+ i = n if e < 0 else e # 行注释:换行符本身保留
924
+ continue
925
+ if ch == "/" and i + 1 < n and text[i + 1] == "*":
926
+ e = text.find("*/", i + 2)
927
+ i = n if e < 0 else e + 2
928
+ out.append(" ")
929
+ continue
930
+ out.append(ch)
931
+ i += 1
932
+ return "".join(out)
933
+
934
+
935
+ def _jsonc_set(text, path, value_json):
936
+ """JSON(C) 顶层就地写键:path=("provider","orch") ("model",)。
937
+ 只动目标片段,其余文本(含注释与原格式)原样保留。返回 (new_text, ok)
938
+ 空文件按空对象起笔(write_model 缺文件按需创建的产物)。"""
939
+ brace = text.find("{")
940
+ if brace < 0 and not text.strip():
941
+ return ('{\n "%s": %s\n}' % (path[0], value_json), True)
942
+ if brace < 0:
943
+ return text, False
944
+ end, keys = _jsonc_scan_object(text, brace)
945
+ head = path[0]
946
+ if len(path) == 1:
947
+ for _k, _ks, _ke, vs, ve in keys:
948
+ if _k == head:
949
+ return text[:vs] + value_json + text[ve:], True
950
+ return _jsonc_insert_entry(text, brace, end, head, value_json), True
951
+ # 二级路径:先定位一级键的值对象
952
+ tgt = None
953
+ for k, _ks, _ke, vs, ve in keys:
954
+ if k == head:
955
+ tgt = (vs, ve)
956
+ break
957
+ if tgt is None: # 一级键不存在:整体插入
958
+ block = json.dumps({path[1]: json.loads(value_json)}, ensure_ascii=False)
959
+ return _jsonc_insert_entry(text, brace, end, head, block), True
960
+ vs, ve = tgt
961
+ if text[vs:ve].lstrip()[0:1] != "{":
962
+ return text, False # 一级值不是对象:保守放弃(不覆盖用户的非标结构)
963
+ end2, keys2 = _jsonc_scan_object(text, vs)
964
+ for k, _ks, _ke, v2s, v2e in keys2:
965
+ if k == path[1]:
966
+ return text[:v2s] + value_json + text[v2e:], True
967
+ seg = text[vs:end2 + 1]
968
+ new_seg = _jsonc_insert_entry(seg, 0, end2 - vs, path[1], value_json)
969
+ if new_seg is None:
970
+ return text, False
971
+ return text[:vs] + new_seg + text[end2 + 1:], True
972
+
973
+
974
+ def _jsonc_insert_entry(text, brace, end, key, value_json):
975
+ """在 {brace..end} 对象的开头插入 "key": value(带尾逗号,不依赖原文件的
976
+ 逗号风格);对象为空时去掉多余逗号。"""
977
+ m = re.match(r"\{([ \t\r\n]*)", text[brace:end + 1])
978
+ first = brace + (m.end(1) if m else 1) # m 从 brace 起 match:end(1) 已含 '{'
979
+ if first >= end: # 空对象 {}
980
+ return text[:brace + 1] + ' "%s": %s ' % (key, value_json) + text[end:]
981
+ return text[:first] + '"%s": %s,\n ' % (key, value_json) + text[first:]
982
+
983
+
984
+ def _sync_settings_env(path, updates, remove_keys=()):
985
+ """把键值对写进目标 settings.json env 段(claude/qwen 等同构:交互 TUI
986
+ 启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
987
+ 不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
988
+ try:
989
+ if os.path.isfile(path):
990
+ text = open(path, encoding="utf-8", errors="replace").read()
991
+ try:
992
+ data = json.loads(text)
993
+ except Exception:
994
+ return "settings.json 不是合法 JSON,已中止(避免覆盖)"
995
+ else:
996
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
997
+ data = {}
998
+ if not isinstance(data, dict):
999
+ return "settings.json 结构异常(顶层不是对象),已中止"
1000
+ env = data.get("env")
1001
+ if not isinstance(env, dict):
1002
+ env = {}
1003
+ for k in remove_keys:
1004
+ env.pop(k, None)
1005
+ env.update(updates)
1006
+ data["env"] = env
1007
+ if os.path.isfile(path):
1008
+ shutil.copyfile(path, path + ".bak")
1009
+ Path(path).write_bytes(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
1010
+ return None
1011
+ except Exception as e:
1012
+ return repr(e)
1013
+
1014
+
1015
+ def _guard_home(path):
1016
+ """展开并校验配置路径必须在用户主目录内;通过则返回 realpath,否则 None。"""
1017
+ if not path:
1018
+ return None
1019
+ path = os.path.realpath(path)
1020
+ home = os.path.abspath(os.path.expanduser("~"))
1021
+ try:
1022
+ if os.path.commonpath([path, home]) != home:
1023
+ return None
1024
+ except ValueError:
1025
+ return None
1026
+ return path
1027
+
1028
+
1029
+ def _sync_claude_settings(entry, model, prov):
1030
+ """claude-code 专属:把 anthropic 供应商的端点+密钥+模型写进
1031
+ ~/.claude/settings.json 的 env 段(交互 TUI 与无头共用该文件,只认
1032
+ ANTHROPIC_*;编排降级链给的 ORCH_API_KEY 对它等于没 key)。
1033
+ 返回错误串或 None。"""
1034
+ path = _guard_home(_config_path(entry))
1035
+ if not path:
1036
+ return "claude 配置路径无效或越出用户主目录,已拒绝"
1037
+ updates = {"ANTHROPIC_BASE_URL": prov.get("base_url") or "",
1038
+ # AUTH_TOKEN Bearer 头(Z.ai 等原生 anthropic 网关的用法);
1039
+ # x-api-key 互斥,清掉可能残留的 ANTHROPIC_API_KEY 防止带错头
1040
+ "ANTHROPIC_AUTH_TOKEN": prov.get("api_key") or ""}
1041
+ if model:
1042
+ updates["ANTHROPIC_MODEL"] = model
1043
+ return _sync_settings_env(path, updates, remove_keys=("ANTHROPIC_API_KEY",))
1044
+
1045
+
1046
+ def _sync_qwen_settings(entry, model, prov):
1047
+ """qwencode 专属:openai 兼容供应商写进 ~/.qwen/settings.json 的 env 段
1048
+ (qwen-code 实测认 OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL,存在即
1049
+ 优先走 openai 兼容通道——2026-09-15 真机对维云端点实测请求到达并鉴权)。
1050
+ anthropic 协议未实证,不开(协议不匹配时宁可提示)。返回错误串或 None。"""
1051
+ path = _guard_home(_config_path(entry))
1052
+ if not path:
1053
+ return "qwen 配置路径无效或越出用户主目录,已拒绝"
1054
+ updates = {"OPENAI_API_KEY": prov.get("api_key") or "",
1055
+ "OPENAI_BASE_URL": prov.get("base_url") or ""}
1056
+ if model:
1057
+ updates["OPENAI_MODEL"] = model
1058
+ return _sync_settings_env(path, updates)
1059
+
1060
+
1061
+ def _opencode_config_candidates(entry):
1062
+ """opencode 配置的候选路径:catalog 登记的 jsonc 优先,其次同目录的
1063
+ opencode.json(opencode 两种文件名都认,用户现有安装多用 json)。"""
1064
+ out = []
1065
+ primary = _config_path(entry)
1066
+ if primary:
1067
+ out.append(primary)
1068
+ alt = os.path.join(os.path.abspath(os.path.expanduser("~/.config/opencode")),
1069
+ "opencode.json")
1070
+ if alt not in out:
1071
+ out.append(alt)
1072
+ return out
1073
+
1074
+
1075
+ def _sync_opencode_settings(entry, model, prov):
1076
+ """opencode 专属:把绑定供应商写进其配置的 provider.orch 段 + 顶层 model
1077
+ (opencode 交互 TUI 只认自家配置文件里的凭据,ORCH_API_KEY env 对它等于
1078
+ key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
1079
+ 全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1080
+ 文本级就地 patch(保留注释)。返回错误串或 None。"""
1081
+ npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1082
+ block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
1083
+ "options": {"baseURL": prov.get("base_url") or "",
1084
+ "apiKey": prov.get("api_key") or ""},
1085
+ "models": {model: {"name": model}} if model else {}}
1086
+ top_model = ("orch/" + model) if model else ""
1087
+ targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
1088
+ or [_opencode_config_candidates(entry)[0]]
1089
+ errs = []
1090
+ for path in targets:
1091
+ try:
1092
+ path = os.path.realpath(path)
1093
+ home = os.path.abspath(os.path.expanduser("~"))
1094
+ try:
1095
+ if os.path.commonpath([path, home]) != home:
1096
+ errs.append("路径越出主目录:%s" % path)
1097
+ continue
1098
+ except ValueError:
1099
+ errs.append("路径越出主目录:%s" % path)
1100
+ continue
1101
+ if os.path.isfile(path):
1102
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
1103
+ else:
1104
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1105
+ text = ""
1106
+ try:
1107
+ data = json.loads(text) if text.strip() else {}
1108
+ except Exception:
1109
+ data = None
1110
+ if data is not None: # 纯 JSON:整体读改写(保序)
1111
+ if not isinstance(data, dict):
1112
+ errs.append("结构异常(顶层不是对象):%s" % path)
1113
+ continue
1114
+ provs = data.get("provider")
1115
+ if not isinstance(provs, dict):
1116
+ provs = {}
1117
+ provs["orch"] = block
1118
+ data["provider"] = provs
1119
+ if top_model:
1120
+ data["model"] = top_model
1121
+ new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1122
+ else: # JSONC(带注释):文本级 patch
1123
+ block_json = json.dumps(block, ensure_ascii=False)
1124
+ new_text, ok = _jsonc_set(text, ("provider", "orch"), block_json)
1125
+ if not ok:
1126
+ errs.append("JSONC 就地改写失败:%s" % path)
1127
+ continue
1128
+ if top_model:
1129
+ new_text, _ = _jsonc_set(new_text, ("model",),
1130
+ json.dumps(top_model))
1131
+ if os.path.isfile(path):
1132
+ shutil.copyfile(path, path + ".bak")
1133
+ Path(path).write_bytes(new_text.encode("utf-8"))
1134
+ except Exception as e:
1135
+ errs.append("%s: %r" % (path, e))
1136
+ return ";".join(errs) or None
1137
+
1138
+
1139
+ # 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
1140
+ # 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
1141
+ _AGENT_INJECTORS = {
1142
+ "claude-code": (("anthropic",), _sync_claude_settings),
1143
+ "opencode": (("anthropic", "openai"), _sync_opencode_settings),
1144
+ "qwencode": (("openai",), _sync_qwen_settings),
1145
+ }
1146
+
1147
+ # 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
1148
+ # (mimo opencode 衍生但配置路径未实证,先按提示类;grok 吃 XAI_API_KEY 但
1149
+ # 无端点 env 可指中转,openai 协议供应商也用不上)
1150
+ _NO_CHANNEL_HINT = ("grok-build", "pi", "mimo-code")
1151
+
1152
+
1153
+ def _sync_agent_injection(entry, binding):
1154
+ """打开前把绑定链里第一个可注入供应商落进 CLI 自家配置。与编排降级链解耦:
1155
+ 协议不匹配不降级(claude openai key 等于没 key)。返回给用户看的提示
1156
+ (成功注入 / 不可用原因),None=该 CLI 无需处理。"""
1157
+ from . import modelhub
1158
+ spec = _AGENT_INJECTORS.get(entry["id"])
1159
+ if spec:
1160
+ protocols, injector = spec
1161
+ pick, note = modelhub.launch_pick(entry["id"], protocols)
1162
+ if pick:
1163
+ prov = pick["provider"]
1164
+ err = injector(entry, pick["model"], prov)
1165
+ if err:
1166
+ return "%s 凭据同步失败:%s(打开后可能需在其自带界面登录)" % (
1167
+ entry.get("name", entry["id"]), err)
1168
+ return "已注入 %s(%s · %s)" % (prov.get("name") or "供应商",
1169
+ prov.get("protocol"), prov.get("base_url", ""))
1170
+ return note
1171
+ if entry["id"] in _NO_CHANNEL_HINT:
1172
+ if binding.get("env"):
1173
+ return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
1174
+ return "未绑定可用供应商:打开后需在其自带界面登录;要打开即用请到「CLI 绑定」页绑定"
1175
+ return None
1176
+
1177
+
1178
+ def _sync_launch_model(entry, binding):
1179
+ """打开前把「CLI 绑定」页选中的模型落到该 CLI 自己的配置文件——保证
1180
+ 交互/网页版启动即选中绑定模型(绑定页是运行时模型唯一真源,目录页的
1181
+ 「默认模型」只是手动快照,会滞后)。dsh 额外同步端点与 models 目录;
1182
+ codex 额外落 provider 段(交互 TUI 只认 config.toml,不认编排的 -c 覆盖
1183
+ ORCH_API_KEY env)。返回给用户看的同步笔记列表。"""
1184
+ notes = []
1185
+ model = (binding.get("model") or "").strip()
1186
+ prov = binding.get("provider") or {}
1187
+ fmt = (entry.get("config") or {}).get("format")
1188
+ is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1189
+ is_codex = entry["id"] in ("codex-cli", "codex")
1190
+ cp = binding.get("codex_provider")
1191
+ if model and fmt in _WRITABLE_FORMATS:
1192
+ w = write_model(entry, model)
1193
+ notes.append("模型已同步为 %s" % w["model"] if w.get("ok")
1194
+ else "模型同步失败:%s" % w.get("error"))
1195
+ elif model and not is_dsh and entry["id"] not in _AGENT_INJECTORS:
1196
+ # 有专属注入通道的 CLI 由 _sync_agent_injection 负责落模型(含 jsonc),
1197
+ # 不再报「只读配置」以免与注入成功的提示互相矛盾
1198
+ notes.append("该工具模型为只读配置,按其现有配置打开(绑定模型 %s 未自动写入)" % model)
1199
+ if is_dsh and model and prov.get("base_url"):
1200
+ err = _sync_dsh_settings(entry, model, prov["base_url"])
1201
+ notes.append("dsh 端点已同步为 %s" % prov["base_url"] if not err
1202
+ else "dsh 端点同步失败:%s" % err)
1203
+ elif is_dsh and not model:
1204
+ _fixed, note = _dsh_selfcheck_model(entry)
1205
+ if note:
1206
+ notes.append(note)
1207
+ if is_codex and cp:
1208
+ err = _sync_codex_settings(entry, model, cp)
1209
+ notes.append("codex 供应商已同步为 %s" % cp.get("base_url", "") if not err
1210
+ else "codex 供应商同步失败:%s" % err)
1211
+ inj = _sync_agent_injection(entry, binding)
1212
+ if inj:
1213
+ notes.append(inj)
1214
+ return notes
1215
+
1216
+
1217
+ def launch(entry, open_browser=True):
1218
+ """一键打开:web 类后台起服务并自动开浏览器;console 类新开终端窗口跑交互 TUI。
1219
+
1220
+ 打开前把「CLI 绑定」页的模型落盘到该 CLI 配置文件(dsh 连端点与 models
1221
+ 目录一起同步),并注入绑定密钥 env——保证打开即选中可用模型。"""
1222
+ if not detect_entry(entry).get("installed"):
1223
+ return {"ok": False, "error": "未安装,无法打开"}
1224
+ launch = entry.get("launch") or {}
1225
+ cmd = (launch.get("command") or "").strip()
1226
+ if not cmd:
1227
+ return {"ok": False, "error": "未配置打开命令(可在 data/catalog.json 补 launch 字段)"}
1228
+ is_dsh = entry["id"] in ("deepseek-harness", "dsh")
1229
+ try:
1230
+ from . import modelhub
1231
+ binding = modelhub.resolve_binding(entry["id"]) or {}
1232
+ except Exception:
1233
+ binding = {}
1234
+ env = os.environ.copy()
1235
+ env.update(binding.get("env") or {})
1236
+ notes = _sync_launch_model(entry, binding)
1237
+ if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
1238
+ notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
1239
+ "要打开即用,请到「CLI 绑定」页给 DeepSeek Harness 绑定 openai 协议供应商")
1240
+ name = entry.get("name", entry["id"])
1241
+ kind = (launch.get("kind") or "console").lower()
1242
+ extra = (";".join(notes)) if notes else ""
1243
+
1244
+ if kind == "web":
1245
+ try:
1246
+ port = int(launch.get("port") or 0)
1247
+ except (TypeError, ValueError):
1248
+ port = 0
1249
+ if port <= 0:
1250
+ return {"ok": False, "error": "web 类打开必须配置固定端口(launch.port)"}
1251
+ bare = "http://127.0.0.1:%d" % port
1252
+ log_path = _launch_log_path(entry)
1253
+ if not log_path:
1254
+ return {"ok": False, "error": "启动日志路径不可信,已拒绝打开"}
1255
+ if _port_open(port):
1256
+ # 已在运行:新实例抢不到端口、新 token 也拿不到——从上次启动日志
1257
+ # 恢复带 token 的信任 URL(dsh web 有 /?token=... 围栏,裸开是 401)。
1258
+ # 模型同步照做:正在跑的实例不重启,改的是它下次生效的配置
1259
+ url = _best_url(log_path, port) or bare
1260
+ if open_browser:
1261
+ webbrowser.open(url)
1262
+ msg = "服务已在运行,已打开 " + url
1263
+ if extra:
1264
+ msg += "(" + extra + ")"
1265
+ return {"ok": True, "kind": "web", "url": url, "message": msg}
1266
+ # 后台起服务:无窗口、不阻塞请求;子进程输出重定向到启动日志(cmd
1267
+ # 重定向,路径含空格才加引号)——web 类普遍会在启动行打印带 token
1268
+ # 信任 URL(如 dsh web),就绪线程从日志提取后再开浏览器
1269
+ ls = str(log_path)
1270
+ spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1271
+ try:
1272
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1273
+ subprocess.Popen(["cmd", "/c", spawn_cmd], cwd=str(paths.ROOT), env=env,
1274
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1275
+ stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW)
1276
+ except Exception as e:
1277
+ return {"ok": False, "error": "无法启动服务: %r" % e}
1278
+ if open_browser:
1279
+ threading.Thread(target=_open_when_ready, args=(port, bare, log_path),
1280
+ name="launch-wait-%d" % port, daemon=True).start()
1281
+ return {"ok": True, "kind": "web", "url": bare,
1282
+ "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1283
+ % (name, bare, (";" + extra) if extra else "")}
1284
+
1285
+ if sys.platform != "win32":
1286
+ return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
1287
+ # start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
1288
+ # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1289
+ argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
1290
+ "cmd", "/k", cmd]
1291
+ try:
1292
+ subprocess.Popen(argv, cwd=str(paths.ROOT), env=env,
1293
+ creationflags=CREATE_NO_WINDOW)
1294
+ except Exception as e:
1295
+ return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1296
+ return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1297
+ % (name, ("(" + extra + ")") if extra else "")}
1298
+
1299
+
1300
+ def _open_when_ready(port, url, log_path, timeout=30):
1301
+ """ web 服务端口就绪后开浏览器。优先从启动日志提取带 token 的信任 URL
1302
+ (端口通了 token 行可能还差几十毫秒才落盘,故就绪后最多再等 6 秒);
1303
+ 超时兜底也开——服务可能只是慢,用户手动刷新即可。"""
1304
+ deadline = time.time() + timeout
1305
+ while time.time() < deadline:
1306
+ if _port_open(port):
1307
+ break
1308
+ time.sleep(0.5)
1309
+ token_deadline = time.time() + 6
1310
+ best = None
1311
+ while time.time() < token_deadline:
1312
+ best = _best_url(log_path, port)
1313
+ if best and "token=" in best:
1314
+ break
1315
+ time.sleep(0.5)
1316
+ try:
1317
+ webbrowser.open(best or url)
1318
+ except Exception:
1319
+ pass
1320
+
1321
+
1322
+ # ---------------------------------------------------------------- 安装/升级
1323
+
1324
+ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
1325
+ """执行 install/upgrade/uninstall 命令(在任务队列里跑,日志实时落盘)。"""
1326
+ if op == "uninstall":
1327
+ cmd = catalog.uninstall_command(entry)
1328
+ if not cmd:
1329
+ return {"ok": False,
1330
+ "error": "无法推导卸载命令:请在 data/catalog.json \"%s\" 里配置 uninstall 字段"
1331
+ % entry["id"]}
1332
+ else:
1333
+ cmd = entry.get(op)
1334
+ if not cmd:
1335
+ return {"ok": False,
1336
+ "error": "未配置 %s 命令:请在 data/catalog.json 的 \"%s\" 里补充,或用官方渠道安装"
1337
+ % (op, entry["id"])}
1338
+ res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
1339
+ timeout=1800, cancel_event=cancel_event, log_path=log_path)
1340
+ detect_all(force=True)
1341
+ with _LOCK:
1342
+ _STATE["versions"].pop(entry["id"], None)
1343
+ return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
1344
+ "error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
1345
+
1346
+
1347
+ # ---------------------------------------------------------------- 版本检查
1348
+
1349
+ _UPDATE_CACHE = {} # agent_id (ts, {current, latest, updatable, note})
1350
+ UPDATE_TTL = 600
1351
+
1352
+
1353
+ def _npm_pkg_name(cmd):
1354
+ """从 npm 安装命令里取包名(实现已统一到 catalog.npm_pkg_name)。"""
1355
+ return catalog.npm_pkg_name(cmd)
1356
+
1357
+
1358
+ def _ver_tuple(s):
1359
+ return [int(x) for x in re.findall(r"\d+", str(s or ""))[:4]]
1360
+
1361
+
1362
+ def check_update(entry, force=False):
1363
+ """检查是否有新版本可用。npm 走 npm view;winget 走 winget upgrade 列表;
1364
+ 其他渠道标记为不支持。结果缓存 10 分钟。"""
1365
+ eid = entry["id"]
1366
+ if not force:
1367
+ cached = _UPDATE_CACHE.get(eid)
1368
+ if cached and time.time() - cached[0] < UPDATE_TTL:
1369
+ return cached[1]
1370
+ current = version_of(entry)
1371
+ cur_num = ".".join(str(x) for x in _ver_tuple(current)) or "-"
1372
+ result = {"current": current, "latest": None, "updatable": None, "note": ""}
1373
+
1374
+ cmd = entry.get("install") or entry.get("upgrade") or ""
1375
+ pkg = _npm_pkg_name(cmd)
1376
+ if pkg:
1377
+ r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
1378
+ latest = ""
1379
+ if r["ok"]:
1380
+ for line in (r["stdout"] or "").splitlines():
1381
+ line = line.strip()
1382
+ if line and re.match(r"^\d", line):
1383
+ latest = line
1384
+ if not latest:
1385
+ result["note"] = "查询 npm 失败(网络或 registry 问题):" + (r["stderr"] or "")[-200:]
1386
+ else:
1387
+ result["latest"] = latest
1388
+ result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
1389
+ if not result["updatable"]:
1390
+ result["note"] = "已是最新版本"
1391
+ elif "winget" in cmd:
1392
+ m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
1393
+ wid = m.group(1) if m else None
1394
+ if not wid:
1395
+ result["note"] = "无法从命令中解析 winget ID"
1396
+ else:
1397
+ r = runner.run_process(argv=["cmd", "/c", "winget", "upgrade"], timeout=180)
1398
+ if not r["ok"]:
1399
+ result["note"] = "winget upgrade 查询失败:" + (r["stderr"] or "")[-200:]
1400
+ else:
1401
+ hit = [l for l in (r["stdout"] or "").splitlines() if wid.lower() in l.lower()]
1402
+ result["updatable"] = bool(hit)
1403
+ if hit:
1404
+ result["latest"] = "(winget 有可用更新)"
1405
+ else:
1406
+ result["latest"] = cur_num
1407
+ result["note"] = "已是最新版本"
1408
+ else:
1409
+ result["note"] = "该渠道暂不支持自动检查更新,可直接点升级尝试"
1410
+
1411
+ with _LOCK:
1412
+ _UPDATE_CACHE[eid] = (time.time(), result)
1413
+ return result
1414
+
1415
+
1416
+ # 「进入智能体目录页自动检查更新」的后台任务状态
1417
+ _UPDATE_CHECK = {"running": False, "total": 0, "done": 0}
1418
+
1419
+
1420
+ def updates_checking():
1421
+ with _LOCK:
1422
+ return bool(_UPDATE_CHECK["running"])
1423
+
1424
+
1425
+ def update_info(entry):
1426
+ """单个 CLI 的更新检查结果(供管理页卡片展示)。没查过时为 unknown。"""
1427
+ with _LOCK:
1428
+ cached = _UPDATE_CACHE.get(entry["id"])
1429
+ if not cached:
1430
+ return {"status": "unknown", "latest": None, "updatable": None,
1431
+ "note": "", "checked_at": ""}
1432
+ res = cached[1] or {}
1433
+ up = res.get("updatable")
1434
+ status = "updatable" if up is True else ("current" if up is False else "unsupported")
1435
+ return {"status": status, "latest": res.get("latest"), "updatable": up,
1436
+ "note": res.get("note") or "",
1437
+ "checked_at": time.strftime("%H:%M", time.localtime(cached[0]))}
1438
+
1439
+
1440
+ def _checkable_entries():
1441
+ """已安装且配了 install/upgrade 的条目——只有这些才谈得上「是否有新版本」。"""
1442
+ detected = detect_all()
1443
+ out = []
1444
+ for e in catalog.load():
1445
+ det = (detected or {}).get(e["id"]) or {}
1446
+ if det.get("installed") and (e.get("upgrade") or e.get("install")):
1447
+ out.append(e)
1448
+ return out
1449
+
1450
+
1451
+ def check_updates_async(force=False):
1452
+ """后台逐个检查已安装 CLI 的远端最新版本,返回本次要检查的条目数。
1453
+
1454
+ 进入「智能体目录」页时自动触发。单条结果复用 check_update 的 10 分钟缓存,
1455
+ 所以反复进出页面几乎不产生额外子进程/网络开销;已在跑时不重复起线程。
1456
+ """
1457
+ with _LOCK:
1458
+ if _UPDATE_CHECK["running"]:
1459
+ return 0
1460
+ entries = _checkable_entries()
1461
+ _UPDATE_CHECK.update(running=True, total=len(entries), done=0)
1462
+
1463
+ def _worker():
1464
+ try:
1465
+ for e in entries:
1466
+ try:
1467
+ check_update(e, force=force)
1468
+ except Exception:
1469
+ pass
1470
+ finally:
1471
+ with _LOCK:
1472
+ _UPDATE_CHECK["done"] += 1
1473
+ finally:
1474
+ with _LOCK:
1475
+ _UPDATE_CHECK["running"] = False
1476
+
1477
+ threading.Thread(target=_worker, name="catalog-update-check", daemon=True).start()
1478
+ return len(entries)
1479
+
1480
+
1481
+ def catalog_view():
1482
+ """管理页数据:catalog + 检测 + 版本 + 模型 + 编排启用状态。"""
1483
+ from . import registry
1484
+ entries = catalog.load()
1485
+ detected = detect_all(force=False)
1486
+ enabled = registry.load_enabled()
1487
+ view = []
1488
+ for e in entries:
1489
+ det = detected.get(e["id"]) or {}
1490
+ pref = enabled.get(e["id"]) or {}
1491
+ orch_enabled = bool(pref.get("enabled", e.get("default_enabled", False))) if e.get("orch") else False
1492
+ view.append({
1493
+ "id": e["id"], "name": e.get("name", e["id"]), "note": e.get("note", ""),
1494
+ "group": "installed" if det.get("installed") else "installable",
1495
+ "installed": det.get("installed", False),
1496
+ "detail": det.get("detail", ""),
1497
+ "version": version_of(e),
1498
+ "config_path": _config_path(e),
1499
+ "config_writable": (e.get("config") or {}).get("format") in _WRITABLE_FORMATS,
1500
+ "model": read_model(e),
1501
+ "orch_kind": (e.get("orch") or {}).get("kind"),
1502
+ "orch_enabled": orch_enabled,
1503
+ "update": update_info(e),
1504
+ "has_install": bool(e.get("install")),
1505
+ "has_upgrade": bool(e.get("upgrade")),
1506
+ # 卸载命令由 install/upgrade 推导(或 catalog 显式配置),供 UI 确认框展示
1507
+ "uninstall_cmd": catalog.uninstall_command(e) if det.get("installed") else None,
1508
+ # 一键打开配置(kind=web/console + command);没配的条目 UI 不出「打开」按钮
1509
+ "launch": e.get("launch"),
1510
+ })
1511
+ return view