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/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
@@ -0,0 +1,10 @@
1
+ """cckit 的受检错误。
2
+
3
+ 所有面向用户的错误都应是 CckitError(或其子类),cli.main 捕获它打印
4
+ "错误: ..." 并返回非零退出码,而不是把堆栈甩给用户。
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ class CckitError(Exception):
10
+ """cckit 的受检错误:可安全展示给用户,无需堆栈。"""
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")