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
|
@@ -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,22 @@
|
|
|
1
|
+
skillkit/__init__.py,sha256=IngF8P1XxBWAuI92A3V92UgRjIvi3AkRi45zleAuT-0,2869
|
|
2
|
+
skillkit/collections.py,sha256=WjU0xqbDb_YKvazrQp5Yf1ciWdrWYBvW00UEFxZ7_tg,7329
|
|
3
|
+
skillkit/deps_installer.py,sha256=i6Ql5e61TVP3-P40p0KS2anE6abPyvBCpuEwaqiGRqo,7681
|
|
4
|
+
skillkit/filter.py,sha256=r_LxABjAyKDV_Lgsiy8FmEmVbXByWfCU2MhrNlsErHE,9950
|
|
5
|
+
skillkit/installer.py,sha256=8Eqn0zir1Vparu5MzSGF_dpSmf6qVA0RuBLu9OUs19Q,43654
|
|
6
|
+
skillkit/linker.py,sha256=BANKoQu_ddV1cVS3spRSPV0jnWPGMllnrHQpAT5yCR0,4731
|
|
7
|
+
skillkit/manifest.py,sha256=iddSN_vzQOKBenEbX6s-QMpztumeJCijG-fPSRxuDUs,6007
|
|
8
|
+
skillkit/mcp_register.py,sha256=Ue8h2E9_SjjAOFPK8JciC1s1Y6ufAckcHID3IBhzj1Y,11028
|
|
9
|
+
skillkit/path_store.py,sha256=foo5AEmKpC8SdLJehYX5W7rudpgjmFfsxd9n36POXF8,16615
|
|
10
|
+
skillkit/paths.py,sha256=Er8Gob7TM70zRpNqaElBwvnwG_3nnENGa98SKGVaTfY,2157
|
|
11
|
+
skillkit/project.py,sha256=wCJzKDcKcd2aQqu7Tri7oMqtxkoJhZzfkxRVhFR7Eq8,1780
|
|
12
|
+
skillkit/tooling.py,sha256=0Fo2f6m39SVAP1dKEq5UIrzXHFkzA-PQeKOsVmc29YY,10969
|
|
13
|
+
skillkit/targets/__init__.py,sha256=Ut_10rxgN8WpUwk7ZADgJ78tpEKmLvFkLY5PljbX10E,488
|
|
14
|
+
skillkit/targets/antigravity.py,sha256=nRiCcqYmSdklH8sPPWUrGOPb2rXoOhCIED44FwK-8tM,875
|
|
15
|
+
skillkit/targets/base.py,sha256=sanSHnkYGZhYWobzC4x6DxmGpX2cSMez3_XiHax4eg8,3384
|
|
16
|
+
skillkit/targets/claude_code.py,sha256=rlEjPHx8N3aFwvKwgOdoaXtMhC82AkoVOr-2HKghtWI,178
|
|
17
|
+
skillkit/targets/codex.py,sha256=JMw0FcAsAtiMNfu_WtR9gUsrnTtblO6n11gSw47KPWo,166
|
|
18
|
+
skillkit/targets/detect.py,sha256=hKhByKWXgPcy-KSc3onzPqp7si8TKFDKs5R_wrvFj8E,942
|
|
19
|
+
s_skillkit-0.1.0.dist-info/METADATA,sha256=XTI3GKd6_UG1yoUX0AywivVt2H8FYiNdaGL9I5QCcyU,2917
|
|
20
|
+
s_skillkit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
21
|
+
s_skillkit-0.1.0.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
|
|
22
|
+
s_skillkit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
skillkit/__init__.py
ADDED
|
@@ -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
|
+
]
|
skillkit/collections.py
ADDED
|
@@ -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()]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Установка runtime-зависимостей навыка (tooling).
|
|
2
|
+
|
|
3
|
+
Навык типа ``tooling`` может декларировать ``runtime_dependencies`` —
|
|
4
|
+
список ``{kind, spec}``, где ``kind ∈ {pip, npm, system}``:
|
|
5
|
+
|
|
6
|
+
- ``pip`` → ставим через **uv** (``uv pip install``) → fallback ``pip``
|
|
7
|
+
(``python -m pip install``). Нет ни uv, ни pip → НЕ падаем, кладём в
|
|
8
|
+
``skipped`` с готовой инструкцией (graceful degradation);
|
|
9
|
+
- ``npm`` → ставим через ``npm install -g`` только если ``npm`` есть в PATH;
|
|
10
|
+
иначе ``skipped`` + инструкция (node/npm — не наша забота ставить);
|
|
11
|
+
- ``system`` → НИКОГДА не ставим сами (apt/brew/choco/права) — только
|
|
12
|
+
инструкция в ``skipped``.
|
|
13
|
+
|
|
14
|
+
Идемпотентность — на менеджере пакетов (повторный install уже стоящего = no-op).
|
|
15
|
+
Функция НИКОГДА не бросает: ошибка любой зависимости → она в ``failed``/
|
|
16
|
+
``skipped``, остальные продолжают. Возврат::
|
|
17
|
+
|
|
18
|
+
{"installed": [{kind, spec}, ...],
|
|
19
|
+
"skipped": [{kind, spec, reason, instruction}, ...],
|
|
20
|
+
"failed": [{kind, spec, reason}, ...]}
|
|
21
|
+
|
|
22
|
+
Кроссплатформенность: выбор менеджера — через ``shutil.which`` (обёрнут в
|
|
23
|
+
``_which`` для мокабельности) + ``python -m pip`` (текущий интерпретатор,
|
|
24
|
+
работает на win/mac/linux). subprocess-примитив ``_run`` тоже подменяем в тестах.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
# kinds, которые умеем обрабатывать (прочее → skipped с reason unknown-kind).
|
|
34
|
+
_KNOWN_KINDS = frozenset({"pip", "npm", "system"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
# Подменяемые в тестах примитивы окружения
|
|
39
|
+
# --------------------------------------------------------------------------
|
|
40
|
+
def _which(name: str) -> str | None:
|
|
41
|
+
"""Путь к исполняемому ``name`` в PATH или None (обёртка для моков)."""
|
|
42
|
+
return shutil.which(name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _pip_available() -> bool:
|
|
46
|
+
"""Доступен ли ``pip`` для текущего интерпретатора (``python -m pip``)."""
|
|
47
|
+
try:
|
|
48
|
+
proc = subprocess.run(
|
|
49
|
+
[sys.executable, "-m", "pip", "--version"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
check=False,
|
|
52
|
+
)
|
|
53
|
+
return proc.returncode == 0
|
|
54
|
+
except OSError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run(cmd: list[str]) -> int:
|
|
59
|
+
"""Запустить команду установки, вернуть returncode (не бросает на OSError)."""
|
|
60
|
+
try:
|
|
61
|
+
return subprocess.run(cmd, check=False).returncode
|
|
62
|
+
except OSError:
|
|
63
|
+
return 127
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------------------
|
|
67
|
+
# Public API
|
|
68
|
+
# --------------------------------------------------------------------------
|
|
69
|
+
def install_runtime_dependencies(
|
|
70
|
+
deps: list[dict[str, Any]] | tuple[dict[str, Any], ...] | None,
|
|
71
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
72
|
+
"""Поставить runtime-зависимости навыка. Никогда не бросает.
|
|
73
|
+
|
|
74
|
+
``deps`` — список ``{"kind": pip|npm|system, "spec": "..."}`` (как в манифесте
|
|
75
|
+
``runtime_dependencies`` / ``aggregated_runtime_dependencies``). Кривые
|
|
76
|
+
элементы (не-dict, без kind/spec, неизвестный kind) пропускаются/идут в
|
|
77
|
+
skipped. Возвращает отчёт ``{installed, skipped, failed}``.
|
|
78
|
+
"""
|
|
79
|
+
report: dict[str, list[dict[str, Any]]] = {
|
|
80
|
+
"installed": [],
|
|
81
|
+
"skipped": [],
|
|
82
|
+
"failed": [],
|
|
83
|
+
}
|
|
84
|
+
if not deps:
|
|
85
|
+
return report
|
|
86
|
+
|
|
87
|
+
for raw in deps:
|
|
88
|
+
if not isinstance(raw, dict):
|
|
89
|
+
continue # мусор — игнор
|
|
90
|
+
kind = raw.get("kind")
|
|
91
|
+
spec = raw.get("spec")
|
|
92
|
+
if not kind or not spec:
|
|
93
|
+
continue # неполная декларация — игнор
|
|
94
|
+
kind = str(kind)
|
|
95
|
+
spec = str(spec)
|
|
96
|
+
if kind not in _KNOWN_KINDS:
|
|
97
|
+
report["skipped"].append(
|
|
98
|
+
{
|
|
99
|
+
"kind": kind,
|
|
100
|
+
"spec": spec,
|
|
101
|
+
"reason": f"неизвестный kind зависимости: {kind}",
|
|
102
|
+
"instruction": f"установите {spec} вручную ({kind}).",
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
_dispatch(kind, spec, report)
|
|
107
|
+
return report
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _dispatch(
|
|
111
|
+
kind: str, spec: str, report: dict[str, list[dict[str, Any]]]
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Маршрутизация одной зависимости по kind в нужную ветку установки."""
|
|
114
|
+
if kind == "pip":
|
|
115
|
+
_install_pip(spec, report)
|
|
116
|
+
elif kind == "npm":
|
|
117
|
+
_install_npm(spec, report)
|
|
118
|
+
else: # system
|
|
119
|
+
report["skipped"].append(
|
|
120
|
+
{
|
|
121
|
+
"kind": "system",
|
|
122
|
+
"spec": spec,
|
|
123
|
+
"reason": "системная зависимость не ставится автоматически",
|
|
124
|
+
"instruction": (
|
|
125
|
+
f"установите системный пакет «{spec}» вашим пакетным "
|
|
126
|
+
"менеджером (apt/brew/choco/...)."
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------
|
|
133
|
+
# pip: uv pip install → python -m pip install
|
|
134
|
+
# --------------------------------------------------------------------------
|
|
135
|
+
def _install_pip(spec: str, report: dict[str, list[dict[str, Any]]]) -> None:
|
|
136
|
+
entry = {"kind": "pip", "spec": spec}
|
|
137
|
+
if _which("uv") is not None:
|
|
138
|
+
cmd = ["uv", "pip", "install", spec]
|
|
139
|
+
elif _pip_available():
|
|
140
|
+
cmd = [sys.executable, "-m", "pip", "install", spec]
|
|
141
|
+
else:
|
|
142
|
+
report["skipped"].append(
|
|
143
|
+
{
|
|
144
|
+
**entry,
|
|
145
|
+
"reason": "не найдены ни uv, ни pip",
|
|
146
|
+
"instruction": (
|
|
147
|
+
f"установите uv (https://docs.astral.sh/uv/) или pip, затем "
|
|
148
|
+
f"`pip install {spec}`."
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
return
|
|
153
|
+
rc = _run(cmd)
|
|
154
|
+
if rc == 0:
|
|
155
|
+
report["installed"].append(entry)
|
|
156
|
+
else:
|
|
157
|
+
report["failed"].append(
|
|
158
|
+
{**entry, "reason": f"менеджер вернул код {rc}"}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------
|
|
163
|
+
# npm: npm install -g (только если npm есть)
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
def _install_npm(spec: str, report: dict[str, list[dict[str, Any]]]) -> None:
|
|
166
|
+
entry = {"kind": "npm", "spec": spec}
|
|
167
|
+
if _which("npm") is None:
|
|
168
|
+
report["skipped"].append(
|
|
169
|
+
{
|
|
170
|
+
**entry,
|
|
171
|
+
"reason": "npm не найден в PATH",
|
|
172
|
+
"instruction": (
|
|
173
|
+
f"установите Node.js/npm (https://nodejs.org), затем "
|
|
174
|
+
f"`npm install -g {spec}`."
|
|
175
|
+
),
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
rc = _run(["npm", "install", "-g", spec])
|
|
180
|
+
if rc == 0:
|
|
181
|
+
report["installed"].append(entry)
|
|
182
|
+
else:
|
|
183
|
+
report["failed"].append(
|
|
184
|
+
{**entry, "reason": f"npm вернул код {rc}"}
|
|
185
|
+
)
|