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.
- s_skillkit-0.1.0/.gitignore +50 -0
- s_skillkit-0.1.0/LICENSE +21 -0
- s_skillkit-0.1.0/PKG-INFO +61 -0
- s_skillkit-0.1.0/README.md +45 -0
- s_skillkit-0.1.0/pyproject.toml +48 -0
- s_skillkit-0.1.0/skillkit/__init__.py +99 -0
- s_skillkit-0.1.0/skillkit/collections.py +179 -0
- s_skillkit-0.1.0/skillkit/deps_installer.py +185 -0
- s_skillkit-0.1.0/skillkit/filter.py +258 -0
- s_skillkit-0.1.0/skillkit/installer.py +958 -0
- s_skillkit-0.1.0/skillkit/linker.py +124 -0
- s_skillkit-0.1.0/skillkit/manifest.py +191 -0
- s_skillkit-0.1.0/skillkit/mcp_register.py +243 -0
- s_skillkit-0.1.0/skillkit/path_store.py +384 -0
- s_skillkit-0.1.0/skillkit/paths.py +46 -0
- s_skillkit-0.1.0/skillkit/project.py +59 -0
- s_skillkit-0.1.0/skillkit/targets/__init__.py +16 -0
- s_skillkit-0.1.0/skillkit/targets/antigravity.py +18 -0
- s_skillkit-0.1.0/skillkit/targets/base.py +82 -0
- s_skillkit-0.1.0/skillkit/targets/claude_code.py +8 -0
- s_skillkit-0.1.0/skillkit/targets/codex.py +8 -0
- s_skillkit-0.1.0/skillkit/targets/detect.py +26 -0
- s_skillkit-0.1.0/skillkit/tooling.py +253 -0
- s_skillkit-0.1.0/tests/conftest.py +21 -0
- s_skillkit-0.1.0/tests/test_collections.py +88 -0
- s_skillkit-0.1.0/tests/test_deps_installer.py +167 -0
- s_skillkit-0.1.0/tests/test_filter.py +170 -0
- s_skillkit-0.1.0/tests/test_git_install.py +124 -0
- s_skillkit-0.1.0/tests/test_installer.py +192 -0
- s_skillkit-0.1.0/tests/test_manifest.py +71 -0
- s_skillkit-0.1.0/tests/test_mcp_register.py +239 -0
- s_skillkit-0.1.0/tests/test_path_store.py +320 -0
- s_skillkit-0.1.0/tests/test_paths.py +32 -0
- s_skillkit-0.1.0/tests/test_project.py +42 -0
- s_skillkit-0.1.0/tests/test_stub_guard.py +89 -0
- s_skillkit-0.1.0/tests/test_targets.py +58 -0
- s_skillkit-0.1.0/tests/test_tooling.py +262 -0
- 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/
|
s_skillkit-0.1.0/LICENSE
ADDED
|
@@ -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()]
|