s-skillkit 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.
- s_skillkit-0.1.0.dist-info/METADATA +61 -0
- s_skillkit-0.1.0.dist-info/RECORD +22 -0
- s_skillkit-0.1.0.dist-info/WHEEL +4 -0
- s_skillkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- skillkit/__init__.py +99 -0
- skillkit/collections.py +179 -0
- skillkit/deps_installer.py +185 -0
- skillkit/filter.py +258 -0
- skillkit/installer.py +958 -0
- skillkit/linker.py +124 -0
- skillkit/manifest.py +191 -0
- skillkit/mcp_register.py +243 -0
- skillkit/path_store.py +384 -0
- skillkit/paths.py +46 -0
- skillkit/project.py +59 -0
- skillkit/targets/__init__.py +16 -0
- skillkit/targets/antigravity.py +18 -0
- skillkit/targets/base.py +82 -0
- skillkit/targets/claude_code.py +8 -0
- skillkit/targets/codex.py +8 -0
- skillkit/targets/detect.py +26 -0
- skillkit/tooling.py +253 -0
skillkit/filter.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Фильтрация файлов skill при install: .skillignore + manifest files allowlist.
|
|
2
|
+
|
|
3
|
+
Логика после git clone:
|
|
4
|
+
1. Если SKILL.md frontmatter содержит `files:` — это allowlist.
|
|
5
|
+
Оставляем ТОЛЬКО matched paths. `.skillignore` игнорируется.
|
|
6
|
+
2. Иначе если `.skillignore` есть — gitignore-стиль patterns. Удаляем matched.
|
|
7
|
+
3. Иначе ничего не делаем.
|
|
8
|
+
|
|
9
|
+
Всегда сохраняются: `.git/`, `SKILL.md`, `.skillignore`, `_skill_meta.json`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pathspec
|
|
20
|
+
|
|
21
|
+
SKILLIGNORE_FILENAME = ".skillignore"
|
|
22
|
+
|
|
23
|
+
# Корневые пути которые НИКОГДА не удаляются (даже если matched ignore-паттерн
|
|
24
|
+
# или НЕ matched allowlist). .git/ — для воспроизводимости; SKILL.md — основа
|
|
25
|
+
# skill; .skillignore сам себя; _skill_meta.json — установщик пишет позже.
|
|
26
|
+
_PRESERVED_ROOT_NAMES: frozenset[str] = frozenset(
|
|
27
|
+
{".git", "SKILL.md", ".skillignore", "_skill_meta.json"}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _on_rm_error(func, path, exc_info):
|
|
32
|
+
"""Чинит read-only-файлы (Windows: .git/objects/...) перед удалением."""
|
|
33
|
+
try:
|
|
34
|
+
os.chmod(path, stat.S_IWRITE)
|
|
35
|
+
func(path)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_skill_md_files_allowlist(skill_md_path: Path) -> list[str] | None:
|
|
41
|
+
"""Парсит SKILL.md frontmatter; возвращает list patterns если `files:` поле есть.
|
|
42
|
+
|
|
43
|
+
Возвращает None если поле отсутствует, SKILL.md нет frontmatter, или файл не существует.
|
|
44
|
+
"""
|
|
45
|
+
if not skill_md_path.exists():
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
content = skill_md_path.read_text(encoding="utf-8")
|
|
49
|
+
if not content.startswith("---"):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# Найти end of frontmatter (закрывающий `---` после первого).
|
|
53
|
+
end_match = re.search(r"\n---\s*\n", content[3:])
|
|
54
|
+
if not end_match:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
frontmatter = content[3 : 3 + end_match.start()]
|
|
58
|
+
files = _extract_yaml_list(frontmatter, "files")
|
|
59
|
+
return files if files else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_yaml_list(frontmatter: str, key: str) -> list[str] | None:
|
|
63
|
+
"""Простой парсер YAML списка из строки. Без pyyaml dep.
|
|
64
|
+
|
|
65
|
+
Поддерживает:
|
|
66
|
+
files:
|
|
67
|
+
- "SKILL.md"
|
|
68
|
+
- src/**
|
|
69
|
+
- 'tests/*.py'
|
|
70
|
+
|
|
71
|
+
Возвращает list или None если key не найден / список пуст.
|
|
72
|
+
"""
|
|
73
|
+
pattern = re.compile(
|
|
74
|
+
rf"^{re.escape(key)}:\s*\n((?:[ \t]+-[ \t]+.+\n?)+)",
|
|
75
|
+
re.MULTILINE,
|
|
76
|
+
)
|
|
77
|
+
match = pattern.search(frontmatter)
|
|
78
|
+
if not match:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
items_block = match.group(1)
|
|
82
|
+
items: list[str] = []
|
|
83
|
+
for raw_line in items_block.split("\n"):
|
|
84
|
+
line = raw_line.strip()
|
|
85
|
+
if not line.startswith("- "):
|
|
86
|
+
continue
|
|
87
|
+
value = line[2:].strip()
|
|
88
|
+
# Strip surrounding quotes (single or double).
|
|
89
|
+
if len(value) >= 2 and (
|
|
90
|
+
(value.startswith('"') and value.endswith('"'))
|
|
91
|
+
or (value.startswith("'") and value.endswith("'"))
|
|
92
|
+
):
|
|
93
|
+
value = value[1:-1]
|
|
94
|
+
if value:
|
|
95
|
+
items.append(value)
|
|
96
|
+
return items or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply_skill_filter(
|
|
100
|
+
slug_dir: Path, *, extra_preserved: tuple[str, ...] = ()
|
|
101
|
+
) -> dict[str, int]:
|
|
102
|
+
"""Применяет .skillignore И/ИЛИ manifest.files allowlist в slug_dir.
|
|
103
|
+
|
|
104
|
+
Логика:
|
|
105
|
+
- Если SKILL.md имеет `files:` → ALLOWLIST mode (оставляет только matched).
|
|
106
|
+
.skillignore игнорируется в этом случае (allowlist эксплицитнее).
|
|
107
|
+
- Иначе если .skillignore есть → IGNORE mode (удаляет matched).
|
|
108
|
+
- Иначе ничего не делает.
|
|
109
|
+
|
|
110
|
+
`extra_preserved` — дополнительные корневые пути (напр. `_local/`,
|
|
111
|
+
`browser_profiles/`), которые НИКОГДА не удаляются. Нужно при update,
|
|
112
|
+
чтобы allowlist новой версии не стёр пользовательский runtime-state.
|
|
113
|
+
|
|
114
|
+
Возвращает {"removed": N, "kept": M}.
|
|
115
|
+
`kept` НЕ учитывает файлы внутри .git/.
|
|
116
|
+
"""
|
|
117
|
+
skill_md = slug_dir / "SKILL.md"
|
|
118
|
+
allowlist_patterns = parse_skill_md_files_allowlist(skill_md)
|
|
119
|
+
|
|
120
|
+
if allowlist_patterns is not None:
|
|
121
|
+
return _apply_allowlist(slug_dir, allowlist_patterns, extra_preserved)
|
|
122
|
+
|
|
123
|
+
skillignore = slug_dir / SKILLIGNORE_FILENAME
|
|
124
|
+
if skillignore.exists():
|
|
125
|
+
patterns = [
|
|
126
|
+
line
|
|
127
|
+
for line in skillignore.read_text(encoding="utf-8").splitlines()
|
|
128
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
129
|
+
]
|
|
130
|
+
if patterns:
|
|
131
|
+
return _apply_ignore(slug_dir, patterns, extra_preserved)
|
|
132
|
+
|
|
133
|
+
return {"removed": 0, "kept": _count_files(slug_dir)}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _normalize_preserved_roots(extra_preserved: tuple[str, ...]) -> frozenset[str]:
|
|
137
|
+
"""Корневые имена из extra_preserved (`_local/` → `_local`, `.env` → `.env`)."""
|
|
138
|
+
roots: set[str] = set()
|
|
139
|
+
for p in extra_preserved:
|
|
140
|
+
norm = p.replace("\\", "/").strip().strip("/")
|
|
141
|
+
if not norm:
|
|
142
|
+
continue
|
|
143
|
+
roots.add(norm.split("/")[0])
|
|
144
|
+
return frozenset(roots)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_preserved(
|
|
148
|
+
slug_dir: Path, path: Path, rel: Path, extra_roots: frozenset[str] = frozenset()
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""True если path внутри slug_dir НЕЛЬЗЯ удалять (preserved root или внутри .git)."""
|
|
151
|
+
# Файл/папка прямо в корне с защищённым именем.
|
|
152
|
+
if path.parent == slug_dir and path.name in _PRESERVED_ROOT_NAMES:
|
|
153
|
+
return True
|
|
154
|
+
if not rel.parts:
|
|
155
|
+
return False
|
|
156
|
+
# Что угодно внутри .git/ или внутри extra-preserved root (_local/, ...).
|
|
157
|
+
return rel.parts[0] == ".git" or rel.parts[0] in extra_roots
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _remove(path: Path) -> bool:
|
|
161
|
+
"""Удаляет file или dir; True если что-то удалили."""
|
|
162
|
+
if path.is_symlink() or path.is_file():
|
|
163
|
+
try:
|
|
164
|
+
path.unlink()
|
|
165
|
+
return True
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
return False
|
|
168
|
+
if path.is_dir():
|
|
169
|
+
shutil.rmtree(path, onerror=_on_rm_error)
|
|
170
|
+
return True
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _apply_ignore(
|
|
175
|
+
slug_dir: Path, patterns: list[str], extra_preserved: tuple[str, ...] = ()
|
|
176
|
+
) -> dict[str, int]:
|
|
177
|
+
spec = pathspec.PathSpec.from_lines("gitignore", patterns)
|
|
178
|
+
extra_roots = _normalize_preserved_roots(extra_preserved)
|
|
179
|
+
removed = 0
|
|
180
|
+
|
|
181
|
+
# Сортировка по убыванию глубины — сначала листья, потом директории.
|
|
182
|
+
# Это позволяет удалять файлы внутри директории до самой директории.
|
|
183
|
+
for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
|
|
184
|
+
if not path.exists():
|
|
185
|
+
continue # уже удалён вместе с родителем
|
|
186
|
+
try:
|
|
187
|
+
rel = path.relative_to(slug_dir)
|
|
188
|
+
except ValueError:
|
|
189
|
+
continue
|
|
190
|
+
if _is_preserved(slug_dir, path, rel, extra_roots):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
rel_str = str(rel).replace("\\", "/")
|
|
194
|
+
# Для директорий pathspec ожидает trailing slash, чтобы matched `tests/`.
|
|
195
|
+
match_target = rel_str + "/" if path.is_dir() else rel_str
|
|
196
|
+
if (spec.match_file(match_target) or spec.match_file(rel_str)) and _remove(path):
|
|
197
|
+
removed += 1
|
|
198
|
+
|
|
199
|
+
return {"removed": removed, "kept": _count_files(slug_dir)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_allowlist(
|
|
203
|
+
slug_dir: Path, patterns: list[str], extra_preserved: tuple[str, ...] = ()
|
|
204
|
+
) -> dict[str, int]:
|
|
205
|
+
spec = pathspec.PathSpec.from_lines("gitignore", patterns)
|
|
206
|
+
extra_roots = _normalize_preserved_roots(extra_preserved)
|
|
207
|
+
removed = 0
|
|
208
|
+
|
|
209
|
+
# Pass 1: удалить файлы НЕ в allowlist.
|
|
210
|
+
for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
|
|
211
|
+
if not path.exists():
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
rel = path.relative_to(slug_dir)
|
|
215
|
+
except ValueError:
|
|
216
|
+
continue
|
|
217
|
+
if _is_preserved(slug_dir, path, rel, extra_roots):
|
|
218
|
+
continue
|
|
219
|
+
if not path.is_file() and not path.is_symlink():
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
rel_str = str(rel).replace("\\", "/")
|
|
223
|
+
if not spec.match_file(rel_str) and _remove(path):
|
|
224
|
+
removed += 1
|
|
225
|
+
|
|
226
|
+
# Pass 2: удалить пустые директории (которые остались после удаления детей
|
|
227
|
+
# и сами не в allowlist).
|
|
228
|
+
for path in sorted(slug_dir.rglob("*"), key=lambda p: -len(p.parts)):
|
|
229
|
+
if not path.exists() or not path.is_dir():
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
rel = path.relative_to(slug_dir)
|
|
233
|
+
except ValueError:
|
|
234
|
+
continue
|
|
235
|
+
if _is_preserved(slug_dir, path, rel, extra_roots):
|
|
236
|
+
continue
|
|
237
|
+
if not any(path.iterdir()):
|
|
238
|
+
try:
|
|
239
|
+
path.rmdir()
|
|
240
|
+
removed += 1
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
return {"removed": removed, "kept": _count_files(slug_dir)}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _count_files(slug_dir: Path) -> int:
|
|
248
|
+
count = 0
|
|
249
|
+
for p in slug_dir.rglob("*"):
|
|
250
|
+
try:
|
|
251
|
+
rel = p.relative_to(slug_dir)
|
|
252
|
+
except ValueError:
|
|
253
|
+
continue
|
|
254
|
+
if rel.parts and rel.parts[0] == ".git":
|
|
255
|
+
continue
|
|
256
|
+
if p.is_file():
|
|
257
|
+
count += 1
|
|
258
|
+
return count
|