@hupan56/wlkj 3.4.9 → 3.5.1
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.
- package/bin/cli.js +1713 -1713
- package/package.json +1 -1
- package/templates/qoder/config.yaml +229 -229
- package/templates/qoder/scripts/deployment/setup/install_qoderwork.py +835 -835
- package/templates/qoder/scripts/protocol/mcp/kg_mcp_server.py +351 -351
|
@@ -1,835 +1,835 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
# v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
|
|
3
|
-
import os as _o, sys as _s
|
|
4
|
-
_f = _o.path.abspath(__file__)
|
|
5
|
-
for _ in range(10):
|
|
6
|
-
_f = _o.path.dirname(_f)
|
|
7
|
-
_cp = _o.path.join(_f, 'foundation')
|
|
8
|
-
if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
|
|
9
|
-
break
|
|
10
|
-
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
11
|
-
from bootstrap import setup; setup()
|
|
12
|
-
from foundation.core.paths import get_repo_root
|
|
13
|
-
|
|
14
|
-
"""
|
|
15
|
-
install_qoderwork.py - 把 .qoder/skills/ 安装到 QoderWork 桌面端可识别的位置
|
|
16
|
-
|
|
17
|
-
QoderWork 从 %USERPROFILE%\\.qoderwork\\skills\\ 加载技能(每个子目录一个 skill),
|
|
18
|
-
从 %USERPROFILE%\\.qoderwork\\commands\\ 加载斜杠命令。
|
|
19
|
-
本项目源在 <repo>/.qoder/skills/ 和 <repo>/.qoder/commands/,QoderWork 不会读项目
|
|
20
|
-
目录,必须软链/拷过去。
|
|
21
|
-
(注: 旧版本误用 ~/.qoderworkcn/,已废弃,官方路径为 ~/.qoderwork/)
|
|
22
|
-
|
|
23
|
-
为什么用 junction(mklink /J)而不是 symlink(mklink /D):
|
|
24
|
-
- junction 不需要管理员权限,普通用户可建
|
|
25
|
-
- 本地评估默认启用,跨进程透明
|
|
26
|
-
- symlink 在未开开发者模式的 Windows 上需要 SeCreateSymbolicLink 特权
|
|
27
|
-
|
|
28
|
-
用法:
|
|
29
|
-
python .qoder/scripts/install_qoderwork.py # 安装/同步(幂等)
|
|
30
|
-
python .qoder/scripts/install_qoderwork.py --uninstall # 删除所有 junction(不删源)
|
|
31
|
-
python .qoder/scripts/install_qoderwork.py --check # 仅检查,不改动
|
|
32
|
-
python .qoder/scripts/install_qoderwork.py --copy # 改用拷贝(非 Windows 或不想软链)
|
|
33
|
-
|
|
34
|
-
幂等:已存在的 junction 会跳过;已存在的非 junction 目录会报错让人工处理。
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
import argparse
|
|
38
|
-
import json
|
|
39
|
-
import os
|
|
40
|
-
import shutil
|
|
41
|
-
import subprocess
|
|
42
|
-
import sys
|
|
43
|
-
from pathlib import Path
|
|
44
|
-
|
|
45
|
-
# 确保 UTF-8 输出(Windows 控制台默认 GBK)
|
|
46
|
-
if sys.platform == "win32":
|
|
47
|
-
try:
|
|
48
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
49
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
50
|
-
except (AttributeError, IOError):
|
|
51
|
-
pass
|
|
52
|
-
|
|
53
|
-
# 路径自检:项目根 = 本文件上四级 (v3.0: setup/ -> scripts/ -> .qoder/ -> repo)
|
|
54
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
55
|
-
PROJECT_ROOT = get_repo_root()
|
|
56
|
-
SOURCE_SKILLS_DIR = PROJECT_ROOT / ".qoder" / "skills"
|
|
57
|
-
SOURCE_COMMANDS_DIR = PROJECT_ROOT / ".qoder" / "commands"
|
|
58
|
-
|
|
59
|
-
# QoderWork 目标目录 (官方: ~/.qoderwork/skills/ 和 ~/.qoderwork/commands/)
|
|
60
|
-
# 注意: 之前误用了 ~/.qoderworkcn/ (国内版旧路径), 官方文档明确是 ~/.qoderwork/
|
|
61
|
-
# https://docs.qoder.com/zh/qoderwork/skills
|
|
62
|
-
if sys.platform == "win32":
|
|
63
|
-
_HOME = Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
64
|
-
else:
|
|
65
|
-
_HOME = Path.home()
|
|
66
|
-
QODERWORK_SKILLS_DIR = _HOME / ".qoderwork" / "skills"
|
|
67
|
-
QODERWORK_COMMANDS_DIR = _HOME / ".qoderwork" / "commands"
|
|
68
|
-
QODERWORK_MCP_FILE = _HOME / ".qoderwork" / "mcp.json"
|
|
69
|
-
|
|
70
|
-
# Playwright MCP 配置 (让 QoderWork 的 AI 能用 Playwright 工具驱动浏览器, 快且稳)
|
|
71
|
-
# 可选增强: 不装 playwright 也不影响 QoderWork 自带连接器
|
|
72
|
-
# ⚠️ 实测: npx @playwright/mcp@latest 冷启动 11.7s, QoderWork 会超时杀掉 → 工具注册失败
|
|
73
|
-
# → AI 瞎猜工具名(BrowserNavigate) → 全失败。
|
|
74
|
-
# 解法: 优先用"全局已装的 cli.js + node 直启"(0.57s), 探测不到才回退 npx。
|
|
75
|
-
def _find_playwright_mcp_cli():
|
|
76
|
-
"""探测全局已装的 @playwright/mcp/cli.js 路径, 返回 (node, cli.js) 或 None。
|
|
77
|
-
Windows 下 npm 是 .cmd, 必须 shell=True 才能调到。"""
|
|
78
|
-
import shutil, subprocess
|
|
79
|
-
node = shutil.which("node")
|
|
80
|
-
if not node:
|
|
81
|
-
return None
|
|
82
|
-
# npm root -g 拿全局 node_modules 路径 (最准, 跨自定义prefix)
|
|
83
|
-
try:
|
|
84
|
-
out = subprocess.run("npm root -g", capture_output=True, text=True,
|
|
85
|
-
shell=True, timeout=10)
|
|
86
|
-
root = out.stdout.strip()
|
|
87
|
-
if root:
|
|
88
|
-
cli = os.path.join(root, "@playwright", "mcp", "cli.js")
|
|
89
|
-
if os.path.isfile(cli):
|
|
90
|
-
return node, cli
|
|
91
|
-
except Exception:
|
|
92
|
-
pass
|
|
93
|
-
# 回退: 试常见全局路径
|
|
94
|
-
for cand in [os.path.join(os.path.expanduser("~"), "AppData", "Roaming", "npm",
|
|
95
|
-
"node_modules", "@playwright", "mcp", "cli.js")]:
|
|
96
|
-
if os.path.isfile(cand):
|
|
97
|
-
return node, cand
|
|
98
|
-
return None
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _playwright_mcp_entry():
|
|
102
|
-
"""生成 playwright MCP 配置: 优先 node+cli.js(快), 回退 npx(慢但通用)。"""
|
|
103
|
-
found = _find_playwright_mcp_cli()
|
|
104
|
-
if found:
|
|
105
|
-
node, cli = found
|
|
106
|
-
return {"playwright": {"command": node, "args": [cli]}}
|
|
107
|
-
# 回退: npx (冷启动慢, 可能被 QoderWork 超时杀, 但通用)
|
|
108
|
-
return {"playwright": {"command": "npx", "args": ["@playwright/mcp@latest"]}}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
PLAYWRIGHT_MCP_ENTRY = _playwright_mcp_entry()
|
|
112
|
-
|
|
113
|
-
# 旧的错误路径 (迁移清理用)
|
|
114
|
-
LEGACY_WRONG_DIR = _HOME / ".qoderworkcn" / "skills"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def is_junction(path: Path) -> bool:
|
|
118
|
-
"""判断目录是否是 junction/symlink(reparse point)。"""
|
|
119
|
-
if not path.exists():
|
|
120
|
-
return False
|
|
121
|
-
# Windows: 用 dir 命令检测 <JUNCTION> 标记最可靠(无需 ctypes)
|
|
122
|
-
if sys.platform == "win32":
|
|
123
|
-
try:
|
|
124
|
-
# os.lstat: reparse point 的 FILE_ATTRIBUTE_REPARSE_POINT (0x400) 会体现在 st_file_attributes
|
|
125
|
-
import stat as _stat
|
|
126
|
-
st = path.lstat()
|
|
127
|
-
# FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
128
|
-
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
|
|
129
|
-
except (OSError, AttributeError):
|
|
130
|
-
return False
|
|
131
|
-
else:
|
|
132
|
-
return path.is_symlink()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def find_source_skills() -> list:
|
|
136
|
-
"""扫描 .qoder/skills/*/SKILL.md,返回 [(name, src_dir), ...]。"""
|
|
137
|
-
if not SOURCE_SKILLS_DIR.is_dir():
|
|
138
|
-
return []
|
|
139
|
-
result = []
|
|
140
|
-
for child in sorted(SOURCE_SKILLS_DIR.iterdir()):
|
|
141
|
-
if not child.is_dir():
|
|
142
|
-
continue
|
|
143
|
-
if (child / "SKILL.md").is_file():
|
|
144
|
-
result.append((child.name, child))
|
|
145
|
-
return result
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def file_equal(a: Path, b: Path) -> bool:
|
|
149
|
-
"""快速判断两文件内容是否相同(先比大小再比内容,避免无谓全读)。"""
|
|
150
|
-
try:
|
|
151
|
-
if a.stat().st_size != b.stat().st_size:
|
|
152
|
-
return False
|
|
153
|
-
return a.read_bytes() == b.read_bytes()
|
|
154
|
-
except OSError:
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def create_junction(link: Path, target: Path) -> bool:
|
|
159
|
-
"""用 mklink /J 创建 junction。返回是否成功。"""
|
|
160
|
-
# 用 errors="replace" 防止 Windows GBK 输出(如"为...创建的联接")触发 UnicodeDecodeError
|
|
161
|
-
cmd = ["cmd", "/c", "mklink", "/J", str(link), str(target)]
|
|
162
|
-
try:
|
|
163
|
-
r = subprocess.run(
|
|
164
|
-
cmd,
|
|
165
|
-
stdout=subprocess.PIPE,
|
|
166
|
-
stderr=subprocess.PIPE,
|
|
167
|
-
# 不用 text=True,自己 decode 防编码异常
|
|
168
|
-
)
|
|
169
|
-
return r.returncode == 0
|
|
170
|
-
except (OSError, subprocess.SubprocessError):
|
|
171
|
-
return False
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def install_one(name: str, src: Path, mode: str, dry: bool = False) -> str:
|
|
175
|
-
"""
|
|
176
|
-
安装一个 skill 到 QoderWork 目录。
|
|
177
|
-
返回状态字符串:'created' / 'ok-existing' / 'copied' / 'error: ...' / 'skip: ...'
|
|
178
|
-
"""
|
|
179
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
180
|
-
if link.exists() or link.is_symlink():
|
|
181
|
-
if is_junction(link):
|
|
182
|
-
# 悬空 junction 检测: junction 存在但目标已移走/删除
|
|
183
|
-
# (常见于团队重命名 skill 后, 旧 junction 变成死链)
|
|
184
|
-
# link.exists() 对悬空 junction 返回 False, 但 link.is_symlink() 可能 True
|
|
185
|
-
# 用 os.path.exists 解析目标判断
|
|
186
|
-
try:
|
|
187
|
-
target_alive = os.path.exists(str(link))
|
|
188
|
-
except OSError:
|
|
189
|
-
target_alive = False
|
|
190
|
-
if target_alive:
|
|
191
|
-
return "ok-existing"
|
|
192
|
-
else:
|
|
193
|
-
# 悬空 junction, 删除后重建
|
|
194
|
-
if dry:
|
|
195
|
-
return "would-recreate (dangling)"
|
|
196
|
-
try:
|
|
197
|
-
if sys.platform == "win32":
|
|
198
|
-
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
199
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
200
|
-
else:
|
|
201
|
-
os.unlink(str(link))
|
|
202
|
-
except (OSError, subprocess.SubprocessError) as e:
|
|
203
|
-
return "error: 悬空 junction 无法删除 (手动 rmdir {}): {}".format(link, e)
|
|
204
|
-
# 落到下面 recreate 逻辑
|
|
205
|
-
else:
|
|
206
|
-
# 非 junction 的真实目录/文件 -> 不覆盖
|
|
207
|
-
return f"skip: {name} 已存在且不是软链(避免覆盖,请人工确认 {link})"
|
|
208
|
-
|
|
209
|
-
if dry:
|
|
210
|
-
return "would-create"
|
|
211
|
-
|
|
212
|
-
QODERWORK_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
213
|
-
|
|
214
|
-
if mode == "copy":
|
|
215
|
-
try:
|
|
216
|
-
shutil.copytree(src, link)
|
|
217
|
-
return "copied"
|
|
218
|
-
except (OSError, shutil.Error) as e:
|
|
219
|
-
return f"error: copy 失败 {e}"
|
|
220
|
-
else:
|
|
221
|
-
# junction 模式(默认)
|
|
222
|
-
if sys.platform != "win32":
|
|
223
|
-
# 非 Windows 退化为 symlink,失败再退化为 copy
|
|
224
|
-
try:
|
|
225
|
-
os.symlink(src, link, target_is_directory=True)
|
|
226
|
-
return "created(symlink)"
|
|
227
|
-
except (OSError, NotImplementedError):
|
|
228
|
-
try:
|
|
229
|
-
shutil.copytree(src, link)
|
|
230
|
-
return "copied(fallback)"
|
|
231
|
-
except (OSError, shutil.Error) as e:
|
|
232
|
-
return f"error: {e}"
|
|
233
|
-
if create_junction(link, src):
|
|
234
|
-
return "created"
|
|
235
|
-
return "error: mklink /J 失败(可能需要检查路径或权限)"
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def uninstall_one(name: str) -> str:
|
|
239
|
-
"""删除一个 junction(只删 link,不删源)。"""
|
|
240
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
241
|
-
if not link.exists() and not link.is_symlink():
|
|
242
|
-
return "absent"
|
|
243
|
-
if not is_junction(link):
|
|
244
|
-
return f"skip: {name} 不是软链(不动真实目录,请人工删除 {link})"
|
|
245
|
-
try:
|
|
246
|
-
# 删 junction 用 rmdir(不递归到源)
|
|
247
|
-
if sys.platform == "win32":
|
|
248
|
-
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
249
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
250
|
-
else:
|
|
251
|
-
os.unlink(str(link))
|
|
252
|
-
return "removed"
|
|
253
|
-
except (OSError, subprocess.SubprocessError) as e:
|
|
254
|
-
return f"error: {e}"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def setup_playwright_mcp(dry=False):
|
|
258
|
-
"""把 Playwright MCP 写进 ~/.qoderwork/mcp.json (可选增强, 不影响自带连接器)。
|
|
259
|
-
|
|
260
|
-
让 QoderWork 的 AI 能用 Playwright 工具(browser_navigate/click/type/screenshot)
|
|
261
|
-
驱动浏览器 —— 快(10s级 vs 自带连接器 5-8分钟)、稳(不折腾 tab)、不依赖扩展。
|
|
262
|
-
已有 playwright 配置则跳过(幂等); 没有则追加。
|
|
263
|
-
"""
|
|
264
|
-
import json
|
|
265
|
-
mcp_path = QODERWORK_MCP_FILE
|
|
266
|
-
# 读现有配置
|
|
267
|
-
existing = {}
|
|
268
|
-
if mcp_path.is_file():
|
|
269
|
-
try:
|
|
270
|
-
existing = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
271
|
-
if not isinstance(existing, dict):
|
|
272
|
-
existing = {}
|
|
273
|
-
except (json.JSONDecodeError, OSError):
|
|
274
|
-
existing = {}
|
|
275
|
-
servers = existing.get("mcpServers", {})
|
|
276
|
-
if not isinstance(servers, dict):
|
|
277
|
-
servers = {}
|
|
278
|
-
if "playwright" in servers:
|
|
279
|
-
# 已有配置: 若是旧的 npx@latest(慢, 易超时失败), 升级成 node+cli.js(快)
|
|
280
|
-
cur = servers["playwright"]
|
|
281
|
-
if cur.get("command") == "npx":
|
|
282
|
-
new_entry = PLAYWRIGHT_MCP_ENTRY["playwright"]
|
|
283
|
-
if new_entry.get("command") != "npx": # 探测到了 cli.js
|
|
284
|
-
if dry:
|
|
285
|
-
return "would-upgrade"
|
|
286
|
-
servers["playwright"] = new_entry
|
|
287
|
-
existing["mcpServers"] = servers
|
|
288
|
-
mcp_path.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
289
|
-
return "upgraded"
|
|
290
|
-
return "ok-existing"
|
|
291
|
-
if dry:
|
|
292
|
-
return "would-add"
|
|
293
|
-
# 追加 playwright
|
|
294
|
-
servers["playwright"] = PLAYWRIGHT_MCP_ENTRY["playwright"]
|
|
295
|
-
existing["mcpServers"] = servers
|
|
296
|
-
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
-
mcp_path.write_text(
|
|
298
|
-
json.dumps(existing, indent=2, ensure_ascii=False) + "\n",
|
|
299
|
-
encoding="utf-8",
|
|
300
|
-
)
|
|
301
|
-
return "added"
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def setup_lanhu_mcp(dry=False):
|
|
305
|
-
"""注册蓝湖 MCP 到 ~/.qoderwork/mcp.json (可选增强, 设计稿直读)。
|
|
306
|
-
|
|
307
|
-
蓝湖 MCP (dsphper/lanhu-mcp) 是第三方 HTTP server, 让设计师发蓝湖链接,
|
|
308
|
-
AI 直读设计稿(颜色/尺寸/字体/切图)。注册成 url 模式 (Streamable HTTP),
|
|
309
|
-
跟 playwright/kg/mysql 这些 stdio server 并列。
|
|
310
|
-
|
|
311
|
-
注意: 蓝湖是 HTTP server, 需要用户手动启动
|
|
312
|
-
python .qoder/scripts/setup_lanhu.py start
|
|
313
|
-
(启动脚本自动读当前开发者的 cookie 注入, 不用手动配 .env)
|
|
314
|
-
本函数只注册 url (幂等), 不负责 clone/装依赖/启动 —— 那些由 setup_lanhu.py
|
|
315
|
-
在 setup.py Step 4.5 完成。这里只做 mcp.json 条目同步, 保证团队所有人
|
|
316
|
-
跑 install_qoderwork 后都能看到蓝湖 server。
|
|
317
|
-
|
|
318
|
-
返回: 'ok-existing' / 'added' / 'would-add'
|
|
319
|
-
"""
|
|
320
|
-
# 复用 setup_lanhu.py 的注册逻辑 (单一事实源)
|
|
321
|
-
try:
|
|
322
|
-
sys.path.insert(0, str(SCRIPT_DIR))
|
|
323
|
-
from setup_lanhu import register_lanhu_mcp
|
|
324
|
-
except ImportError:
|
|
325
|
-
return 'would-add'
|
|
326
|
-
return register_lanhu_mcp(developer_name='Developer', dry=dry)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def detect_python_command():
|
|
330
|
-
"""探测本机可用的 python 命令名 (跨平台), 用于写进 mcp.json 的 command 字段。
|
|
331
|
-
|
|
332
|
-
⚠️ 关键约束: 返回值必须是单个可执行文件名/路径, 不能含空格或参数。
|
|
333
|
-
旧版返回 'py -3' (带参数), MCP 客户端 spawn('py -3', args) 会把
|
|
334
|
-
'py -3' 当字面文件名 → 找不到可执行文件 → MCP 显示黄色(连接失败)。
|
|
335
|
-
|
|
336
|
-
优先级 (Windows): python > py > python3
|
|
337
|
-
- 故意把 python 放 py 前面: 'python' 是单文件名, 最稳;
|
|
338
|
-
'py' 需要配合 -3 参数, 但我们返回纯 'py' (不带 -3) 也通常能用
|
|
339
|
-
(py 启动器会选默认版本)。
|
|
340
|
-
优先级 (mac/linux): python3 > python
|
|
341
|
-
|
|
342
|
-
返回: 命令名字符串 (如 'python' / 'py' / 'python3'), 失败返回 None。
|
|
343
|
-
"""
|
|
344
|
-
if sys.platform == 'win32':
|
|
345
|
-
candidates = ('python', 'py', 'python3')
|
|
346
|
-
else:
|
|
347
|
-
candidates = ('python3', 'python')
|
|
348
|
-
for name in candidates:
|
|
349
|
-
if not shutil.which(name):
|
|
350
|
-
continue
|
|
351
|
-
try:
|
|
352
|
-
r = subprocess.run([name, '--version'], capture_output=True, text=True,
|
|
353
|
-
encoding='utf-8', errors='replace')
|
|
354
|
-
if r.returncode == 0:
|
|
355
|
-
return name # 纯命令名, 不带参数
|
|
356
|
-
except (OSError, subprocess.SubprocessProcessError):
|
|
357
|
-
continue
|
|
358
|
-
return None
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def find_python_with_core_deps():
|
|
362
|
-
"""找一个装了核心依赖 (duckdb/pymysql/yaml/requests) 的 Python 可执行文件。
|
|
363
|
-
|
|
364
|
-
用于写 mcp.json: 如果某个 Python 已经装了核心依赖, 直接用它当 command,
|
|
365
|
-
launcher 启动后 _find_python_with_deps 会秒命中 sys.executable, 不用再遍历。
|
|
366
|
-
解决"多 Python 共存, 装到了 A 但 mcp.json 指向 B"的常见问题。
|
|
367
|
-
|
|
368
|
-
返回: python 可执行文件路径(str) 或 None(没找到装了依赖的)。
|
|
369
|
-
"""
|
|
370
|
-
# 先测当前 Python (install_qoderwork 正在跑的这个)
|
|
371
|
-
for mod in ('duckdb', 'pymysql', 'yaml', 'requests'):
|
|
372
|
-
try:
|
|
373
|
-
__import__(mod)
|
|
374
|
-
except ImportError:
|
|
375
|
-
break
|
|
376
|
-
else:
|
|
377
|
-
# 当前 Python 四个核心依赖都有 → 用它
|
|
378
|
-
return sys.executable
|
|
379
|
-
|
|
380
|
-
# 当前 Python 没装全, 找其它 Python
|
|
381
|
-
for name in ('python', 'python3', 'py'):
|
|
382
|
-
p = shutil.which(name)
|
|
383
|
-
if not p or p == sys.executable:
|
|
384
|
-
continue
|
|
385
|
-
try:
|
|
386
|
-
r = subprocess.run(
|
|
387
|
-
[p, '-c', 'import duckdb, pymysql, yaml, requests'],
|
|
388
|
-
capture_output=True, timeout=8)
|
|
389
|
-
if r.returncode == 0:
|
|
390
|
-
return p
|
|
391
|
-
except (OSError, subprocess.SubprocessProcessError):
|
|
392
|
-
continue
|
|
393
|
-
return None
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def rewrite_mcp_json_for_launcher():
|
|
397
|
-
"""把 mcp.json 里所有 python MCP (kg/mysql/lanhu) 改成 launcher 模式。
|
|
398
|
-
|
|
399
|
-
★ kg 走云平台 SSE url(共识),不走 launcher——在 for srv 循环已配 SSE,
|
|
400
|
-
这里跳过 kg(不改 launcher),保住 SSE 配置。
|
|
401
|
-
|
|
402
|
-
旧 (写死路径): {"command": "E:\\...\\python.exe", "args": ["E:\\...\\server.py"]}
|
|
403
|
-
新 (launcher): {"command": "<py cmd>", "args": ["<home>/.qoderwork/mcp_launcher.py", "<name>"]}
|
|
404
|
-
|
|
405
|
-
幂等: 已是 launcher 模式 (args 含 mcp_launcher.py) 则跳过。
|
|
406
|
-
全新机器: mcp.json 不存在时创建空骨架 + 写入 kg/mysql/lanhu 条目。
|
|
407
|
-
mysql env 抽到 ~/.qoderwork/.mcp-mysql-env.json (launcher 读)。
|
|
408
|
-
"""
|
|
409
|
-
# 全新机器: mcp.json 不存在 → 创建空骨架 (playwright 后续会追加)
|
|
410
|
-
if not QODERWORK_MCP_FILE.is_file():
|
|
411
|
-
QODERWORK_MCP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
412
|
-
QODERWORK_MCP_FILE.write_text(
|
|
413
|
-
'{"mcpServers": {}}\n', encoding="utf-8")
|
|
414
|
-
try:
|
|
415
|
-
cfg = json.loads(QODERWORK_MCP_FILE.read_text(encoding="utf-8"))
|
|
416
|
-
except (json.JSONDecodeError, OSError):
|
|
417
|
-
return
|
|
418
|
-
servers = cfg.get("mcpServers", {})
|
|
419
|
-
if not isinstance(servers, dict):
|
|
420
|
-
return
|
|
421
|
-
|
|
422
|
-
# 选 Python 写进 mcp.json:
|
|
423
|
-
# v3.0 修复: 用装了核心依赖的 Python 绝对路径 (不用命令名)!
|
|
424
|
-
# 原因: QoderWork 桌面应用不继承 shell PATH, 命令名(python/py -3)找不到 → 黄灯。
|
|
425
|
-
# 绝对路径每次 install 时探测, 换机器重跑 update 自动刷新。
|
|
426
|
-
# 依赖查找: 先找装了 duckdb 的 python (find_python_with_core_deps),
|
|
427
|
-
# 找不到回退 detect_python_command。
|
|
428
|
-
# ⚠ find_python_with_core_deps 返回 None (不抛异常) 时, 旧代码因 try/except
|
|
429
|
-
# 捕不到 → py_cmd 留 None → mcp.json 写 "command": null → QoderWork 起 MCP
|
|
430
|
-
# 失败变黄。这里显式 None 判断 + 多级回退, 绝不写 null。
|
|
431
|
-
py_cmd = find_python_with_core_deps()
|
|
432
|
-
if not py_cmd:
|
|
433
|
-
py_cmd = detect_python_command() or sys.executable or "python"
|
|
434
|
-
launcher = str(_HOME / ".qoderwork" / "mcp_launcher.py")
|
|
435
|
-
# MCP 名 → launcher 子命令
|
|
436
|
-
name_map = {
|
|
437
|
-
"qoder-knowledge-graph": "kg",
|
|
438
|
-
"qoder-mysql": "mysql",
|
|
439
|
-
"qoder-zentao": "zentao",
|
|
440
|
-
"lanhu": "lanhu",
|
|
441
|
-
}
|
|
442
|
-
changed = False
|
|
443
|
-
for srv, sub in name_map.items():
|
|
444
|
-
# 共识:kg 走云平台(默认 http://10.
|
|
445
|
-
if srv == "qoder-knowledge-graph":
|
|
446
|
-
_pm = "http://10.
|
|
447
|
-
try:
|
|
448
|
-
import yaml as _y
|
|
449
|
-
_cfg_p = get_repo_root() / ".qoder" / "config.yaml"
|
|
450
|
-
if _cfg_p.is_file():
|
|
451
|
-
_cfg = _y.safe_load(_cfg_p.read_text(encoding="utf-8")) or {}
|
|
452
|
-
_pm = (_cfg.get("platform_mcp") or {}).get("url", "") or _pm
|
|
453
|
-
except Exception:
|
|
454
|
-
pass
|
|
455
|
-
servers[srv] = {"url": _pm, "type": "sse", "enabled": True}
|
|
456
|
-
changed = True
|
|
457
|
-
continue
|
|
458
|
-
entry = servers.get(srv)
|
|
459
|
-
# 全新机器: kg/mysql/lanhu 条目可能不存在 → 创建它 (launcher 模式)
|
|
460
|
-
if not entry:
|
|
461
|
-
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
462
|
-
changed = True
|
|
463
|
-
continue
|
|
464
|
-
# 已是 launcher 模式? 检查 command 是否需要更新 (Python 路径可能变了)
|
|
465
|
-
args = entry.get("args", []) if isinstance(entry, dict) else []
|
|
466
|
-
if args and any("mcp_launcher.py" in str(a) for a in args):
|
|
467
|
-
# launcher 模式, 但 command 可能指向旧的/错的 Python → 更新
|
|
468
|
-
old_cmd = entry.get("command", "") if isinstance(entry, dict) else ""
|
|
469
|
-
if old_cmd != py_cmd:
|
|
470
|
-
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
471
|
-
changed = True
|
|
472
|
-
# 补 enabled (旧版可能没写, 导致黄灯)
|
|
473
|
-
if isinstance(entry, dict) and not entry.get("enabled"):
|
|
474
|
-
entry["enabled"] = True
|
|
475
|
-
changed = True
|
|
476
|
-
continue
|
|
477
|
-
# mysql env 抽离到独立文件
|
|
478
|
-
if srv == "qoder-mysql" and isinstance(entry, dict) and entry.get("env"):
|
|
479
|
-
env_file = _HOME / ".qoderwork" / ".mcp-mysql-env.json"
|
|
480
|
-
try:
|
|
481
|
-
env_file.write_text(json.dumps(entry["env"], indent=2, ensure_ascii=False) + "\n",
|
|
482
|
-
encoding="utf-8")
|
|
483
|
-
except OSError:
|
|
484
|
-
pass
|
|
485
|
-
# 改成 launcher 模式
|
|
486
|
-
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
487
|
-
changed = True
|
|
488
|
-
|
|
489
|
-
if changed:
|
|
490
|
-
cfg["mcpServers"] = servers
|
|
491
|
-
try:
|
|
492
|
-
QODERWORK_MCP_FILE.write_text(
|
|
493
|
-
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
494
|
-
print(f"\n--- mcp.json 重写为 launcher 模式 ---")
|
|
495
|
-
print(f" [UPD] kg/mysql/zentao/lanhu → command={py_cmd} (命令名, 跨机器通用)")
|
|
496
|
-
print(f" args=[mcp_launcher.py, <名>] (launcher 内部找装依赖的 Python)")
|
|
497
|
-
print(f" 不再写死绝对路径, 换机器/重装Python不会失效")
|
|
498
|
-
except OSError as e:
|
|
499
|
-
print(f" [ERR] mcp.json 重写失败: {e}")
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
def main():
|
|
503
|
-
parser = argparse.ArgumentParser(
|
|
504
|
-
description="把 .qoder/skills/ 安装到 QoderWork 桌面端目录"
|
|
505
|
-
)
|
|
506
|
-
parser.add_argument("--uninstall", action="store_true",
|
|
507
|
-
help="删除所有 junction(不删源)")
|
|
508
|
-
parser.add_argument("--check", action="store_true",
|
|
509
|
-
help="仅检查状态,不改动")
|
|
510
|
-
parser.add_argument("--copy", action="store_true",
|
|
511
|
-
help="用拷贝代替 junction(非 Windows 或不想软链时用)")
|
|
512
|
-
parser.add_argument("--force-commands", action="store_true",
|
|
513
|
-
help="强制覆盖已存在的 command 文件(升级时用;默认已存在则跳过)")
|
|
514
|
-
parser.add_argument("--mcp-only", action="store_true",
|
|
515
|
-
help="只刷新 launcher + mcp.json,不装 skill(update 时调)")
|
|
516
|
-
args = parser.parse_args()
|
|
517
|
-
|
|
518
|
-
# --mcp-only: 轻量模式, 只刷 launcher + mcp.json, 不装 skill
|
|
519
|
-
if args.mcp_only:
|
|
520
|
-
print("=" * 56)
|
|
521
|
-
print("MCP 配置刷新 (--mcp-only)")
|
|
522
|
-
print("=" * 56)
|
|
523
|
-
# 1. 复制 launcher
|
|
524
|
-
launcher_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "protocol" / "mcp" / "mcp_launcher.py" # v3.0: protocol/mcp子包
|
|
525
|
-
launcher_dst = _HOME / ".qoderwork" / "mcp_launcher.py"
|
|
526
|
-
try:
|
|
527
|
-
launcher_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
-
if launcher_src.is_file():
|
|
529
|
-
import shutil as _sh
|
|
530
|
-
_sh.copy2(str(launcher_src), str(launcher_dst))
|
|
531
|
-
print(f" [OK] launcher 已刷新: {launcher_dst}")
|
|
532
|
-
else:
|
|
533
|
-
print(f" [ERR] 源不存在: {launcher_src}")
|
|
534
|
-
except OSError as e:
|
|
535
|
-
print(f" [ERR] 复制失败: {e}")
|
|
536
|
-
# 2. 重写 mcp.json 为 launcher 模式 (创建 kg/mysql/lanhu)
|
|
537
|
-
rewrite_mcp_json_for_launcher()
|
|
538
|
-
# 3. 补 playwright (rewrite 不含它)
|
|
539
|
-
try:
|
|
540
|
-
setup_playwright_mcp(dry=False)
|
|
541
|
-
except Exception:
|
|
542
|
-
pass
|
|
543
|
-
# 4. 写 .repo-root 锚点 (launcher 定位仓库根用)
|
|
544
|
-
# 防御: 二次验证 PROJECT_ROOT 是"真仓库" (有 workspace/data/.git),
|
|
545
|
-
# 防止测试在临时目录跑 install 污染全局 .repo-root。
|
|
546
|
-
is_real_repo = any((PROJECT_ROOT / m).exists() for m in ('workspace', 'data', '.git'))
|
|
547
|
-
anchor = _HOME / ".qoderwork" / ".repo-root"
|
|
548
|
-
try:
|
|
549
|
-
anchor.parent.mkdir(parents=True, exist_ok=True)
|
|
550
|
-
if is_real_repo:
|
|
551
|
-
anchor.write_text(str(PROJECT_ROOT), encoding="utf-8")
|
|
552
|
-
print(f" [OK] .repo-root 锚点: {anchor} -> {PROJECT_ROOT}")
|
|
553
|
-
else:
|
|
554
|
-
print(f" [SKIP] .repo-root 未更新 ({PROJECT_ROOT} 非真仓库, 缺 workspace/data/.git)")
|
|
555
|
-
except OSError:
|
|
556
|
-
pass
|
|
557
|
-
# 5. 复制 wlkj 薄壳 (QoderWork 桌面端任意 cwd 直跑 wlkj, 不用每次先算 R)
|
|
558
|
-
# 治"相对路径在会话临时 cwd 失效"踩坑 (真实: 连续 2 条命令报错浪费一轮)
|
|
559
|
-
shim_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "deployment" / "setup" / "wlkj_shim.py"
|
|
560
|
-
shim_dst = _HOME / ".qoderwork" / "wlkj.py"
|
|
561
|
-
try:
|
|
562
|
-
if shim_src.is_file():
|
|
563
|
-
import shutil as _sh2
|
|
564
|
-
_sh2.copy2(str(shim_src), str(shim_dst))
|
|
565
|
-
print(f" [OK] wlkj 薄壳: {shim_dst} (任意 cwd 可跑: python ~/.qoderwork/wlkj.py ...)")
|
|
566
|
-
except OSError as e:
|
|
567
|
-
print(f" [ERR] wlkj 薄壳复制失败: {e}")
|
|
568
|
-
return
|
|
569
|
-
|
|
570
|
-
print("=" * 56)
|
|
571
|
-
print("QoderWork 技能安装器")
|
|
572
|
-
print(f" 源: {SOURCE_SKILLS_DIR}")
|
|
573
|
-
print(f" 目标: {QODERWORK_SKILLS_DIR}")
|
|
574
|
-
print("=" * 56)
|
|
575
|
-
|
|
576
|
-
# 迁移清理: 旧版本错装到了 ~/.qoderworkcn/, 清理掉避免混淆
|
|
577
|
-
if not args.check and LEGACY_WRONG_DIR.exists():
|
|
578
|
-
legacy_junctions = []
|
|
579
|
-
try:
|
|
580
|
-
for child in LEGACY_WRONG_DIR.iterdir():
|
|
581
|
-
if is_junction(child):
|
|
582
|
-
legacy_junctions.append(child.name)
|
|
583
|
-
except OSError:
|
|
584
|
-
pass
|
|
585
|
-
if legacy_junctions:
|
|
586
|
-
print(f"\n[迁移] 发现旧版本错装在 {LEGACY_WRONG_DIR} ({len(legacy_junctions)} 个)")
|
|
587
|
-
print(f" QoderWork 实际读的是 ~/.qoderwork/, 正在清理旧 junction...")
|
|
588
|
-
for name in legacy_junctions:
|
|
589
|
-
try:
|
|
590
|
-
subprocess.run(["cmd", "/c", "rmdir", str(LEGACY_WRONG_DIR / name)],
|
|
591
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
592
|
-
except (OSError, subprocess.SubprocessError):
|
|
593
|
-
pass
|
|
594
|
-
print(f" 已清理 {len(legacy_junctions)} 个旧 junction")
|
|
595
|
-
# 若旧目录空了, 删掉
|
|
596
|
-
try:
|
|
597
|
-
remaining = list(LEGACY_WRONG_DIR.iterdir())
|
|
598
|
-
if not remaining and LEGACY_WRONG_DIR.parent.exists():
|
|
599
|
-
LEGACY_WRONG_DIR.rmdir()
|
|
600
|
-
print(f" 已删除空目录 {LEGACY_WRONG_DIR.parent}")
|
|
601
|
-
except OSError:
|
|
602
|
-
pass
|
|
603
|
-
|
|
604
|
-
sources = find_source_skills()
|
|
605
|
-
if not sources:
|
|
606
|
-
print(f"[ERR] 源目录无 skill:{SOURCE_SKILLS_DIR}")
|
|
607
|
-
print(" 检查 .qoder/skills/*/SKILL.md 是否存在")
|
|
608
|
-
return 1
|
|
609
|
-
|
|
610
|
-
mode = "copy" if args.copy else "junction"
|
|
611
|
-
action = "check" if args.check else ("uninstall" if args.uninstall else "install")
|
|
612
|
-
print(f"\n模式: {action} / link={mode},发现 {len(sources)} 个源 skill\n")
|
|
613
|
-
|
|
614
|
-
results = {}
|
|
615
|
-
for name, src in sources:
|
|
616
|
-
if action == "uninstall":
|
|
617
|
-
results[name] = uninstall_one(name)
|
|
618
|
-
elif action == "check":
|
|
619
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
620
|
-
if link.exists() or link.is_symlink():
|
|
621
|
-
results[name] = "ok(junction)" if is_junction(link) else "WARN(非软链)"
|
|
622
|
-
else:
|
|
623
|
-
results[name] = "missing"
|
|
624
|
-
else: # install
|
|
625
|
-
results[name] = install_one(name, src, mode, dry=False)
|
|
626
|
-
|
|
627
|
-
# 打印结果
|
|
628
|
-
print("-" * 56)
|
|
629
|
-
counts = {"ok": 0, "create": 0, "copy": 0, "warn": 0, "err": 0, "missing": 0}
|
|
630
|
-
for name, status in sorted(results.items()):
|
|
631
|
-
tag = ""
|
|
632
|
-
if status.startswith("ok") or status == "copied" or status.startswith("created"):
|
|
633
|
-
if status.startswith("created") or status == "copied":
|
|
634
|
-
counts["create"] += 1
|
|
635
|
-
tag = "[NEW]"
|
|
636
|
-
else:
|
|
637
|
-
counts["ok"] += 1
|
|
638
|
-
tag = "[OK]"
|
|
639
|
-
elif status.startswith("skip"):
|
|
640
|
-
counts["warn"] += 1
|
|
641
|
-
tag = "[WARN]"
|
|
642
|
-
elif status.startswith("error"):
|
|
643
|
-
counts["err"] += 1
|
|
644
|
-
tag = "[ERR]"
|
|
645
|
-
elif status == "missing":
|
|
646
|
-
counts["missing"] += 1
|
|
647
|
-
tag = "[MISS]"
|
|
648
|
-
elif status == "removed":
|
|
649
|
-
counts["ok"] += 1
|
|
650
|
-
tag = "[DEL]"
|
|
651
|
-
elif status.startswith("WARN"):
|
|
652
|
-
counts["warn"] += 1
|
|
653
|
-
tag = "[WARN]"
|
|
654
|
-
else:
|
|
655
|
-
counts["ok"] += 1
|
|
656
|
-
tag = "[OK]"
|
|
657
|
-
print(f" {tag:6} {name:22} {status}")
|
|
658
|
-
|
|
659
|
-
print("-" * 56)
|
|
660
|
-
print(f"\n小结: 新建 {counts['create']} / 已存在 {counts['ok']} / "
|
|
661
|
-
f"缺失 {counts['missing']} / 警告 {counts['warn']} / 错误 {counts['err']}")
|
|
662
|
-
|
|
663
|
-
# 同时安装 commands (让 QoderWork 用户级也能看到 /wl-* 命令)
|
|
664
|
-
if action in ("install", "check") and SOURCE_COMMANDS_DIR.is_dir():
|
|
665
|
-
print("\n--- Commands (/wl-* 斜杠命令) ---")
|
|
666
|
-
cmd_count = {"ok": 0, "new": 0, "upd": 0}
|
|
667
|
-
for cmd_file in sorted(SOURCE_COMMANDS_DIR.rglob("*.md")):
|
|
668
|
-
name = cmd_file.name # e.g. wl-prd.md (子目录归类的也平铺到目标)
|
|
669
|
-
target = QODERWORK_COMMANDS_DIR / name
|
|
670
|
-
if action == "check":
|
|
671
|
-
if target.exists():
|
|
672
|
-
cmd_count["ok"] += 1
|
|
673
|
-
else:
|
|
674
|
-
print(f" [MISS] {name}")
|
|
675
|
-
else: # install
|
|
676
|
-
need_copy = False
|
|
677
|
-
is_update = False
|
|
678
|
-
if not target.exists():
|
|
679
|
-
need_copy = True
|
|
680
|
-
else:
|
|
681
|
-
# 始终按内容同步(幂等): 不同就刷新。
|
|
682
|
-
# command 是纯文本小文件, 内容不同说明源已升级, 必须覆盖,
|
|
683
|
-
# 否则 QoderWork 用旧 command → 走错流程(如旧 wl-test 搜代码)。
|
|
684
|
-
if not file_equal(cmd_file, target):
|
|
685
|
-
need_copy = True
|
|
686
|
-
is_update = True
|
|
687
|
-
if need_copy:
|
|
688
|
-
try:
|
|
689
|
-
QODERWORK_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
690
|
-
shutil.copy2(str(cmd_file), str(target))
|
|
691
|
-
if is_update:
|
|
692
|
-
cmd_count["upd"] += 1
|
|
693
|
-
else:
|
|
694
|
-
cmd_count["new"] += 1
|
|
695
|
-
except OSError as e:
|
|
696
|
-
print(f" [ERR] {name}: {e}")
|
|
697
|
-
else:
|
|
698
|
-
cmd_count["ok"] += 1
|
|
699
|
-
msg = f" commands: {cmd_count['new']} 新建 / {cmd_count['ok']} 已存在"
|
|
700
|
-
if cmd_count["upd"]:
|
|
701
|
-
msg += f" / {cmd_count['upd']} 刷新"
|
|
702
|
-
print(msg)
|
|
703
|
-
|
|
704
|
-
# === 2.5. repo_root.py (QoderWork 桌面端仓库根定位器) ===
|
|
705
|
-
# QoderWork 桌面端工作目录是临时会话目录, 不是仓库根, 相对路径失效。
|
|
706
|
-
# repo_root.py 从 mcp.json 反推仓库根, 命令文件靠它定位脚本/模板。
|
|
707
|
-
if action in ("install", "check"):
|
|
708
|
-
repo_root_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "deployment" / "setup" / "repo_root.py" # v3.0: deployment/setup子包
|
|
709
|
-
repo_root_dst = _HOME / ".qoderwork" / "repo_root.py"
|
|
710
|
-
if repo_root_src.is_file():
|
|
711
|
-
if action == "check":
|
|
712
|
-
if repo_root_dst.exists():
|
|
713
|
-
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
714
|
-
print(f" [OK] {repo_root_dst}")
|
|
715
|
-
else:
|
|
716
|
-
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
717
|
-
print(f" [MISS] 未安装到 {repo_root_dst}")
|
|
718
|
-
else:
|
|
719
|
-
try:
|
|
720
|
-
repo_root_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
-
shutil.copy2(str(repo_root_src), str(repo_root_dst))
|
|
722
|
-
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
723
|
-
print(f" [OK] 已安装: {repo_root_dst}")
|
|
724
|
-
except OSError as e:
|
|
725
|
-
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
726
|
-
print(f" [ERR] {e}")
|
|
727
|
-
|
|
728
|
-
# === 2.6. mcp_launcher.py + .repo-root (统一 MCP 启动器, 消除写死路径) ===
|
|
729
|
-
# mcp_launcher.py 让 mcp.json 不再写死仓库路径/venv路径, 跨 Win/Mac 通用。
|
|
730
|
-
# .repo-root 锚点文件让 launcher 能定位仓库根 (不依赖 mcp.json 里的路径)。
|
|
731
|
-
if action in ("install", "check"):
|
|
732
|
-
print(f"\n--- mcp_launcher.py (统一 MCP 启动器) ---")
|
|
733
|
-
launcher_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "protocol" / "mcp" / "mcp_launcher.py" # v3.0: protocol/mcp子包
|
|
734
|
-
launcher_dst = _HOME / ".qoderwork" / "mcp_launcher.py"
|
|
735
|
-
anchor_dst = _HOME / ".qoderwork" / ".repo-root"
|
|
736
|
-
if action == "check":
|
|
737
|
-
if launcher_dst.exists():
|
|
738
|
-
print(f" [OK] launcher 已装: {launcher_dst}")
|
|
739
|
-
else:
|
|
740
|
-
print(f" [MISS] launcher 未装")
|
|
741
|
-
if anchor_dst.exists():
|
|
742
|
-
print(f" [OK] .repo-root 锚点在: {anchor_dst}")
|
|
743
|
-
else:
|
|
744
|
-
print(f" [MISS] .repo-root 锚点缺失 (launcher 会回退向上找)")
|
|
745
|
-
else:
|
|
746
|
-
try:
|
|
747
|
-
launcher_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
748
|
-
if launcher_src.is_file():
|
|
749
|
-
shutil.copy2(str(launcher_src), str(launcher_dst))
|
|
750
|
-
print(f" [OK] launcher 已装: {launcher_dst}")
|
|
751
|
-
else:
|
|
752
|
-
print(f" [ERR] 源不存在: {launcher_src}")
|
|
753
|
-
# 写 .repo-root 锚点 (记录当前仓库根, launcher 读它定位)
|
|
754
|
-
anchor_dst.write_text(str(PROJECT_ROOT) + "\n", encoding="utf-8")
|
|
755
|
-
print(f" [OK] .repo-root 锚点已写: {PROJECT_ROOT}")
|
|
756
|
-
except OSError as e:
|
|
757
|
-
print(f" [ERR] {e}")
|
|
758
|
-
|
|
759
|
-
# === 2.7. 重写 mcp.json: 所有 python MCP 改 launcher 模式 (消除写死路径) ===
|
|
760
|
-
if action == "install":
|
|
761
|
-
rewrite_mcp_json_for_launcher()
|
|
762
|
-
|
|
763
|
-
# === 3. Playwright MCP (可选增强: 让 QoderWork 能用 Playwright 驱动浏览器) ===
|
|
764
|
-
if action in ("install", "check"):
|
|
765
|
-
print("\n--- Playwright MCP (浏览器自动化增强, 可选) ---")
|
|
766
|
-
pw_status = setup_playwright_mcp(dry=(action == "check"))
|
|
767
|
-
if pw_status == "ok-existing":
|
|
768
|
-
print(f" [OK] playwright MCP 已配置: {QODERWORK_MCP_FILE}")
|
|
769
|
-
elif pw_status == "added":
|
|
770
|
-
print(f" [NEW] 已写入 playwright MCP: {QODERWORK_MCP_FILE}")
|
|
771
|
-
print(" QoderWork 重启后, AI 可用 Playwright 工具驱动浏览器(快、稳)")
|
|
772
|
-
elif pw_status == "upgraded":
|
|
773
|
-
print(f" [FIX] playwright MCP 已升级: npx(慢,易超时) → node+cli.js(0.5s启动)")
|
|
774
|
-
print(" ⚠️ 必须 重启 QoderWork 生效。旧 npx 配置会被 QoderWork 超时杀掉导致工具注册失败")
|
|
775
|
-
elif pw_status == "would-upgrade":
|
|
776
|
-
print(f" [FIX] 检测到旧 npx 配置(慢), 安装时会升级为 node+cli.js")
|
|
777
|
-
elif pw_status == "would-add":
|
|
778
|
-
print(f" [MISS] 未配置 playwright MCP, 安装时会写入 {QODERWORK_MCP_FILE}")
|
|
779
|
-
# 检查 playwright mcp 是否已装(优先看 cli.js, 决定能否走快路径)
|
|
780
|
-
pw_cli = _find_playwright_mcp_cli()
|
|
781
|
-
if pw_cli:
|
|
782
|
-
print(f" 启动方式: node + cli.js (0.5s, 快) ← {pw_cli[1]}")
|
|
783
|
-
else:
|
|
784
|
-
npx_ok = shutil.which("npx") is not None
|
|
785
|
-
print(f" 启动方式: {'npx (11s冷启动, 可能被QoderWork超时杀)' if npx_ok else '✗ 未装 Node.js'}")
|
|
786
|
-
if npx_ok:
|
|
787
|
-
print(" 提示: 装 @playwright/mcp 全局后重跑本脚本, 会自动切换到快的 node 启动: npm i -g @playwright/mcp")
|
|
788
|
-
|
|
789
|
-
# === 4. 蓝湖 MCP (可选增强: 设计师发蓝湖链接, AI 直读设计稿) ===
|
|
790
|
-
if action in ("install", "check"):
|
|
791
|
-
print("\n--- 蓝湖 MCP (设计稿直读增强, 可选) ---")
|
|
792
|
-
lh_status = setup_lanhu_mcp(dry=(action == "check"))
|
|
793
|
-
if lh_status == "ok-existing":
|
|
794
|
-
print(f" [OK] 蓝湖 MCP 已注册: {QODERWORK_MCP_FILE}")
|
|
795
|
-
elif lh_status == "added":
|
|
796
|
-
print(f" [NEW] 已写入蓝湖 MCP (STDIO): {QODERWORK_MCP_FILE}")
|
|
797
|
-
print(" QoderWork 开自动起、关自动停; 命令内授权调 mcp__lanhu__*")
|
|
798
|
-
elif lh_status == "upgraded":
|
|
799
|
-
print(f" [UPD] 蓝湖 MCP 升级 HTTP→STDIO: {QODERWORK_MCP_FILE}")
|
|
800
|
-
print(" 重启 QoderWork 后自动开关, 无需手动 start")
|
|
801
|
-
elif lh_status == "would-add":
|
|
802
|
-
print(f" [MISS] 未注册蓝湖 MCP, 安装时会写入 {QODERWORK_MCP_FILE}")
|
|
803
|
-
# STDIO 模式由 QoderWork 自动管, 不查端口; 只确认 wrapper + 源码就绪
|
|
804
|
-
wrapper = PROJECT_ROOT / ".qoder" / "scripts" / "protocol" / "mcp" / "lanhu_stdio_wrapper.py"
|
|
805
|
-
lanhu_dir = PROJECT_ROOT / "external" / "lanhu-mcp"
|
|
806
|
-
if wrapper.is_file():
|
|
807
|
-
print(f" wrapper 就绪: {wrapper.name} (按角色读 cookie)")
|
|
808
|
-
else:
|
|
809
|
-
print(f" [MISS] wrapper 缺失: {wrapper}")
|
|
810
|
-
if not lanhu_dir.is_dir():
|
|
811
|
-
print(" 蓝湖源码: 未 clone (跑 setup.py 或 setup_lanhu.py 安装)")
|
|
812
|
-
|
|
813
|
-
if action == "install" and counts["err"] == 0:
|
|
814
|
-
print("\n✓ 安装完成。重启 QoderWork(或新建对话)后技能生效。")
|
|
815
|
-
print(" 在 QoderWork 里直接用自然语言即可,例如:")
|
|
816
|
-
print(' "写个保险异常筛选的 PRD" -> /wl-prd(正经需求, 完整档)')
|
|
817
|
-
print(' "列表加个导出按钮" -> /wl-prd(小改动, 自动走快速档)')
|
|
818
|
-
print(' "查一下考勤代码在哪" -> /wl-search')
|
|
819
|
-
elif action == "install" and counts["err"] > 0:
|
|
820
|
-
print(f"\n⚠ 有 {counts['err']} 个安装失败,见上方 [ERR] 行。")
|
|
821
|
-
print(" 常见原因: 目标路径被占用为非软链。手动删除该目录后重跑本脚本。")
|
|
822
|
-
return 1
|
|
823
|
-
elif action == "uninstall":
|
|
824
|
-
print("\n✓ 卸载完成(源文件未动)。")
|
|
825
|
-
elif action == "check":
|
|
826
|
-
if counts["missing"] > 0 or counts["warn"] > 0:
|
|
827
|
-
print("\n⚠ 检测到缺失/异常,建议跑: python .qoder/scripts/install_qoderwork.py")
|
|
828
|
-
return 1
|
|
829
|
-
else:
|
|
830
|
-
print("\n✓ 全部 skill 已正确安装到 QoderWork 目录。")
|
|
831
|
-
return 0
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
if __name__ == "__main__":
|
|
835
|
-
sys.exit(main())
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# v3.0 路径自举: 引导到 common/bootstrap, 统一 sys.path 逻辑
|
|
3
|
+
import os as _o, sys as _s
|
|
4
|
+
_f = _o.path.abspath(__file__)
|
|
5
|
+
for _ in range(10):
|
|
6
|
+
_f = _o.path.dirname(_f)
|
|
7
|
+
_cp = _o.path.join(_f, 'foundation')
|
|
8
|
+
if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
|
|
9
|
+
break
|
|
10
|
+
if _cp not in _s.path: _s.path.insert(0, _cp)
|
|
11
|
+
from bootstrap import setup; setup()
|
|
12
|
+
from foundation.core.paths import get_repo_root
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
install_qoderwork.py - 把 .qoder/skills/ 安装到 QoderWork 桌面端可识别的位置
|
|
16
|
+
|
|
17
|
+
QoderWork 从 %USERPROFILE%\\.qoderwork\\skills\\ 加载技能(每个子目录一个 skill),
|
|
18
|
+
从 %USERPROFILE%\\.qoderwork\\commands\\ 加载斜杠命令。
|
|
19
|
+
本项目源在 <repo>/.qoder/skills/ 和 <repo>/.qoder/commands/,QoderWork 不会读项目
|
|
20
|
+
目录,必须软链/拷过去。
|
|
21
|
+
(注: 旧版本误用 ~/.qoderworkcn/,已废弃,官方路径为 ~/.qoderwork/)
|
|
22
|
+
|
|
23
|
+
为什么用 junction(mklink /J)而不是 symlink(mklink /D):
|
|
24
|
+
- junction 不需要管理员权限,普通用户可建
|
|
25
|
+
- 本地评估默认启用,跨进程透明
|
|
26
|
+
- symlink 在未开开发者模式的 Windows 上需要 SeCreateSymbolicLink 特权
|
|
27
|
+
|
|
28
|
+
用法:
|
|
29
|
+
python .qoder/scripts/install_qoderwork.py # 安装/同步(幂等)
|
|
30
|
+
python .qoder/scripts/install_qoderwork.py --uninstall # 删除所有 junction(不删源)
|
|
31
|
+
python .qoder/scripts/install_qoderwork.py --check # 仅检查,不改动
|
|
32
|
+
python .qoder/scripts/install_qoderwork.py --copy # 改用拷贝(非 Windows 或不想软链)
|
|
33
|
+
|
|
34
|
+
幂等:已存在的 junction 会跳过;已存在的非 junction 目录会报错让人工处理。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
# 确保 UTF-8 输出(Windows 控制台默认 GBK)
|
|
46
|
+
if sys.platform == "win32":
|
|
47
|
+
try:
|
|
48
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
49
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
50
|
+
except (AttributeError, IOError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# 路径自检:项目根 = 本文件上四级 (v3.0: setup/ -> scripts/ -> .qoder/ -> repo)
|
|
54
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
55
|
+
PROJECT_ROOT = get_repo_root()
|
|
56
|
+
SOURCE_SKILLS_DIR = PROJECT_ROOT / ".qoder" / "skills"
|
|
57
|
+
SOURCE_COMMANDS_DIR = PROJECT_ROOT / ".qoder" / "commands"
|
|
58
|
+
|
|
59
|
+
# QoderWork 目标目录 (官方: ~/.qoderwork/skills/ 和 ~/.qoderwork/commands/)
|
|
60
|
+
# 注意: 之前误用了 ~/.qoderworkcn/ (国内版旧路径), 官方文档明确是 ~/.qoderwork/
|
|
61
|
+
# https://docs.qoder.com/zh/qoderwork/skills
|
|
62
|
+
if sys.platform == "win32":
|
|
63
|
+
_HOME = Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
64
|
+
else:
|
|
65
|
+
_HOME = Path.home()
|
|
66
|
+
QODERWORK_SKILLS_DIR = _HOME / ".qoderwork" / "skills"
|
|
67
|
+
QODERWORK_COMMANDS_DIR = _HOME / ".qoderwork" / "commands"
|
|
68
|
+
QODERWORK_MCP_FILE = _HOME / ".qoderwork" / "mcp.json"
|
|
69
|
+
|
|
70
|
+
# Playwright MCP 配置 (让 QoderWork 的 AI 能用 Playwright 工具驱动浏览器, 快且稳)
|
|
71
|
+
# 可选增强: 不装 playwright 也不影响 QoderWork 自带连接器
|
|
72
|
+
# ⚠️ 实测: npx @playwright/mcp@latest 冷启动 11.7s, QoderWork 会超时杀掉 → 工具注册失败
|
|
73
|
+
# → AI 瞎猜工具名(BrowserNavigate) → 全失败。
|
|
74
|
+
# 解法: 优先用"全局已装的 cli.js + node 直启"(0.57s), 探测不到才回退 npx。
|
|
75
|
+
def _find_playwright_mcp_cli():
|
|
76
|
+
"""探测全局已装的 @playwright/mcp/cli.js 路径, 返回 (node, cli.js) 或 None。
|
|
77
|
+
Windows 下 npm 是 .cmd, 必须 shell=True 才能调到。"""
|
|
78
|
+
import shutil, subprocess
|
|
79
|
+
node = shutil.which("node")
|
|
80
|
+
if not node:
|
|
81
|
+
return None
|
|
82
|
+
# npm root -g 拿全局 node_modules 路径 (最准, 跨自定义prefix)
|
|
83
|
+
try:
|
|
84
|
+
out = subprocess.run("npm root -g", capture_output=True, text=True,
|
|
85
|
+
shell=True, timeout=10)
|
|
86
|
+
root = out.stdout.strip()
|
|
87
|
+
if root:
|
|
88
|
+
cli = os.path.join(root, "@playwright", "mcp", "cli.js")
|
|
89
|
+
if os.path.isfile(cli):
|
|
90
|
+
return node, cli
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
# 回退: 试常见全局路径
|
|
94
|
+
for cand in [os.path.join(os.path.expanduser("~"), "AppData", "Roaming", "npm",
|
|
95
|
+
"node_modules", "@playwright", "mcp", "cli.js")]:
|
|
96
|
+
if os.path.isfile(cand):
|
|
97
|
+
return node, cand
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _playwright_mcp_entry():
|
|
102
|
+
"""生成 playwright MCP 配置: 优先 node+cli.js(快), 回退 npx(慢但通用)。"""
|
|
103
|
+
found = _find_playwright_mcp_cli()
|
|
104
|
+
if found:
|
|
105
|
+
node, cli = found
|
|
106
|
+
return {"playwright": {"command": node, "args": [cli]}}
|
|
107
|
+
# 回退: npx (冷启动慢, 可能被 QoderWork 超时杀, 但通用)
|
|
108
|
+
return {"playwright": {"command": "npx", "args": ["@playwright/mcp@latest"]}}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
PLAYWRIGHT_MCP_ENTRY = _playwright_mcp_entry()
|
|
112
|
+
|
|
113
|
+
# 旧的错误路径 (迁移清理用)
|
|
114
|
+
LEGACY_WRONG_DIR = _HOME / ".qoderworkcn" / "skills"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_junction(path: Path) -> bool:
|
|
118
|
+
"""判断目录是否是 junction/symlink(reparse point)。"""
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return False
|
|
121
|
+
# Windows: 用 dir 命令检测 <JUNCTION> 标记最可靠(无需 ctypes)
|
|
122
|
+
if sys.platform == "win32":
|
|
123
|
+
try:
|
|
124
|
+
# os.lstat: reparse point 的 FILE_ATTRIBUTE_REPARSE_POINT (0x400) 会体现在 st_file_attributes
|
|
125
|
+
import stat as _stat
|
|
126
|
+
st = path.lstat()
|
|
127
|
+
# FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
128
|
+
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
|
|
129
|
+
except (OSError, AttributeError):
|
|
130
|
+
return False
|
|
131
|
+
else:
|
|
132
|
+
return path.is_symlink()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def find_source_skills() -> list:
|
|
136
|
+
"""扫描 .qoder/skills/*/SKILL.md,返回 [(name, src_dir), ...]。"""
|
|
137
|
+
if not SOURCE_SKILLS_DIR.is_dir():
|
|
138
|
+
return []
|
|
139
|
+
result = []
|
|
140
|
+
for child in sorted(SOURCE_SKILLS_DIR.iterdir()):
|
|
141
|
+
if not child.is_dir():
|
|
142
|
+
continue
|
|
143
|
+
if (child / "SKILL.md").is_file():
|
|
144
|
+
result.append((child.name, child))
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def file_equal(a: Path, b: Path) -> bool:
|
|
149
|
+
"""快速判断两文件内容是否相同(先比大小再比内容,避免无谓全读)。"""
|
|
150
|
+
try:
|
|
151
|
+
if a.stat().st_size != b.stat().st_size:
|
|
152
|
+
return False
|
|
153
|
+
return a.read_bytes() == b.read_bytes()
|
|
154
|
+
except OSError:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_junction(link: Path, target: Path) -> bool:
|
|
159
|
+
"""用 mklink /J 创建 junction。返回是否成功。"""
|
|
160
|
+
# 用 errors="replace" 防止 Windows GBK 输出(如"为...创建的联接")触发 UnicodeDecodeError
|
|
161
|
+
cmd = ["cmd", "/c", "mklink", "/J", str(link), str(target)]
|
|
162
|
+
try:
|
|
163
|
+
r = subprocess.run(
|
|
164
|
+
cmd,
|
|
165
|
+
stdout=subprocess.PIPE,
|
|
166
|
+
stderr=subprocess.PIPE,
|
|
167
|
+
# 不用 text=True,自己 decode 防编码异常
|
|
168
|
+
)
|
|
169
|
+
return r.returncode == 0
|
|
170
|
+
except (OSError, subprocess.SubprocessError):
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def install_one(name: str, src: Path, mode: str, dry: bool = False) -> str:
|
|
175
|
+
"""
|
|
176
|
+
安装一个 skill 到 QoderWork 目录。
|
|
177
|
+
返回状态字符串:'created' / 'ok-existing' / 'copied' / 'error: ...' / 'skip: ...'
|
|
178
|
+
"""
|
|
179
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
180
|
+
if link.exists() or link.is_symlink():
|
|
181
|
+
if is_junction(link):
|
|
182
|
+
# 悬空 junction 检测: junction 存在但目标已移走/删除
|
|
183
|
+
# (常见于团队重命名 skill 后, 旧 junction 变成死链)
|
|
184
|
+
# link.exists() 对悬空 junction 返回 False, 但 link.is_symlink() 可能 True
|
|
185
|
+
# 用 os.path.exists 解析目标判断
|
|
186
|
+
try:
|
|
187
|
+
target_alive = os.path.exists(str(link))
|
|
188
|
+
except OSError:
|
|
189
|
+
target_alive = False
|
|
190
|
+
if target_alive:
|
|
191
|
+
return "ok-existing"
|
|
192
|
+
else:
|
|
193
|
+
# 悬空 junction, 删除后重建
|
|
194
|
+
if dry:
|
|
195
|
+
return "would-recreate (dangling)"
|
|
196
|
+
try:
|
|
197
|
+
if sys.platform == "win32":
|
|
198
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
199
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
200
|
+
else:
|
|
201
|
+
os.unlink(str(link))
|
|
202
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
203
|
+
return "error: 悬空 junction 无法删除 (手动 rmdir {}): {}".format(link, e)
|
|
204
|
+
# 落到下面 recreate 逻辑
|
|
205
|
+
else:
|
|
206
|
+
# 非 junction 的真实目录/文件 -> 不覆盖
|
|
207
|
+
return f"skip: {name} 已存在且不是软链(避免覆盖,请人工确认 {link})"
|
|
208
|
+
|
|
209
|
+
if dry:
|
|
210
|
+
return "would-create"
|
|
211
|
+
|
|
212
|
+
QODERWORK_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
if mode == "copy":
|
|
215
|
+
try:
|
|
216
|
+
shutil.copytree(src, link)
|
|
217
|
+
return "copied"
|
|
218
|
+
except (OSError, shutil.Error) as e:
|
|
219
|
+
return f"error: copy 失败 {e}"
|
|
220
|
+
else:
|
|
221
|
+
# junction 模式(默认)
|
|
222
|
+
if sys.platform != "win32":
|
|
223
|
+
# 非 Windows 退化为 symlink,失败再退化为 copy
|
|
224
|
+
try:
|
|
225
|
+
os.symlink(src, link, target_is_directory=True)
|
|
226
|
+
return "created(symlink)"
|
|
227
|
+
except (OSError, NotImplementedError):
|
|
228
|
+
try:
|
|
229
|
+
shutil.copytree(src, link)
|
|
230
|
+
return "copied(fallback)"
|
|
231
|
+
except (OSError, shutil.Error) as e:
|
|
232
|
+
return f"error: {e}"
|
|
233
|
+
if create_junction(link, src):
|
|
234
|
+
return "created"
|
|
235
|
+
return "error: mklink /J 失败(可能需要检查路径或权限)"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def uninstall_one(name: str) -> str:
|
|
239
|
+
"""删除一个 junction(只删 link,不删源)。"""
|
|
240
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
241
|
+
if not link.exists() and not link.is_symlink():
|
|
242
|
+
return "absent"
|
|
243
|
+
if not is_junction(link):
|
|
244
|
+
return f"skip: {name} 不是软链(不动真实目录,请人工删除 {link})"
|
|
245
|
+
try:
|
|
246
|
+
# 删 junction 用 rmdir(不递归到源)
|
|
247
|
+
if sys.platform == "win32":
|
|
248
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
249
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
250
|
+
else:
|
|
251
|
+
os.unlink(str(link))
|
|
252
|
+
return "removed"
|
|
253
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
254
|
+
return f"error: {e}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def setup_playwright_mcp(dry=False):
|
|
258
|
+
"""把 Playwright MCP 写进 ~/.qoderwork/mcp.json (可选增强, 不影响自带连接器)。
|
|
259
|
+
|
|
260
|
+
让 QoderWork 的 AI 能用 Playwright 工具(browser_navigate/click/type/screenshot)
|
|
261
|
+
驱动浏览器 —— 快(10s级 vs 自带连接器 5-8分钟)、稳(不折腾 tab)、不依赖扩展。
|
|
262
|
+
已有 playwright 配置则跳过(幂等); 没有则追加。
|
|
263
|
+
"""
|
|
264
|
+
import json
|
|
265
|
+
mcp_path = QODERWORK_MCP_FILE
|
|
266
|
+
# 读现有配置
|
|
267
|
+
existing = {}
|
|
268
|
+
if mcp_path.is_file():
|
|
269
|
+
try:
|
|
270
|
+
existing = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
271
|
+
if not isinstance(existing, dict):
|
|
272
|
+
existing = {}
|
|
273
|
+
except (json.JSONDecodeError, OSError):
|
|
274
|
+
existing = {}
|
|
275
|
+
servers = existing.get("mcpServers", {})
|
|
276
|
+
if not isinstance(servers, dict):
|
|
277
|
+
servers = {}
|
|
278
|
+
if "playwright" in servers:
|
|
279
|
+
# 已有配置: 若是旧的 npx@latest(慢, 易超时失败), 升级成 node+cli.js(快)
|
|
280
|
+
cur = servers["playwright"]
|
|
281
|
+
if cur.get("command") == "npx":
|
|
282
|
+
new_entry = PLAYWRIGHT_MCP_ENTRY["playwright"]
|
|
283
|
+
if new_entry.get("command") != "npx": # 探测到了 cli.js
|
|
284
|
+
if dry:
|
|
285
|
+
return "would-upgrade"
|
|
286
|
+
servers["playwright"] = new_entry
|
|
287
|
+
existing["mcpServers"] = servers
|
|
288
|
+
mcp_path.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
289
|
+
return "upgraded"
|
|
290
|
+
return "ok-existing"
|
|
291
|
+
if dry:
|
|
292
|
+
return "would-add"
|
|
293
|
+
# 追加 playwright
|
|
294
|
+
servers["playwright"] = PLAYWRIGHT_MCP_ENTRY["playwright"]
|
|
295
|
+
existing["mcpServers"] = servers
|
|
296
|
+
mcp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
mcp_path.write_text(
|
|
298
|
+
json.dumps(existing, indent=2, ensure_ascii=False) + "\n",
|
|
299
|
+
encoding="utf-8",
|
|
300
|
+
)
|
|
301
|
+
return "added"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def setup_lanhu_mcp(dry=False):
|
|
305
|
+
"""注册蓝湖 MCP 到 ~/.qoderwork/mcp.json (可选增强, 设计稿直读)。
|
|
306
|
+
|
|
307
|
+
蓝湖 MCP (dsphper/lanhu-mcp) 是第三方 HTTP server, 让设计师发蓝湖链接,
|
|
308
|
+
AI 直读设计稿(颜色/尺寸/字体/切图)。注册成 url 模式 (Streamable HTTP),
|
|
309
|
+
跟 playwright/kg/mysql 这些 stdio server 并列。
|
|
310
|
+
|
|
311
|
+
注意: 蓝湖是 HTTP server, 需要用户手动启动
|
|
312
|
+
python .qoder/scripts/setup_lanhu.py start
|
|
313
|
+
(启动脚本自动读当前开发者的 cookie 注入, 不用手动配 .env)
|
|
314
|
+
本函数只注册 url (幂等), 不负责 clone/装依赖/启动 —— 那些由 setup_lanhu.py
|
|
315
|
+
在 setup.py Step 4.5 完成。这里只做 mcp.json 条目同步, 保证团队所有人
|
|
316
|
+
跑 install_qoderwork 后都能看到蓝湖 server。
|
|
317
|
+
|
|
318
|
+
返回: 'ok-existing' / 'added' / 'would-add'
|
|
319
|
+
"""
|
|
320
|
+
# 复用 setup_lanhu.py 的注册逻辑 (单一事实源)
|
|
321
|
+
try:
|
|
322
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
323
|
+
from setup_lanhu import register_lanhu_mcp
|
|
324
|
+
except ImportError:
|
|
325
|
+
return 'would-add'
|
|
326
|
+
return register_lanhu_mcp(developer_name='Developer', dry=dry)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def detect_python_command():
|
|
330
|
+
"""探测本机可用的 python 命令名 (跨平台), 用于写进 mcp.json 的 command 字段。
|
|
331
|
+
|
|
332
|
+
⚠️ 关键约束: 返回值必须是单个可执行文件名/路径, 不能含空格或参数。
|
|
333
|
+
旧版返回 'py -3' (带参数), MCP 客户端 spawn('py -3', args) 会把
|
|
334
|
+
'py -3' 当字面文件名 → 找不到可执行文件 → MCP 显示黄色(连接失败)。
|
|
335
|
+
|
|
336
|
+
优先级 (Windows): python > py > python3
|
|
337
|
+
- 故意把 python 放 py 前面: 'python' 是单文件名, 最稳;
|
|
338
|
+
'py' 需要配合 -3 参数, 但我们返回纯 'py' (不带 -3) 也通常能用
|
|
339
|
+
(py 启动器会选默认版本)。
|
|
340
|
+
优先级 (mac/linux): python3 > python
|
|
341
|
+
|
|
342
|
+
返回: 命令名字符串 (如 'python' / 'py' / 'python3'), 失败返回 None。
|
|
343
|
+
"""
|
|
344
|
+
if sys.platform == 'win32':
|
|
345
|
+
candidates = ('python', 'py', 'python3')
|
|
346
|
+
else:
|
|
347
|
+
candidates = ('python3', 'python')
|
|
348
|
+
for name in candidates:
|
|
349
|
+
if not shutil.which(name):
|
|
350
|
+
continue
|
|
351
|
+
try:
|
|
352
|
+
r = subprocess.run([name, '--version'], capture_output=True, text=True,
|
|
353
|
+
encoding='utf-8', errors='replace')
|
|
354
|
+
if r.returncode == 0:
|
|
355
|
+
return name # 纯命令名, 不带参数
|
|
356
|
+
except (OSError, subprocess.SubprocessProcessError):
|
|
357
|
+
continue
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def find_python_with_core_deps():
|
|
362
|
+
"""找一个装了核心依赖 (duckdb/pymysql/yaml/requests) 的 Python 可执行文件。
|
|
363
|
+
|
|
364
|
+
用于写 mcp.json: 如果某个 Python 已经装了核心依赖, 直接用它当 command,
|
|
365
|
+
launcher 启动后 _find_python_with_deps 会秒命中 sys.executable, 不用再遍历。
|
|
366
|
+
解决"多 Python 共存, 装到了 A 但 mcp.json 指向 B"的常见问题。
|
|
367
|
+
|
|
368
|
+
返回: python 可执行文件路径(str) 或 None(没找到装了依赖的)。
|
|
369
|
+
"""
|
|
370
|
+
# 先测当前 Python (install_qoderwork 正在跑的这个)
|
|
371
|
+
for mod in ('duckdb', 'pymysql', 'yaml', 'requests'):
|
|
372
|
+
try:
|
|
373
|
+
__import__(mod)
|
|
374
|
+
except ImportError:
|
|
375
|
+
break
|
|
376
|
+
else:
|
|
377
|
+
# 当前 Python 四个核心依赖都有 → 用它
|
|
378
|
+
return sys.executable
|
|
379
|
+
|
|
380
|
+
# 当前 Python 没装全, 找其它 Python
|
|
381
|
+
for name in ('python', 'python3', 'py'):
|
|
382
|
+
p = shutil.which(name)
|
|
383
|
+
if not p or p == sys.executable:
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
r = subprocess.run(
|
|
387
|
+
[p, '-c', 'import duckdb, pymysql, yaml, requests'],
|
|
388
|
+
capture_output=True, timeout=8)
|
|
389
|
+
if r.returncode == 0:
|
|
390
|
+
return p
|
|
391
|
+
except (OSError, subprocess.SubprocessProcessError):
|
|
392
|
+
continue
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def rewrite_mcp_json_for_launcher():
|
|
397
|
+
"""把 mcp.json 里所有 python MCP (kg/mysql/lanhu) 改成 launcher 模式。
|
|
398
|
+
|
|
399
|
+
★ kg 走云平台 SSE url(共识),不走 launcher——在 for srv 循环已配 SSE,
|
|
400
|
+
这里跳过 kg(不改 launcher),保住 SSE 配置。
|
|
401
|
+
|
|
402
|
+
旧 (写死路径): {"command": "E:\\...\\python.exe", "args": ["E:\\...\\server.py"]}
|
|
403
|
+
新 (launcher): {"command": "<py cmd>", "args": ["<home>/.qoderwork/mcp_launcher.py", "<name>"]}
|
|
404
|
+
|
|
405
|
+
幂等: 已是 launcher 模式 (args 含 mcp_launcher.py) 则跳过。
|
|
406
|
+
全新机器: mcp.json 不存在时创建空骨架 + 写入 kg/mysql/lanhu 条目。
|
|
407
|
+
mysql env 抽到 ~/.qoderwork/.mcp-mysql-env.json (launcher 读)。
|
|
408
|
+
"""
|
|
409
|
+
# 全新机器: mcp.json 不存在 → 创建空骨架 (playwright 后续会追加)
|
|
410
|
+
if not QODERWORK_MCP_FILE.is_file():
|
|
411
|
+
QODERWORK_MCP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
QODERWORK_MCP_FILE.write_text(
|
|
413
|
+
'{"mcpServers": {}}\n', encoding="utf-8")
|
|
414
|
+
try:
|
|
415
|
+
cfg = json.loads(QODERWORK_MCP_FILE.read_text(encoding="utf-8"))
|
|
416
|
+
except (json.JSONDecodeError, OSError):
|
|
417
|
+
return
|
|
418
|
+
servers = cfg.get("mcpServers", {})
|
|
419
|
+
if not isinstance(servers, dict):
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
# 选 Python 写进 mcp.json:
|
|
423
|
+
# v3.0 修复: 用装了核心依赖的 Python 绝对路径 (不用命令名)!
|
|
424
|
+
# 原因: QoderWork 桌面应用不继承 shell PATH, 命令名(python/py -3)找不到 → 黄灯。
|
|
425
|
+
# 绝对路径每次 install 时探测, 换机器重跑 update 自动刷新。
|
|
426
|
+
# 依赖查找: 先找装了 duckdb 的 python (find_python_with_core_deps),
|
|
427
|
+
# 找不到回退 detect_python_command。
|
|
428
|
+
# ⚠ find_python_with_core_deps 返回 None (不抛异常) 时, 旧代码因 try/except
|
|
429
|
+
# 捕不到 → py_cmd 留 None → mcp.json 写 "command": null → QoderWork 起 MCP
|
|
430
|
+
# 失败变黄。这里显式 None 判断 + 多级回退, 绝不写 null。
|
|
431
|
+
py_cmd = find_python_with_core_deps()
|
|
432
|
+
if not py_cmd:
|
|
433
|
+
py_cmd = detect_python_command() or sys.executable or "python"
|
|
434
|
+
launcher = str(_HOME / ".qoderwork" / "mcp_launcher.py")
|
|
435
|
+
# MCP 名 → launcher 子命令
|
|
436
|
+
name_map = {
|
|
437
|
+
"qoder-knowledge-graph": "kg",
|
|
438
|
+
"qoder-mysql": "mysql",
|
|
439
|
+
"qoder-zentao": "zentao",
|
|
440
|
+
"lanhu": "lanhu",
|
|
441
|
+
}
|
|
442
|
+
changed = False
|
|
443
|
+
for srv, sub in name_map.items():
|
|
444
|
+
# 共识:kg 走云平台(默认 http://10.87.106.252/mcp,config platform_mcp.url 可覆盖)
|
|
445
|
+
if srv == "qoder-knowledge-graph":
|
|
446
|
+
_pm = "http://10.87.106.252/mcp"
|
|
447
|
+
try:
|
|
448
|
+
import yaml as _y
|
|
449
|
+
_cfg_p = get_repo_root() / ".qoder" / "config.yaml"
|
|
450
|
+
if _cfg_p.is_file():
|
|
451
|
+
_cfg = _y.safe_load(_cfg_p.read_text(encoding="utf-8")) or {}
|
|
452
|
+
_pm = (_cfg.get("platform_mcp") or {}).get("url", "") or _pm
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
servers[srv] = {"url": _pm, "type": "sse", "enabled": True}
|
|
456
|
+
changed = True
|
|
457
|
+
continue
|
|
458
|
+
entry = servers.get(srv)
|
|
459
|
+
# 全新机器: kg/mysql/lanhu 条目可能不存在 → 创建它 (launcher 模式)
|
|
460
|
+
if not entry:
|
|
461
|
+
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
462
|
+
changed = True
|
|
463
|
+
continue
|
|
464
|
+
# 已是 launcher 模式? 检查 command 是否需要更新 (Python 路径可能变了)
|
|
465
|
+
args = entry.get("args", []) if isinstance(entry, dict) else []
|
|
466
|
+
if args and any("mcp_launcher.py" in str(a) for a in args):
|
|
467
|
+
# launcher 模式, 但 command 可能指向旧的/错的 Python → 更新
|
|
468
|
+
old_cmd = entry.get("command", "") if isinstance(entry, dict) else ""
|
|
469
|
+
if old_cmd != py_cmd:
|
|
470
|
+
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
471
|
+
changed = True
|
|
472
|
+
# 补 enabled (旧版可能没写, 导致黄灯)
|
|
473
|
+
if isinstance(entry, dict) and not entry.get("enabled"):
|
|
474
|
+
entry["enabled"] = True
|
|
475
|
+
changed = True
|
|
476
|
+
continue
|
|
477
|
+
# mysql env 抽离到独立文件
|
|
478
|
+
if srv == "qoder-mysql" and isinstance(entry, dict) and entry.get("env"):
|
|
479
|
+
env_file = _HOME / ".qoderwork" / ".mcp-mysql-env.json"
|
|
480
|
+
try:
|
|
481
|
+
env_file.write_text(json.dumps(entry["env"], indent=2, ensure_ascii=False) + "\n",
|
|
482
|
+
encoding="utf-8")
|
|
483
|
+
except OSError:
|
|
484
|
+
pass
|
|
485
|
+
# 改成 launcher 模式
|
|
486
|
+
servers[srv] = {"command": py_cmd, "args": [launcher, sub], "enabled": True}
|
|
487
|
+
changed = True
|
|
488
|
+
|
|
489
|
+
if changed:
|
|
490
|
+
cfg["mcpServers"] = servers
|
|
491
|
+
try:
|
|
492
|
+
QODERWORK_MCP_FILE.write_text(
|
|
493
|
+
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
494
|
+
print(f"\n--- mcp.json 重写为 launcher 模式 ---")
|
|
495
|
+
print(f" [UPD] kg/mysql/zentao/lanhu → command={py_cmd} (命令名, 跨机器通用)")
|
|
496
|
+
print(f" args=[mcp_launcher.py, <名>] (launcher 内部找装依赖的 Python)")
|
|
497
|
+
print(f" 不再写死绝对路径, 换机器/重装Python不会失效")
|
|
498
|
+
except OSError as e:
|
|
499
|
+
print(f" [ERR] mcp.json 重写失败: {e}")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def main():
|
|
503
|
+
parser = argparse.ArgumentParser(
|
|
504
|
+
description="把 .qoder/skills/ 安装到 QoderWork 桌面端目录"
|
|
505
|
+
)
|
|
506
|
+
parser.add_argument("--uninstall", action="store_true",
|
|
507
|
+
help="删除所有 junction(不删源)")
|
|
508
|
+
parser.add_argument("--check", action="store_true",
|
|
509
|
+
help="仅检查状态,不改动")
|
|
510
|
+
parser.add_argument("--copy", action="store_true",
|
|
511
|
+
help="用拷贝代替 junction(非 Windows 或不想软链时用)")
|
|
512
|
+
parser.add_argument("--force-commands", action="store_true",
|
|
513
|
+
help="强制覆盖已存在的 command 文件(升级时用;默认已存在则跳过)")
|
|
514
|
+
parser.add_argument("--mcp-only", action="store_true",
|
|
515
|
+
help="只刷新 launcher + mcp.json,不装 skill(update 时调)")
|
|
516
|
+
args = parser.parse_args()
|
|
517
|
+
|
|
518
|
+
# --mcp-only: 轻量模式, 只刷 launcher + mcp.json, 不装 skill
|
|
519
|
+
if args.mcp_only:
|
|
520
|
+
print("=" * 56)
|
|
521
|
+
print("MCP 配置刷新 (--mcp-only)")
|
|
522
|
+
print("=" * 56)
|
|
523
|
+
# 1. 复制 launcher
|
|
524
|
+
launcher_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "protocol" / "mcp" / "mcp_launcher.py" # v3.0: protocol/mcp子包
|
|
525
|
+
launcher_dst = _HOME / ".qoderwork" / "mcp_launcher.py"
|
|
526
|
+
try:
|
|
527
|
+
launcher_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
if launcher_src.is_file():
|
|
529
|
+
import shutil as _sh
|
|
530
|
+
_sh.copy2(str(launcher_src), str(launcher_dst))
|
|
531
|
+
print(f" [OK] launcher 已刷新: {launcher_dst}")
|
|
532
|
+
else:
|
|
533
|
+
print(f" [ERR] 源不存在: {launcher_src}")
|
|
534
|
+
except OSError as e:
|
|
535
|
+
print(f" [ERR] 复制失败: {e}")
|
|
536
|
+
# 2. 重写 mcp.json 为 launcher 模式 (创建 kg/mysql/lanhu)
|
|
537
|
+
rewrite_mcp_json_for_launcher()
|
|
538
|
+
# 3. 补 playwright (rewrite 不含它)
|
|
539
|
+
try:
|
|
540
|
+
setup_playwright_mcp(dry=False)
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
# 4. 写 .repo-root 锚点 (launcher 定位仓库根用)
|
|
544
|
+
# 防御: 二次验证 PROJECT_ROOT 是"真仓库" (有 workspace/data/.git),
|
|
545
|
+
# 防止测试在临时目录跑 install 污染全局 .repo-root。
|
|
546
|
+
is_real_repo = any((PROJECT_ROOT / m).exists() for m in ('workspace', 'data', '.git'))
|
|
547
|
+
anchor = _HOME / ".qoderwork" / ".repo-root"
|
|
548
|
+
try:
|
|
549
|
+
anchor.parent.mkdir(parents=True, exist_ok=True)
|
|
550
|
+
if is_real_repo:
|
|
551
|
+
anchor.write_text(str(PROJECT_ROOT), encoding="utf-8")
|
|
552
|
+
print(f" [OK] .repo-root 锚点: {anchor} -> {PROJECT_ROOT}")
|
|
553
|
+
else:
|
|
554
|
+
print(f" [SKIP] .repo-root 未更新 ({PROJECT_ROOT} 非真仓库, 缺 workspace/data/.git)")
|
|
555
|
+
except OSError:
|
|
556
|
+
pass
|
|
557
|
+
# 5. 复制 wlkj 薄壳 (QoderWork 桌面端任意 cwd 直跑 wlkj, 不用每次先算 R)
|
|
558
|
+
# 治"相对路径在会话临时 cwd 失效"踩坑 (真实: 连续 2 条命令报错浪费一轮)
|
|
559
|
+
shim_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "deployment" / "setup" / "wlkj_shim.py"
|
|
560
|
+
shim_dst = _HOME / ".qoderwork" / "wlkj.py"
|
|
561
|
+
try:
|
|
562
|
+
if shim_src.is_file():
|
|
563
|
+
import shutil as _sh2
|
|
564
|
+
_sh2.copy2(str(shim_src), str(shim_dst))
|
|
565
|
+
print(f" [OK] wlkj 薄壳: {shim_dst} (任意 cwd 可跑: python ~/.qoderwork/wlkj.py ...)")
|
|
566
|
+
except OSError as e:
|
|
567
|
+
print(f" [ERR] wlkj 薄壳复制失败: {e}")
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
print("=" * 56)
|
|
571
|
+
print("QoderWork 技能安装器")
|
|
572
|
+
print(f" 源: {SOURCE_SKILLS_DIR}")
|
|
573
|
+
print(f" 目标: {QODERWORK_SKILLS_DIR}")
|
|
574
|
+
print("=" * 56)
|
|
575
|
+
|
|
576
|
+
# 迁移清理: 旧版本错装到了 ~/.qoderworkcn/, 清理掉避免混淆
|
|
577
|
+
if not args.check and LEGACY_WRONG_DIR.exists():
|
|
578
|
+
legacy_junctions = []
|
|
579
|
+
try:
|
|
580
|
+
for child in LEGACY_WRONG_DIR.iterdir():
|
|
581
|
+
if is_junction(child):
|
|
582
|
+
legacy_junctions.append(child.name)
|
|
583
|
+
except OSError:
|
|
584
|
+
pass
|
|
585
|
+
if legacy_junctions:
|
|
586
|
+
print(f"\n[迁移] 发现旧版本错装在 {LEGACY_WRONG_DIR} ({len(legacy_junctions)} 个)")
|
|
587
|
+
print(f" QoderWork 实际读的是 ~/.qoderwork/, 正在清理旧 junction...")
|
|
588
|
+
for name in legacy_junctions:
|
|
589
|
+
try:
|
|
590
|
+
subprocess.run(["cmd", "/c", "rmdir", str(LEGACY_WRONG_DIR / name)],
|
|
591
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
592
|
+
except (OSError, subprocess.SubprocessError):
|
|
593
|
+
pass
|
|
594
|
+
print(f" 已清理 {len(legacy_junctions)} 个旧 junction")
|
|
595
|
+
# 若旧目录空了, 删掉
|
|
596
|
+
try:
|
|
597
|
+
remaining = list(LEGACY_WRONG_DIR.iterdir())
|
|
598
|
+
if not remaining and LEGACY_WRONG_DIR.parent.exists():
|
|
599
|
+
LEGACY_WRONG_DIR.rmdir()
|
|
600
|
+
print(f" 已删除空目录 {LEGACY_WRONG_DIR.parent}")
|
|
601
|
+
except OSError:
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
sources = find_source_skills()
|
|
605
|
+
if not sources:
|
|
606
|
+
print(f"[ERR] 源目录无 skill:{SOURCE_SKILLS_DIR}")
|
|
607
|
+
print(" 检查 .qoder/skills/*/SKILL.md 是否存在")
|
|
608
|
+
return 1
|
|
609
|
+
|
|
610
|
+
mode = "copy" if args.copy else "junction"
|
|
611
|
+
action = "check" if args.check else ("uninstall" if args.uninstall else "install")
|
|
612
|
+
print(f"\n模式: {action} / link={mode},发现 {len(sources)} 个源 skill\n")
|
|
613
|
+
|
|
614
|
+
results = {}
|
|
615
|
+
for name, src in sources:
|
|
616
|
+
if action == "uninstall":
|
|
617
|
+
results[name] = uninstall_one(name)
|
|
618
|
+
elif action == "check":
|
|
619
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
620
|
+
if link.exists() or link.is_symlink():
|
|
621
|
+
results[name] = "ok(junction)" if is_junction(link) else "WARN(非软链)"
|
|
622
|
+
else:
|
|
623
|
+
results[name] = "missing"
|
|
624
|
+
else: # install
|
|
625
|
+
results[name] = install_one(name, src, mode, dry=False)
|
|
626
|
+
|
|
627
|
+
# 打印结果
|
|
628
|
+
print("-" * 56)
|
|
629
|
+
counts = {"ok": 0, "create": 0, "copy": 0, "warn": 0, "err": 0, "missing": 0}
|
|
630
|
+
for name, status in sorted(results.items()):
|
|
631
|
+
tag = ""
|
|
632
|
+
if status.startswith("ok") or status == "copied" or status.startswith("created"):
|
|
633
|
+
if status.startswith("created") or status == "copied":
|
|
634
|
+
counts["create"] += 1
|
|
635
|
+
tag = "[NEW]"
|
|
636
|
+
else:
|
|
637
|
+
counts["ok"] += 1
|
|
638
|
+
tag = "[OK]"
|
|
639
|
+
elif status.startswith("skip"):
|
|
640
|
+
counts["warn"] += 1
|
|
641
|
+
tag = "[WARN]"
|
|
642
|
+
elif status.startswith("error"):
|
|
643
|
+
counts["err"] += 1
|
|
644
|
+
tag = "[ERR]"
|
|
645
|
+
elif status == "missing":
|
|
646
|
+
counts["missing"] += 1
|
|
647
|
+
tag = "[MISS]"
|
|
648
|
+
elif status == "removed":
|
|
649
|
+
counts["ok"] += 1
|
|
650
|
+
tag = "[DEL]"
|
|
651
|
+
elif status.startswith("WARN"):
|
|
652
|
+
counts["warn"] += 1
|
|
653
|
+
tag = "[WARN]"
|
|
654
|
+
else:
|
|
655
|
+
counts["ok"] += 1
|
|
656
|
+
tag = "[OK]"
|
|
657
|
+
print(f" {tag:6} {name:22} {status}")
|
|
658
|
+
|
|
659
|
+
print("-" * 56)
|
|
660
|
+
print(f"\n小结: 新建 {counts['create']} / 已存在 {counts['ok']} / "
|
|
661
|
+
f"缺失 {counts['missing']} / 警告 {counts['warn']} / 错误 {counts['err']}")
|
|
662
|
+
|
|
663
|
+
# 同时安装 commands (让 QoderWork 用户级也能看到 /wl-* 命令)
|
|
664
|
+
if action in ("install", "check") and SOURCE_COMMANDS_DIR.is_dir():
|
|
665
|
+
print("\n--- Commands (/wl-* 斜杠命令) ---")
|
|
666
|
+
cmd_count = {"ok": 0, "new": 0, "upd": 0}
|
|
667
|
+
for cmd_file in sorted(SOURCE_COMMANDS_DIR.rglob("*.md")):
|
|
668
|
+
name = cmd_file.name # e.g. wl-prd.md (子目录归类的也平铺到目标)
|
|
669
|
+
target = QODERWORK_COMMANDS_DIR / name
|
|
670
|
+
if action == "check":
|
|
671
|
+
if target.exists():
|
|
672
|
+
cmd_count["ok"] += 1
|
|
673
|
+
else:
|
|
674
|
+
print(f" [MISS] {name}")
|
|
675
|
+
else: # install
|
|
676
|
+
need_copy = False
|
|
677
|
+
is_update = False
|
|
678
|
+
if not target.exists():
|
|
679
|
+
need_copy = True
|
|
680
|
+
else:
|
|
681
|
+
# 始终按内容同步(幂等): 不同就刷新。
|
|
682
|
+
# command 是纯文本小文件, 内容不同说明源已升级, 必须覆盖,
|
|
683
|
+
# 否则 QoderWork 用旧 command → 走错流程(如旧 wl-test 搜代码)。
|
|
684
|
+
if not file_equal(cmd_file, target):
|
|
685
|
+
need_copy = True
|
|
686
|
+
is_update = True
|
|
687
|
+
if need_copy:
|
|
688
|
+
try:
|
|
689
|
+
QODERWORK_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
690
|
+
shutil.copy2(str(cmd_file), str(target))
|
|
691
|
+
if is_update:
|
|
692
|
+
cmd_count["upd"] += 1
|
|
693
|
+
else:
|
|
694
|
+
cmd_count["new"] += 1
|
|
695
|
+
except OSError as e:
|
|
696
|
+
print(f" [ERR] {name}: {e}")
|
|
697
|
+
else:
|
|
698
|
+
cmd_count["ok"] += 1
|
|
699
|
+
msg = f" commands: {cmd_count['new']} 新建 / {cmd_count['ok']} 已存在"
|
|
700
|
+
if cmd_count["upd"]:
|
|
701
|
+
msg += f" / {cmd_count['upd']} 刷新"
|
|
702
|
+
print(msg)
|
|
703
|
+
|
|
704
|
+
# === 2.5. repo_root.py (QoderWork 桌面端仓库根定位器) ===
|
|
705
|
+
# QoderWork 桌面端工作目录是临时会话目录, 不是仓库根, 相对路径失效。
|
|
706
|
+
# repo_root.py 从 mcp.json 反推仓库根, 命令文件靠它定位脚本/模板。
|
|
707
|
+
if action in ("install", "check"):
|
|
708
|
+
repo_root_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "deployment" / "setup" / "repo_root.py" # v3.0: deployment/setup子包
|
|
709
|
+
repo_root_dst = _HOME / ".qoderwork" / "repo_root.py"
|
|
710
|
+
if repo_root_src.is_file():
|
|
711
|
+
if action == "check":
|
|
712
|
+
if repo_root_dst.exists():
|
|
713
|
+
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
714
|
+
print(f" [OK] {repo_root_dst}")
|
|
715
|
+
else:
|
|
716
|
+
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
717
|
+
print(f" [MISS] 未安装到 {repo_root_dst}")
|
|
718
|
+
else:
|
|
719
|
+
try:
|
|
720
|
+
repo_root_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
shutil.copy2(str(repo_root_src), str(repo_root_dst))
|
|
722
|
+
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
723
|
+
print(f" [OK] 已安装: {repo_root_dst}")
|
|
724
|
+
except OSError as e:
|
|
725
|
+
print(f"\n--- repo_root.py (仓库根定位器) ---")
|
|
726
|
+
print(f" [ERR] {e}")
|
|
727
|
+
|
|
728
|
+
# === 2.6. mcp_launcher.py + .repo-root (统一 MCP 启动器, 消除写死路径) ===
|
|
729
|
+
# mcp_launcher.py 让 mcp.json 不再写死仓库路径/venv路径, 跨 Win/Mac 通用。
|
|
730
|
+
# .repo-root 锚点文件让 launcher 能定位仓库根 (不依赖 mcp.json 里的路径)。
|
|
731
|
+
if action in ("install", "check"):
|
|
732
|
+
print(f"\n--- mcp_launcher.py (统一 MCP 启动器) ---")
|
|
733
|
+
launcher_src = SOURCE_COMMANDS_DIR.parent / "scripts" / "protocol" / "mcp" / "mcp_launcher.py" # v3.0: protocol/mcp子包
|
|
734
|
+
launcher_dst = _HOME / ".qoderwork" / "mcp_launcher.py"
|
|
735
|
+
anchor_dst = _HOME / ".qoderwork" / ".repo-root"
|
|
736
|
+
if action == "check":
|
|
737
|
+
if launcher_dst.exists():
|
|
738
|
+
print(f" [OK] launcher 已装: {launcher_dst}")
|
|
739
|
+
else:
|
|
740
|
+
print(f" [MISS] launcher 未装")
|
|
741
|
+
if anchor_dst.exists():
|
|
742
|
+
print(f" [OK] .repo-root 锚点在: {anchor_dst}")
|
|
743
|
+
else:
|
|
744
|
+
print(f" [MISS] .repo-root 锚点缺失 (launcher 会回退向上找)")
|
|
745
|
+
else:
|
|
746
|
+
try:
|
|
747
|
+
launcher_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
748
|
+
if launcher_src.is_file():
|
|
749
|
+
shutil.copy2(str(launcher_src), str(launcher_dst))
|
|
750
|
+
print(f" [OK] launcher 已装: {launcher_dst}")
|
|
751
|
+
else:
|
|
752
|
+
print(f" [ERR] 源不存在: {launcher_src}")
|
|
753
|
+
# 写 .repo-root 锚点 (记录当前仓库根, launcher 读它定位)
|
|
754
|
+
anchor_dst.write_text(str(PROJECT_ROOT) + "\n", encoding="utf-8")
|
|
755
|
+
print(f" [OK] .repo-root 锚点已写: {PROJECT_ROOT}")
|
|
756
|
+
except OSError as e:
|
|
757
|
+
print(f" [ERR] {e}")
|
|
758
|
+
|
|
759
|
+
# === 2.7. 重写 mcp.json: 所有 python MCP 改 launcher 模式 (消除写死路径) ===
|
|
760
|
+
if action == "install":
|
|
761
|
+
rewrite_mcp_json_for_launcher()
|
|
762
|
+
|
|
763
|
+
# === 3. Playwright MCP (可选增强: 让 QoderWork 能用 Playwright 驱动浏览器) ===
|
|
764
|
+
if action in ("install", "check"):
|
|
765
|
+
print("\n--- Playwright MCP (浏览器自动化增强, 可选) ---")
|
|
766
|
+
pw_status = setup_playwright_mcp(dry=(action == "check"))
|
|
767
|
+
if pw_status == "ok-existing":
|
|
768
|
+
print(f" [OK] playwright MCP 已配置: {QODERWORK_MCP_FILE}")
|
|
769
|
+
elif pw_status == "added":
|
|
770
|
+
print(f" [NEW] 已写入 playwright MCP: {QODERWORK_MCP_FILE}")
|
|
771
|
+
print(" QoderWork 重启后, AI 可用 Playwright 工具驱动浏览器(快、稳)")
|
|
772
|
+
elif pw_status == "upgraded":
|
|
773
|
+
print(f" [FIX] playwright MCP 已升级: npx(慢,易超时) → node+cli.js(0.5s启动)")
|
|
774
|
+
print(" ⚠️ 必须 重启 QoderWork 生效。旧 npx 配置会被 QoderWork 超时杀掉导致工具注册失败")
|
|
775
|
+
elif pw_status == "would-upgrade":
|
|
776
|
+
print(f" [FIX] 检测到旧 npx 配置(慢), 安装时会升级为 node+cli.js")
|
|
777
|
+
elif pw_status == "would-add":
|
|
778
|
+
print(f" [MISS] 未配置 playwright MCP, 安装时会写入 {QODERWORK_MCP_FILE}")
|
|
779
|
+
# 检查 playwright mcp 是否已装(优先看 cli.js, 决定能否走快路径)
|
|
780
|
+
pw_cli = _find_playwright_mcp_cli()
|
|
781
|
+
if pw_cli:
|
|
782
|
+
print(f" 启动方式: node + cli.js (0.5s, 快) ← {pw_cli[1]}")
|
|
783
|
+
else:
|
|
784
|
+
npx_ok = shutil.which("npx") is not None
|
|
785
|
+
print(f" 启动方式: {'npx (11s冷启动, 可能被QoderWork超时杀)' if npx_ok else '✗ 未装 Node.js'}")
|
|
786
|
+
if npx_ok:
|
|
787
|
+
print(" 提示: 装 @playwright/mcp 全局后重跑本脚本, 会自动切换到快的 node 启动: npm i -g @playwright/mcp")
|
|
788
|
+
|
|
789
|
+
# === 4. 蓝湖 MCP (可选增强: 设计师发蓝湖链接, AI 直读设计稿) ===
|
|
790
|
+
if action in ("install", "check"):
|
|
791
|
+
print("\n--- 蓝湖 MCP (设计稿直读增强, 可选) ---")
|
|
792
|
+
lh_status = setup_lanhu_mcp(dry=(action == "check"))
|
|
793
|
+
if lh_status == "ok-existing":
|
|
794
|
+
print(f" [OK] 蓝湖 MCP 已注册: {QODERWORK_MCP_FILE}")
|
|
795
|
+
elif lh_status == "added":
|
|
796
|
+
print(f" [NEW] 已写入蓝湖 MCP (STDIO): {QODERWORK_MCP_FILE}")
|
|
797
|
+
print(" QoderWork 开自动起、关自动停; 命令内授权调 mcp__lanhu__*")
|
|
798
|
+
elif lh_status == "upgraded":
|
|
799
|
+
print(f" [UPD] 蓝湖 MCP 升级 HTTP→STDIO: {QODERWORK_MCP_FILE}")
|
|
800
|
+
print(" 重启 QoderWork 后自动开关, 无需手动 start")
|
|
801
|
+
elif lh_status == "would-add":
|
|
802
|
+
print(f" [MISS] 未注册蓝湖 MCP, 安装时会写入 {QODERWORK_MCP_FILE}")
|
|
803
|
+
# STDIO 模式由 QoderWork 自动管, 不查端口; 只确认 wrapper + 源码就绪
|
|
804
|
+
wrapper = PROJECT_ROOT / ".qoder" / "scripts" / "protocol" / "mcp" / "lanhu_stdio_wrapper.py"
|
|
805
|
+
lanhu_dir = PROJECT_ROOT / "external" / "lanhu-mcp"
|
|
806
|
+
if wrapper.is_file():
|
|
807
|
+
print(f" wrapper 就绪: {wrapper.name} (按角色读 cookie)")
|
|
808
|
+
else:
|
|
809
|
+
print(f" [MISS] wrapper 缺失: {wrapper}")
|
|
810
|
+
if not lanhu_dir.is_dir():
|
|
811
|
+
print(" 蓝湖源码: 未 clone (跑 setup.py 或 setup_lanhu.py 安装)")
|
|
812
|
+
|
|
813
|
+
if action == "install" and counts["err"] == 0:
|
|
814
|
+
print("\n✓ 安装完成。重启 QoderWork(或新建对话)后技能生效。")
|
|
815
|
+
print(" 在 QoderWork 里直接用自然语言即可,例如:")
|
|
816
|
+
print(' "写个保险异常筛选的 PRD" -> /wl-prd(正经需求, 完整档)')
|
|
817
|
+
print(' "列表加个导出按钮" -> /wl-prd(小改动, 自动走快速档)')
|
|
818
|
+
print(' "查一下考勤代码在哪" -> /wl-search')
|
|
819
|
+
elif action == "install" and counts["err"] > 0:
|
|
820
|
+
print(f"\n⚠ 有 {counts['err']} 个安装失败,见上方 [ERR] 行。")
|
|
821
|
+
print(" 常见原因: 目标路径被占用为非软链。手动删除该目录后重跑本脚本。")
|
|
822
|
+
return 1
|
|
823
|
+
elif action == "uninstall":
|
|
824
|
+
print("\n✓ 卸载完成(源文件未动)。")
|
|
825
|
+
elif action == "check":
|
|
826
|
+
if counts["missing"] > 0 or counts["warn"] > 0:
|
|
827
|
+
print("\n⚠ 检测到缺失/异常,建议跑: python .qoder/scripts/install_qoderwork.py")
|
|
828
|
+
return 1
|
|
829
|
+
else:
|
|
830
|
+
print("\n✓ 全部 skill 已正确安装到 QoderWork 目录。")
|
|
831
|
+
return 0
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
if __name__ == "__main__":
|
|
835
|
+
sys.exit(main())
|