@hupan56/wlkj 2.2.3 → 2.2.5
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 +532 -532
- package/package.json +28 -28
- package/templates/qoder/hooks/inject-workflow-state.py +117 -117
- package/templates/qoder/hooks/session-start.py +204 -204
- package/templates/qoder/scripts/common/developer.py +231 -161
- package/templates/qoder/scripts/common/paths.py +310 -310
- package/templates/qoder/scripts/common/task_utils.py +392 -387
- package/templates/qoder/scripts/init_developer.py +75 -75
- package/templates/qoder/scripts/install_qoderwork.py +367 -367
- package/templates/qoder/scripts/role.py +39 -39
- package/templates/qoder/scripts/syncgate.py +333 -333
- package/templates/qoder/scripts/team_sync.py +439 -439
- package/templates/qoder/skills/design-review/SKILL.md +25 -25
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -180
- package/templates/qoder/skills/prd-review/SKILL.md +36 -36
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -141
- package/templates/qoder/skills/spec-coder/SKILL.md +68 -68
- package/templates/qoder/skills/spec-generator/SKILL.md +66 -66
- package/templates/qoder/skills/test-generator/SKILL.md +71 -71
- package/templates/root/AGENTS.md +182 -182
|
@@ -1,367 +1,367 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
install_qoderwork.py - 把 .qoder/skills/ 安装到 QoderWork 桌面端可识别的位置
|
|
4
|
-
|
|
5
|
-
QoderWork 从 %USERPROFILE%\\.qoderwork\\skills\\ 加载技能(每个子目录一个 skill),
|
|
6
|
-
从 %USERPROFILE%\\.qoderwork\\commands\\ 加载斜杠命令。
|
|
7
|
-
本项目源在 <repo>/.qoder/skills/ 和 <repo>/.qoder/commands/,QoderWork 不会读项目
|
|
8
|
-
目录,必须软链/拷过去。
|
|
9
|
-
(注: 旧版本误用 ~/.qoderworkcn/,已废弃,官方路径为 ~/.qoderwork/)
|
|
10
|
-
|
|
11
|
-
为什么用 junction(mklink /J)而不是 symlink(mklink /D):
|
|
12
|
-
- junction 不需要管理员权限,普通用户可建
|
|
13
|
-
- 本地评估默认启用,跨进程透明
|
|
14
|
-
- symlink 在未开开发者模式的 Windows 上需要 SeCreateSymbolicLink 特权
|
|
15
|
-
|
|
16
|
-
用法:
|
|
17
|
-
python .qoder/scripts/install_qoderwork.py # 安装/同步(幂等)
|
|
18
|
-
python .qoder/scripts/install_qoderwork.py --uninstall # 删除所有 junction(不删源)
|
|
19
|
-
python .qoder/scripts/install_qoderwork.py --check # 仅检查,不改动
|
|
20
|
-
python .qoder/scripts/install_qoderwork.py --copy # 改用拷贝(非 Windows 或不想软链)
|
|
21
|
-
|
|
22
|
-
幂等:已存在的 junction 会跳过;已存在的非 junction 目录会报错让人工处理。
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
import argparse
|
|
26
|
-
import os
|
|
27
|
-
import shutil
|
|
28
|
-
import subprocess
|
|
29
|
-
import sys
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
# 确保 UTF-8 输出(Windows 控制台默认 GBK)
|
|
33
|
-
if sys.platform == "win32":
|
|
34
|
-
try:
|
|
35
|
-
sys.stdout.reconfigure(encoding="utf-8")
|
|
36
|
-
sys.stderr.reconfigure(encoding="utf-8")
|
|
37
|
-
except (AttributeError, IOError):
|
|
38
|
-
pass
|
|
39
|
-
|
|
40
|
-
# 路径自检:项目根 = 本文件上三级(scripts/ -> .qoder/ -> repo)
|
|
41
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
42
|
-
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
|
43
|
-
SOURCE_SKILLS_DIR = PROJECT_ROOT / ".qoder" / "skills"
|
|
44
|
-
SOURCE_COMMANDS_DIR = PROJECT_ROOT / ".qoder" / "commands"
|
|
45
|
-
|
|
46
|
-
# QoderWork 目标目录 (官方: ~/.qoderwork/skills/ 和 ~/.qoderwork/commands/)
|
|
47
|
-
# 注意: 之前误用了 ~/.qoderworkcn/ (国内版旧路径), 官方文档明确是 ~/.qoderwork/
|
|
48
|
-
# https://docs.qoder.com/zh/qoderwork/skills
|
|
49
|
-
if sys.platform == "win32":
|
|
50
|
-
_HOME = Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
51
|
-
else:
|
|
52
|
-
_HOME = Path.home()
|
|
53
|
-
QODERWORK_SKILLS_DIR = _HOME / ".qoderwork" / "skills"
|
|
54
|
-
QODERWORK_COMMANDS_DIR = _HOME / ".qoderwork" / "commands"
|
|
55
|
-
|
|
56
|
-
# 旧的错误路径 (迁移清理用)
|
|
57
|
-
LEGACY_WRONG_DIR = _HOME / ".qoderworkcn" / "skills"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def is_junction(path: Path) -> bool:
|
|
61
|
-
"""判断目录是否是 junction/symlink(reparse point)。"""
|
|
62
|
-
if not path.exists():
|
|
63
|
-
return False
|
|
64
|
-
# Windows: 用 dir 命令检测 <JUNCTION> 标记最可靠(无需 ctypes)
|
|
65
|
-
if sys.platform == "win32":
|
|
66
|
-
try:
|
|
67
|
-
# os.lstat: reparse point 的 FILE_ATTRIBUTE_REPARSE_POINT (0x400) 会体现在 st_file_attributes
|
|
68
|
-
import stat as _stat
|
|
69
|
-
st = path.lstat()
|
|
70
|
-
# FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
71
|
-
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
|
|
72
|
-
except (OSError, AttributeError):
|
|
73
|
-
return False
|
|
74
|
-
else:
|
|
75
|
-
return path.is_symlink()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def find_source_skills() -> list:
|
|
79
|
-
"""扫描 .qoder/skills/*/SKILL.md,返回 [(name, src_dir), ...]。"""
|
|
80
|
-
if not SOURCE_SKILLS_DIR.is_dir():
|
|
81
|
-
return []
|
|
82
|
-
result = []
|
|
83
|
-
for child in sorted(SOURCE_SKILLS_DIR.iterdir()):
|
|
84
|
-
if not child.is_dir():
|
|
85
|
-
continue
|
|
86
|
-
if (child / "SKILL.md").is_file():
|
|
87
|
-
result.append((child.name, child))
|
|
88
|
-
return result
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def file_equal(a: Path, b: Path) -> bool:
|
|
92
|
-
"""快速判断两文件内容是否相同(先比大小再比内容,避免无谓全读)。"""
|
|
93
|
-
try:
|
|
94
|
-
if a.stat().st_size != b.stat().st_size:
|
|
95
|
-
return False
|
|
96
|
-
return a.read_bytes() == b.read_bytes()
|
|
97
|
-
except OSError:
|
|
98
|
-
return False
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def create_junction(link: Path, target: Path) -> bool:
|
|
102
|
-
"""用 mklink /J 创建 junction。返回是否成功。"""
|
|
103
|
-
# 用 errors="replace" 防止 Windows GBK 输出(如"为...创建的联接")触发 UnicodeDecodeError
|
|
104
|
-
cmd = ["cmd", "/c", "mklink", "/J", str(link), str(target)]
|
|
105
|
-
try:
|
|
106
|
-
r = subprocess.run(
|
|
107
|
-
cmd,
|
|
108
|
-
stdout=subprocess.PIPE,
|
|
109
|
-
stderr=subprocess.PIPE,
|
|
110
|
-
# 不用 text=True,自己 decode 防编码异常
|
|
111
|
-
)
|
|
112
|
-
return r.returncode == 0
|
|
113
|
-
except (OSError, subprocess.SubprocessError):
|
|
114
|
-
return False
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def install_one(name: str, src: Path, mode: str, dry: bool = False) -> str:
|
|
118
|
-
"""
|
|
119
|
-
安装一个 skill 到 QoderWork 目录。
|
|
120
|
-
返回状态字符串:'created' / 'ok-existing' / 'copied' / 'error: ...' / 'skip: ...'
|
|
121
|
-
"""
|
|
122
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
123
|
-
if link.exists() or link.is_symlink():
|
|
124
|
-
if is_junction(link):
|
|
125
|
-
# 悬空 junction 检测: junction 存在但目标已移走/删除
|
|
126
|
-
# (常见于团队重命名 skill 后, 旧 junction 变成死链)
|
|
127
|
-
# link.exists() 对悬空 junction 返回 False, 但 link.is_symlink() 可能 True
|
|
128
|
-
# 用 os.path.exists 解析目标判断
|
|
129
|
-
try:
|
|
130
|
-
target_alive = os.path.exists(str(link))
|
|
131
|
-
except OSError:
|
|
132
|
-
target_alive = False
|
|
133
|
-
if target_alive:
|
|
134
|
-
return "ok-existing"
|
|
135
|
-
else:
|
|
136
|
-
# 悬空 junction, 删除后重建
|
|
137
|
-
if dry:
|
|
138
|
-
return "would-recreate (dangling)"
|
|
139
|
-
try:
|
|
140
|
-
if sys.platform == "win32":
|
|
141
|
-
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
142
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
143
|
-
else:
|
|
144
|
-
os.unlink(str(link))
|
|
145
|
-
except (OSError, subprocess.SubprocessError) as e:
|
|
146
|
-
return "error: 悬空 junction 无法删除 (手动 rmdir {}): {}".format(link, e)
|
|
147
|
-
# 落到下面 recreate 逻辑
|
|
148
|
-
else:
|
|
149
|
-
# 非 junction 的真实目录/文件 -> 不覆盖
|
|
150
|
-
return f"skip: {name} 已存在且不是软链(避免覆盖,请人工确认 {link})"
|
|
151
|
-
|
|
152
|
-
if dry:
|
|
153
|
-
return "would-create"
|
|
154
|
-
|
|
155
|
-
QODERWORK_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
156
|
-
|
|
157
|
-
if mode == "copy":
|
|
158
|
-
try:
|
|
159
|
-
shutil.copytree(src, link)
|
|
160
|
-
return "copied"
|
|
161
|
-
except (OSError, shutil.Error) as e:
|
|
162
|
-
return f"error: copy 失败 {e}"
|
|
163
|
-
else:
|
|
164
|
-
# junction 模式(默认)
|
|
165
|
-
if sys.platform != "win32":
|
|
166
|
-
# 非 Windows 退化为 symlink,失败再退化为 copy
|
|
167
|
-
try:
|
|
168
|
-
os.symlink(src, link, target_is_directory=True)
|
|
169
|
-
return "created(symlink)"
|
|
170
|
-
except (OSError, NotImplementedError):
|
|
171
|
-
try:
|
|
172
|
-
shutil.copytree(src, link)
|
|
173
|
-
return "copied(fallback)"
|
|
174
|
-
except (OSError, shutil.Error) as e:
|
|
175
|
-
return f"error: {e}"
|
|
176
|
-
if create_junction(link, src):
|
|
177
|
-
return "created"
|
|
178
|
-
return "error: mklink /J 失败(可能需要检查路径或权限)"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def uninstall_one(name: str) -> str:
|
|
182
|
-
"""删除一个 junction(只删 link,不删源)。"""
|
|
183
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
184
|
-
if not link.exists() and not link.is_symlink():
|
|
185
|
-
return "absent"
|
|
186
|
-
if not is_junction(link):
|
|
187
|
-
return f"skip: {name} 不是软链(不动真实目录,请人工删除 {link})"
|
|
188
|
-
try:
|
|
189
|
-
# 删 junction 用 rmdir(不递归到源)
|
|
190
|
-
if sys.platform == "win32":
|
|
191
|
-
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
192
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
193
|
-
else:
|
|
194
|
-
os.unlink(str(link))
|
|
195
|
-
return "removed"
|
|
196
|
-
except (OSError, subprocess.SubprocessError) as e:
|
|
197
|
-
return f"error: {e}"
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def main():
|
|
201
|
-
parser = argparse.ArgumentParser(
|
|
202
|
-
description="把 .qoder/skills/ 安装到 QoderWork 桌面端目录"
|
|
203
|
-
)
|
|
204
|
-
parser.add_argument("--uninstall", action="store_true",
|
|
205
|
-
help="删除所有 junction(不删源)")
|
|
206
|
-
parser.add_argument("--check", action="store_true",
|
|
207
|
-
help="仅检查状态,不改动")
|
|
208
|
-
parser.add_argument("--copy", action="store_true",
|
|
209
|
-
help="用拷贝代替 junction(非 Windows 或不想软链时用)")
|
|
210
|
-
parser.add_argument("--force-commands", action="store_true",
|
|
211
|
-
help="强制覆盖已存在的 command 文件(升级时用;默认已存在则跳过)")
|
|
212
|
-
args = parser.parse_args()
|
|
213
|
-
|
|
214
|
-
print("=" * 56)
|
|
215
|
-
print("QoderWork 技能安装器")
|
|
216
|
-
print(f" 源: {SOURCE_SKILLS_DIR}")
|
|
217
|
-
print(f" 目标: {QODERWORK_SKILLS_DIR}")
|
|
218
|
-
print("=" * 56)
|
|
219
|
-
|
|
220
|
-
# 迁移清理: 旧版本错装到了 ~/.qoderworkcn/, 清理掉避免混淆
|
|
221
|
-
if not args.check and LEGACY_WRONG_DIR.exists():
|
|
222
|
-
legacy_junctions = []
|
|
223
|
-
try:
|
|
224
|
-
for child in LEGACY_WRONG_DIR.iterdir():
|
|
225
|
-
if is_junction(child):
|
|
226
|
-
legacy_junctions.append(child.name)
|
|
227
|
-
except OSError:
|
|
228
|
-
pass
|
|
229
|
-
if legacy_junctions:
|
|
230
|
-
print(f"\n[迁移] 发现旧版本错装在 {LEGACY_WRONG_DIR} ({len(legacy_junctions)} 个)")
|
|
231
|
-
print(f" QoderWork 实际读的是 ~/.qoderwork/, 正在清理旧 junction...")
|
|
232
|
-
for name in legacy_junctions:
|
|
233
|
-
try:
|
|
234
|
-
subprocess.run(["cmd", "/c", "rmdir", str(LEGACY_WRONG_DIR / name)],
|
|
235
|
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
236
|
-
except (OSError, subprocess.SubprocessError):
|
|
237
|
-
pass
|
|
238
|
-
print(f" 已清理 {len(legacy_junctions)} 个旧 junction")
|
|
239
|
-
# 若旧目录空了, 删掉
|
|
240
|
-
try:
|
|
241
|
-
remaining = list(LEGACY_WRONG_DIR.iterdir())
|
|
242
|
-
if not remaining and LEGACY_WRONG_DIR.parent.exists():
|
|
243
|
-
LEGACY_WRONG_DIR.rmdir()
|
|
244
|
-
print(f" 已删除空目录 {LEGACY_WRONG_DIR.parent}")
|
|
245
|
-
except OSError:
|
|
246
|
-
pass
|
|
247
|
-
|
|
248
|
-
sources = find_source_skills()
|
|
249
|
-
if not sources:
|
|
250
|
-
print(f"[ERR] 源目录无 skill:{SOURCE_SKILLS_DIR}")
|
|
251
|
-
print(" 检查 .qoder/skills/*/SKILL.md 是否存在")
|
|
252
|
-
return 1
|
|
253
|
-
|
|
254
|
-
mode = "copy" if args.copy else "junction"
|
|
255
|
-
action = "check" if args.check else ("uninstall" if args.uninstall else "install")
|
|
256
|
-
print(f"\n模式: {action} / link={mode},发现 {len(sources)} 个源 skill\n")
|
|
257
|
-
|
|
258
|
-
results = {}
|
|
259
|
-
for name, src in sources:
|
|
260
|
-
if action == "uninstall":
|
|
261
|
-
results[name] = uninstall_one(name)
|
|
262
|
-
elif action == "check":
|
|
263
|
-
link = QODERWORK_SKILLS_DIR / name
|
|
264
|
-
if link.exists() or link.is_symlink():
|
|
265
|
-
results[name] = "ok(junction)" if is_junction(link) else "WARN(非软链)"
|
|
266
|
-
else:
|
|
267
|
-
results[name] = "missing"
|
|
268
|
-
else: # install
|
|
269
|
-
results[name] = install_one(name, src, mode, dry=False)
|
|
270
|
-
|
|
271
|
-
# 打印结果
|
|
272
|
-
print("-" * 56)
|
|
273
|
-
counts = {"ok": 0, "create": 0, "copy": 0, "warn": 0, "err": 0, "missing": 0}
|
|
274
|
-
for name, status in sorted(results.items()):
|
|
275
|
-
tag = ""
|
|
276
|
-
if status.startswith("ok") or status == "copied" or status.startswith("created"):
|
|
277
|
-
if status.startswith("created") or status == "copied":
|
|
278
|
-
counts["create"] += 1
|
|
279
|
-
tag = "[NEW]"
|
|
280
|
-
else:
|
|
281
|
-
counts["ok"] += 1
|
|
282
|
-
tag = "[OK]"
|
|
283
|
-
elif status.startswith("skip"):
|
|
284
|
-
counts["warn"] += 1
|
|
285
|
-
tag = "[WARN]"
|
|
286
|
-
elif status.startswith("error"):
|
|
287
|
-
counts["err"] += 1
|
|
288
|
-
tag = "[ERR]"
|
|
289
|
-
elif status == "missing":
|
|
290
|
-
counts["missing"] += 1
|
|
291
|
-
tag = "[MISS]"
|
|
292
|
-
elif status == "removed":
|
|
293
|
-
counts["ok"] += 1
|
|
294
|
-
tag = "[DEL]"
|
|
295
|
-
elif status.startswith("WARN"):
|
|
296
|
-
counts["warn"] += 1
|
|
297
|
-
tag = "[WARN]"
|
|
298
|
-
else:
|
|
299
|
-
counts["ok"] += 1
|
|
300
|
-
tag = "[OK]"
|
|
301
|
-
print(f" {tag:6} {name:22} {status}")
|
|
302
|
-
|
|
303
|
-
print("-" * 56)
|
|
304
|
-
print(f"\n小结: 新建 {counts['create']} / 已存在 {counts['ok']} / "
|
|
305
|
-
f"缺失 {counts['missing']} / 警告 {counts['warn']} / 错误 {counts['err']}")
|
|
306
|
-
|
|
307
|
-
# 同时安装 commands (让 QoderWork 用户级也能看到 /wl-* 命令)
|
|
308
|
-
if action in ("install", "check") and SOURCE_COMMANDS_DIR.is_dir():
|
|
309
|
-
print("\n--- Commands (/wl-* 斜杠命令) ---")
|
|
310
|
-
cmd_count = {"ok": 0, "new": 0, "upd": 0}
|
|
311
|
-
for cmd_file in sorted(SOURCE_COMMANDS_DIR.glob("wl-*.md")):
|
|
312
|
-
name = cmd_file.name # e.g. wl-prd.md
|
|
313
|
-
target = QODERWORK_COMMANDS_DIR / name
|
|
314
|
-
if action == "check":
|
|
315
|
-
if target.exists():
|
|
316
|
-
cmd_count["ok"] += 1
|
|
317
|
-
else:
|
|
318
|
-
print(f" [MISS] {name}")
|
|
319
|
-
else: # install
|
|
320
|
-
need_copy = False
|
|
321
|
-
if not target.exists():
|
|
322
|
-
need_copy = True
|
|
323
|
-
elif args.force_commands:
|
|
324
|
-
# 强制刷新(升级场景):仅当内容不同才覆盖,避免无谓写
|
|
325
|
-
import shutil
|
|
326
|
-
if not file_equal(cmd_file, target):
|
|
327
|
-
need_copy = True
|
|
328
|
-
if need_copy:
|
|
329
|
-
try:
|
|
330
|
-
QODERWORK_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
331
|
-
import shutil
|
|
332
|
-
shutil.copy2(str(cmd_file), str(target))
|
|
333
|
-
if target.exists() and args.force_commands:
|
|
334
|
-
cmd_count["upd"] += 1
|
|
335
|
-
else:
|
|
336
|
-
cmd_count["new"] += 1
|
|
337
|
-
except OSError as e:
|
|
338
|
-
print(f" [ERR] {name}: {e}")
|
|
339
|
-
else:
|
|
340
|
-
cmd_count["ok"] += 1
|
|
341
|
-
msg = f" commands: {cmd_count['new']} 新建 / {cmd_count['ok']} 已存在"
|
|
342
|
-
if cmd_count["upd"]:
|
|
343
|
-
msg += f" / {cmd_count['upd']} 刷新"
|
|
344
|
-
print(msg)
|
|
345
|
-
|
|
346
|
-
if action == "install" and counts["err"] == 0:
|
|
347
|
-
print("\n✓ 安装完成。重启 QoderWork(或新建对话)后技能生效。")
|
|
348
|
-
print(" 在 QoderWork 里直接用自然语言即可,例如:")
|
|
349
|
-
print(' "写个保险异常筛选的 PRD" -> prd-generator + prototype-generator')
|
|
350
|
-
print(' "查一下考勤代码在哪" -> wl-search')
|
|
351
|
-
elif action == "install" and counts["err"] > 0:
|
|
352
|
-
print(f"\n⚠ 有 {counts['err']} 个安装失败,见上方 [ERR] 行。")
|
|
353
|
-
print(" 常见原因: 目标路径被占用为非软链。手动删除该目录后重跑本脚本。")
|
|
354
|
-
return 1
|
|
355
|
-
elif action == "uninstall":
|
|
356
|
-
print("\n✓ 卸载完成(源文件未动)。")
|
|
357
|
-
elif action == "check":
|
|
358
|
-
if counts["missing"] > 0 or counts["warn"] > 0:
|
|
359
|
-
print("\n⚠ 检测到缺失/异常,建议跑: python .qoder/scripts/install_qoderwork.py")
|
|
360
|
-
return 1
|
|
361
|
-
else:
|
|
362
|
-
print("\n✓ 全部 skill 已正确安装到 QoderWork 目录。")
|
|
363
|
-
return 0
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if __name__ == "__main__":
|
|
367
|
-
sys.exit(main())
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
install_qoderwork.py - 把 .qoder/skills/ 安装到 QoderWork 桌面端可识别的位置
|
|
4
|
+
|
|
5
|
+
QoderWork 从 %USERPROFILE%\\.qoderwork\\skills\\ 加载技能(每个子目录一个 skill),
|
|
6
|
+
从 %USERPROFILE%\\.qoderwork\\commands\\ 加载斜杠命令。
|
|
7
|
+
本项目源在 <repo>/.qoder/skills/ 和 <repo>/.qoder/commands/,QoderWork 不会读项目
|
|
8
|
+
目录,必须软链/拷过去。
|
|
9
|
+
(注: 旧版本误用 ~/.qoderworkcn/,已废弃,官方路径为 ~/.qoderwork/)
|
|
10
|
+
|
|
11
|
+
为什么用 junction(mklink /J)而不是 symlink(mklink /D):
|
|
12
|
+
- junction 不需要管理员权限,普通用户可建
|
|
13
|
+
- 本地评估默认启用,跨进程透明
|
|
14
|
+
- symlink 在未开开发者模式的 Windows 上需要 SeCreateSymbolicLink 特权
|
|
15
|
+
|
|
16
|
+
用法:
|
|
17
|
+
python .qoder/scripts/install_qoderwork.py # 安装/同步(幂等)
|
|
18
|
+
python .qoder/scripts/install_qoderwork.py --uninstall # 删除所有 junction(不删源)
|
|
19
|
+
python .qoder/scripts/install_qoderwork.py --check # 仅检查,不改动
|
|
20
|
+
python .qoder/scripts/install_qoderwork.py --copy # 改用拷贝(非 Windows 或不想软链)
|
|
21
|
+
|
|
22
|
+
幂等:已存在的 junction 会跳过;已存在的非 junction 目录会报错让人工处理。
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# 确保 UTF-8 输出(Windows 控制台默认 GBK)
|
|
33
|
+
if sys.platform == "win32":
|
|
34
|
+
try:
|
|
35
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
36
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
37
|
+
except (AttributeError, IOError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
# 路径自检:项目根 = 本文件上三级(scripts/ -> .qoder/ -> repo)
|
|
41
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
42
|
+
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
|
43
|
+
SOURCE_SKILLS_DIR = PROJECT_ROOT / ".qoder" / "skills"
|
|
44
|
+
SOURCE_COMMANDS_DIR = PROJECT_ROOT / ".qoder" / "commands"
|
|
45
|
+
|
|
46
|
+
# QoderWork 目标目录 (官方: ~/.qoderwork/skills/ 和 ~/.qoderwork/commands/)
|
|
47
|
+
# 注意: 之前误用了 ~/.qoderworkcn/ (国内版旧路径), 官方文档明确是 ~/.qoderwork/
|
|
48
|
+
# https://docs.qoder.com/zh/qoderwork/skills
|
|
49
|
+
if sys.platform == "win32":
|
|
50
|
+
_HOME = Path(os.environ.get("USERPROFILE", str(Path.home())))
|
|
51
|
+
else:
|
|
52
|
+
_HOME = Path.home()
|
|
53
|
+
QODERWORK_SKILLS_DIR = _HOME / ".qoderwork" / "skills"
|
|
54
|
+
QODERWORK_COMMANDS_DIR = _HOME / ".qoderwork" / "commands"
|
|
55
|
+
|
|
56
|
+
# 旧的错误路径 (迁移清理用)
|
|
57
|
+
LEGACY_WRONG_DIR = _HOME / ".qoderworkcn" / "skills"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_junction(path: Path) -> bool:
|
|
61
|
+
"""判断目录是否是 junction/symlink(reparse point)。"""
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return False
|
|
64
|
+
# Windows: 用 dir 命令检测 <JUNCTION> 标记最可靠(无需 ctypes)
|
|
65
|
+
if sys.platform == "win32":
|
|
66
|
+
try:
|
|
67
|
+
# os.lstat: reparse point 的 FILE_ATTRIBUTE_REPARSE_POINT (0x400) 会体现在 st_file_attributes
|
|
68
|
+
import stat as _stat
|
|
69
|
+
st = path.lstat()
|
|
70
|
+
# FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
|
71
|
+
return bool(getattr(st, "st_file_attributes", 0) & 0x400)
|
|
72
|
+
except (OSError, AttributeError):
|
|
73
|
+
return False
|
|
74
|
+
else:
|
|
75
|
+
return path.is_symlink()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_source_skills() -> list:
|
|
79
|
+
"""扫描 .qoder/skills/*/SKILL.md,返回 [(name, src_dir), ...]。"""
|
|
80
|
+
if not SOURCE_SKILLS_DIR.is_dir():
|
|
81
|
+
return []
|
|
82
|
+
result = []
|
|
83
|
+
for child in sorted(SOURCE_SKILLS_DIR.iterdir()):
|
|
84
|
+
if not child.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
if (child / "SKILL.md").is_file():
|
|
87
|
+
result.append((child.name, child))
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def file_equal(a: Path, b: Path) -> bool:
|
|
92
|
+
"""快速判断两文件内容是否相同(先比大小再比内容,避免无谓全读)。"""
|
|
93
|
+
try:
|
|
94
|
+
if a.stat().st_size != b.stat().st_size:
|
|
95
|
+
return False
|
|
96
|
+
return a.read_bytes() == b.read_bytes()
|
|
97
|
+
except OSError:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_junction(link: Path, target: Path) -> bool:
|
|
102
|
+
"""用 mklink /J 创建 junction。返回是否成功。"""
|
|
103
|
+
# 用 errors="replace" 防止 Windows GBK 输出(如"为...创建的联接")触发 UnicodeDecodeError
|
|
104
|
+
cmd = ["cmd", "/c", "mklink", "/J", str(link), str(target)]
|
|
105
|
+
try:
|
|
106
|
+
r = subprocess.run(
|
|
107
|
+
cmd,
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
# 不用 text=True,自己 decode 防编码异常
|
|
111
|
+
)
|
|
112
|
+
return r.returncode == 0
|
|
113
|
+
except (OSError, subprocess.SubprocessError):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def install_one(name: str, src: Path, mode: str, dry: bool = False) -> str:
|
|
118
|
+
"""
|
|
119
|
+
安装一个 skill 到 QoderWork 目录。
|
|
120
|
+
返回状态字符串:'created' / 'ok-existing' / 'copied' / 'error: ...' / 'skip: ...'
|
|
121
|
+
"""
|
|
122
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
123
|
+
if link.exists() or link.is_symlink():
|
|
124
|
+
if is_junction(link):
|
|
125
|
+
# 悬空 junction 检测: junction 存在但目标已移走/删除
|
|
126
|
+
# (常见于团队重命名 skill 后, 旧 junction 变成死链)
|
|
127
|
+
# link.exists() 对悬空 junction 返回 False, 但 link.is_symlink() 可能 True
|
|
128
|
+
# 用 os.path.exists 解析目标判断
|
|
129
|
+
try:
|
|
130
|
+
target_alive = os.path.exists(str(link))
|
|
131
|
+
except OSError:
|
|
132
|
+
target_alive = False
|
|
133
|
+
if target_alive:
|
|
134
|
+
return "ok-existing"
|
|
135
|
+
else:
|
|
136
|
+
# 悬空 junction, 删除后重建
|
|
137
|
+
if dry:
|
|
138
|
+
return "would-recreate (dangling)"
|
|
139
|
+
try:
|
|
140
|
+
if sys.platform == "win32":
|
|
141
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
142
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
143
|
+
else:
|
|
144
|
+
os.unlink(str(link))
|
|
145
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
146
|
+
return "error: 悬空 junction 无法删除 (手动 rmdir {}): {}".format(link, e)
|
|
147
|
+
# 落到下面 recreate 逻辑
|
|
148
|
+
else:
|
|
149
|
+
# 非 junction 的真实目录/文件 -> 不覆盖
|
|
150
|
+
return f"skip: {name} 已存在且不是软链(避免覆盖,请人工确认 {link})"
|
|
151
|
+
|
|
152
|
+
if dry:
|
|
153
|
+
return "would-create"
|
|
154
|
+
|
|
155
|
+
QODERWORK_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
if mode == "copy":
|
|
158
|
+
try:
|
|
159
|
+
shutil.copytree(src, link)
|
|
160
|
+
return "copied"
|
|
161
|
+
except (OSError, shutil.Error) as e:
|
|
162
|
+
return f"error: copy 失败 {e}"
|
|
163
|
+
else:
|
|
164
|
+
# junction 模式(默认)
|
|
165
|
+
if sys.platform != "win32":
|
|
166
|
+
# 非 Windows 退化为 symlink,失败再退化为 copy
|
|
167
|
+
try:
|
|
168
|
+
os.symlink(src, link, target_is_directory=True)
|
|
169
|
+
return "created(symlink)"
|
|
170
|
+
except (OSError, NotImplementedError):
|
|
171
|
+
try:
|
|
172
|
+
shutil.copytree(src, link)
|
|
173
|
+
return "copied(fallback)"
|
|
174
|
+
except (OSError, shutil.Error) as e:
|
|
175
|
+
return f"error: {e}"
|
|
176
|
+
if create_junction(link, src):
|
|
177
|
+
return "created"
|
|
178
|
+
return "error: mklink /J 失败(可能需要检查路径或权限)"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def uninstall_one(name: str) -> str:
|
|
182
|
+
"""删除一个 junction(只删 link,不删源)。"""
|
|
183
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
184
|
+
if not link.exists() and not link.is_symlink():
|
|
185
|
+
return "absent"
|
|
186
|
+
if not is_junction(link):
|
|
187
|
+
return f"skip: {name} 不是软链(不动真实目录,请人工删除 {link})"
|
|
188
|
+
try:
|
|
189
|
+
# 删 junction 用 rmdir(不递归到源)
|
|
190
|
+
if sys.platform == "win32":
|
|
191
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)],
|
|
192
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
193
|
+
else:
|
|
194
|
+
os.unlink(str(link))
|
|
195
|
+
return "removed"
|
|
196
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
197
|
+
return f"error: {e}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main():
|
|
201
|
+
parser = argparse.ArgumentParser(
|
|
202
|
+
description="把 .qoder/skills/ 安装到 QoderWork 桌面端目录"
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument("--uninstall", action="store_true",
|
|
205
|
+
help="删除所有 junction(不删源)")
|
|
206
|
+
parser.add_argument("--check", action="store_true",
|
|
207
|
+
help="仅检查状态,不改动")
|
|
208
|
+
parser.add_argument("--copy", action="store_true",
|
|
209
|
+
help="用拷贝代替 junction(非 Windows 或不想软链时用)")
|
|
210
|
+
parser.add_argument("--force-commands", action="store_true",
|
|
211
|
+
help="强制覆盖已存在的 command 文件(升级时用;默认已存在则跳过)")
|
|
212
|
+
args = parser.parse_args()
|
|
213
|
+
|
|
214
|
+
print("=" * 56)
|
|
215
|
+
print("QoderWork 技能安装器")
|
|
216
|
+
print(f" 源: {SOURCE_SKILLS_DIR}")
|
|
217
|
+
print(f" 目标: {QODERWORK_SKILLS_DIR}")
|
|
218
|
+
print("=" * 56)
|
|
219
|
+
|
|
220
|
+
# 迁移清理: 旧版本错装到了 ~/.qoderworkcn/, 清理掉避免混淆
|
|
221
|
+
if not args.check and LEGACY_WRONG_DIR.exists():
|
|
222
|
+
legacy_junctions = []
|
|
223
|
+
try:
|
|
224
|
+
for child in LEGACY_WRONG_DIR.iterdir():
|
|
225
|
+
if is_junction(child):
|
|
226
|
+
legacy_junctions.append(child.name)
|
|
227
|
+
except OSError:
|
|
228
|
+
pass
|
|
229
|
+
if legacy_junctions:
|
|
230
|
+
print(f"\n[迁移] 发现旧版本错装在 {LEGACY_WRONG_DIR} ({len(legacy_junctions)} 个)")
|
|
231
|
+
print(f" QoderWork 实际读的是 ~/.qoderwork/, 正在清理旧 junction...")
|
|
232
|
+
for name in legacy_junctions:
|
|
233
|
+
try:
|
|
234
|
+
subprocess.run(["cmd", "/c", "rmdir", str(LEGACY_WRONG_DIR / name)],
|
|
235
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
236
|
+
except (OSError, subprocess.SubprocessError):
|
|
237
|
+
pass
|
|
238
|
+
print(f" 已清理 {len(legacy_junctions)} 个旧 junction")
|
|
239
|
+
# 若旧目录空了, 删掉
|
|
240
|
+
try:
|
|
241
|
+
remaining = list(LEGACY_WRONG_DIR.iterdir())
|
|
242
|
+
if not remaining and LEGACY_WRONG_DIR.parent.exists():
|
|
243
|
+
LEGACY_WRONG_DIR.rmdir()
|
|
244
|
+
print(f" 已删除空目录 {LEGACY_WRONG_DIR.parent}")
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
sources = find_source_skills()
|
|
249
|
+
if not sources:
|
|
250
|
+
print(f"[ERR] 源目录无 skill:{SOURCE_SKILLS_DIR}")
|
|
251
|
+
print(" 检查 .qoder/skills/*/SKILL.md 是否存在")
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
mode = "copy" if args.copy else "junction"
|
|
255
|
+
action = "check" if args.check else ("uninstall" if args.uninstall else "install")
|
|
256
|
+
print(f"\n模式: {action} / link={mode},发现 {len(sources)} 个源 skill\n")
|
|
257
|
+
|
|
258
|
+
results = {}
|
|
259
|
+
for name, src in sources:
|
|
260
|
+
if action == "uninstall":
|
|
261
|
+
results[name] = uninstall_one(name)
|
|
262
|
+
elif action == "check":
|
|
263
|
+
link = QODERWORK_SKILLS_DIR / name
|
|
264
|
+
if link.exists() or link.is_symlink():
|
|
265
|
+
results[name] = "ok(junction)" if is_junction(link) else "WARN(非软链)"
|
|
266
|
+
else:
|
|
267
|
+
results[name] = "missing"
|
|
268
|
+
else: # install
|
|
269
|
+
results[name] = install_one(name, src, mode, dry=False)
|
|
270
|
+
|
|
271
|
+
# 打印结果
|
|
272
|
+
print("-" * 56)
|
|
273
|
+
counts = {"ok": 0, "create": 0, "copy": 0, "warn": 0, "err": 0, "missing": 0}
|
|
274
|
+
for name, status in sorted(results.items()):
|
|
275
|
+
tag = ""
|
|
276
|
+
if status.startswith("ok") or status == "copied" or status.startswith("created"):
|
|
277
|
+
if status.startswith("created") or status == "copied":
|
|
278
|
+
counts["create"] += 1
|
|
279
|
+
tag = "[NEW]"
|
|
280
|
+
else:
|
|
281
|
+
counts["ok"] += 1
|
|
282
|
+
tag = "[OK]"
|
|
283
|
+
elif status.startswith("skip"):
|
|
284
|
+
counts["warn"] += 1
|
|
285
|
+
tag = "[WARN]"
|
|
286
|
+
elif status.startswith("error"):
|
|
287
|
+
counts["err"] += 1
|
|
288
|
+
tag = "[ERR]"
|
|
289
|
+
elif status == "missing":
|
|
290
|
+
counts["missing"] += 1
|
|
291
|
+
tag = "[MISS]"
|
|
292
|
+
elif status == "removed":
|
|
293
|
+
counts["ok"] += 1
|
|
294
|
+
tag = "[DEL]"
|
|
295
|
+
elif status.startswith("WARN"):
|
|
296
|
+
counts["warn"] += 1
|
|
297
|
+
tag = "[WARN]"
|
|
298
|
+
else:
|
|
299
|
+
counts["ok"] += 1
|
|
300
|
+
tag = "[OK]"
|
|
301
|
+
print(f" {tag:6} {name:22} {status}")
|
|
302
|
+
|
|
303
|
+
print("-" * 56)
|
|
304
|
+
print(f"\n小结: 新建 {counts['create']} / 已存在 {counts['ok']} / "
|
|
305
|
+
f"缺失 {counts['missing']} / 警告 {counts['warn']} / 错误 {counts['err']}")
|
|
306
|
+
|
|
307
|
+
# 同时安装 commands (让 QoderWork 用户级也能看到 /wl-* 命令)
|
|
308
|
+
if action in ("install", "check") and SOURCE_COMMANDS_DIR.is_dir():
|
|
309
|
+
print("\n--- Commands (/wl-* 斜杠命令) ---")
|
|
310
|
+
cmd_count = {"ok": 0, "new": 0, "upd": 0}
|
|
311
|
+
for cmd_file in sorted(SOURCE_COMMANDS_DIR.glob("wl-*.md")):
|
|
312
|
+
name = cmd_file.name # e.g. wl-prd.md
|
|
313
|
+
target = QODERWORK_COMMANDS_DIR / name
|
|
314
|
+
if action == "check":
|
|
315
|
+
if target.exists():
|
|
316
|
+
cmd_count["ok"] += 1
|
|
317
|
+
else:
|
|
318
|
+
print(f" [MISS] {name}")
|
|
319
|
+
else: # install
|
|
320
|
+
need_copy = False
|
|
321
|
+
if not target.exists():
|
|
322
|
+
need_copy = True
|
|
323
|
+
elif args.force_commands:
|
|
324
|
+
# 强制刷新(升级场景):仅当内容不同才覆盖,避免无谓写
|
|
325
|
+
import shutil
|
|
326
|
+
if not file_equal(cmd_file, target):
|
|
327
|
+
need_copy = True
|
|
328
|
+
if need_copy:
|
|
329
|
+
try:
|
|
330
|
+
QODERWORK_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
import shutil
|
|
332
|
+
shutil.copy2(str(cmd_file), str(target))
|
|
333
|
+
if target.exists() and args.force_commands:
|
|
334
|
+
cmd_count["upd"] += 1
|
|
335
|
+
else:
|
|
336
|
+
cmd_count["new"] += 1
|
|
337
|
+
except OSError as e:
|
|
338
|
+
print(f" [ERR] {name}: {e}")
|
|
339
|
+
else:
|
|
340
|
+
cmd_count["ok"] += 1
|
|
341
|
+
msg = f" commands: {cmd_count['new']} 新建 / {cmd_count['ok']} 已存在"
|
|
342
|
+
if cmd_count["upd"]:
|
|
343
|
+
msg += f" / {cmd_count['upd']} 刷新"
|
|
344
|
+
print(msg)
|
|
345
|
+
|
|
346
|
+
if action == "install" and counts["err"] == 0:
|
|
347
|
+
print("\n✓ 安装完成。重启 QoderWork(或新建对话)后技能生效。")
|
|
348
|
+
print(" 在 QoderWork 里直接用自然语言即可,例如:")
|
|
349
|
+
print(' "写个保险异常筛选的 PRD" -> prd-generator + prototype-generator')
|
|
350
|
+
print(' "查一下考勤代码在哪" -> wl-search')
|
|
351
|
+
elif action == "install" and counts["err"] > 0:
|
|
352
|
+
print(f"\n⚠ 有 {counts['err']} 个安装失败,见上方 [ERR] 行。")
|
|
353
|
+
print(" 常见原因: 目标路径被占用为非软链。手动删除该目录后重跑本脚本。")
|
|
354
|
+
return 1
|
|
355
|
+
elif action == "uninstall":
|
|
356
|
+
print("\n✓ 卸载完成(源文件未动)。")
|
|
357
|
+
elif action == "check":
|
|
358
|
+
if counts["missing"] > 0 or counts["warn"] > 0:
|
|
359
|
+
print("\n⚠ 检测到缺失/异常,建议跑: python .qoder/scripts/install_qoderwork.py")
|
|
360
|
+
return 1
|
|
361
|
+
else:
|
|
362
|
+
print("\n✓ 全部 skill 已正确安装到 QoderWork 目录。")
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
sys.exit(main())
|