s-skillkit 0.1.0__tar.gz

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.
Files changed (38) hide show
  1. s_skillkit-0.1.0/.gitignore +50 -0
  2. s_skillkit-0.1.0/LICENSE +21 -0
  3. s_skillkit-0.1.0/PKG-INFO +61 -0
  4. s_skillkit-0.1.0/README.md +45 -0
  5. s_skillkit-0.1.0/pyproject.toml +48 -0
  6. s_skillkit-0.1.0/skillkit/__init__.py +99 -0
  7. s_skillkit-0.1.0/skillkit/collections.py +179 -0
  8. s_skillkit-0.1.0/skillkit/deps_installer.py +185 -0
  9. s_skillkit-0.1.0/skillkit/filter.py +258 -0
  10. s_skillkit-0.1.0/skillkit/installer.py +958 -0
  11. s_skillkit-0.1.0/skillkit/linker.py +124 -0
  12. s_skillkit-0.1.0/skillkit/manifest.py +191 -0
  13. s_skillkit-0.1.0/skillkit/mcp_register.py +243 -0
  14. s_skillkit-0.1.0/skillkit/path_store.py +384 -0
  15. s_skillkit-0.1.0/skillkit/paths.py +46 -0
  16. s_skillkit-0.1.0/skillkit/project.py +59 -0
  17. s_skillkit-0.1.0/skillkit/targets/__init__.py +16 -0
  18. s_skillkit-0.1.0/skillkit/targets/antigravity.py +18 -0
  19. s_skillkit-0.1.0/skillkit/targets/base.py +82 -0
  20. s_skillkit-0.1.0/skillkit/targets/claude_code.py +8 -0
  21. s_skillkit-0.1.0/skillkit/targets/codex.py +8 -0
  22. s_skillkit-0.1.0/skillkit/targets/detect.py +26 -0
  23. s_skillkit-0.1.0/skillkit/tooling.py +253 -0
  24. s_skillkit-0.1.0/tests/conftest.py +21 -0
  25. s_skillkit-0.1.0/tests/test_collections.py +88 -0
  26. s_skillkit-0.1.0/tests/test_deps_installer.py +167 -0
  27. s_skillkit-0.1.0/tests/test_filter.py +170 -0
  28. s_skillkit-0.1.0/tests/test_git_install.py +124 -0
  29. s_skillkit-0.1.0/tests/test_installer.py +192 -0
  30. s_skillkit-0.1.0/tests/test_manifest.py +71 -0
  31. s_skillkit-0.1.0/tests/test_mcp_register.py +239 -0
  32. s_skillkit-0.1.0/tests/test_path_store.py +320 -0
  33. s_skillkit-0.1.0/tests/test_paths.py +32 -0
  34. s_skillkit-0.1.0/tests/test_project.py +42 -0
  35. s_skillkit-0.1.0/tests/test_stub_guard.py +89 -0
  36. s_skillkit-0.1.0/tests/test_targets.py +58 -0
  37. s_skillkit-0.1.0/tests/test_tooling.py +262 -0
  38. s_skillkit-0.1.0/tests/test_traversal_guard.py +63 -0
@@ -0,0 +1,50 @@
1
+ # === atlas universal gitignore ===
2
+
3
+ # OS / IDE
4
+ .DS_Store
5
+ Thumbs.db
6
+ .vscode/
7
+ .idea/
8
+ *.swp
9
+ *.swo
10
+
11
+ # Sensitive
12
+ .env
13
+ .env.local
14
+ *.key
15
+ *.pem
16
+ secrets/
17
+ private/
18
+
19
+ # Python
20
+ __pycache__/
21
+ *.py[cod]
22
+ .venv/
23
+ venv/
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ *.egg-info/
27
+
28
+ # Node / JS
29
+ node_modules/
30
+ .next/
31
+ dist/
32
+ build/
33
+
34
+ # Temporary / large
35
+ *.log
36
+ *.tmp
37
+ nul
38
+ NUL
39
+ *.zip
40
+ *.rar
41
+ *.7z
42
+
43
+ # Media (selectively unignore via !path/*.ext if needed for fixtures)
44
+ *.mp4
45
+ *.mov
46
+ *.avi
47
+ *.mkv
48
+
49
+ # git worktrees (агентские)
50
+ .worktrees/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry
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.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: s-skillkit
3
+ Version: 0.1.0
4
+ Summary: Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs.
5
+ Author: Dmitry
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pathspec>=0.12.0
10
+ Requires-Dist: platformdirs>=4.0
11
+ Requires-Dist: tomli-w>=1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # s-skillkit
18
+
19
+ Локальное ядро управления навыками (skills) — **SIBLING** `librarykit`
20
+ (не зависит от него, от httpx, auth или сети). Только локальная ФС-механика.
21
+
22
+ ## Что внутри
23
+
24
+ - **`SkillStore`** (`skillkit.installer`) — материализация навыка в центральный
25
+ стор и линковка (junction на Windows / symlink на POSIX) в scope агента:
26
+ - `install_from_git` / `install_from_path` / `install` / `materialize`
27
+ - `update` (инкрементальный sha-diff), `link_existing`, `migrate_scope`
28
+ - `remove` (keep-local / purge)
29
+ - P0 **stub-would-clobber guard** (stub не затирает живой контент).
30
+ - **`targets`** — `IAgentTarget` + `detect_agent` / `get_target` (Claude Code,
31
+ Codex, Antigravity).
32
+ - **`manifest`** — `build_manifest` для publish + ридеры frontmatter /
33
+ `_skill_meta.toml`.
34
+ - **`filter`** — `.skillignore` / `files`-allowlist фильтр (pathspec).
35
+ - **`project`** — проектный манифест `.skills-hub/skills.toml`.
36
+ - **`Paths`** — инъекция каталогов (`store_dir` / `config_dir` / `bin_dir`).
37
+ `Paths.default()` — нативная раскладка через platformdirs.
38
+
39
+ ## Инъекция вместо завязки на конфиг
40
+
41
+ Кит НЕ читает env/config. Каталоги передаются явным `Paths`; git-учётка —
42
+ инъектируемым `credential_resolver` (callable `url -> url`). Потребитель (CLI)
43
+ читает env-токен и собирает резолвер:
44
+
45
+ ```python
46
+ from skillkit import SkillStore, Paths, get_target
47
+
48
+ paths = Paths.default() # или из ClientConfig
49
+
50
+ def resolver(url: str) -> str:
51
+ token = os.environ.get("SKILLS_HUB_GIT_TOKEN")
52
+ if token and url.startswith("https://") and "@" not in url:
53
+ return url.replace("https://", f"https://oauth2:{token}@", 1)
54
+ return url
55
+
56
+ store = SkillStore(get_target(None), paths.store_dir, credential_resolver=resolver)
57
+ ```
58
+
59
+ ## Зависимости
60
+
61
+ `tomli-w`, `pathspec`, `platformdirs`. requires-python `>=3.11`. MIT.
@@ -0,0 +1,45 @@
1
+ # s-skillkit
2
+
3
+ Локальное ядро управления навыками (skills) — **SIBLING** `librarykit`
4
+ (не зависит от него, от httpx, auth или сети). Только локальная ФС-механика.
5
+
6
+ ## Что внутри
7
+
8
+ - **`SkillStore`** (`skillkit.installer`) — материализация навыка в центральный
9
+ стор и линковка (junction на Windows / symlink на POSIX) в scope агента:
10
+ - `install_from_git` / `install_from_path` / `install` / `materialize`
11
+ - `update` (инкрементальный sha-diff), `link_existing`, `migrate_scope`
12
+ - `remove` (keep-local / purge)
13
+ - P0 **stub-would-clobber guard** (stub не затирает живой контент).
14
+ - **`targets`** — `IAgentTarget` + `detect_agent` / `get_target` (Claude Code,
15
+ Codex, Antigravity).
16
+ - **`manifest`** — `build_manifest` для publish + ридеры frontmatter /
17
+ `_skill_meta.toml`.
18
+ - **`filter`** — `.skillignore` / `files`-allowlist фильтр (pathspec).
19
+ - **`project`** — проектный манифест `.skills-hub/skills.toml`.
20
+ - **`Paths`** — инъекция каталогов (`store_dir` / `config_dir` / `bin_dir`).
21
+ `Paths.default()` — нативная раскладка через platformdirs.
22
+
23
+ ## Инъекция вместо завязки на конфиг
24
+
25
+ Кит НЕ читает env/config. Каталоги передаются явным `Paths`; git-учётка —
26
+ инъектируемым `credential_resolver` (callable `url -> url`). Потребитель (CLI)
27
+ читает env-токен и собирает резолвер:
28
+
29
+ ```python
30
+ from skillkit import SkillStore, Paths, get_target
31
+
32
+ paths = Paths.default() # или из ClientConfig
33
+
34
+ def resolver(url: str) -> str:
35
+ token = os.environ.get("SKILLS_HUB_GIT_TOKEN")
36
+ if token and url.startswith("https://") and "@" not in url:
37
+ return url.replace("https://", f"https://oauth2:{token}@", 1)
38
+ return url
39
+
40
+ store = SkillStore(get_target(None), paths.store_dir, credential_resolver=resolver)
41
+ ```
42
+
43
+ ## Зависимости
44
+
45
+ `tomli-w`, `pathspec`, `platformdirs`. requires-python `>=3.11`. MIT.
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "s-skillkit"
3
+ version = "0.1.0"
4
+ description = "Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Dmitry" }]
9
+ # skillkit — SIBLING librarykit (НЕ зависит от него, httpx, auth, сети).
10
+ # Только локальная ФС-механика навыков. Core deps:
11
+ # tomli-w — запись TOML (project-манифест .skills-hub/skills.toml,
12
+ # _skill_meta.toml ридеры используют stdlib tomllib на чтение);
13
+ # pathspec — gitignore-стиль .skillignore / files-allowlist фильтр;
14
+ # platformdirs — нативные дефолтные пути (Paths.default) по ОС.
15
+ dependencies = [
16
+ "tomli-w>=1.0",
17
+ "pathspec>=0.12.0",
18
+ "platformdirs>=4.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["skillkit"]
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py311"
34
+ src = ["."]
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "B", "UP", "N", "SIM", "RUF"]
38
+ # RUF001/RUF002/RUF003 — кириллица в строках/докстрингах/комментах намеренная
39
+ # (русскоязычный код), ruff принимает её за «двусмысленную» латиницу.
40
+ # RUF022 — __all__ сгруппирован по модулям с комментами осознанно.
41
+ # RUF059 — распаковка кортежа в тестах с неиспользуемой частью (фикстура
42
+ # возвращает (store, target, store_dir); читаемее, чем индексирование).
43
+ ignore = ["RUF001", "RUF002", "RUF003", "RUF022", "RUF059"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ testpaths = ["tests"]
48
+ pythonpath = ["."]
@@ -0,0 +1,99 @@
1
+ """skillkit — локальное ядро управления навыками (SIBLING librarykit).
2
+
3
+ 0 завязок на сеть/auth/librarykit: только stdlib + tomli-w + pathspec +
4
+ platformdirs. Отвечает за ФС-механику навыка:
5
+
6
+ - ``SkillStore`` — материализация навыка в центральный стор (git clone /
7
+ локальная папка / stub) + junction/symlink-линковка в scope агента,
8
+ инкрементальный update по sha-diff, remove/keep-local/purge, migrate_scope.
9
+ Включает P0 stub-would-clobber guard.
10
+ - ``targets`` — ``IAgentTarget`` + детект агента (Claude Code / Codex /
11
+ Antigravity).
12
+ - ``manifest`` — build manifest для publish + ридеры frontmatter / _skill_meta.toml.
13
+ - ``filter`` — .skillignore / files-allowlist фильтр.
14
+ - ``project`` — проектный манифест ``.skills-hub/skills.toml``.
15
+ - ``Paths`` — инъекция файловых каталогов (store/config/bin) вместо завязки на
16
+ конфиг потребителя.
17
+
18
+ Хаб-специфика (track_skill_event, install-by-slug bundle, /me/installs) живёт в
19
+ CLI, НЕ здесь.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from skillkit import (
24
+ collections,
25
+ deps_installer,
26
+ filter,
27
+ linker,
28
+ manifest,
29
+ mcp_register,
30
+ path_store,
31
+ project,
32
+ targets,
33
+ tooling,
34
+ )
35
+ from skillkit.collections import LocalCollectionError
36
+ from skillkit.installer import (
37
+ CredentialResolver,
38
+ InstallResult,
39
+ PathTraversalError,
40
+ RemoveResult,
41
+ SkillInstaller,
42
+ SkillStore,
43
+ read_meta,
44
+ safe_copy_tree,
45
+ skill_dir_name,
46
+ write_meta,
47
+ )
48
+ from skillkit.manifest import BuiltManifest, build_manifest, git_commit_sha
49
+ from skillkit.paths import Paths
50
+ from skillkit.targets import (
51
+ AntigravityTarget,
52
+ BaseAgentTarget,
53
+ ClaudeCodeTarget,
54
+ CodexTarget,
55
+ IAgentTarget,
56
+ detect_agent,
57
+ get_target,
58
+ )
59
+
60
+ __all__ = [
61
+ # Paths-инъекция
62
+ "Paths",
63
+ # стор / установщик
64
+ "SkillStore",
65
+ "SkillInstaller",
66
+ "InstallResult",
67
+ "RemoveResult",
68
+ "CredentialResolver",
69
+ "PathTraversalError",
70
+ "safe_copy_tree",
71
+ "read_meta",
72
+ "write_meta",
73
+ "skill_dir_name",
74
+ # targets
75
+ "IAgentTarget",
76
+ "BaseAgentTarget",
77
+ "ClaudeCodeTarget",
78
+ "CodexTarget",
79
+ "AntigravityTarget",
80
+ "detect_agent",
81
+ "get_target",
82
+ # manifest
83
+ "BuiltManifest",
84
+ "build_manifest",
85
+ "git_commit_sha",
86
+ # локальные коллекции
87
+ "LocalCollectionError",
88
+ # подмодули (ФС-механика навыка)
89
+ "linker",
90
+ "filter",
91
+ "manifest",
92
+ "project",
93
+ "targets",
94
+ "path_store",
95
+ "tooling",
96
+ "deps_installer",
97
+ "mcp_register",
98
+ "collections",
99
+ ]
@@ -0,0 +1,179 @@
1
+ """Локальные коллекции навыков — БЕЗ хаба и сети.
2
+
3
+ Хранилище: ``<config_dir>/collections.toml`` — та же папка, что config
4
+ потребителя. skillkit НЕ читает env/config: каталог приходит от потребителя
5
+ через ``Paths`` (инъекция). Standalone-дефолт — ``Paths.default().config_dir``;
6
+ CLI переопределяет провайдер (``set_config_dir_provider``), чтобы учесть свой
7
+ ``SKILLS_HUB_CONFIG_DIR`` и профили ``--profile``.
8
+
9
+ Формат:
10
+ [collections.<name>]
11
+ title = "…"
12
+ skills = ["slug1", "slug2"]
13
+
14
+ Имя коллекции — slug-подобное (буквы/цифры/``-``/``_``): оно становится ключом
15
+ toml-таблицы. Слаги навыков НЕ валидируются против хаба — коллекция может
16
+ ссылаться на навыки, которых (ещё) нет в локальном сторе.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import tomllib
22
+ from collections.abc import Callable
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import tomli_w
27
+
28
+ from skillkit.paths import Paths
29
+
30
+ _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
31
+
32
+
33
+ class LocalCollectionError(ValueError):
34
+ """Ожидаемая ошибка операции: ``code`` + ``message`` для ``emit_error``."""
35
+
36
+ def __init__(self, code: str, message: str) -> None:
37
+ super().__init__(message)
38
+ self.code = code
39
+ self.message = message
40
+
41
+
42
+ # --------------------------------------------------------------------------
43
+ # config_dir — провайдер каталога (инъекция от потребителя)
44
+ # --------------------------------------------------------------------------
45
+ def _default_config_dir() -> Path:
46
+ """Standalone-дефолт: нативный config-каталог через platformdirs."""
47
+ return Paths.default().config_dir
48
+
49
+
50
+ _config_dir_provider: Callable[[], Path] = _default_config_dir
51
+
52
+
53
+ def set_config_dir_provider(provider: Callable[[], Path]) -> None:
54
+ """Задать провайдер config-каталога (точка инъекции для CLI/потребителя)."""
55
+ global _config_dir_provider
56
+ _config_dir_provider = provider
57
+
58
+
59
+ def collections_path() -> Path:
60
+ """Путь файла локальных коллекций (рядом с config потребителя)."""
61
+ return _config_dir_provider() / "collections.toml"
62
+
63
+
64
+ def _normalize(body: dict[str, Any], name: str) -> dict[str, Any]:
65
+ """Приводит запись к канону {title: str, skills: [str]} (дедуп слагов)."""
66
+ skills: list[str] = []
67
+ for s in body.get("skills") or []:
68
+ s_str = str(s).strip()
69
+ if s_str and s_str not in skills:
70
+ skills.append(s_str)
71
+ return {"title": str(body.get("title") or name), "skills": skills}
72
+
73
+
74
+ def load_all() -> dict[str, dict[str, Any]]:
75
+ """{name: {"title": str, "skills": [slug, ...]}}; пусто если файла нет."""
76
+ p = collections_path()
77
+ if not p.exists():
78
+ return {}
79
+ try:
80
+ # utf-8-sig — толерантность к BOM (как ClientConfig.load).
81
+ data = tomllib.loads(p.read_text(encoding="utf-8-sig"))
82
+ except tomllib.TOMLDecodeError as e:
83
+ raise LocalCollectionError("PARSE", f"{p} повреждён: {e}") from e
84
+ raw = data.get("collections") or {}
85
+ out: dict[str, dict[str, Any]] = {}
86
+ for name, body in raw.items():
87
+ if isinstance(body, dict):
88
+ out[str(name)] = _normalize(body, str(name))
89
+ return out
90
+
91
+
92
+ def save_all(collections: dict[str, dict[str, Any]]) -> None:
93
+ """Пишет весь набор (ключи сортируются для стабильного diff)."""
94
+ p = collections_path()
95
+ p.parent.mkdir(parents=True, exist_ok=True)
96
+ ordered = {n: _normalize(collections[n], n) for n in sorted(collections)}
97
+ p.write_text(tomli_w.dumps({"collections": ordered}), encoding="utf-8")
98
+
99
+
100
+ def validate_name(name: str) -> None:
101
+ if not _NAME_RE.match(name or ""):
102
+ raise LocalCollectionError(
103
+ "VALIDATION",
104
+ f"Имя коллекции «{name}» некорректно: допустимы буквы/цифры/«-»/«_» "
105
+ "(без пробелов), первый символ — буква или цифра",
106
+ )
107
+
108
+
109
+ def get(name: str) -> dict[str, Any] | None:
110
+ """Коллекция по имени либо None."""
111
+ return load_all().get(name)
112
+
113
+
114
+ def create(name: str, *, title: str | None = None) -> dict[str, Any]:
115
+ """Создаёт пустую коллекцию. ALREADY_EXISTS если имя занято."""
116
+ validate_name(name)
117
+ collections = load_all()
118
+ if name in collections:
119
+ raise LocalCollectionError(
120
+ "ALREADY_EXISTS", f"Локальная коллекция «{name}» уже существует"
121
+ )
122
+ collections[name] = {"title": title or name, "skills": []}
123
+ save_all(collections)
124
+ return collections[name]
125
+
126
+
127
+ def delete(name: str) -> dict[str, Any]:
128
+ """Удаляет коллекцию (установленные навыки не трогает). NOT_FOUND если нет."""
129
+ collections = load_all()
130
+ if name not in collections:
131
+ raise LocalCollectionError(
132
+ "NOT_FOUND", f"Локальная коллекция «{name}» не найдена"
133
+ )
134
+ removed = collections.pop(name)
135
+ save_all(collections)
136
+ return removed
137
+
138
+
139
+ def add_skill(name: str, slug: str) -> tuple[dict[str, Any], bool]:
140
+ """(коллекция, added). added=False если слаг уже был (идемпотентно)."""
141
+ slug = str(slug or "").strip()
142
+ if not slug:
143
+ raise LocalCollectionError("VALIDATION", "Слаг навыка пуст")
144
+ collections = load_all()
145
+ if name not in collections:
146
+ raise LocalCollectionError(
147
+ "NOT_FOUND", f"Локальная коллекция «{name}» не найдена"
148
+ )
149
+ coll = collections[name]
150
+ if slug in coll["skills"]:
151
+ return coll, False
152
+ coll["skills"].append(slug)
153
+ save_all(collections)
154
+ return coll, True
155
+
156
+
157
+ def remove_skill(name: str, slug: str) -> tuple[dict[str, Any], bool]:
158
+ """(коллекция, removed). removed=False если слага не было."""
159
+ collections = load_all()
160
+ if name not in collections:
161
+ raise LocalCollectionError(
162
+ "NOT_FOUND", f"Локальная коллекция «{name}» не найдена"
163
+ )
164
+ coll = collections[name]
165
+ if slug not in coll["skills"]:
166
+ return coll, False
167
+ coll["skills"] = [s for s in coll["skills"] if s != slug]
168
+ save_all(collections)
169
+ return coll, True
170
+
171
+
172
+ def missing_in_store(skills: list[str], store_dir: Path) -> list[str]:
173
+ """Слаги, которых нет в локальном сторе.
174
+
175
+ Критерий «есть в сторе» тот же, что у ``SkillStore.link_existing``:
176
+ существует каталог ``<store>/<slug>`` (для slug-less навыка — числовой id).
177
+ """
178
+ root = Path(store_dir)
179
+ return [s for s in skills if not (root / s).exists()]