s-skillkit 0.1.0__tar.gz → 0.1.2__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 (40) hide show
  1. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/PKG-INFO +1 -1
  2. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/pyproject.toml +48 -48
  3. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/deps_installer.py +189 -185
  4. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/installer.py +25 -0
  5. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_deps_installer.py +202 -167
  6. s_skillkit-0.1.2/tests/test_w272_empty_manifest_guard.py +114 -0
  7. s_skillkit-0.1.2/uv.lock +139 -0
  8. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/.gitignore +0 -0
  9. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/LICENSE +0 -0
  10. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/README.md +0 -0
  11. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/__init__.py +0 -0
  12. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/collections.py +0 -0
  13. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/filter.py +0 -0
  14. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/linker.py +0 -0
  15. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/manifest.py +0 -0
  16. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/mcp_register.py +0 -0
  17. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/path_store.py +0 -0
  18. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/paths.py +0 -0
  19. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/project.py +0 -0
  20. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/__init__.py +0 -0
  21. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/antigravity.py +0 -0
  22. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/base.py +0 -0
  23. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/claude_code.py +0 -0
  24. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/codex.py +0 -0
  25. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/targets/detect.py +0 -0
  26. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/skillkit/tooling.py +0 -0
  27. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/conftest.py +0 -0
  28. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_collections.py +0 -0
  29. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_filter.py +0 -0
  30. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_git_install.py +0 -0
  31. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_installer.py +0 -0
  32. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_manifest.py +0 -0
  33. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_mcp_register.py +0 -0
  34. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_path_store.py +0 -0
  35. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_paths.py +0 -0
  36. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_project.py +0 -0
  37. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_stub_guard.py +0 -0
  38. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_targets.py +0 -0
  39. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_tooling.py +0 -0
  40. {s_skillkit-0.1.0 → s_skillkit-0.1.2}/tests/test_traversal_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-skillkit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -1,48 +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 = ["."]
1
+ [project]
2
+ name = "s-skillkit"
3
+ version = "0.1.2"
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 = ["."]
@@ -1,185 +1,189 @@
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
- )
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
+ # ``--upgrade``: при обновлении навыка повторный apply «pkg>=X» должен
138
+ # ПОДНЯТЬ уже стоящий пакет до новой версии (без флага pip/uv считают
139
+ # требование удовлетворённым и ничего не делают). Идемпотентность сохраняется
140
+ # для уже-последней версии --upgrade это no-op.
141
+ if _which("uv") is not None:
142
+ cmd = ["uv", "pip", "install", "--upgrade", spec]
143
+ elif _pip_available():
144
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", spec]
145
+ else:
146
+ report["skipped"].append(
147
+ {
148
+ **entry,
149
+ "reason": "не найдены ни uv, ни pip",
150
+ "instruction": (
151
+ f"установите uv (https://docs.astral.sh/uv/) или pip, затем "
152
+ f"`pip install {spec}`."
153
+ ),
154
+ }
155
+ )
156
+ return
157
+ rc = _run(cmd)
158
+ if rc == 0:
159
+ report["installed"].append(entry)
160
+ else:
161
+ report["failed"].append(
162
+ {**entry, "reason": f"менеджер вернул код {rc}"}
163
+ )
164
+
165
+
166
+ # --------------------------------------------------------------------------
167
+ # npm: npm install -g (только если npm есть)
168
+ # --------------------------------------------------------------------------
169
+ def _install_npm(spec: str, report: dict[str, list[dict[str, Any]]]) -> None:
170
+ entry = {"kind": "npm", "spec": spec}
171
+ if _which("npm") is None:
172
+ report["skipped"].append(
173
+ {
174
+ **entry,
175
+ "reason": "npm не найден в PATH",
176
+ "instruction": (
177
+ f"установите Node.js/npm (https://nodejs.org), затем "
178
+ f"`npm install -g {spec}`."
179
+ ),
180
+ }
181
+ )
182
+ return
183
+ rc = _run(["npm", "install", "-g", spec])
184
+ if rc == 0:
185
+ report["installed"].append(entry)
186
+ else:
187
+ report["failed"].append(
188
+ {**entry, "reason": f"npm вернул код {rc}"}
189
+ )
@@ -747,6 +747,31 @@ class SkillStore:
747
747
  is_update=True, scope=scope, skill_id=skill_id,
748
748
  )
749
749
 
750
+ # BUG-3 (defence-in-depth): версия с ПУСТЫМ files-инвентарём (например,
751
+ # git-synced бэкендом до фикса BUG-1) НЕ должна трактоваться как «все
752
+ # файлы удалены». sha-diff против пустого new.files дал бы removed=ВСЕ и
753
+ # затёр стор (воспроизведено: update 1.0.0→1.0.1 оставил только
754
+ # _skill_meta.json). При наличии repo_url делаем полный ре-clone новой
755
+ # версии (как git-url путь), сохраняя preserved-пути, вместо diff.
756
+ if not manifest.get("files"):
757
+ preserved = _preserved_for(manifest, self._target)
758
+ with self._clone_version(repo_url, version) as cloned:
759
+ safe_copy_tree(cloned, slug_dir)
760
+ version = _version_from_skill_md(slug_dir, version)
761
+ filter_result = apply_skill_filter(slug_dir, extra_preserved=preserved)
762
+ write_meta(
763
+ slug_dir,
764
+ self._build_meta(
765
+ slug, version, commit_sha, manifest, scope, project, skill_id,
766
+ source="hub", repo_url=repo_url,
767
+ ),
768
+ )
769
+ return InstallResult(
770
+ slug=slug, version=version, target_dir=slug_dir,
771
+ is_update=True, scope=scope, skill_id=skill_id,
772
+ filter_result=filter_result,
773
+ )
774
+
750
775
  added, changed, removed = _manifest_diff(old_manifest, manifest)
751
776
  preserved = _preserved_for(manifest, self._target)
752
777