cckit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cckit/installer.py ADDED
@@ -0,0 +1,538 @@
1
+ """cckit add 的完整安装流程,以及 remove 的卸载流程。
2
+
3
+ add:clone → 锁 commit sha → 校验 manifest → lint → 展示计划等确认 →
4
+ 移入 store → 建 env → 跑 postinstall → 写 registry → 建 link 启用。
5
+ 安装是确定性的,不调 LLM(见 D-01)。
6
+
7
+ 硬性要求(见 Docs/07):
8
+ - 锁 commit sha,不锁分支/tag
9
+ - 安装前展示计划并等确认(-y 跳过,文档需警示)
10
+ - postinstall 只允许操作 kit 自己的目录(靠声明+审查+文档,不强制沙箱)
11
+ - 系统级依赖只检查存在性 + 版本约束比对 + 给当前平台 hint,绝不自动安装;
12
+ version_cmd 收紧为 `<bin> <版本标志>` 白名单,拒绝任意命令执行
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import datetime
17
+ import json
18
+ import os
19
+ import re
20
+ import shlex
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
29
+ from packaging.version import InvalidVersion
30
+
31
+ from . import config, env as env_mod, lint, link, manifest, registry, state
32
+ from .errors import CckitError
33
+
34
+
35
+ class InstallError(CckitError):
36
+ """安装失败。"""
37
+
38
+
39
+ @dataclass
40
+ class Plan:
41
+ """安装前展示给用户的执行计划。"""
42
+ source: str
43
+ ref: str | None
44
+ sha: str | None
45
+ kit: str
46
+ version: str
47
+ description: str
48
+ python_packages: list[str] = field(default_factory=list)
49
+ node_packages: list[str] = field(default_factory=list)
50
+ postinstall: list[dict] = field(default_factory=list)
51
+ system_missing: list[dict] = field(default_factory=list)
52
+ system_version_warn: list[dict] = field(default_factory=list)
53
+ system_version_error: list[dict] = field(default_factory=list)
54
+ skills: list[dict] = field(default_factory=list)
55
+
56
+
57
+ # ---- clone ----
58
+ def _clone(source: str, ref: str | None, tmp_root: Path, is_local_path: bool) -> tuple[Path, str | None]:
59
+ """clone 到临时目录,返回 (kit 目录, commit sha)。本地非 git 目录 sha 为 None。"""
60
+ tmp_dir = tmp_root / "kit"
61
+ if is_local_path:
62
+ p = Path(source).expanduser().resolve()
63
+ if not p.is_dir():
64
+ raise InstallError(f"本地路径不存在或不是目录: {source}")
65
+ if (p / ".git").is_dir():
66
+ subprocess.run(["git", "clone", "--quiet", str(p), str(tmp_dir)], check=True)
67
+ if ref:
68
+ subprocess.run(["git", "-C", str(tmp_dir), "checkout", "--quiet", ref], check=True)
69
+ sha = subprocess.run(
70
+ ["git", "-C", str(tmp_dir), "rev-parse", "HEAD"],
71
+ capture_output=True, text=True, check=True).stdout.strip()
72
+ else:
73
+ shutil.copytree(p, tmp_dir, ignore=shutil.ignore_patterns(".git"))
74
+ sha = None
75
+ else:
76
+ subprocess.run(["git", "clone", "--quiet", source, str(tmp_dir)], check=True)
77
+ if ref:
78
+ subprocess.run(["git", "-C", str(tmp_dir), "checkout", "--quiet", ref], check=True)
79
+ sha = subprocess.run(
80
+ ["git", "-C", str(tmp_dir), "rev-parse", "HEAD"],
81
+ capture_output=True, text=True, check=True).stdout.strip()
82
+ return tmp_dir, sha
83
+
84
+
85
+ # ---- 计划 ----
86
+
87
+ def _read_requirements(path: Path | None) -> list[str]:
88
+ if not path or not path.is_file():
89
+ return []
90
+ return [ln.strip() for ln in path.read_text(encoding="utf-8", errors="replace").splitlines()
91
+ if ln.strip() and not ln.strip().startswith("#")]
92
+
93
+
94
+ def _read_package_deps(path: Path | None) -> list[str]:
95
+ if not path or not path.is_file():
96
+ return []
97
+ try:
98
+ pkg = json.loads(path.read_text(encoding="utf-8"))
99
+ except (json.JSONDecodeError, OSError):
100
+ return []
101
+ deps = {}
102
+ deps.update(pkg.get("dependencies") or {})
103
+ deps.update(pkg.get("devDependencies") or {})
104
+ return [f"{k}@{v}" for k, v in deps.items()]
105
+
106
+ # ---- 系统依赖版本约束 ----
107
+
108
+ _VERSION_RE = re.compile(r"\d+(?:\.\d+)+")
109
+ _ALLOWED_VERSION_FLAGS = frozenset({"--version", "-V", "-v", "version", "-version"})
110
+
111
+
112
+ def _unsafe_version_cmd(argv: list[str], bin_name: str) -> str | None:
113
+ """校验 version_cmd 是否落在白名单内,返回不安全原因;None = 安全可执行。
114
+
115
+ 白名单只允许 `<bin> <版本标志>`:第一个词必须等于 bin,后跟且仅跟一个
116
+ 版本标志。其余(多余参数、其它标志、别的二进制)一律拒绝 —— 作者写的
117
+ version_cmd 会在用户确认安装之前就执行,必须收紧到"只读的版本查询"。
118
+ """
119
+ if not argv:
120
+ return "命令为空"
121
+ if argv[0] != bin_name:
122
+ return f"命令 {argv[0]!r} 不是依赖 {bin_name!r} 本身"
123
+ if len(argv) != 2:
124
+ return f"参数个数必须恰为 2(<bin> <版本标志>),实际 {len(argv)}"
125
+ if argv[1] not in _ALLOWED_VERSION_FLAGS:
126
+ return f"版本标志 {argv[1]!r} 不在白名单 {sorted(_ALLOWED_VERSION_FLAGS)!r}"
127
+ return None
128
+
129
+
130
+ def _version_satisfies(installed: str, constraint: str) -> bool:
131
+ """按约束判断已装版本是否满足(基于 PEP 440 的 SpecifierSet)。"""
132
+ try:
133
+ return SpecifierSet(constraint).contains(installed)
134
+ except (InvalidSpecifier, InvalidVersion) as e:
135
+ raise ValueError(f"无法解析版本约束 {constraint!r}: {e}") from e
136
+
137
+
138
+ def _check_system_version(dep: dict) -> tuple[str | None, str]:
139
+ """检查单个 system 依赖的版本约束,返回 (级别, 消息)。
140
+
141
+ - 未声明 version → (None, ""),跳过。
142
+ - 版本满足 → (None, "")。
143
+ - 版本不满足 → ("error", 消息)。
144
+ - 无法运行/解析版本,或 version_cmd 被白名单拒绝 → ("warn", 消息)。
145
+ """
146
+ constraint = dep.get("version")
147
+ if not constraint:
148
+ return None, ""
149
+ bin_name = dep["bin"]
150
+ version_cmd = dep.get("version_cmd") or f"{bin_name} --version"
151
+ try:
152
+ argv = shlex.split(version_cmd)
153
+ except ValueError as e:
154
+ return "warn", f"version_cmd 无法解析: {e}(要求 {constraint})"
155
+ reason = _unsafe_version_cmd(argv, bin_name)
156
+ if reason is not None:
157
+ return "warn", (
158
+ f"version_cmd {version_cmd!r} 被拒绝({reason});"
159
+ f"为安全起见仅允许 <bin> 后跟一个版本标志,请手动验证版本(要求 {constraint})"
160
+ )
161
+ try:
162
+ proc = subprocess.run(argv, capture_output=True, text=True, timeout=10)
163
+ except (OSError, ValueError) as e:
164
+ return "warn", f"无法运行版本检查命令 {version_cmd!r}: {e}"
165
+ except subprocess.TimeoutExpired:
166
+ return "warn", f"版本检查命令 {version_cmd!r} 超时,无法验证版本(要求 {constraint})"
167
+
168
+ output = (proc.stdout + "\n" + proc.stderr).strip()
169
+ m = _VERSION_RE.search(output) if output else None
170
+ if m is not None:
171
+ try:
172
+ if _version_satisfies(m.group(0), constraint):
173
+ return None, ""
174
+ except ValueError as e:
175
+ return "warn", str(e)
176
+ if proc.returncode != 0:
177
+ return "warn", f"版本检查命令 {version_cmd!r} 失败(exit {proc.returncode}),无法验证版本(要求 {constraint})"
178
+ if m is None:
179
+ return "warn", f"无法从 {version_cmd!r} 的输出解析版本(要求 {constraint})"
180
+ return "error", f"版本不满足:要求 {constraint},当前 {m.group(0)}"
181
+
182
+
183
+ def compute_plan(source: str, ref: str | None, sha: str | None,
184
+ data: dict, kit_dir: Path) -> Plan:
185
+ """根据 manifest + 依赖文件 + 系统探测,算出要展示的执行计划。"""
186
+ requires = data.get("requires") or {}
187
+ py_file = (requires.get("python") or {}).get("file")
188
+ node_file = (requires.get("node") or {}).get("file")
189
+ plan = Plan(
190
+ source=source, ref=ref, sha=sha, kit=data["kit"], version=data.get("version", ""),
191
+ description=data.get("description", ""),
192
+ python_packages=_read_requirements(kit_dir / py_file) if py_file else [],
193
+ node_packages=_read_package_deps(kit_dir / node_file) if node_file else [],
194
+ postinstall=list(data.get("postinstall") or []),
195
+ skills=list(data.get("skills") or []),
196
+ )
197
+ platform = config.platform_name()
198
+ for dep in requires.get("system", []):
199
+ if shutil.which(dep["bin"]) is None:
200
+ plan.system_missing.append({
201
+ "bin": dep["bin"],
202
+ "hint": (dep.get("hint") or {}).get(platform),
203
+ })
204
+ continue
205
+ level, msg = _check_system_version(dep)
206
+ if level == "error":
207
+ plan.system_version_error.append({"bin": dep["bin"], "detail": msg})
208
+ elif level == "warn":
209
+ plan.system_version_warn.append({"bin": dep["bin"], "detail": msg})
210
+ return plan
211
+
212
+
213
+ def render_plan(plan: Plan) -> str:
214
+ """把计划渲染成给用户看的文本(完整列出包、postinstall、缺失系统依赖、来源+sha)。"""
215
+ lines = [f"来源: {plan.source}"]
216
+ if plan.ref:
217
+ lines.append(f"ref : {plan.ref}")
218
+ lines.append(f"sha : {plan.sha or '(本地目录,无 git)'}")
219
+ lines.append(f"kit : {plan.kit} {plan.version}")
220
+ lines.append(f"说明: {plan.description}")
221
+ lines.append("")
222
+ lines.append("将安装的 skill:")
223
+ for sk in plan.skills:
224
+ needs = ", ".join(sk.get("needs", [])) or "(纯 prompt)"
225
+ lines.append(f" - {sk['name']} needs: {needs}")
226
+ if plan.python_packages:
227
+ lines.append("")
228
+ lines.append("Python 包(requirements.txt):")
229
+ for p in plan.python_packages:
230
+ lines.append(f" - {p}")
231
+ if plan.node_packages:
232
+ lines.append("")
233
+ lines.append("Node 包(package.json):")
234
+ for p in plan.node_packages:
235
+ lines.append(f" - {p}")
236
+ if plan.postinstall:
237
+ lines.append("")
238
+ lines.append("将执行的 postinstall:")
239
+ for step in plan.postinstall:
240
+ lines.append(f" - {step['run']} (when: {step.get('when', 'always')})")
241
+ if plan.system_missing:
242
+ lines.append("")
243
+ lines.append("缺失的系统依赖(只提示,不自动装):")
244
+ for d in plan.system_missing:
245
+ lines.append(f" - {d['bin']} → {d.get('hint') or '(无 hint)'}")
246
+ if plan.system_version_warn:
247
+ lines.append("")
248
+ lines.append("系统依赖版本无法验证:")
249
+ for d in plan.system_version_warn:
250
+ lines.append(f" - {d['bin']} → {d.get('detail', '')}")
251
+ if plan.system_version_error:
252
+ lines.append("")
253
+ lines.append("系统依赖版本不满足(该 skill 可能无法正常工作):")
254
+ for d in plan.system_version_error:
255
+ lines.append(f" - {d['bin']} → {d.get('detail', '')}")
256
+ lines.append("")
257
+ lines.append("⚠️ 安装一个 kit 等同于在本机运行该仓库作者的代码。请只安装你信任的来源。")
258
+ return "\n".join(lines)
259
+
260
+
261
+ # ---- env / postinstall ----
262
+
263
+ def build_envs(data: dict, kit_dir: Path) -> dict[str, dict[str, Path]]:
264
+ """按 skills 的 needs 为每个 skill 建 env。返回 {name: {runtime: env_dir}}。
265
+
266
+ 一个 skill 可同时需要 python 与 node(各自建独立 env);needs 不含
267
+ python/node 的纯 prompt skill 返回 {}。中途某 skill 失败时清掉本次已建
268
+ 的 env 再抛,避免留下孤儿 env(否则 install 的回滚拿不到 env_dirs)。
269
+ """
270
+ requires = data.get("requires") or {}
271
+ runtime = requires.get("runtime") or {}
272
+ py_constraint = runtime.get("python")
273
+ py_file = (requires.get("python") or {}).get("file")
274
+ node_file = (requires.get("node") or {}).get("file")
275
+ req_path = kit_dir / py_file if py_file else None
276
+ pkg_path = kit_dir / node_file if node_file else None
277
+
278
+ result: dict[str, dict[str, Path]] = {}
279
+ created: list[Path] = []
280
+ try:
281
+ for sk in data.get("skills", []):
282
+ name = sk["name"]
283
+ needs = set(sk.get("needs", []))
284
+ envs: dict[str, Path] = {}
285
+ if "python" in needs:
286
+ env_dir = config.envs_dir() / f"{data['kit']}__{name}__python"
287
+ env_mod.create_python_env(env_dir, py_constraint, req_path)
288
+ created.append(env_dir)
289
+ envs["python"] = env_dir
290
+ if "node" in needs:
291
+ env_dir = config.envs_dir() / f"{data['kit']}__{name}__node"
292
+ env_mod.create_node_env(env_dir, pkg_path)
293
+ created.append(env_dir)
294
+ envs["node"] = env_dir
295
+ result[name] = envs
296
+ except Exception:
297
+ for d in created:
298
+ shutil.rmtree(d, ignore_errors=True)
299
+ raise
300
+ return result
301
+
302
+
303
+ def _script_interpreter(script: Path, py_interp: Path | None) -> str:
304
+ ext = script.suffix.lower()
305
+ if ext == ".py":
306
+ return str(py_interp) if py_interp else "python"
307
+ if ext in (".js", ".mjs"):
308
+ return "node"
309
+ raise InstallError(f"不支持的 postinstall 脚本类型 {script.name}(v0.1 仅支持 .py/.js)")
310
+
311
+
312
+ def run_postinstall(steps: list[dict], kit_dir: Path,
313
+ envs: dict[str, dict[str, Path]]) -> None:
314
+ """执行 postinstall。when=python/node 仅在对应环境建好后执行。
315
+
316
+ 脚本 cwd 为 kit 根;约束:只允许操作 kit 自己的目录(靠声明+审查+文档)。
317
+ """
318
+ python_envs = [d for m in envs.values() for rt, d in m.items() if rt == "python"]
319
+ node_built = any(rt == "node" for m in envs.values() for rt in m)
320
+ py_interp = env_mod.python_interpreter(python_envs[0]) if python_envs else None
321
+ for step in steps:
322
+ run = step.get("run")
323
+ when = step.get("when", "always")
324
+ script = kit_dir / run
325
+ if not script.is_file():
326
+ raise InstallError(f"postinstall 脚本不存在: {run}")
327
+ if when == "python" and py_interp is None:
328
+ continue
329
+ if when == "node" and not node_built:
330
+ continue
331
+ interp = _script_interpreter(script, py_interp)
332
+ env = os.environ.copy()
333
+ env["CCKIT_KIT_DIR"] = str(kit_dir)
334
+ subprocess.run([interp, str(script)], cwd=str(kit_dir), env=env, check=True)
335
+
336
+
337
+ # ---- registry 记录 ----
338
+
339
+ def _registry_record(source: str, ref: str | None, sha: str | None, data: dict,
340
+ store_target: Path, envs: dict[str, dict[str, Path]],
341
+ scope: str) -> dict:
342
+ root = config.project_root()
343
+ known = "global" if scope == "global" else str(root)
344
+ skills = []
345
+ for sk in data.get("skills", []):
346
+ skills.append({
347
+ "name": sk["name"],
348
+ "envs": {rt: str(d) for rt, d in envs[sk["name"]].items()},
349
+ "needs": list(sk.get("needs", [])),
350
+ })
351
+ return {
352
+ "source": {"url": source, "ref": ref, "sha": sha},
353
+ "version": data.get("version"),
354
+ "installed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
355
+ "store": str(store_target),
356
+ "skills": skills,
357
+ "known_scopes": [known],
358
+ "override_scopes": [],
359
+ }
360
+
361
+
362
+ def _installed_descriptions() -> list[str]:
363
+ """已装 skill 的 description 列表,供 lint 语义重叠检测。"""
364
+ store = config.store_dir()
365
+ descs: list[str] = []
366
+ if store.is_dir():
367
+ for skill_dir in store.glob("*/*"):
368
+ if not skill_dir.is_dir():
369
+ continue
370
+ d = str(manifest.read_skill_frontmatter(skill_dir).get("description") or "").strip()
371
+ if d:
372
+ descs.append(d)
373
+ return descs
374
+
375
+
376
+ # ---- add 主流程 ----
377
+
378
+ def install(source: str, *, ref: str | None = None, project: bool = False,
379
+ no_enable: bool = False, only: str | None = None,
380
+ assume_yes: bool = False, is_local_path: bool = False) -> None:
381
+ """安装一个 kit。破坏性/高风险操作在非 -y 时需确认。
382
+
383
+ is_local_path=True 时把 source 当本地目录(非 git 目录则 copytree、无 sha);
384
+ 否则当 git URL 直接交给 `git clone`。
385
+ """
386
+ scope = "project" if project else "global"
387
+ if project:
388
+ # 项目作用域:确保项目根存在。尚无 .claude / cckit.lock 时以 cwd 为项目根,
389
+ # 并建出 .claude 目录(否则 set_state 无法定位项目 skills 目录)。
390
+ root = config.project_root() or Path.cwd().resolve()
391
+ (root / ".claude").mkdir(parents=True, exist_ok=True)
392
+ tmp_root = Path(tempfile.mkdtemp(prefix="cckit-clone-"))
393
+ store_target: Path | None = None
394
+ env_dirs: list[Path] = []
395
+ reg_added = False
396
+ kit_name: str | None = None
397
+ try:
398
+ kit_dir, sha = _clone(source, ref, tmp_root, is_local_path)
399
+
400
+ # 校验 manifest(存在性 → schema → 语义)。失败即中止,不留残留。
401
+ data = manifest.load(kit_dir / "cckit.yaml")
402
+ manifest.validate_schema(data)
403
+ manifest.semantic_check(data, kit_dir)
404
+ kit_name = data["kit"]
405
+
406
+ # --only 提前校验:写错名字直接报错,而不是静默不 link(见 M4)
407
+ only_names: set[str] | None = None
408
+ if only:
409
+ only_names = {s.strip() for s in only.split(",") if s.strip()}
410
+ if not only_names:
411
+ raise InstallError("--only 没有指定任何有效的 skill 名")
412
+ unknown = only_names - {sk["name"] for sk in data.get("skills", [])}
413
+ if unknown:
414
+ raise InstallError(
415
+ f"--only 指定的 skill 不在 kit 中: {', '.join(sorted(unknown))}")
416
+
417
+ # lint。error 中止;warn 展示。
418
+ msgs = lint.LintKit(kit_dir, data, _installed_descriptions()).lint_kit()
419
+ for m in msgs:
420
+ print(f"[{m.level}] {m.message}")
421
+ if any(m.level == "error" for m in msgs):
422
+ raise InstallError("lint 发现错误,已中止(未留下任何残留)")
423
+
424
+ kit = data["kit"]
425
+ store_target = config.store_dir() / kit
426
+ if store_target.exists():
427
+ raise InstallError(f"kit {kit!r} 已安装(store 已存在),请先 `cckit remove {kit}`")
428
+
429
+ # 项目安装时检测同名全局 skill(全局盖项目,见 Docs/06 2.3)
430
+ if project:
431
+ global_names = {s.name for s in state.list_skills("global")}
432
+ for sk in data.get("skills", []):
433
+ if sk["name"] in global_names:
434
+ print(f"[warn] 全局已存在同名 skill {sk['name']!r},"
435
+ f"全局会覆盖项目版(enterprise > personal > project)")
436
+
437
+ # 展示计划并等确认
438
+ plan = compute_plan(source, ref, sha, data, kit_dir)
439
+ print(render_plan(plan))
440
+ sys.stdout.flush() # 确认前把计划刷出:uv/npm 子进程直接写 fd,绕过缓冲
441
+ if not assume_yes:
442
+ ans = input("继续安装? [y/N] ").strip().lower()
443
+ if ans not in ("y", "yes"):
444
+ raise InstallError("已取消安装")
445
+ else:
446
+ print("[warn] -y 已跳过确认。安装等于运行仓库作者代码,请自行确认来源可信。")
447
+ sys.stdout.flush() # -y 路径无 input,补一次 flush 保证计划先于子进程输出
448
+
449
+ # 移入 store
450
+ config.store_dir().mkdir(parents=True, exist_ok=True)
451
+ shutil.move(str(kit_dir), str(store_target))
452
+ kit_dir = store_target
453
+
454
+ # 建 env
455
+ envs = build_envs(data, store_target)
456
+ env_dirs = [d for m in envs.values() for d in m.values()]
457
+
458
+ # postinstall
459
+ run_postinstall(data.get("postinstall") or [], store_target, envs)
460
+
461
+ # 写 registry(先于 link —— set_state 需要 registry 定位 skill)
462
+ registry.add_kit(kit, _registry_record(source, ref, sha, data, store_target, envs, scope))
463
+ reg_added = True
464
+
465
+ # 建 link 启用
466
+ if not no_enable:
467
+ for sk in data.get("skills", []):
468
+ if only_names is not None and sk["name"] not in only_names:
469
+ continue
470
+ state.set_state(sk["name"], "enabled", scope, kit=kit)
471
+
472
+ used, limit = state.budget()
473
+ print(f"已安装 {kit} {data.get('version', '')}")
474
+ print(f"清单预算: {used} / {limit} 字符")
475
+ print("提示:开关改动将在新会话生效。")
476
+ except Exception:
477
+ # 回滚:临时目录、store、env、registry 记录(尽力而为)
478
+ shutil.rmtree(tmp_root, ignore_errors=True)
479
+ if store_target is not None and store_target.exists():
480
+ shutil.rmtree(store_target, ignore_errors=True)
481
+ for d in env_dirs:
482
+ shutil.rmtree(d, ignore_errors=True)
483
+ if reg_added and kit_name:
484
+ registry.remove_kit(kit_name)
485
+ raise
486
+ finally:
487
+ shutil.rmtree(tmp_root, ignore_errors=True)
488
+
489
+
490
+ # ---- remove ----
491
+
492
+ def _skills_dir_for_scope(scope_value: str) -> Path | None:
493
+ if scope_value == "global":
494
+ return config.claude_config_dir() / "skills"
495
+ return Path(scope_value) / ".claude" / "skills"
496
+
497
+
498
+ def remove_kit(kit: str, keep_env: bool = False) -> None:
499
+ """删 link → 删 env → 删 store → 清 registry 与 skillOverrides 残留。"""
500
+ info = registry.get_kit(kit)
501
+ if info is None:
502
+ raise CckitError(f"kit {kit!r} 未安装")
503
+ names = [sk["name"] for sk in info.get("skills", [])]
504
+
505
+ # 删 link(所有 known_scopes)
506
+ for scope_value in info.get("known_scopes", []):
507
+ skills_dir = _skills_dir_for_scope(scope_value)
508
+ if skills_dir is None:
509
+ continue
510
+ for name in names:
511
+ lp = skills_dir / name
512
+ if link.is_link(lp) or link.is_dangling(lp):
513
+ link.remove(lp)
514
+
515
+ # 删 env
516
+ if not keep_env:
517
+ for sk in info.get("skills", []):
518
+ for env_dir in (sk.get("envs") or {}).values():
519
+ shutil.rmtree(Path(env_dir), ignore_errors=True)
520
+
521
+ # 删 store
522
+ store = Path(info.get("store", ""))
523
+ if store.exists():
524
+ shutil.rmtree(store, ignore_errors=True)
525
+
526
+ # 清 registry
527
+ registry.remove_kit(kit)
528
+
529
+ # 清 skillOverrides 残留
530
+ for scope_value in info.get("known_scopes", []):
531
+ if scope_value == "global":
532
+ state.remove_overrides(names, "global")
533
+ else:
534
+ state.remove_overrides(names, "project", root=Path(scope_value))
535
+ # 清项目级覆盖残留(全局 kit 的 skill 被 --project 覆盖时记入 override_scopes)
536
+ for scope_value in info.get("override_scopes", []):
537
+ if scope_value and scope_value != "global":
538
+ state.remove_overrides(names, "project", root=Path(scope_value))
cckit/link.py ADDED
@@ -0,0 +1,79 @@
1
+ """跨平台目录链接层。
2
+
3
+ Windows 用 junction,POSIX 用 symlink。选择理由:
4
+ - Windows 上 symlink 需要管理员权限或开发者模式(实测 FAIL:需要管理员权限),
5
+ junction 不需要。junction 只支持目录+本地绝对路径,而 skill 恰好都满足。
6
+ - POSIX 上 symlink 本来就免权限,没必要绕。
7
+
8
+ 三个平台差异都是实测结论,不是推断:
9
+ 创建 Windows: _winapi.CreateJunction Linux: os.symlink
10
+ 删除 os.unlink 两边都行;os.rmdir 只在 Windows 行,Linux 报 NotADirectoryError
11
+ 检测 Windows: islink=False isjunction=True Linux: islink=True isjunction=False
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ IS_WIN = sys.platform == "win32"
22
+
23
+
24
+ def create(target: Path, link: Path) -> None:
25
+ """在 link 处建一个指向 target 的链接。target 必须是已存在的目录。
26
+
27
+ 若 link 已存在(哪怕是用户手写的真实目录),两个平台都会拒绝并抛错,
28
+ 不会静默覆盖 —— 实测 Windows winerror 183 / Linux FileExistsError。
29
+ 调用方负责先判断 exists 再决定报错还是提示用户。
30
+ """
31
+ target, link = target.resolve(), Path(link)
32
+ if not target.is_dir():
33
+ raise NotADirectoryError(f"链接目标不是目录: {target}")
34
+ link.parent.mkdir(parents=True, exist_ok=True)
35
+
36
+ if not IS_WIN:
37
+ os.symlink(target, link, target_is_directory=True)
38
+ return
39
+ try:
40
+ import _winapi # CPython 私有但稳定;自测套件在用
41
+ _winapi.CreateJunction(str(target), str(link))
42
+ except (ImportError, AttributeError):
43
+ # 兜底:老版本 / 非 CPython 实现
44
+ subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)],
45
+ check=True, capture_output=True)
46
+
47
+
48
+ def is_link(path: Path) -> bool:
49
+ """path 是否是我们建的链接。
50
+
51
+ 必须两个都查:junction 上 islink() 返回 False,symlink 上 isjunction()
52
+ 返回 False。只查一个会在另一个平台上把链接误判成普通目录 —— 那会让
53
+ cckit 把自己装的 skill 当成用户手写的,从而拒绝管理它,且不报错。
54
+ """
55
+ return os.path.islink(path) or (
56
+ IS_WIN and hasattr(os.path, "isjunction") and os.path.isjunction(path)
57
+ )
58
+
59
+
60
+ def remove(link: Path) -> None:
61
+ """删掉链接本身,绝不动目标目录里的内容。
62
+
63
+ os.unlink 两个平台都可用。不要用 os.rmdir(Linux 上对 symlink 抛
64
+ NotADirectoryError),更不要用 shutil.rmtree(语义是递归删内容,
65
+ 一旦哪天在某平台上跟随了链接就会删穿到 store 里的源码)。
66
+ """
67
+ if not os.path.lexists(link):
68
+ return # 已经没了,幂等
69
+ if not is_link(link):
70
+ raise IsADirectoryError(f"拒绝删除:{link} 是真实目录而非链接")
71
+ os.unlink(link)
72
+
73
+
74
+ def is_dangling(link: Path) -> bool:
75
+ """链接存在但目标已消失。doctor 用它找残留。
76
+
77
+ lexists 看链接自身,exists 会跟随到目标 —— 目标没了就返回 False。
78
+ """
79
+ return os.path.lexists(link) and not os.path.exists(link)