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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
+ ]
@@ -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
+ )