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/doctor.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""cckit doctor —— 只诊断不自动修,每项给出可复制修复命令。
|
|
2
|
+
|
|
3
|
+
检查项覆盖 Docs/04 的 doctor 表:uv 在 PATH、悬空 link、link 指向与 registry
|
|
4
|
+
一致、env 完整+解释器可执行、系统依赖、disableSkillShellExecution、清单预算、
|
|
5
|
+
同名冲突。这些大多是"不检查就 debug 一整天"的静默失效。
|
|
6
|
+
--fix 只处理明确安全的项:清理悬空 link、重建损坏的 python env。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import config, env as env_mod, link, manifest, registry, state
|
|
15
|
+
from .errors import CckitError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Finding:
|
|
20
|
+
check: str
|
|
21
|
+
status: str # ok | warn | error
|
|
22
|
+
message: str
|
|
23
|
+
fix: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(fix: bool = False) -> list[Finding]:
|
|
27
|
+
findings: list[Finding] = []
|
|
28
|
+
|
|
29
|
+
# 1. uv 是否在 PATH
|
|
30
|
+
if shutil.which("uv"):
|
|
31
|
+
findings.append(Finding("uv", "ok", "uv 在 PATH 中"))
|
|
32
|
+
else:
|
|
33
|
+
findings.append(Finding(
|
|
34
|
+
"uv", "error", "找不到 uv,无法建 env",
|
|
35
|
+
"安装 uv: https://docs.astral.sh/uv/getting-started/installation/"))
|
|
36
|
+
|
|
37
|
+
findings += _check_links()
|
|
38
|
+
findings += _check_envs()
|
|
39
|
+
findings += _check_system_deps()
|
|
40
|
+
findings += _check_shell_execution()
|
|
41
|
+
findings += _check_budget()
|
|
42
|
+
findings += _check_name_conflicts()
|
|
43
|
+
|
|
44
|
+
if fix:
|
|
45
|
+
_apply_fixes(findings)
|
|
46
|
+
|
|
47
|
+
return findings
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_links() -> list[Finding]:
|
|
51
|
+
findings: list[Finding] = []
|
|
52
|
+
# 复用 state 的作用域/link 解析,不自己扫目录(避免两套扫描逻辑漂移)
|
|
53
|
+
for _, d in state.scope_skills_dirs():
|
|
54
|
+
if not d.is_dir():
|
|
55
|
+
continue
|
|
56
|
+
for entry in d.iterdir():
|
|
57
|
+
if link.is_dangling(entry):
|
|
58
|
+
findings.append(Finding(
|
|
59
|
+
"link", "error", f"悬空链接: {entry}(目标已消失)",
|
|
60
|
+
f"清理:`cckit doctor --fix` 或手动删除 {entry}"))
|
|
61
|
+
elif link.is_link(entry):
|
|
62
|
+
target = entry.resolve()
|
|
63
|
+
kit = state.resolve_managed_kit(target)
|
|
64
|
+
if kit:
|
|
65
|
+
rec = registry.get_kit(kit)
|
|
66
|
+
rec_store = rec.get("store") if rec else None
|
|
67
|
+
if rec_store and Path(rec_store) != target.parent:
|
|
68
|
+
findings.append(Finding(
|
|
69
|
+
"link", "warn",
|
|
70
|
+
f"link {entry} 指向 {target},与 registry 记录 {rec_store} 不一致(手工改动导致漂移)",
|
|
71
|
+
f"重建:`cckit remove {kit}` 后重新 add"))
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _check_envs() -> list[Finding]:
|
|
76
|
+
findings: list[Finding] = []
|
|
77
|
+
for kit, info in registry.load().get("kits", {}).items():
|
|
78
|
+
for sk in info.get("skills", []):
|
|
79
|
+
for runtime, env_dir in (sk.get("envs") or {}).items():
|
|
80
|
+
label = f"{kit}/{sk['name']} [{runtime}]"
|
|
81
|
+
if env_mod.check_env(Path(env_dir), runtime):
|
|
82
|
+
findings.append(Finding("env", "ok", f"{label} env 完整"))
|
|
83
|
+
else:
|
|
84
|
+
findings.append(Finding(
|
|
85
|
+
"env", "error", f"{label} env 缺失或解释器不可执行",
|
|
86
|
+
f"重建:`cckit doctor --fix` 或 `cckit remove {kit}` 后重新 add"))
|
|
87
|
+
return findings
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _check_system_deps() -> list[Finding]:
|
|
91
|
+
findings: list[Finding] = []
|
|
92
|
+
platform = config.platform_name()
|
|
93
|
+
for kit, info in registry.load().get("kits", {}).items():
|
|
94
|
+
mf = Path(info.get("store", "")) / "cckit.yaml"
|
|
95
|
+
if not mf.is_file():
|
|
96
|
+
continue
|
|
97
|
+
data = manifest.load(mf)
|
|
98
|
+
for dep in (data.get("requires") or {}).get("system", []):
|
|
99
|
+
bin_ = dep["bin"]
|
|
100
|
+
if shutil.which(bin_):
|
|
101
|
+
findings.append(Finding("system", "ok", f"{bin_} 可用({kit})"))
|
|
102
|
+
else:
|
|
103
|
+
hint = (dep.get("hint") or {}).get(platform)
|
|
104
|
+
findings.append(Finding(
|
|
105
|
+
"system", "warn", f"{bin_} 缺失({kit})",
|
|
106
|
+
hint or f"请手动安装 {bin_}"))
|
|
107
|
+
return findings
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_shell_execution() -> list[Finding]:
|
|
111
|
+
findings: list[Finding] = []
|
|
112
|
+
for scope, label in (("global", "全局"), ("project", "项目")):
|
|
113
|
+
try:
|
|
114
|
+
settings = state.read_settings(scope)
|
|
115
|
+
except CckitError:
|
|
116
|
+
continue
|
|
117
|
+
if settings.get("disableSkillShellExecution"):
|
|
118
|
+
findings.append(Finding(
|
|
119
|
+
"settings", "error",
|
|
120
|
+
f"{label} settings 开启了 disableSkillShellExecution,"
|
|
121
|
+
f"所有带脚本 skill 会静默失效",
|
|
122
|
+
f"把 {label} settings.json 的 disableSkillShellExecution 改为 false 或删除"))
|
|
123
|
+
return findings
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_budget() -> list[Finding]:
|
|
127
|
+
used, limit = state.budget()
|
|
128
|
+
if used > limit:
|
|
129
|
+
return [Finding(
|
|
130
|
+
"budget", "error",
|
|
131
|
+
f"清单预算超限: {used} / {limit} 字符,description 会被静默丢弃",
|
|
132
|
+
"把不常用 skill 降为 name-only:`cckit name-only <skill>`")]
|
|
133
|
+
return [Finding("budget", "ok", f"清单预算: {used} / {limit} 字符")]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_name_conflicts() -> list[Finding]:
|
|
137
|
+
findings: list[Finding] = []
|
|
138
|
+
# 复用 state.list_skills 的扫描结果,不自己枚举目录
|
|
139
|
+
by_name: dict[str, set[tuple[str, str | None]]] = {}
|
|
140
|
+
for s in state.list_skills(None):
|
|
141
|
+
if s.state == "installed":
|
|
142
|
+
continue # 没 link 不被扫描,不参与覆盖
|
|
143
|
+
by_name.setdefault(s.name, set()).add((s.scope, s.kit))
|
|
144
|
+
for name, identities in sorted(by_name.items()):
|
|
145
|
+
g = {kit for scope, kit in identities if scope == "global"}
|
|
146
|
+
p = {kit for scope, kit in identities if scope == "project"}
|
|
147
|
+
if not g or not p:
|
|
148
|
+
continue
|
|
149
|
+
# 同一个 managed kit 的 skill 同时出现在全局与项目作用域 = D-15 的
|
|
150
|
+
# "全局 skill 按项目覆盖",不是同名冲突;同名冲突必须来自不同来源。
|
|
151
|
+
if g == p and len(g) == 1 and None not in g:
|
|
152
|
+
continue
|
|
153
|
+
findings.append(Finding(
|
|
154
|
+
"conflict", "warn",
|
|
155
|
+
f"同名 skill {name!r} 同时存在全局与项目,全局会覆盖项目版",
|
|
156
|
+
f"二选一:`cckit disable {name}`(全局)或 `cckit disable {name} --project`"))
|
|
157
|
+
return findings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _apply_fixes(findings: list[Finding]) -> None:
|
|
161
|
+
"""只做明确安全的修复:清理悬空 link、重建损坏的 python/node env。"""
|
|
162
|
+
for _, d in state.scope_skills_dirs():
|
|
163
|
+
if not d.is_dir():
|
|
164
|
+
continue
|
|
165
|
+
for entry in d.iterdir():
|
|
166
|
+
if link.is_dangling(entry):
|
|
167
|
+
link.remove(entry)
|
|
168
|
+
|
|
169
|
+
for kit, info in registry.load().get("kits", {}).items():
|
|
170
|
+
mf = Path(info.get("store", "")) / "cckit.yaml"
|
|
171
|
+
if not mf.is_file():
|
|
172
|
+
continue
|
|
173
|
+
data = manifest.load(mf)
|
|
174
|
+
requires = data.get("requires") or {}
|
|
175
|
+
py_file = (requires.get("python") or {}).get("file")
|
|
176
|
+
node_file = (requires.get("node") or {}).get("file")
|
|
177
|
+
req = Path(info.get("store", "")) / py_file if py_file else None
|
|
178
|
+
pkg = Path(info.get("store", "")) / node_file if node_file else None
|
|
179
|
+
constraint = (requires.get("runtime") or {}).get("python")
|
|
180
|
+
for sk in info.get("skills", []):
|
|
181
|
+
for runtime, env_dir in (sk.get("envs") or {}).items():
|
|
182
|
+
if runtime == "python" and not env_mod.check_env(Path(env_dir), "python"):
|
|
183
|
+
env_mod.create_python_env(Path(env_dir), constraint, req)
|
|
184
|
+
elif runtime == "node" and not env_mod.check_env(Path(env_dir), "node"):
|
|
185
|
+
env_mod.create_node_env(Path(env_dir), pkg)
|
cckit/env.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""skill 运行环境:uv venv 创建与依赖安装、解释器解析。
|
|
2
|
+
|
|
3
|
+
平台差异(venv 布局:Windows Scripts/ vs POSIX bin/)只在这里出现,
|
|
4
|
+
上层只问"这个 skill 的解释器在哪",不关心平台。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .errors import CckitError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def python_interpreter(env_dir: Path) -> Path:
|
|
17
|
+
"""venv 内 python 解释器路径。Windows 在 Scripts/,POSIX 在 bin/。"""
|
|
18
|
+
if os.name == "nt":
|
|
19
|
+
return env_dir / "Scripts" / "python.exe"
|
|
20
|
+
return env_dir / "bin" / "python"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_python_env(env_dir: Path, python_constraint: str | None = None,
|
|
24
|
+
requirements: Path | None = None) -> None:
|
|
25
|
+
"""uv venv 建环境,并按 requirements.txt 装依赖(uv pip install)。"""
|
|
26
|
+
env_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
cmd = ["uv", "venv", str(env_dir)]
|
|
28
|
+
if python_constraint:
|
|
29
|
+
cmd += ["--python", python_constraint]
|
|
30
|
+
subprocess.run(cmd, check=True)
|
|
31
|
+
if requirements and requirements.is_file():
|
|
32
|
+
interp = python_interpreter(env_dir)
|
|
33
|
+
subprocess.run(
|
|
34
|
+
["uv", "pip", "install", "-r", str(requirements), "--python", str(interp)],
|
|
35
|
+
check=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_node_env(env_dir: Path, package_json: Path | None = None) -> None:
|
|
40
|
+
"""Node 环境:把 package.json 拷进 env 目录再 npm install。"""
|
|
41
|
+
env_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
if package_json and package_json.is_file():
|
|
43
|
+
shutil.copy(package_json, env_dir / "package.json")
|
|
44
|
+
subprocess.run(["npm", "install", "--prefix", str(env_dir)], check=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_env(env_dir: Path, runtime: str) -> bool:
|
|
48
|
+
"""env 是否完整且解释器可执行。"""
|
|
49
|
+
if runtime == "python":
|
|
50
|
+
p = python_interpreter(env_dir)
|
|
51
|
+
return p.is_file() and os.access(p, os.X_OK)
|
|
52
|
+
if runtime == "node":
|
|
53
|
+
return (env_dir / "node_modules").is_dir()
|
|
54
|
+
return False
|
cckit/errors.py
ADDED
cckit/exec.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""cckit exec —— skill 脚本的统一入口。
|
|
2
|
+
|
|
3
|
+
定位 skill/env、解析解释器、注入 CCKIT_SKILL_DIR / CCKIT_KIT_DIR /
|
|
4
|
+
CCKIT_ENV_DIR 与 manifest env 变量、记录用量(追加 ~/.cckit/usage.jsonl,
|
|
5
|
+
天然并发安全)、cwd 保持调用方 cwd。scripts 未在 manifest 声明的路径拒绝执行。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import config, env as env_mod, manifest, registry
|
|
16
|
+
from .errors import CckitError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExecError(CckitError):
|
|
20
|
+
"""exec 失败。"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _runtime_for_script(script_path: Path) -> str:
|
|
24
|
+
"""脚本扩展名 → runtime。.py → python;.js/.mjs → node。"""
|
|
25
|
+
ext = script_path.suffix.lower()
|
|
26
|
+
if ext == ".py":
|
|
27
|
+
return "python"
|
|
28
|
+
if ext in (".js", ".mjs"):
|
|
29
|
+
return "node"
|
|
30
|
+
raise ExecError(f"不支持的脚本类型 {script_path.name}(v0.1 仅支持 .py/.js/.mjs)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(skill: str, script: str, args: list[str], scope: str = "global") -> int:
|
|
34
|
+
# 按作用域定位:同名 skill 全局/项目各装一份时,靠 scope 消歧。
|
|
35
|
+
kit_name, skill_info = registry.find_skill(skill, scope)
|
|
36
|
+
kit_info = registry.get_kit(kit_name)
|
|
37
|
+
|
|
38
|
+
store = Path(kit_info["store"])
|
|
39
|
+
data = manifest.load(store / "cckit.yaml")
|
|
40
|
+
skill_def = next((s for s in data.get("skills", []) if s["name"] == skill), None)
|
|
41
|
+
if skill_def is None:
|
|
42
|
+
raise ExecError(f"skill {skill!r} 不在 kit {kit_name} 的 skills[] 中")
|
|
43
|
+
|
|
44
|
+
# 脚本白名单:未在 manifest scripts[] 声明的路径拒绝执行
|
|
45
|
+
whitelist = set(skill_def.get("scripts") or [])
|
|
46
|
+
if script not in whitelist:
|
|
47
|
+
raise ExecError(f"脚本 {script!r} 未在 manifest 的 scripts[] 中声明,拒绝执行")
|
|
48
|
+
|
|
49
|
+
skill_dir = (store / skill).resolve()
|
|
50
|
+
script_path = (skill_dir / script).resolve()
|
|
51
|
+
if not script_path.is_file():
|
|
52
|
+
raise ExecError(f"脚本不存在: {script_path}")
|
|
53
|
+
if not script_path.is_relative_to(skill_dir):
|
|
54
|
+
raise ExecError(f"脚本路径越出 skill 目录,拒绝执行: {script}")
|
|
55
|
+
|
|
56
|
+
# 按脚本扩展名选 runtime,再取对应 env(一个 skill 可同时有 python 与 node)
|
|
57
|
+
runtime = _runtime_for_script(script_path)
|
|
58
|
+
envs = skill_info.get("envs") or {}
|
|
59
|
+
env_dir = Path(envs[runtime]) if envs.get(runtime) else None
|
|
60
|
+
if env_dir is None or not env_mod.check_env(env_dir, runtime):
|
|
61
|
+
raise ExecError(f"skill {skill!r} 的 {runtime} env 缺失或损坏,请运行 `cckit doctor`")
|
|
62
|
+
interp = str(env_mod.python_interpreter(env_dir)) if runtime == "python" else "node"
|
|
63
|
+
|
|
64
|
+
# 注入环境变量
|
|
65
|
+
env = os.environ.copy()
|
|
66
|
+
env["CCKIT_SKILL_DIR"] = str(skill_dir)
|
|
67
|
+
env["CCKIT_KIT_DIR"] = str(store)
|
|
68
|
+
env["CCKIT_ENV_DIR"] = str(env_dir)
|
|
69
|
+
if runtime == "node":
|
|
70
|
+
# node_modules 装在 env 目录下,require() 需要 NODE_PATH 才找得到
|
|
71
|
+
env["NODE_PATH"] = str(env_dir / "node_modules")
|
|
72
|
+
for var in data.get("env", []):
|
|
73
|
+
name = var.get("name")
|
|
74
|
+
if not name:
|
|
75
|
+
continue
|
|
76
|
+
if name in os.environ:
|
|
77
|
+
env[name] = os.environ[name]
|
|
78
|
+
elif var.get("required"):
|
|
79
|
+
raise ExecError(
|
|
80
|
+
f"缺少必需的 manifest 环境变量 {name!r}({var.get('description', '')})")
|
|
81
|
+
|
|
82
|
+
_record_usage(skill, kit_name, script, runtime)
|
|
83
|
+
|
|
84
|
+
# cwd 保持调用方 cwd(不 chdir),相对路径正常解析。
|
|
85
|
+
cmd = [interp, str(script_path), *args]
|
|
86
|
+
try:
|
|
87
|
+
proc = subprocess.run(cmd, env=env)
|
|
88
|
+
except FileNotFoundError as e:
|
|
89
|
+
raise ExecError(f"无法执行 {interp}: {e}")
|
|
90
|
+
return proc.returncode
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _record_usage(skill: str, kit: str, script: str, runtime: str) -> None:
|
|
94
|
+
path = config.usage_log_path()
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
rec = {
|
|
97
|
+
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
98
|
+
"skill": skill,
|
|
99
|
+
"kit": kit,
|
|
100
|
+
"script": script,
|
|
101
|
+
"runtime": runtime,
|
|
102
|
+
}
|
|
103
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
104
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|