codebee 0.1.1 → 0.1.3

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