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/lint.py ADDED
@@ -0,0 +1,309 @@
1
+ """kit 的 lint 检查。
2
+
3
+ 覆盖:skill 名合法性(小写 kebab-case、Windows 保留名、仅大小写不同的重名、
4
+ synced 保留)、description 质量(缺失=error、含触发条件=warn、超 1536=warn、
5
+ 与已装 skill 语义重叠=warn)、CRLF 检查、prompt injection 启发式扫描
6
+ (07 第 6 条)、依赖 typosquatting 提示(07 第 7 条)。
7
+
8
+ 全部是确定性的启发式,不调 LLM。返回 LintMessage 列表,级别 error / warn。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from . import manifest
18
+
19
+ # Windows 保留名(大小写不敏感)
20
+ WINDOWS_RESERVED = {
21
+ "con", "prn", "aux", "nul",
22
+ *[f"com{i}" for i in range(1, 10)],
23
+ *[f"lpt{i}" for i in range(1, 10)],
24
+ }
25
+ SYNCED_RESERVED = "synced"
26
+
27
+ # description 触发条件(启发式)
28
+ _TRIGGER_RE = re.compile(
29
+ r"(?i)\b(use\s+when|use\s+this\s+(when|if|for)|when\s+(the\s+)?user\s|"
30
+ r"whenever|trigger\s|触发|使用时机|适用于)\b"
31
+ )
32
+
33
+ # prompt injection 启发式(07 第 6 条)。定位是警告,不是可靠防护。
34
+ _PROMPT_INJECTION = [
35
+ (re.compile(r"(?i)ignore\s+(all\s+)?(previous|prior|above|earlier)\s+instructions"),
36
+ "要求忽略既有指令"),
37
+ (re.compile(r"(?i)\byou\s+are\s+now\b"), "尝试重新定义助手角色"),
38
+ (re.compile(r"(?i)(cat|read|print|get|open)\s+[`'\"]?\.?env\b"), "要求读取 .env 文件"),
39
+ (re.compile(r"(?i)(id_rsa|ssh[_-]?key|private[_\s]?key|authorized_keys)"),
40
+ "要求访问 SSH 私钥"),
41
+ (re.compile(r"(?i)(curl|wget|fetch|http)\b.{0,80}(--data|-d\s|POST|upload|exfiltrate)"),
42
+ "疑似向外部地址发送数据"),
43
+ ]
44
+
45
+ # 知名 PyPI 包,用于 typosquatting 提示(启发式,非全量)
46
+ _KNOWN_PYPI = {
47
+ "requests", "numpy", "pandas", "scipy", "matplotlib", "flask", "django",
48
+ "fastapi", "uvicorn", "httpx", "aiohttp", "beautifulsoup4", "lxml",
49
+ "urllib3", "certifi", "idna", "charset-normalizer", "pyyaml", "pytest",
50
+ "pydantic", "sqlalchemy", "click", "jinja2", "pillow", "opencv-python",
51
+ "scikit-learn", "tensorflow", "torch", "tqdm", "rich", "typer",
52
+ }
53
+
54
+ # 知名 npm 包,用于 package.json 依赖的 typosquatting 提示(启发式,非全量)
55
+ _KNOWN_NPM = {
56
+ "express", "lodash", "react", "react-dom", "axios", "chalk", "commander",
57
+ "debug", "dotenv", "fs-extra", "inquirer", "minimist", "moment",
58
+ "node-fetch", "typescript", "webpack", "eslint", "prettier", "jest",
59
+ "yargs", "glob", "semver", "uuid", "winston", "zod", "next", "vue",
60
+ "mongoose", "socket.io",
61
+ }
62
+
63
+ _STOP = {
64
+ "the", "and", "for", "you", "this", "that", "with", "from", "your",
65
+ "when", "will", "not", "are", "use", "into", "can", "its", "have",
66
+ "has", "was", "were", "been", "being", "they", "them", "their", "then",
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class LintMessage:
72
+ level: str # 'error' | 'warn'
73
+ message: str
74
+
75
+ class LintKit:
76
+ def __init__(self, kit_dir: Path, data: dict, installed_descs: list[str] | None = None):
77
+ self.kit_dir = kit_dir
78
+ self.data = data
79
+ self.installed_descs = installed_descs or []
80
+
81
+ @staticmethod
82
+ def _skill_desc(skill_dir: Path) -> tuple[str, str]:
83
+ fm = manifest.read_skill_frontmatter(skill_dir)
84
+ return (str(fm.get("description") or ""), str(fm.get("when_to_use") or ""))
85
+
86
+ def _lint_skill_names(self) -> list[LintMessage]:
87
+ """skill 名合法性:Windows 保留名、synced 保留、仅大小写不同的重名。
88
+
89
+ 大写检查不在这里 —— add 路径先过 schema(slug pattern 只允许小写),此处
90
+ 再查是死代码且误导读者(见 M9)。这里只留 schema 之外才可能出现的约束。
91
+ """
92
+ msgs: list[LintMessage] = []
93
+ names = [sk["name"] for sk in self.data.get("skills", [])]
94
+ by_lower: dict[str, list[str]] = {}
95
+ for n in names:
96
+ ln = n.lower()
97
+ if ln in WINDOWS_RESERVED:
98
+ msgs.append(LintMessage("error", f"skill 名 {n!r} 是 Windows 保留名"))
99
+ if ln == SYNCED_RESERVED:
100
+ msgs.append(LintMessage("error", f"skill 名 {n!r} 是 CC 保留目录名 synced"))
101
+ by_lower.setdefault(ln, []).append(n)
102
+ for ln, lst in by_lower.items():
103
+ if len(lst) > 1:
104
+ msgs.append(LintMessage("error", f"仅大小写不同的重名 skill: {lst}"))
105
+ return msgs
106
+
107
+ def _lint_descriptions(self) -> list[LintMessage]:
108
+ msgs: list[LintMessage] = []
109
+ for sk in self.data.get("skills", []):
110
+ desc, when = self._skill_desc(self.kit_dir / sk["name"])
111
+ if not desc.strip():
112
+ msgs.append(LintMessage(
113
+ "error", f"skill {sk['name']!r} 的 description 缺失(CC 靠它选工具)"))
114
+ continue
115
+ if _TRIGGER_RE.search(desc):
116
+ msgs.append(LintMessage(
117
+ "warn",
118
+ f"skill {sk['name']!r} 的 description 含触发条件(如 'Use when...'),"
119
+ f"应移到 when_to_use"))
120
+ total = len(desc) + len(when)
121
+ if total > 1536:
122
+ msgs.append(LintMessage(
123
+ "warn",
124
+ f"skill {sk['name']!r} description+when_to_use 共 {total} 字符,"
125
+ f"超过 1536 会被 CC 截断"))
126
+ for other in self.installed_descs:
127
+ if TextSimilarity(desc, other).similarity() > 0.9:
128
+ msgs.append(LintMessage(
129
+ "warn",
130
+ f"skill {sk['name']!r} 的 description 与已装 skill 语义重叠"
131
+ f"(CC 可能挑错工具)"))
132
+ break
133
+ return msgs
134
+
135
+ def _lint_crlf(self) -> list[LintMessage]:
136
+ return CRLFLint(self.kit_dir).lint_crlf()
137
+
138
+ def _lint_prompt_injection(self) -> list[LintMessage]:
139
+ msgs: list[LintMessage] = []
140
+ for sk in self.data.get("skills", []):
141
+ md = self.kit_dir / sk["name"] / "SKILL.md"
142
+ if not md.is_file():
143
+ continue
144
+ text = md.read_text(encoding="utf-8", errors="replace")
145
+ for pat, desc in _PROMPT_INJECTION:
146
+ if pat.search(text):
147
+ msgs.append(LintMessage(
148
+ "warn",
149
+ f"skill {sk['name']!r} 的 SKILL.md 疑似 prompt injection:{desc}"))
150
+ break
151
+ return msgs
152
+
153
+ def _lint_typosquatting(self) -> list[LintMessage]:
154
+ return TyposquatLint(self.kit_dir, self.data).lint_typosquatting()
155
+
156
+ def lint_kit(self) -> list[LintMessage]:
157
+ """对一个已通过 schema 校验的 kit 做全部 lint。"""
158
+ msgs: list[LintMessage] = []
159
+ msgs += self._lint_skill_names()
160
+ msgs += self._lint_descriptions()
161
+ msgs += self._lint_crlf()
162
+ msgs += self._lint_prompt_injection()
163
+ msgs += self._lint_typosquatting()
164
+ return msgs
165
+
166
+
167
+ class TextSimilarity:
168
+ """文本相似度计算,用于 description 语义重叠提示。
169
+
170
+ 目前用简单的 token 集交集/并集比值,不调 LLM (基于 token 集合的 Jaccard 相似度)。token 是小写拉丁字母数字
171
+ 序列(长度>2)与 CJK 字符(单字+双字 bi-gram),停用词过滤掉。
172
+ """
173
+ def __init__(self, a: str, b: str):
174
+ self.a = a
175
+ self.b = b
176
+
177
+ @staticmethod
178
+ def _tokens(text: str) -> set[str]:
179
+ s = text.lower()
180
+ latin = {w for w in re.findall(r"[a-z0-9]{3,}", s) if w not in _STOP}
181
+ cjk = re.findall(r"[一-鿿]", s)
182
+ cjk_bi = {cjk[i] + cjk[i + 1] for i in range(len(cjk) - 1)}
183
+ return latin | set(cjk) | cjk_bi
184
+
185
+ def similarity(self) -> float:
186
+ ta, tb = self._tokens(self.a), self._tokens(self.b)
187
+ if not ta or not tb:
188
+ return 0.0
189
+ return len(ta & tb) / len(ta | tb)
190
+
191
+
192
+ class CRLFLint:
193
+ """检查 kit 内文本文件是否含 CRLF 换行,在 Linux 上 .sh 会 bad interpreter。"""
194
+ def __init__(self, kit_dir: Path):
195
+ self.kit_dir = kit_dir
196
+
197
+ def _text_files(self) -> list[Path]:
198
+ skip = {".git", "__pycache__", ".venv", "node_modules"}
199
+ out: list[Path] = []
200
+ for f in self.kit_dir.rglob("*"):
201
+ if not f.is_file():
202
+ continue
203
+ if any(part in skip for part in f.parts):
204
+ continue
205
+ if f.stat().st_size > 1_000_000:
206
+ continue
207
+ out.append(f)
208
+ return out
209
+
210
+ def lint_crlf(self) -> list[LintMessage]:
211
+ msgs: list[LintMessage] = []
212
+ for f in self._text_files():
213
+ try:
214
+ raw = f.read_bytes()
215
+ except OSError:
216
+ continue
217
+ if b"\r\n" in raw:
218
+ rel = f.relative_to(self.kit_dir)
219
+ msgs.append(LintMessage(
220
+ "warn",
221
+ f"{rel} 含 CRLF 换行,在 Linux 上 .sh 会 bad interpreter,建议转 LF"))
222
+ return msgs
223
+
224
+
225
+ class TyposquatLint:
226
+ """检查 kit 内依赖是否疑似 typosquatting,用于提示(07 第 7 条)。"""
227
+ def __init__(self, kit_dir: Path, data: dict):
228
+ self.kit_dir = kit_dir
229
+ self.data = data
230
+
231
+ def lint_typosquatting(self) -> list[LintMessage]:
232
+ """typosquatting 提示:requirements.txt(python)与 package.json(node)都扫。"""
233
+ msgs: list[LintMessage] = []
234
+ requires = self.data.get("requires") or {}
235
+ py_file = (requires.get("python") or {}).get("file")
236
+ if py_file:
237
+ req = self.kit_dir / py_file
238
+ if req.is_file():
239
+ msgs += self._typosquat_check(self._parse_requirements(req), _KNOWN_PYPI)
240
+ node_file = (requires.get("node") or {}).get("file")
241
+ if node_file:
242
+ pkg = self.kit_dir / node_file
243
+ if pkg.is_file():
244
+ msgs += self._typosquat_check(self._parse_package_names(pkg), _KNOWN_NPM)
245
+ return msgs
246
+
247
+ def _typosquat_check(self, names: list[str], known: set[str]) -> list[LintMessage]:
248
+ """对一组依赖名做 typosquatting 提示(与 known 集比对)。"""
249
+ msgs: list[LintMessage] = []
250
+ for pkg in names:
251
+ name = pkg.lower()
252
+ if name in known:
253
+ continue
254
+ for k in known:
255
+ if abs(len(k) - len(name)) <= 1 and self._lev(name, k) <= 1:
256
+ msgs.append(LintMessage(
257
+ "warn",
258
+ f"依赖 {pkg!r} 与知名包 {k!r} 极其相似,疑似 typosquatting"))
259
+ break
260
+ return msgs
261
+
262
+ @staticmethod
263
+ def _parse_package_names(path: Path) -> list[str]:
264
+ """package.json 的 dependencies + devDependencies 键名。"""
265
+ try:
266
+ pkg = json.loads(path.read_text(encoding="utf-8"))
267
+ except (json.JSONDecodeError, OSError):
268
+ return []
269
+ deps = {}
270
+ deps.update(pkg.get("dependencies") or {})
271
+ deps.update(pkg.get("devDependencies") or {})
272
+ return list(deps.keys())
273
+
274
+ @staticmethod
275
+ def _parse_requirements(path: Path) -> list[str]:
276
+ names: list[str] = []
277
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
278
+ line = line.strip()
279
+ if not line or line.startswith("#") or line.startswith("-"):
280
+ continue
281
+ m = re.match(r"^[A-Za-z0-9_.-]+", line)
282
+ if m:
283
+ names.append(m.group(0))
284
+ return names
285
+
286
+ @staticmethod
287
+ def _lev(a: str, b: str) -> int:
288
+ """Damerau-Levenshtein 距离(相邻换位计 1),用于 typosquatting 提示。
289
+
290
+ 用普通 Levenshtein 会把 'reqeusts'(换位)→ 'requests' 算成 2,漏掉最常见的
291
+ 手误。相邻换位算 1 才能命中 Docs/07 第 7 条给的示例。
292
+ """
293
+ if not a:
294
+ return len(b)
295
+ if not b:
296
+ return len(a)
297
+ la, lb = len(a), len(b)
298
+ d = [[0] * (lb + 1) for _ in range(la + 1)]
299
+ for i in range(la + 1):
300
+ d[i][0] = i
301
+ for j in range(lb + 1):
302
+ d[0][j] = j
303
+ for i in range(1, la + 1):
304
+ for j in range(1, lb + 1):
305
+ cost = 0 if a[i - 1] == b[j - 1] else 1
306
+ d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
307
+ if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
308
+ d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
309
+ return d[la][lb]
cckit/manifest.py ADDED
@@ -0,0 +1,113 @@
1
+ """cckit.yaml 的加载与校验。
2
+
3
+ 三步:存在性检查 → yaml.safe_load → JSON Schema 校验 → 语义检查
4
+ (needs 引用、skill 目录与 skills[] 一致、platforms 匹配当前平台、依赖文件存在)。
5
+ 没有 cckit.yaml 直接拒绝 —— 这是"合法 kit"的唯一判据(见 Docs/03)。
6
+
7
+ 另外提供 SKILL.md frontmatter 的读取(供 lint / state 读 description)。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ from . import config, schema
16
+ from .errors import CckitError
17
+
18
+
19
+ class ManifestError(CckitError):
20
+ """cckit.yaml 不合法。"""
21
+
22
+
23
+ def load(manifest_path: Path) -> dict:
24
+ """读 cckit.yaml;没有它直接拒绝。"""
25
+ if not manifest_path.exists():
26
+ raise ManifestError(f"找不到 cckit.yaml: {manifest_path}(没有它,cckit 拒绝安装)")
27
+ try:
28
+ with open(manifest_path, encoding="utf-8") as f:
29
+ data = yaml.safe_load(f)
30
+ except yaml.YAMLError as e:
31
+ raise ManifestError(f"cckit.yaml 解析失败: {e}")
32
+ if not isinstance(data, dict):
33
+ raise ManifestError("cckit.yaml 顶层必须是 mapping")
34
+ return data
35
+
36
+
37
+ def validate_schema(data: dict) -> None:
38
+ """JSON Schema 校验(Draft 2020-12),一次报出全部错误。"""
39
+ errors = sorted(schema.validator().iter_errors(data), key=lambda e: list(e.path))
40
+ if errors:
41
+ lines = [
42
+ " - " + ("/".join(map(str, e.path)) or "(root)") + ": " + e.message
43
+ for e in errors
44
+ ]
45
+ raise ManifestError("cckit.yaml 未通过 JSON Schema 校验:\n" + "\n".join(lines))
46
+
47
+
48
+ def semantic_check(data: dict, kit_dir: Path, platform: str | None = None) -> None:
49
+ """schema 之外的语义约束,涉及文件系统与当前平台。"""
50
+ platform = platform or config.platform_name()
51
+
52
+ # 1. 平台匹配:当前平台不在 platforms 内直接拒绝
53
+ plats = data.get("platforms")
54
+ if plats and platform not in plats:
55
+ raise ManifestError(f"当前平台 {platform} 不在 kit 声明的 platforms {plats} 中")
56
+
57
+ # 2. needs 引用:非 python/node 的值必须声明在 requires.system[].bin
58
+ # 注意:requires.system[].version / version_cmd 校验在 installer.py 的 compute_plan() 的 _check_system_version() 里做,这里不重复做
59
+ requires = data.get("requires") or {}
60
+ system_bins = {d.get("bin") for d in (requires.get("system") or [])}
61
+ for sk in data.get("skills", []):
62
+ for need in sk.get("needs", []):
63
+ if need in ("python", "node"):
64
+ continue
65
+ if need not in system_bins:
66
+ raise ManifestError(
67
+ f"skill {sk['name']!r} 的 needs 引用了未声明的系统依赖 {need!r}")
68
+
69
+ # 3. 依赖文件存在性(requirements.txt / package.json)
70
+ for key in ("python", "node"):
71
+ fname = (requires.get(key) or {}).get("file")
72
+ if fname and not (kit_dir / fname).is_file():
73
+ raise ManifestError(f"requires.{key}.file 指向的文件不存在: {fname}")
74
+
75
+ # 4. skill 目录与 skills[] 一致
76
+ for sk in data.get("skills", []):
77
+ sdir = kit_dir / sk["name"]
78
+ if not sdir.is_dir():
79
+ raise ManifestError(f"skill {sk['name']!r} 缺少对应目录: {sdir}")
80
+ if not (sdir / "SKILL.md").is_file():
81
+ raise ManifestError(f"skill {sk['name']!r} 缺少 SKILL.md")
82
+
83
+ # 5. scripts[] 声明的脚本路径必须存在(相对 skill 目录,与 exec 白名单一致)
84
+ for sk in data.get("skills", []):
85
+ sdir = kit_dir / sk["name"]
86
+ for script in sk.get("scripts") or []:
87
+ if not (sdir / script).is_file():
88
+ raise ManifestError(
89
+ f"skill {sk['name']!r} 声明的脚本不存在: {script}")
90
+
91
+
92
+ def read_skill_frontmatter(skill_dir: Path) -> dict:
93
+ """读 skill 目录下 SKILL.md 的 YAML frontmatter,失败返回空 dict。"""
94
+ md = skill_dir / "SKILL.md"
95
+ if not md.is_file():
96
+ return {}
97
+ text = md.read_text(encoding="utf-8", errors="replace")
98
+ lines = text.splitlines()
99
+ if not lines or lines[0].strip() != "---":
100
+ return {}
101
+ end = None
102
+ for i in range(1, len(lines)):
103
+ if lines[i].strip() == "---":
104
+ end = i
105
+ break
106
+ if end is None:
107
+ return {}
108
+ fm = "\n".join(lines[1:end])
109
+ try:
110
+ data = yaml.safe_load(fm)
111
+ except yaml.YAMLError:
112
+ return {}
113
+ return data if isinstance(data, dict) else {}
cckit/registry.py ADDED
@@ -0,0 +1,136 @@
1
+ """registry.json 的读写。
2
+
3
+ registry 只存"从文件系统观测不出来"的东西:kit 来源(URL/ref/sha)、版本、
4
+ envs 路径映射(每个 skill 的 runtime → env_dir)、known_scopes。启用状态是派生的,不写这里(见 state.py)。
5
+ 写入必须原子(临时文件 + os.replace),避免半截 JSON。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from . import config
15
+ from .errors import CckitError
16
+
17
+
18
+ def load() -> dict:
19
+ """读 registry;不存在时返回空结构;损坏时抛受检 CckitError(不静默、不裸 traceback)。"""
20
+ path = config.registry_path()
21
+ if not path.exists():
22
+ return {"version": 1, "kits": {}}
23
+ with open(path, encoding="utf-8") as f:
24
+ try:
25
+ data = json.load(f)
26
+ except json.JSONDecodeError as e:
27
+ raise CckitError(f"registry.json 不是合法 JSON,已损坏: {e}") from e
28
+ if not isinstance(data, dict) or not isinstance(data.get("kits"), dict):
29
+ raise CckitError(f"registry.json 结构损坏: {path}")
30
+ data.setdefault("version", 1)
31
+ return data
32
+
33
+
34
+ def save(data: dict) -> None:
35
+ """原子写:临时文件 + os.replace。"""
36
+ path = config.registry_path()
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".registry-", suffix=".tmp")
39
+ try:
40
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
41
+ json.dump(data, f, ensure_ascii=False, indent=2)
42
+ f.write("\n")
43
+ f.flush()
44
+ os.fsync(f.fileno())
45
+ os.replace(tmp, path)
46
+ finally:
47
+ if os.path.exists(tmp):
48
+ os.unlink(tmp)
49
+
50
+
51
+ def get_kit(name: str) -> dict | None:
52
+ """按 kit 名取记录,不存在返回 None。"""
53
+ return load().get("kits", {}).get(name)
54
+
55
+
56
+ def add_kit(name: str, info: dict) -> None:
57
+ """新增或覆盖一个 kit 记录。"""
58
+ data = load()
59
+ data.setdefault("kits", {})[name] = info
60
+ save(data)
61
+
62
+
63
+ def remove_kit(name: str) -> dict | None:
64
+ """删除一个 kit 记录,返回被删内容(不存在返回 None)。"""
65
+ data = load()
66
+ info = data.get("kits", {}).pop(name, None)
67
+ if info is not None:
68
+ save(data)
69
+ return info
70
+
71
+
72
+ def add_override_scope(name: str, scope_value: str) -> None:
73
+ """把项目根追加进 kit 的 override_scopes(去重)。
74
+
75
+ override_scopes 记录"仅在项目级 override 过、无 link"的项目根,供 remove
76
+ 清理 settings.local.json 的 skillOverrides 残留。它与 known_scopes 刻意分离:
77
+ known_scopes 表示"安装过",参与 find_skill / list_skills 的作用域定位;
78
+ override_scopes 表示"被项目覆盖过",若混入 known_scopes 会把全局 kit 误判成
79
+ 项目 kit,进而污染 find_skill 消歧与 list_skills 的 installed 归属。
80
+ """
81
+ data = load()
82
+ info = data.get("kits", {}).get(name)
83
+ if info is None:
84
+ raise CckitError(f"kit {name!r} 不在 registry 中")
85
+ scopes = info.get("override_scopes")
86
+ if not isinstance(scopes, list):
87
+ scopes = []
88
+ if scope_value not in scopes:
89
+ scopes.append(scope_value)
90
+ info["override_scopes"] = scopes
91
+ save(data)
92
+
93
+
94
+ def _kit_in_scope(info: dict, scope: str) -> bool:
95
+ """kit 的 known_scopes 是否包含给定作用域。
96
+
97
+ project 作用域按当前项目根路径比对(与 installer 写 known_scopes 时一致)。
98
+ """
99
+ scopes = info.get("known_scopes") or []
100
+ if scope == "global":
101
+ return "global" in scopes
102
+ root = config.project_root()
103
+ return root is not None and str(root) in scopes
104
+
105
+
106
+ def find_skill_matches(name: str, scope: str | None = None) -> list[tuple[str, dict]]:
107
+ """返回所有匹配的 (kit, skill) 列表,不报错(0 或 >1 都原样返回)。
108
+
109
+ 供 state.set_state 的项目作用域回退逻辑用:先找项目 kit,没有时再回退全局
110
+ kit —— 但必须保留"多个 kit 同名"的歧义,不能把它静默吞成"回退全局"。
111
+ """
112
+ matches: list[tuple[str, dict]] = []
113
+ for kit, info in load().get("kits", {}).items():
114
+ if scope is not None and not _kit_in_scope(info, scope):
115
+ continue
116
+ for sk in info.get("skills", []):
117
+ if sk.get("name") == name:
118
+ matches.append((kit, sk))
119
+ return matches
120
+
121
+
122
+ def find_skill(name: str, scope: str | None = None) -> tuple[str, dict]:
123
+ """按 skill 名查找,要求唯一;0 或 >1 都报错。
124
+
125
+ scope 给定时("global"/"project")只在对应作用域内找 —— 同名 skill
126
+ 全局与项目各装一份时,靠作用域即可唯一定位(见 Docs/04:所有命令默认
127
+ 全局,`--project` 作用项目)。scope=None 时在全部 kit 里找。
128
+ """
129
+ matches = find_skill_matches(name, scope)
130
+ if not matches:
131
+ where = f"({scope} 作用域)" if scope else ""
132
+ raise CckitError(f"skill {name!r} 不在 registry 中{where}(不是 cckit 管理或未安装)")
133
+ if len(matches) > 1:
134
+ kits = ", ".join(k for k, _ in matches)
135
+ raise CckitError(f"skill {name!r} 在多个 kit 中出现({kits}),无法唯一定位")
136
+ return matches[0]
cckit/schema.py ADDED
@@ -0,0 +1,42 @@
1
+ """cckit.yaml 的 JSON Schema 加载器。
2
+
3
+ schema 的源文件在 Docs/schema/cckit.schema.json;这里在包内保留一份副本
4
+ (src/cckit/cckit.schema.json),运行时无需依赖文档目录。测试会校验两份一致。
5
+
6
+ homepage 的 `format: uri` 用自定义 checker 校验(仅靠 stdlib urllib.parse)。
7
+ jsonschema 自带的 uri checker 依赖可选的 rfc3987,未装时静默放行一切 ——
8
+ 等于没校验,所以这里显式实现一个宽松但有效的版本。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from importlib import resources
14
+ from urllib.parse import urlparse
15
+
16
+ from jsonschema import Draft202012Validator, FormatChecker
17
+
18
+
19
+ def _is_uri(instance: object) -> bool:
20
+ """宽松但有效的 URI 校验:非空、无空白、有 scheme。"""
21
+ if not isinstance(instance, str) or not instance.strip():
22
+ return False
23
+ if any(ch.isspace() for ch in instance):
24
+ return False
25
+ try:
26
+ parsed = urlparse(instance)
27
+ except ValueError:
28
+ return False
29
+ return bool(parsed.scheme)
30
+
31
+
32
+ def load() -> dict:
33
+ """读入 JSON Schema dict。"""
34
+ text = resources.files("cckit").joinpath("cckit.schema.json").read_text(encoding="utf-8")
35
+ return json.loads(text)
36
+
37
+
38
+ def validator() -> Draft202012Validator:
39
+ """构造一个 Draft 2020-12 校验器,带 format_checker 真正校验 homepage 的 uri。"""
40
+ checker = FormatChecker()
41
+ checker.checks("uri")(_is_uri)
42
+ return Draft202012Validator(load(), format_checker=checker)