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/__init__.py +8 -0
- cckit/__main__.py +7 -0
- cckit/cckit.schema.json +136 -0
- cckit/cli.py +294 -0
- cckit/config.py +93 -0
- cckit/doctor.py +185 -0
- cckit/env.py +54 -0
- cckit/errors.py +10 -0
- cckit/exec.py +104 -0
- cckit/installer.py +538 -0
- cckit/link.py +79 -0
- cckit/lint.py +309 -0
- cckit/manifest.py +113 -0
- cckit/registry.py +136 -0
- cckit/schema.py +42 -0
- cckit/state.py +484 -0
- cckit-0.1.0.dist-info/METADATA +9 -0
- cckit-0.1.0.dist-info/RECORD +21 -0
- cckit-0.1.0.dist-info/WHEEL +4 -0
- cckit-0.1.0.dist-info/entry_points.txt +2 -0
- cckit-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|