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/state.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""cckit.state —— 唯一允许读写 skill 状态的模块。
|
|
2
|
+
|
|
3
|
+
CLI 与未来的 Web 都只走这里,不允许调用方直接碰文件系统或 settings.json
|
|
4
|
+
(Docs/09)。启用状态是派生的:现场扫 link + 读 settings.json 的
|
|
5
|
+
skillOverrides,不落库 —— CC 只读这两处,存了必然漂移。
|
|
6
|
+
|
|
7
|
+
写 settings.json 三条硬要求(Docs/09 / D-14):
|
|
8
|
+
1. 文件锁 os.open(O_CREAT|O_EXCL)(含陈旧锁超时+mtime 清理)
|
|
9
|
+
2. 原子替换(临时文件 + os.replace)
|
|
10
|
+
3. 只改 skillOverrides 键,其余用户配置原样保留
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from . import config, link, manifest, registry
|
|
23
|
+
from . import env as env_mod
|
|
24
|
+
from .errors import CckitError
|
|
25
|
+
|
|
26
|
+
Scope = Literal["global", "project"]
|
|
27
|
+
StateName = Literal["installed", "enabled", "name-only", "off"]
|
|
28
|
+
|
|
29
|
+
_LOCK_TIMEOUT = 10.0
|
|
30
|
+
_BUDGET_FRACTION_DEFAULT = 0.01
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SkillState:
|
|
35
|
+
name: str
|
|
36
|
+
kit: str | None # 非 cckit 管理时为 None
|
|
37
|
+
scope: Scope
|
|
38
|
+
state: StateName
|
|
39
|
+
managed: bool # False = 用户手写或插件带的,只读
|
|
40
|
+
env_ok: bool | None # None = 该 skill 无需 env
|
|
41
|
+
desc_chars: int # 用于预算统计
|
|
42
|
+
version: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- 路径与读取 ----
|
|
46
|
+
|
|
47
|
+
def _scope_skills_dir(scope: Scope) -> Path | None:
|
|
48
|
+
if scope == "global":
|
|
49
|
+
return config.claude_config_dir() / "skills"
|
|
50
|
+
root = config.project_root()
|
|
51
|
+
if root is None:
|
|
52
|
+
return None
|
|
53
|
+
return root / ".claude" / "skills"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _scope_settings_path(scope: Scope, root: Path | None = None) -> Path:
|
|
57
|
+
if scope == "global":
|
|
58
|
+
return config.claude_config_dir() / "settings.json"
|
|
59
|
+
r = root or config.project_root()
|
|
60
|
+
if r is None:
|
|
61
|
+
raise CckitError("不在项目内(.claude / cckit.lock 均未找到),无法操作项目作用域")
|
|
62
|
+
return r / ".claude" / "settings.local.json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_json(path: Path) -> dict:
|
|
66
|
+
if not path.exists():
|
|
67
|
+
return {}
|
|
68
|
+
text = path.read_text(encoding="utf-8")
|
|
69
|
+
if not text.strip():
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(text)
|
|
73
|
+
except json.JSONDecodeError as e:
|
|
74
|
+
raise CckitError(f"{path} 不是合法 JSON,已损坏: {e}") from e
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
raise CckitError(f"{path} 顶层不是 JSON object,已损坏")
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_settings(scope: Scope = "global") -> dict:
|
|
81
|
+
"""只读 settings 完整 dict。doctor / budget 读非 skillOverrides 键也走这里。"""
|
|
82
|
+
return _read_json(_scope_settings_path(scope))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_overrides(scope: Scope) -> dict:
|
|
86
|
+
ov = read_settings(scope).get("skillOverrides", {})
|
|
87
|
+
return ov if isinstance(ov, dict) else {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- 锁与原子写 ----
|
|
91
|
+
|
|
92
|
+
def _acquire_lock(lock_path: Path, timeout: float = _LOCK_TIMEOUT) -> None:
|
|
93
|
+
"""跨平台文件锁:os.open(O_CREAT|O_EXCL)。陈旧锁按 mtime 超时后强制清理。"""
|
|
94
|
+
deadline = time.monotonic() + timeout
|
|
95
|
+
while True:
|
|
96
|
+
try:
|
|
97
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
98
|
+
except FileExistsError:
|
|
99
|
+
try:
|
|
100
|
+
mtime = os.path.getmtime(lock_path)
|
|
101
|
+
except OSError:
|
|
102
|
+
continue # 锁刚被删,重试
|
|
103
|
+
if time.time() - mtime > timeout:
|
|
104
|
+
# 陈旧锁:进程崩溃遗留
|
|
105
|
+
try:
|
|
106
|
+
os.unlink(lock_path)
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
continue
|
|
110
|
+
if time.monotonic() > deadline:
|
|
111
|
+
raise CckitError(f"等待文件锁超时(可能其他 cckit 卡住): {lock_path}")
|
|
112
|
+
time.sleep(0.05)
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
os.write(fd, str(os.getpid()).encode())
|
|
116
|
+
finally:
|
|
117
|
+
os.close(fd)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _release_lock(lock_path: Path) -> None:
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(lock_path)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix="." + path.name + ".", suffix=".tmp")
|
|
131
|
+
try:
|
|
132
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
133
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
134
|
+
f.write("\n")
|
|
135
|
+
f.flush()
|
|
136
|
+
os.fsync(f.fileno())
|
|
137
|
+
os.replace(tmp, path)
|
|
138
|
+
finally:
|
|
139
|
+
if os.path.exists(tmp):
|
|
140
|
+
os.unlink(tmp)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_override(scope: Scope, name: str, value: str | None) -> None:
|
|
144
|
+
"""锁 + 原子写 + 只改 skillOverrides。value=None 表示删除该键。"""
|
|
145
|
+
path = _scope_settings_path(scope)
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
lock_path = path.with_name(path.name + ".cckit.lock")
|
|
148
|
+
_acquire_lock(lock_path)
|
|
149
|
+
try:
|
|
150
|
+
data = _read_json(path)
|
|
151
|
+
overrides = data.get("skillOverrides", {})
|
|
152
|
+
overrides = overrides if isinstance(overrides, dict) else {}
|
|
153
|
+
if value is None:
|
|
154
|
+
overrides.pop(name, None)
|
|
155
|
+
else:
|
|
156
|
+
overrides[name] = value
|
|
157
|
+
if overrides:
|
|
158
|
+
data["skillOverrides"] = overrides
|
|
159
|
+
else:
|
|
160
|
+
data.pop("skillOverrides", None)
|
|
161
|
+
_atomic_write_json(path, data)
|
|
162
|
+
finally:
|
|
163
|
+
_release_lock(lock_path)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def remove_overrides(names: list[str], scope: Scope = "global",
|
|
167
|
+
root: Path | None = None) -> None:
|
|
168
|
+
"""批量清掉若干 skill 的 skillOverrides 条目(供 remove 清理残留)。"""
|
|
169
|
+
path = _scope_settings_path(scope, root=root)
|
|
170
|
+
if not path.exists():
|
|
171
|
+
return
|
|
172
|
+
lock_path = path.with_name(path.name + ".cckit.lock")
|
|
173
|
+
_acquire_lock(lock_path)
|
|
174
|
+
try:
|
|
175
|
+
data = _read_json(path)
|
|
176
|
+
overrides = data.get("skillOverrides", {})
|
|
177
|
+
if isinstance(overrides, dict):
|
|
178
|
+
for n in names:
|
|
179
|
+
overrides.pop(n, None)
|
|
180
|
+
if overrides:
|
|
181
|
+
data["skillOverrides"] = overrides
|
|
182
|
+
else:
|
|
183
|
+
data.pop("skillOverrides", None)
|
|
184
|
+
_atomic_write_json(path, data)
|
|
185
|
+
finally:
|
|
186
|
+
_release_lock(lock_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---- 派生状态 ----
|
|
190
|
+
|
|
191
|
+
def _derive_state(has_link: bool, override: str | None) -> str:
|
|
192
|
+
if not has_link:
|
|
193
|
+
return "installed"
|
|
194
|
+
if override == "off":
|
|
195
|
+
return "off"
|
|
196
|
+
if override == "name-only":
|
|
197
|
+
return "name-only"
|
|
198
|
+
return "enabled"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_managed(target: Path, store: Path) -> tuple[bool, str | None]:
|
|
202
|
+
"""target 是否落在 store 内;是则返回 (True, kit 名)。"""
|
|
203
|
+
try:
|
|
204
|
+
rel = target.relative_to(store)
|
|
205
|
+
except ValueError:
|
|
206
|
+
return False, None
|
|
207
|
+
if len(rel.parts) < 2:
|
|
208
|
+
return False, None
|
|
209
|
+
return True, rel.parts[0]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_managed_kit(target: Path) -> str | None:
|
|
213
|
+
"""target 是否落在 store 内;是则返回 kit 名,否则 None。doctor 复用。"""
|
|
214
|
+
managed, kit = _resolve_managed(target, config.store_dir())
|
|
215
|
+
return kit if managed else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def scope_skills_dirs() -> list[tuple[Scope, Path]]:
|
|
219
|
+
"""所有可能存在 link 的 skills 目录(含 known_scopes 里的历史项目目录)。
|
|
220
|
+
|
|
221
|
+
doctor 用它枚举 link、remove 用它清理残留。与 list_skills 共用同一份
|
|
222
|
+
作用域解析逻辑,避免两套扫描漂移(见 Docs/09:状态读写统一走 state)。
|
|
223
|
+
"""
|
|
224
|
+
dirs: list[tuple[Scope, Path]] = []
|
|
225
|
+
dirs.append(("global", config.claude_config_dir() / "skills"))
|
|
226
|
+
root = config.project_root()
|
|
227
|
+
if root:
|
|
228
|
+
dirs.append(("project", root / ".claude" / "skills"))
|
|
229
|
+
for kit_info in registry.load().get("kits", {}).values():
|
|
230
|
+
for sc in kit_info.get("known_scopes", []):
|
|
231
|
+
if sc and sc != "global":
|
|
232
|
+
dirs.append(("project", Path(sc) / ".claude" / "skills"))
|
|
233
|
+
return dirs
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _registry_meta(reg: dict, kit: str, name: str) -> tuple[str | None, dict[str, str]]:
|
|
237
|
+
"""返回 (version, envs: {runtime: env_dir});registry 无记录时返回 (None, {})。"""
|
|
238
|
+
kit_info = reg.get("kits", {}).get(kit)
|
|
239
|
+
if not kit_info:
|
|
240
|
+
return None, {}
|
|
241
|
+
for sk in kit_info.get("skills", []):
|
|
242
|
+
if sk.get("name") == name:
|
|
243
|
+
return kit_info.get("version"), (sk.get("envs") or {})
|
|
244
|
+
return kit_info.get("version"), {}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _env_status(envs: dict[str, str]) -> bool | None:
|
|
248
|
+
"""所有已建 env 都健康才 True;无 env(纯 prompt)为 None;任一损坏为 False。"""
|
|
249
|
+
if not envs:
|
|
250
|
+
return None
|
|
251
|
+
return all(env_mod.check_env(Path(d), rt) for rt, d in envs.items())
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _desc_chars(skill_dir: Path) -> int:
|
|
255
|
+
fm = manifest.read_skill_frontmatter(skill_dir)
|
|
256
|
+
d = str(fm.get("description") or "")
|
|
257
|
+
w = str(fm.get("when_to_use") or "")
|
|
258
|
+
return len(d) + len(w)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---- 对外 API ----
|
|
262
|
+
|
|
263
|
+
def list_skills(scope: Scope | None = None) -> list[SkillState]:
|
|
264
|
+
"""列出 skill。scope=None 列出全部作用域。
|
|
265
|
+
|
|
266
|
+
同时列出非 cckit 管理的 skill(managed=False,只读)。
|
|
267
|
+
启用状态现场从 link + skillOverrides 派生,不读任何落库的启用状态。
|
|
268
|
+
"""
|
|
269
|
+
reg = registry.load()
|
|
270
|
+
store = config.store_dir()
|
|
271
|
+
scopes: list[Scope] = ["global", "project"] if scope is None else [scope]
|
|
272
|
+
result: list[SkillState] = []
|
|
273
|
+
linked_managed: set[tuple[str, str]] = set()
|
|
274
|
+
|
|
275
|
+
for sc in scopes:
|
|
276
|
+
skills_dir = _scope_skills_dir(sc)
|
|
277
|
+
if skills_dir is None or not skills_dir.is_dir():
|
|
278
|
+
continue
|
|
279
|
+
overrides = _read_overrides(sc)
|
|
280
|
+
for entry in sorted(skills_dir.iterdir()):
|
|
281
|
+
name = entry.name
|
|
282
|
+
if name == "synced":
|
|
283
|
+
continue
|
|
284
|
+
is_ln = link.is_link(entry) or link.is_dangling(entry)
|
|
285
|
+
if not is_ln and not entry.is_dir():
|
|
286
|
+
continue
|
|
287
|
+
if is_ln:
|
|
288
|
+
target = entry.resolve()
|
|
289
|
+
managed, kit = _resolve_managed(target, store)
|
|
290
|
+
if managed:
|
|
291
|
+
# 按 (kit, name) 去重:同名 skill 不同 kit 各装一份时,
|
|
292
|
+
# 一个被 link 不能吞掉另一个未 link 的(见 M1)。
|
|
293
|
+
linked_managed.add((kit, name))
|
|
294
|
+
version, envs = _registry_meta(reg, kit, name)
|
|
295
|
+
env_ok = _env_status(envs)
|
|
296
|
+
else:
|
|
297
|
+
kit, version, env_ok = None, None, None
|
|
298
|
+
else:
|
|
299
|
+
target = entry
|
|
300
|
+
managed, kit, version, env_ok = False, None, None, None
|
|
301
|
+
st = _derive_state(True, overrides.get(name))
|
|
302
|
+
result.append(SkillState(name, kit, sc, st, managed, env_ok,
|
|
303
|
+
_desc_chars(target), version))
|
|
304
|
+
|
|
305
|
+
# 已安装但未建 link 的 managed skill → installed。归属作用域由 kit 的
|
|
306
|
+
# known_scopes 决定,不硬编码 "global"(项目 kit 的未 link skill 应报 project)。
|
|
307
|
+
root = config.project_root()
|
|
308
|
+
for kit_name, kit_info in reg.get("kits", {}).items():
|
|
309
|
+
known = kit_info.get("known_scopes") or []
|
|
310
|
+
for sk in kit_info.get("skills", []):
|
|
311
|
+
name = sk.get("name")
|
|
312
|
+
if not name or (kit_name, name) in linked_managed:
|
|
313
|
+
continue
|
|
314
|
+
for sc in scopes:
|
|
315
|
+
in_scope = ("global" in known) if sc == "global" else (
|
|
316
|
+
root is not None and str(root) in known)
|
|
317
|
+
if not in_scope:
|
|
318
|
+
continue
|
|
319
|
+
env_ok = _env_status(sk.get("envs") or {})
|
|
320
|
+
result.append(SkillState(name, kit_name, sc, "installed", True,
|
|
321
|
+
env_ok, _desc_chars(store / kit_name / name),
|
|
322
|
+
kit_info.get("version")))
|
|
323
|
+
|
|
324
|
+
# 全局 kit 的 skill 带项目级覆盖(off/name-only/on)时,项目作用域也要列出
|
|
325
|
+
# 它 —— 即便项目里没有 link。这是 D-15 的"全局 skill 按项目覆盖"读回路径。
|
|
326
|
+
# 注意:project skill 若有同名全局 skill,覆盖是写给项目 kit 的(见 set_state
|
|
327
|
+
# 的定位顺序),不能误归属到全局 kit。故这里只对"项目作用域无同名 kit"的
|
|
328
|
+
# 全局 skill 补项目级条目。
|
|
329
|
+
if (scope is None or scope == "project") and root is not None:
|
|
330
|
+
proj_ov = _read_overrides("project")
|
|
331
|
+
if proj_ov:
|
|
332
|
+
project_kit_names = {
|
|
333
|
+
sk.get("name")
|
|
334
|
+
for kit_info in reg.get("kits", {}).values()
|
|
335
|
+
if registry._kit_in_scope(kit_info, "project")
|
|
336
|
+
for sk in kit_info.get("skills", [])
|
|
337
|
+
if sk.get("name")
|
|
338
|
+
}
|
|
339
|
+
for kit_name, kit_info in reg.get("kits", {}).items():
|
|
340
|
+
known = kit_info.get("known_scopes") or []
|
|
341
|
+
if "global" not in known:
|
|
342
|
+
continue # 只有全局 kit 有"项目级覆盖"语义
|
|
343
|
+
for sk in kit_info.get("skills", []):
|
|
344
|
+
name = sk.get("name")
|
|
345
|
+
if not name or name not in proj_ov:
|
|
346
|
+
continue
|
|
347
|
+
if name in project_kit_names:
|
|
348
|
+
continue # 同名项目 kit 存在,覆盖归属项目 kit,不补全局条目
|
|
349
|
+
env_ok = _env_status(sk.get("envs") or {})
|
|
350
|
+
st = _derive_state(True, proj_ov[name])
|
|
351
|
+
result.append(SkillState(name, kit_name, "project", st, True,
|
|
352
|
+
env_ok,
|
|
353
|
+
_desc_chars(store / kit_name / name),
|
|
354
|
+
kit_info.get("version")))
|
|
355
|
+
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_state(name: str, scope: Scope = "global") -> str:
|
|
360
|
+
"""返回 skill 在指定作用域的当前状态。
|
|
361
|
+
|
|
362
|
+
项目作用域下,全局 skill 可能没有项目 link,只有 settings.local.json 的
|
|
363
|
+
覆盖(off/name-only/on);此时按覆盖推导,而不是一律报 installed。
|
|
364
|
+
"""
|
|
365
|
+
skills_dir = _scope_skills_dir(scope)
|
|
366
|
+
link_path = skills_dir / name if skills_dir else None
|
|
367
|
+
has_link = bool(link_path and (link.is_link(link_path) or link.is_dangling(link_path)))
|
|
368
|
+
if has_link:
|
|
369
|
+
return _derive_state(True, _read_overrides(scope).get(name))
|
|
370
|
+
if scope == "project" and skills_dir is not None:
|
|
371
|
+
ov = _read_overrides("project").get(name)
|
|
372
|
+
if ov is not None:
|
|
373
|
+
return _derive_state(True, ov)
|
|
374
|
+
return "installed"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
_STATE_TO_OVERRIDE = {"enabled": "on", "name-only": "name-only", "off": "off"}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _kit_is_global(kit_name: str) -> bool:
|
|
381
|
+
"""kit 是否装在全局作用域(known_scopes 含 "global")。"""
|
|
382
|
+
info = registry.get_kit(kit_name)
|
|
383
|
+
return bool(info) and "global" in (info.get("known_scopes") or [])
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve_for_project(name: str) -> tuple[str, bool]:
|
|
387
|
+
"""项目作用域定位 skill 的 kit:先项目后全局。返回 (kit_name, is_global_kit)。
|
|
388
|
+
|
|
389
|
+
项目作用域装过 → 项目 kit(is_global=False);没装但全局有 → 全局 kit
|
|
390
|
+
(is_global=True);两处都没有或同名歧义 → 报错。
|
|
391
|
+
"""
|
|
392
|
+
matches = registry.find_skill_matches(name, "project")
|
|
393
|
+
if len(matches) > 1:
|
|
394
|
+
kits = ", ".join(k for k, _ in matches)
|
|
395
|
+
raise CckitError(f"skill {name!r} 在多个 kit 中出现({kits}),无法唯一定位")
|
|
396
|
+
if matches:
|
|
397
|
+
return matches[0][0], False
|
|
398
|
+
gmatches = registry.find_skill_matches(name, "global")
|
|
399
|
+
if len(gmatches) > 1:
|
|
400
|
+
kits = ", ".join(k for k, _ in gmatches)
|
|
401
|
+
raise CckitError(f"skill {name!r} 在多个 kit 中出现({kits}),无法唯一定位")
|
|
402
|
+
if not gmatches:
|
|
403
|
+
raise CckitError(f"skill {name!r} 不在 registry 中(project 作用域)(不是 cckit 管理或未安装)")
|
|
404
|
+
return gmatches[0][0], True
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def set_state(name: str, state: StateName, scope: Scope = "global",
|
|
408
|
+
kit: str | None = None) -> None:
|
|
409
|
+
"""把 skill 切到四态之一。enabled/name-only/off 需要 link;installed 删 link。
|
|
410
|
+
|
|
411
|
+
kit 缺省时按 skill 名在 registry 里唯一查找;多个 kit 同名时报错。
|
|
412
|
+
add 流程知道确切的 kit,应显式传 kit 避免同名歧义。
|
|
413
|
+
|
|
414
|
+
特例(D-15):全局 kit 的 skill 在项目作用域开关时,不建/不删项目 link ——
|
|
415
|
+
off/name-only 只写 settings.local.json 的 skillOverrides,并把项目根记入
|
|
416
|
+
override_scopes;enabled/installed 都=清掉项目覆盖、回到跟随全局(全局 skill
|
|
417
|
+
在项目里没有独立 link 可删,二者在此等价)。
|
|
418
|
+
"""
|
|
419
|
+
if state not in ("installed", "enabled", "name-only", "off"):
|
|
420
|
+
raise CckitError(f"未知状态 {state!r}")
|
|
421
|
+
if kit is not None:
|
|
422
|
+
kit_name = kit
|
|
423
|
+
is_global_kit = _kit_is_global(kit_name)
|
|
424
|
+
elif scope == "project":
|
|
425
|
+
kit_name, is_global_kit = _resolve_for_project(name)
|
|
426
|
+
else:
|
|
427
|
+
kit_name, _ = registry.find_skill(name, scope)
|
|
428
|
+
is_global_kit = True
|
|
429
|
+
|
|
430
|
+
if scope == "project" and is_global_kit:
|
|
431
|
+
# 全局 skill 的项目级覆盖:不碰 link,只写 settings.local.json 的
|
|
432
|
+
# skillOverrides。off/name-only 写覆盖并记入 override_scopes;
|
|
433
|
+
# enabled/installed 都表示"清掉项目覆盖、回到跟随全局"(全局 skill 在
|
|
434
|
+
# 项目里没有独立的 link 可删,enabled 与 installed 在此等价)。
|
|
435
|
+
value = _STATE_TO_OVERRIDE[state] if state in ("off", "name-only") else None
|
|
436
|
+
_write_override("project", name, value)
|
|
437
|
+
if value is not None:
|
|
438
|
+
root = config.project_root()
|
|
439
|
+
if root is not None:
|
|
440
|
+
registry.add_override_scope(kit_name, str(root))
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
target = config.store_dir() / kit_name / name
|
|
444
|
+
skills_dir = _scope_skills_dir(scope)
|
|
445
|
+
if skills_dir is None:
|
|
446
|
+
raise CckitError("无法确定作用域 skills 目录(项目作用域需在项目内)")
|
|
447
|
+
link_path = skills_dir / name
|
|
448
|
+
|
|
449
|
+
if state == "installed":
|
|
450
|
+
if link.is_link(link_path) or link.is_dangling(link_path):
|
|
451
|
+
link.remove(link_path)
|
|
452
|
+
_write_override(scope, name, None)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
if not target.is_dir():
|
|
456
|
+
raise CckitError(f"store 目标不存在(可能被手动删除): {target}")
|
|
457
|
+
if link.is_link(link_path):
|
|
458
|
+
pass
|
|
459
|
+
elif link_path.exists():
|
|
460
|
+
raise CckitError(f"{link_path} 已存在且是真实目录(用户手写 skill),拒绝覆盖")
|
|
461
|
+
else:
|
|
462
|
+
link.create(target, link_path)
|
|
463
|
+
_write_override(scope, name, _STATE_TO_OVERRIDE[state])
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def budget(scope: Scope | None = None) -> tuple[int, int]:
|
|
467
|
+
"""清单预算:(已用字符, 上限)。
|
|
468
|
+
|
|
469
|
+
scope 缺省(None)统计全部作用域;`list --project` 传 "project" 只算项目。
|
|
470
|
+
上限 = 缺省 config.DEFAULT_BUDGET_LIMIT_CHARS 字符 × skillListingBudgetFraction / 0.01。
|
|
471
|
+
只统计 enabled 态的 skill —— name-only / off / installed 的 description
|
|
472
|
+
不进上下文,不占预算。
|
|
473
|
+
"""
|
|
474
|
+
used = sum(s.desc_chars for s in list_skills(scope) if s.state == "enabled")
|
|
475
|
+
settings = read_settings("global")
|
|
476
|
+
fraction = settings.get("skillListingBudgetFraction", _BUDGET_FRACTION_DEFAULT)
|
|
477
|
+
try:
|
|
478
|
+
fraction = float(fraction)
|
|
479
|
+
except (TypeError, ValueError):
|
|
480
|
+
fraction = _BUDGET_FRACTION_DEFAULT
|
|
481
|
+
if fraction <= 0:
|
|
482
|
+
fraction = _BUDGET_FRACTION_DEFAULT
|
|
483
|
+
limit = int(config.DEFAULT_BUDGET_LIMIT_CHARS * fraction / _BUDGET_FRACTION_DEFAULT)
|
|
484
|
+
return used, limit
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
cckit/__init__.py,sha256=konH_gCFP07MmFHlO3i29K9q6PswpCp81sGXu6BgRNI,244
|
|
2
|
+
cckit/__main__.py,sha256=vOsPDqEPQfb84hmDD2XKueJ8g3c29eURX9__B2PTsG8,173
|
|
3
|
+
cckit/cckit.schema.json,sha256=QeLxtyJfrIdkF56HOL33VhSmumSVgC8ME5Fe0SL4uD8,4428
|
|
4
|
+
cckit/cli.py,sha256=NZ4nul7Xs44qFvmJwrF-4hE4OX-CEdtA6NT6hz5wmb4,9392
|
|
5
|
+
cckit/config.py,sha256=eSAxNngplfB2nKjIlSXSFnJWmkshZqdIhXMOuxAmF3g,3056
|
|
6
|
+
cckit/doctor.py,sha256=twxmaFAos1x1nJkO1oXTeLWKkGPXsnlRPUMpgklPmug,7721
|
|
7
|
+
cckit/env.py,sha256=IXX0KlLDA_Ma-WzdOX5KOjppbGKJiOfWkkFssUuR37k,1973
|
|
8
|
+
cckit/errors.py,sha256=xkqCdPslJSDmionyqTJYaHndT4FbEGANy8ElcPA93Ms,332
|
|
9
|
+
cckit/exec.py,sha256=jqXCXy7PcBFhgIaBauGYBwQ3YBmZOl70rClvGe57X88,4063
|
|
10
|
+
cckit/installer.py,sha256=Xx545bn0vAGFcMMQSlfSWErFEL73btr5XLB8HpA0eBE,22355
|
|
11
|
+
cckit/link.py,sha256=Up0eBm5oY_L8vdgsA0AX-SDPQjsZJm7j6JtaVLYB14w,3131
|
|
12
|
+
cckit/lint.py,sha256=4EX6AAKU8cVCSkCJLQSPIvbiKWT3F-2Ot6dt80KUBSI,12470
|
|
13
|
+
cckit/manifest.py,sha256=Jg23723VMqRbS__YHZ3j49JkEB-OpOVEjvAf-YMPIqU,4443
|
|
14
|
+
cckit/registry.py,sha256=rlaBPdXxUdsd_ZyOonlMl1ZI2vIRAPMXDY4WNcWdTR0,5142
|
|
15
|
+
cckit/schema.py,sha256=RWdjsIGyfLnF8TY9ptZLhB8hwEZxhGkM5UWS0jOmObo,1496
|
|
16
|
+
cckit/state.py,sha256=8huzjjlV_2GX7R_HU3SGNyxuHsGaStvvuaM7Se6XNt0,19690
|
|
17
|
+
cckit-0.1.0.dist-info/METADATA,sha256=h1U8IpSkioa3YDaWMcscfaI8V5LC3U0dsnhTybeJEfY,235
|
|
18
|
+
cckit-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
19
|
+
cckit-0.1.0.dist-info/entry_points.txt,sha256=R85PJYWWEaQ5W0BUpqvYqPrP1v-mbVHn72OPmnIi4hA,41
|
|
20
|
+
cckit-0.1.0.dist-info/licenses/LICENSE,sha256=6eJqx6zTFdfY6CegQHI3pcTR55xiwzAuceac5sh8LYA,1086
|
|
21
|
+
cckit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 0Ra1n416
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|