s-skillkit 0.2.1__tar.gz → 0.2.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 (63) hide show
  1. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/CHANGELOG.md +26 -0
  2. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/PKG-INFO +1 -1
  3. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/pyproject.toml +1 -1
  4. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/__init__.py +1 -1
  5. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/deps_installer.py +12 -16
  6. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/__init__.py +27 -2
  7. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/manifest.py +20 -1
  8. s_skillkit-0.2.2/skillkit/pkg_manager.py +190 -0
  9. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_manifest.py +55 -0
  10. s_skillkit-0.2.2/tests/test_pkg_manager.py +117 -0
  11. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_w268_subdir_skill.py +53 -0
  12. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/.gitignore +0 -0
  13. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/AGENTS.md +0 -0
  14. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/LICENSE +0 -0
  15. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/README.md +0 -0
  16. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/binary_installer.py +0 -0
  17. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/bootstrap.py +0 -0
  18. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/collections.py +0 -0
  19. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/config.py +0 -0
  20. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/errors.py +0 -0
  21. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/filter.py +0 -0
  22. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/differ.py +0 -0
  23. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/fetchers.py +0 -0
  24. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/fsops.py +0 -0
  25. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/meta.py +0 -0
  26. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/models.py +0 -0
  27. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/linker.py +0 -0
  28. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/mcp_register.py +0 -0
  29. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/path_store.py +0 -0
  30. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/paths.py +0 -0
  31. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/project.py +0 -0
  32. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/__init__.py +0 -0
  33. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/antigravity.py +0 -0
  34. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/base.py +0 -0
  35. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/claude_code.py +0 -0
  36. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/codex.py +0 -0
  37. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/detect.py +0 -0
  38. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/tooling.py +0 -0
  39. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/uv_bootstrap.py +0 -0
  40. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/conftest.py +0 -0
  41. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_binary_installer.py +0 -0
  42. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_bootstrap.py +0 -0
  43. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_collections.py +0 -0
  44. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_config.py +0 -0
  45. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_deps_binary.py +0 -0
  46. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_deps_installer.py +0 -0
  47. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_errors.py +0 -0
  48. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_filter.py +0 -0
  49. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_git_install.py +0 -0
  50. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_installer.py +0 -0
  51. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_mcp_register.py +0 -0
  52. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_path_store.py +0 -0
  53. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_paths.py +0 -0
  54. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_project.py +0 -0
  55. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_stub_guard.py +0 -0
  56. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_targets.py +0 -0
  57. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_tooling.py +0 -0
  58. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_transactional_install.py +0 -0
  59. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_traversal_guard.py +0 -0
  60. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_typed_fields.py +0 -0
  61. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_uv_bootstrap.py +0 -0
  62. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_w272_empty_manifest_guard.py +0 -0
  63. {s_skillkit-0.2.1 → s_skillkit-0.2.2}/uv.lock +0 -0
@@ -4,6 +4,32 @@
4
4
  [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версионирование —
5
5
  [SemVer](https://semver.org/lang/ru/).
6
6
 
7
+ ## [0.2.2] — 2026-07-21
8
+
9
+ ### Fixed — монорепо-навык (навык в подпапке репозитория)
10
+
11
+ - **`skill_path` переживает установку**: `_skill_meta.json` теперь хранит
12
+ подпапку навыка, а `SkillStore.update()` берёт её оттуда, если не передали
13
+ явно. Раньше в `update` стоял жёсткий `skill_path=None` — обновление
14
+ материализовало КОРЕНЬ монорепо, и в папку навыка налипали `src/`,
15
+ `pyproject.toml` и копия самого навыка, а `SKILL.md` оказывался этажом ниже
16
+ (в таком виде Claude Code навык не видит).
17
+ - **`preserved_paths` читаются из `_skill_meta.toml`** навыка, а не только из
18
+ захардкоженного списка. Навык может защитить свои данные (папку с БД,
19
+ кэш) — они переживут обновление; общие дефолты (`_local/`,
20
+ `browser_profiles/`) при этом сохраняются.
21
+
22
+ ### Added — детект пакетного менеджера (`pkg_manager`)
23
+
24
+ - `detect_project()` определяет экосистему по маркерам: python (`uv.lock`,
25
+ `pyproject.toml`, `requirements.txt`), node с КОНКРЕТНЫМ менеджером по
26
+ lock-файлу (`bun`/`pnpm`/`yarn`/`npm`, при нескольких — по времени
27
+ изменения), rust, go, ruby, php.
28
+ - `missing_manager_report()` вместо голого «не найдены ни uv, ни pip» отдаёт
29
+ машиночитаемые факты: тип проекта и маркер, чего не хватает, команда
30
+ установки под ТЕКУЩУЮ ОС и список реально доступных альтернатив. ИИ-агент
31
+ может довести установку сам.
32
+
7
33
  ## [0.2.0] — 2026-07-15
8
34
 
9
35
  ### Added — install-kit (установка навыка «под ключ» со ВСЕМИ зависимостями)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-skillkit
3
- Version: 0.2.1
3
+ Version: 0.2.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,6 +1,6 @@
1
1
  [project]
2
2
  name = "s-skillkit"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -97,7 +97,7 @@ from skillkit.targets import (
97
97
  get_target,
98
98
  )
99
99
 
100
- __version__ = "0.2.1"
100
+ __version__ = "0.2.2"
101
101
 
102
102
  __all__ = [
103
103
  "__version__",
@@ -38,6 +38,7 @@ from pathlib import Path
38
38
  from typing import Any
39
39
 
40
40
  from skillkit import binary_installer
41
+ from skillkit.pkg_manager import detect_project, missing_manager_report
41
42
 
42
43
  # kinds внешних бинарей (GitHub Releases) — делегируют binary_installer.
43
44
  _BINARY_KINDS = frozenset({"binary", "github-release"})
@@ -182,15 +183,12 @@ def _install_pip(
182
183
  elif _pip_available():
183
184
  cmd = [python_executable or sys.executable, "-m", "pip", "install", "--upgrade", spec]
184
185
  else:
186
+ # #939: не «не найдены ни uv, ни pip», а факты, по которым агент
187
+ # доведёт установку сам: тип проекта, чего не хватает, команда под ЭТУ
188
+ # ОС и чем заменить.
185
189
  report["skipped"].append(
186
- {
187
- **entry,
188
- "reason": "не найдены ни uv, ни pip",
189
- "instruction": (
190
- f"установите uv (https://docs.astral.sh/uv/) или pip, затем "
191
- f"`pip install {spec}`."
192
- ),
193
- }
190
+ {**entry, "reason": "не найдены ни uv, ни pip",
191
+ **missing_manager_report("uv", spec=spec)}
194
192
  )
195
193
  return
196
194
  rc = _run(cmd)
@@ -208,15 +206,13 @@ def _install_pip(
208
206
  def _install_npm(spec: str, report: dict[str, list[dict[str, Any]]]) -> None:
209
207
  entry = {"kind": "npm", "spec": spec}
210
208
  if _which("npm") is None:
209
+ # #939: подсказываем менеджер, принятый В ЭТОМ проекте (pnpm/yarn/bun
210
+ # по lock-файлу), а не абстрактный npm.
211
+ detection = detect_project(None)
212
+ wanted = detection.manager if detection.kind == "node" else "npm"
211
213
  report["skipped"].append(
212
- {
213
- **entry,
214
- "reason": "npm не найден в PATH",
215
- "instruction": (
216
- f"установите Node.js/npm (https://nodejs.org), затем "
217
- f"`npm install -g {spec}`."
218
- ),
219
- }
214
+ {**entry, "reason": "npm не найден в PATH",
215
+ **missing_manager_report(wanted or "npm", spec=spec)}
220
216
  )
221
217
  return
222
218
  rc = _run(["npm", "install", "-g", spec])
@@ -144,6 +144,11 @@ def set_clone_prefix(prefix: str) -> None:
144
144
  _CLONE_PREFIX = prefix
145
145
 
146
146
 
147
+ def _source_skill_path(source) -> str | None: # type: ignore[no-untyped-def]
148
+ """``skill_path`` источника, если он git-овый (иначе None)."""
149
+ return getattr(source, "skill_path", None)
150
+
151
+
147
152
  class SkillStore:
148
153
  """MVP: install через git clone + write meta. Scope = global | project.
149
154
 
@@ -464,6 +469,7 @@ class SkillStore:
464
469
  request.slug, version, request.commit_sha, request.manifest, scope,
465
470
  request.project, self._skill_id_str(request),
466
471
  source=source_label, repo_url=repo_url_meta,
472
+ skill_path=_source_skill_path(src),
467
473
  ),
468
474
  )
469
475
  return {"is_update": False, "filter_result": filter_result,
@@ -643,6 +649,7 @@ class SkillStore:
643
649
  self._build_meta(
644
650
  request.slug, version, request.commit_sha, request.manifest, scope,
645
651
  request.project, skill_id, source=src_label, repo_url=meta_repo,
652
+ skill_path=_source_skill_path(request.source),
646
653
  ),
647
654
  )
648
655
  return InstallResult(
@@ -688,6 +695,7 @@ class SkillStore:
688
695
  self._build_meta(
689
696
  request.slug, version, request.commit_sha, request.manifest, scope,
690
697
  request.project, skill_id, source="hub", repo_url=src.repo_url,
698
+ skill_path=_source_skill_path(src),
691
699
  ),
692
700
  )
693
701
  return InstallResult(
@@ -742,6 +750,7 @@ class SkillStore:
742
750
  self._build_meta(
743
751
  request.slug, version, request.commit_sha, request.manifest, scope,
744
752
  request.project, skill_id, source="hub", repo_url=src.repo_url,
753
+ skill_path=_source_skill_path(src),
745
754
  ),
746
755
  )
747
756
  return InstallResult(
@@ -771,16 +780,27 @@ class SkillStore:
771
780
  skill_id: str | int | None = None,
772
781
  local_src: Path | None = None,
773
782
  git_ref: str | None = None,
783
+ skill_path: str | None = None,
774
784
  ) -> InstallResult:
775
- """Публичный инкрементальный update стор-навыка (требует уже materialized стор)."""
785
+ """Публичный инкрементальный update стор-навыка (требует уже materialized стор).
786
+
787
+ ``skill_path`` (#940): подпапка навыка в монорепо. Если явно не передан —
788
+ берём из меты установки. Раньше здесь стоял жёсткий ``skill_path=None``:
789
+ update клонировал репозиторий и материализовывал ЕГО КОРЕНЬ, из-за чего
790
+ в папку навыка налипали ``src/``, ``pyproject.toml`` и копия самого
791
+ навыка, а тело навыка оказывалось этажом ниже.
792
+ """
776
793
  scope: Scope = "project" if project is not None else "global"
777
794
  dir_name = skill_dir_name(slug, skill_id)
778
795
  slug_dir = self._store_path(dir_name)
796
+ if skill_path is None:
797
+ existing = read_meta(slug_dir) or {}
798
+ skill_path = existing.get("skill_path") or None
779
799
  request = InstallRequest(
780
800
  slug=slug, version=version, commit_sha=commit_sha, manifest=manifest,
781
801
  source=source_from_legacy(
782
802
  repo_url=repo_url, local_src=local_src, git_ref=git_ref,
783
- skill_path=None,
803
+ skill_path=skill_path,
784
804
  ),
785
805
  project=project, skill_id=skill_id,
786
806
  )
@@ -899,6 +919,7 @@ class SkillStore:
899
919
  skill_id: str | None = None,
900
920
  source: Source = "hub",
901
921
  repo_url: str | None = None,
922
+ skill_path: str | None = None,
902
923
  ) -> dict[str, Any]:
903
924
  return {
904
925
  "slug": slug,
@@ -911,6 +932,10 @@ class SkillStore:
911
932
  "project": str(project) if project else None,
912
933
  "source": source, # "hub" | "local-path" | "git-url"
913
934
  "repo_url": repo_url, # origin для git-url; None у hub/local
935
+ # #940: подпапка навыка в монорепо. Без неё update не знал, ЧТО
936
+ # материализовать, брал корень репозитория и натаскивал в папку
937
+ # навыка src/, pyproject.toml и копию самого навыка.
938
+ "skill_path": skill_path,
914
939
  }
915
940
 
916
941
 
@@ -161,6 +161,25 @@ def git_commit_sha(skill_dir: Path) -> str | None:
161
161
  return None
162
162
 
163
163
 
164
+ # Пути, которые НИКОГДА не сносим при обновлении навыка: локальные данные
165
+ # пользователя. Манифест навыка может добавить свои — но не убрать эти.
166
+ _DEFAULT_PRESERVED = ("_local/", "browser_profiles/")
167
+
168
+
169
+ def _preserved_paths(meta_toml: dict) -> list[str]:
170
+ """Дефолты + ``preserved_paths`` из ``_skill_meta.toml`` навыка.
171
+
172
+ Раньше список был ЗАХАРДКОЖЕН, и навык не мог защитить свои данные
173
+ (например, папку с БД): при update она сносилась как «лишний файл».
174
+ """
175
+ out = list(_DEFAULT_PRESERVED)
176
+ for raw in meta_toml.get("preserved_paths") or []:
177
+ item = str(raw).strip()
178
+ if item and item not in out:
179
+ out.append(item)
180
+ return out
181
+
182
+
164
183
  def build_manifest(skill_dir: Path, *, version: str) -> BuiltManifest:
165
184
  if not skill_dir.is_dir():
166
185
  raise FileNotFoundError(f"Skill папка не найдена: {skill_dir}")
@@ -265,7 +284,7 @@ def build_manifest(skill_dir: Path, *, version: str) -> BuiltManifest:
265
284
  tags=tags_list,
266
285
  files=sorted(files, key=lambda f: f["path"]),
267
286
  dependencies=deps,
268
- preserved_paths=["_local/", "browser_profiles/"],
287
+ preserved_paths=_preserved_paths(meta_toml),
269
288
  kind=kind,
270
289
  cli=cli_tools,
271
290
  mcp=mcp_servers,
@@ -0,0 +1,190 @@
1
+ """Детект пакетного менеджера проекта и внятная подсказка, когда его нет.
2
+
3
+ Зачем (#939): у навыка-инструмента бывают ``runtime_dependencies``. Если нужного
4
+ менеджера в системе нет, пользователь раньше получал навык без работающего CLI и
5
+ голое «не найдены ни uv, ни pip» — без ответа на вопросы «а что у меня за
6
+ проект», «что именно поставить» и «какой командой». Для ИИ-агента, который
7
+ доводит установку до конца сам, этого мало: ему нужен машиночитаемый набор
8
+ фактов, а не строка для человека.
9
+
10
+ Модуль ничего не устанавливает и никогда не бросает — только отвечает на два
11
+ вопроса: «какой менеджер тут уместен» и «как его получить на этой ОС».
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ __all__ = [
21
+ "ProjectDetection",
22
+ "detect_project",
23
+ "manager_hint",
24
+ "missing_manager_report",
25
+ ]
26
+
27
+ # Маркеры экосистем. Порядок важен только внутри node (см. _NODE_LOCKS).
28
+ _PY_MARKERS = ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile")
29
+ _NODE_LOCKS = (
30
+ ("bun.lockb", "bun"),
31
+ ("pnpm-lock.yaml", "pnpm"),
32
+ ("yarn.lock", "yarn"),
33
+ ("package-lock.json", "npm"),
34
+ )
35
+
36
+ # Как поставить сам менеджер — по ОС. Значение: {платформа: команда}.
37
+ # `default` — универсальный способ (официальный установщик проекта).
38
+ _MANAGER_INSTALL: dict[str, dict[str, str]] = {
39
+ "uv": {
40
+ "win32": "powershell -c \"irm https://astral.sh/uv/install.ps1 | iex\"",
41
+ "darwin": "brew install uv",
42
+ "default": "curl -LsSf https://astral.sh/uv/install.sh | sh",
43
+ },
44
+ "pip": {
45
+ "default": f"{Path(sys.executable).name} -m ensurepip --upgrade",
46
+ },
47
+ "pipx": {
48
+ "win32": "py -m pip install --user pipx",
49
+ "darwin": "brew install pipx",
50
+ "default": "python3 -m pip install --user pipx",
51
+ },
52
+ "npm": {
53
+ "win32": "winget install OpenJS.NodeJS",
54
+ "darwin": "brew install node",
55
+ "default": "curl -fsSL https://fnm.vercel.app/install | bash # либо пакет node вашей ОС",
56
+ },
57
+ "pnpm": {"default": "npm install -g pnpm"},
58
+ "yarn": {"default": "npm install -g yarn"},
59
+ "bun": {
60
+ "win32": "powershell -c \"irm bun.sh/install.ps1 | iex\"",
61
+ "default": "curl -fsSL https://bun.sh/install | bash",
62
+ },
63
+ "cargo": {
64
+ "win32": "winget install Rustlang.Rustup",
65
+ "default": "curl https://sh.rustup.rs -sSf | sh",
66
+ },
67
+ "go": {
68
+ "win32": "winget install GoLang.Go",
69
+ "darwin": "brew install go",
70
+ "default": "см. https://go.dev/doc/install",
71
+ },
72
+ "bundler": {"default": "gem install bundler"},
73
+ "composer": {"default": "см. https://getcomposer.org/download/"},
74
+ }
75
+
76
+ # Чем можно заменить отсутствующий менеджер (в порядке предпочтения).
77
+ _ALTERNATIVES: dict[str, tuple[str, ...]] = {
78
+ "uv": ("pip", "pipx"),
79
+ "pip": ("uv", "pipx"),
80
+ "pipx": ("uv", "pip"),
81
+ "pnpm": ("npm", "yarn", "bun"),
82
+ "yarn": ("npm", "pnpm"),
83
+ "bun": ("npm", "pnpm"),
84
+ "npm": ("pnpm", "yarn", "bun"),
85
+ }
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class ProjectDetection:
90
+ """Что за проект в папке и каким менеджером его обслуживать.
91
+
92
+ ``kind`` — экосистема (``python``/``node``/``rust``/``go``/``ruby``/``php``/
93
+ ``unknown``); ``manager`` — конкретный менеджер (для node выводится из
94
+ lock-файла); ``marker`` — файл, по которому сделан вывод.
95
+ """
96
+
97
+ kind: str
98
+ manager: str | None
99
+ marker: str | None
100
+
101
+ @property
102
+ def manager_available(self) -> bool:
103
+ return bool(self.manager) and shutil.which(self.manager or "") is not None
104
+
105
+
106
+ def _newest(paths: list[Path]) -> Path:
107
+ """Самый свежий файл (несколько lock-файлов ⇒ верим последнему изменённому)."""
108
+ return max(paths, key=lambda p: p.stat().st_mtime)
109
+
110
+
111
+ def detect_project(root: Path | str | None = None) -> ProjectDetection:
112
+ """Определить экосистему проекта по файлам-маркерам. Никогда не бросает."""
113
+ try:
114
+ base = Path(root or Path.cwd())
115
+ if not base.is_dir():
116
+ return ProjectDetection("unknown", None, None)
117
+
118
+ # node: менеджер определяется lock-файлом; при нескольких — по mtime.
119
+ present_locks = [
120
+ (base / name, mgr) for name, mgr in _NODE_LOCKS if (base / name).exists()
121
+ ]
122
+ if present_locks:
123
+ newest = _newest([p for p, _ in present_locks])
124
+ manager = next(m for p, m in present_locks if p == newest)
125
+ return ProjectDetection("node", manager, newest.name)
126
+ if (base / "package.json").exists():
127
+ return ProjectDetection("node", "npm", "package.json")
128
+
129
+ # python: uv.lock ⇒ проект уже живёт на uv.
130
+ if (base / "uv.lock").exists():
131
+ return ProjectDetection("python", "uv", "uv.lock")
132
+ for marker in _PY_MARKERS:
133
+ if (base / marker).exists():
134
+ return ProjectDetection("python", "uv", marker)
135
+
136
+ for marker, kind, manager in (
137
+ ("Cargo.toml", "rust", "cargo"),
138
+ ("go.mod", "go", "go"),
139
+ ("Gemfile", "ruby", "bundler"),
140
+ ("composer.json", "php", "composer"),
141
+ ):
142
+ if (base / marker).exists():
143
+ return ProjectDetection(kind, manager, marker)
144
+ except OSError:
145
+ pass
146
+ return ProjectDetection("unknown", None, None)
147
+
148
+
149
+ def manager_hint(manager: str) -> str:
150
+ """Команда установки менеджера под ТЕКУЩУЮ ОС (или ссылка на инструкцию)."""
151
+ table = _MANAGER_INSTALL.get(manager)
152
+ if not table:
153
+ return f"установите «{manager}» способом, принятым в вашей ОС"
154
+ return table.get(sys.platform) or table["default"]
155
+
156
+
157
+ def missing_manager_report(
158
+ manager: str,
159
+ *,
160
+ spec: str,
161
+ project_root: Path | str | None = None,
162
+ ) -> dict[str, object]:
163
+ """Машиночитаемое объяснение «менеджера нет» — для ИИ-агента и для человека.
164
+
165
+ Возвращает факты, которых раньше не хватало: что за проект вокруг, какого
166
+ менеджера недостаёт, чем его поставить ИМЕННО на этой ОС и чем заменить.
167
+ """
168
+ detection = detect_project(project_root)
169
+ alternatives = [
170
+ alt for alt in _ALTERNATIVES.get(manager, ()) if shutil.which(alt) is not None
171
+ ]
172
+ return {
173
+ "missing_manager": manager,
174
+ "project_kind": detection.kind,
175
+ "project_marker": detection.marker,
176
+ "platform": sys.platform,
177
+ "install_manager_cmd": manager_hint(manager),
178
+ "available_alternatives": alternatives,
179
+ "instruction": (
180
+ f"Зависимость «{spec}» не установлена: нет менеджера «{manager}». "
181
+ f"Проект определён как {detection.kind}"
182
+ + (f" (по {detection.marker})" if detection.marker else "")
183
+ + f". Поставьте менеджер: {manager_hint(manager)}"
184
+ + (
185
+ f"; либо используйте уже установленный: {', '.join(alternatives)}."
186
+ if alternatives
187
+ else "."
188
+ )
189
+ ),
190
+ }
@@ -213,3 +213,58 @@ def test_build_manifest_onboarding_absent_is_empty(tmp_path: Path) -> None:
213
213
  d = _skill(tmp_path)
214
214
  m = build_manifest(d, version="1.0.0")
215
215
  assert m.onboarding == {}
216
+
217
+
218
+ # ── #940: preserved_paths берутся из манифеста навыка, а не из хардкода ──────
219
+
220
+
221
+ def _skill_with_meta(tmp_path, meta_toml: str):
222
+ """Папка навыка с SKILL.md и заданным _skill_meta.toml."""
223
+ d = tmp_path / "skill"
224
+ d.mkdir(parents=True, exist_ok=True)
225
+ (d / "SKILL.md").write_text(
226
+ "---\nname: demo\ndescription: d\nversion: 1.0.0\n---\nbody\n",
227
+ encoding="utf-8",
228
+ )
229
+ (d / "_skill_meta.toml").write_text(meta_toml, encoding="utf-8")
230
+ return d
231
+
232
+
233
+ def test_preserved_paths_include_manifest_entries(tmp_path) -> None:
234
+ """Навык объявил свою папку с данными — она обязана пережить update.
235
+
236
+ Раньше список был захардкожен (`_local/`, `browser_profiles/`), и всё
237
+ остальное сносилось как «лишние файлы» — включая пользовательскую БД.
238
+ """
239
+ from skillkit.manifest import build_manifest
240
+
241
+ skill = _skill_with_meta(
242
+ tmp_path,
243
+ 'description = "d"\nversion = "1.0.0"\npreserved_paths = ["data/", "cache/"]\n',
244
+ )
245
+ built = build_manifest(skill, version="1.0.0")
246
+
247
+ assert "data/" in built.preserved_paths
248
+ assert "cache/" in built.preserved_paths
249
+ # Дефолты не потеряны: навык может добавить свои пути, но не убрать общие.
250
+ assert "_local/" in built.preserved_paths
251
+ assert "browser_profiles/" in built.preserved_paths
252
+
253
+
254
+ def test_preserved_paths_default_when_manifest_silent(tmp_path) -> None:
255
+ from skillkit.manifest import build_manifest
256
+
257
+ skill = _skill_with_meta(tmp_path, 'description = "d"\nversion = "1.0.0"\n')
258
+ built = build_manifest(skill, version="1.0.0")
259
+ assert list(built.preserved_paths) == ["_local/", "browser_profiles/"]
260
+
261
+
262
+ def test_preserved_paths_no_duplicates(tmp_path) -> None:
263
+ from skillkit.manifest import build_manifest
264
+
265
+ skill = _skill_with_meta(
266
+ tmp_path,
267
+ 'description = "d"\nversion = "1.0.0"\npreserved_paths = ["_local/", "data/"]\n',
268
+ )
269
+ built = build_manifest(skill, version="1.0.0")
270
+ assert built.preserved_paths.count("_local/") == 1
@@ -0,0 +1,117 @@
1
+ """#939: детект пакетного менеджера проекта + внятный ответ, когда его нет.
2
+
3
+ Раньше при отсутствии менеджера пользователь получал навык без работающего CLI
4
+ и строку «не найдены ни uv, ни pip». ИИ-агенту, который доводит установку сам,
5
+ этого мало: нужен машиночитаемый набор фактов — что за проект, чего не хватает,
6
+ какой командой поставить ИМЕННО на этой ОС и чем заменить.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from skillkit.pkg_manager import (
14
+ detect_project,
15
+ manager_hint,
16
+ missing_manager_report,
17
+ )
18
+
19
+
20
+ def _touch(root: Path, *names: str) -> None:
21
+ for n in names:
22
+ p = root / n
23
+ p.parent.mkdir(parents=True, exist_ok=True)
24
+ p.write_text("x", encoding="utf-8")
25
+
26
+
27
+ class TestDetectProject:
28
+ def test_python_by_uv_lock(self, tmp_path: Path) -> None:
29
+ _touch(tmp_path, "pyproject.toml", "uv.lock")
30
+ d = detect_project(tmp_path)
31
+ assert (d.kind, d.manager, d.marker) == ("python", "uv", "uv.lock")
32
+
33
+ def test_python_by_requirements(self, tmp_path: Path) -> None:
34
+ _touch(tmp_path, "requirements.txt")
35
+ assert detect_project(tmp_path).kind == "python"
36
+
37
+ def test_node_manager_from_lock_file(self, tmp_path: Path) -> None:
38
+ """Менеджер node определяется КОНКРЕТНЫМ lock-файлом, а не «просто npm»."""
39
+ _touch(tmp_path, "package.json", "pnpm-lock.yaml")
40
+ d = detect_project(tmp_path)
41
+ assert (d.kind, d.manager) == ("node", "pnpm")
42
+
43
+ def test_node_without_lock_defaults_to_npm(self, tmp_path: Path) -> None:
44
+ _touch(tmp_path, "package.json")
45
+ assert detect_project(tmp_path).manager == "npm"
46
+
47
+ def test_several_locks_newest_wins(self, tmp_path: Path) -> None:
48
+ """Два lock-файла (переезжали между менеджерами) → верим свежему."""
49
+ import os
50
+ import time
51
+
52
+ _touch(tmp_path, "package.json", "package-lock.json")
53
+ time.sleep(0.01)
54
+ _touch(tmp_path, "yarn.lock")
55
+ os.utime(tmp_path / "yarn.lock", (time.time() + 10, time.time() + 10))
56
+ assert detect_project(tmp_path).manager == "yarn"
57
+
58
+ def test_rust_go_ruby_php(self, tmp_path: Path) -> None:
59
+ for marker, kind, manager in (
60
+ ("Cargo.toml", "rust", "cargo"),
61
+ ("go.mod", "go", "go"),
62
+ ("Gemfile", "ruby", "bundler"),
63
+ ("composer.json", "php", "composer"),
64
+ ):
65
+ d_root = tmp_path / kind
66
+ d_root.mkdir()
67
+ _touch(d_root, marker)
68
+ d = detect_project(d_root)
69
+ assert (d.kind, d.manager) == (kind, manager)
70
+
71
+ def test_empty_dir_is_unknown(self, tmp_path: Path) -> None:
72
+ d = detect_project(tmp_path)
73
+ assert d.kind == "unknown" and d.manager is None
74
+
75
+ def test_missing_dir_does_not_raise(self, tmp_path: Path) -> None:
76
+ assert detect_project(tmp_path / "нет-такой").kind == "unknown"
77
+
78
+
79
+ class TestManagerHint:
80
+ def test_hint_is_os_specific(self) -> None:
81
+ hint = manager_hint("uv")
82
+ assert hint
83
+ if sys.platform == "win32":
84
+ assert "powershell" in hint.lower()
85
+ elif sys.platform == "darwin":
86
+ assert "brew" in hint
87
+ else:
88
+ assert "astral.sh" in hint
89
+
90
+ def test_unknown_manager_still_answers(self) -> None:
91
+ assert "выдуманный" in manager_hint("выдуманный")
92
+
93
+
94
+ class TestMissingManagerReport:
95
+ def test_report_carries_facts_for_agent(self, tmp_path: Path) -> None:
96
+ _touch(tmp_path, "pyproject.toml", "uv.lock")
97
+ rep = missing_manager_report("uv", spec="atlas-pm==0.3.5", project_root=tmp_path)
98
+
99
+ assert rep["missing_manager"] == "uv"
100
+ assert rep["project_kind"] == "python"
101
+ assert rep["project_marker"] == "uv.lock"
102
+ assert rep["platform"] == sys.platform
103
+ assert rep["install_manager_cmd"]
104
+ # Инструкция человекочитаемая и содержит и спеку, и команду.
105
+ assert "atlas-pm==0.3.5" in rep["instruction"]
106
+
107
+ def test_alternatives_only_real_ones(self, tmp_path: Path, monkeypatch) -> None:
108
+ """В альтернативы попадает только то, что РЕАЛЬНО есть в системе."""
109
+ monkeypatch.setattr("skillkit.pkg_manager.shutil.which", lambda name: None)
110
+ rep = missing_manager_report("uv", spec="pkg", project_root=tmp_path)
111
+ assert rep["available_alternatives"] == []
112
+
113
+ def test_node_project_reported_with_its_manager(self, tmp_path: Path) -> None:
114
+ _touch(tmp_path, "package.json", "bun.lockb")
115
+ rep = missing_manager_report("bun", spec="cowsay", project_root=tmp_path)
116
+ assert rep["project_kind"] == "node"
117
+ assert "bun" in str(rep["instruction"])
@@ -109,3 +109,56 @@ def test_no_skill_path_uses_repo_root(tmp_path: Path, monkeypatch) -> None:
109
109
  store_dir = tmp_path / "store" / "x"
110
110
  assert (store_dir / "SKILL.md").exists()
111
111
  assert (store_dir / "a.md").exists()
112
+
113
+
114
+ # ── #940: подпапка ПЕРЕЖИВАЕТ установку и применяется при update ────────────
115
+
116
+
117
+ def test_meta_remembers_skill_path(tmp_path: Path, monkeypatch) -> None:
118
+ """Без записи skill_path в мету update не знал, что материализовать."""
119
+ import json
120
+
121
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(_REPO))
122
+ store = SkillStore(ClaudeCodeTarget(root=tmp_path / ".claude"), tmp_path / "store")
123
+ store.materialize(
124
+ slug="atlas", version="1.0.0", commit_sha="a",
125
+ repo_url="https://github.com/x/atlas-cli.git",
126
+ manifest=_manifest(_SKILL_FILES),
127
+ skill_path="skills/atlas",
128
+ )
129
+ meta = json.loads(
130
+ (tmp_path / "store" / "atlas" / "_skill_meta.json").read_text(encoding="utf-8")
131
+ )
132
+ assert meta["skill_path"] == "skills/atlas"
133
+
134
+
135
+ def test_update_reuses_stored_skill_path(tmp_path: Path, monkeypatch) -> None:
136
+ """update без явного skill_path берёт его из меты — и снова кладёт ПОДПАПКУ.
137
+
138
+ Регрессия: раньше здесь стоял жёсткий ``skill_path=None``, поэтому update
139
+ материализовал КОРЕНЬ монорепо — в папку навыка налипали src/, pyproject.toml
140
+ и копия skills/atlas/, а SKILL.md оказывался этажом ниже.
141
+ """
142
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(_REPO))
143
+ store = SkillStore(ClaudeCodeTarget(root=tmp_path / ".claude"), tmp_path / "store")
144
+ store.materialize(
145
+ slug="atlas", version="1.0.0", commit_sha="a",
146
+ repo_url="https://github.com/x/atlas-cli.git",
147
+ manifest=_manifest(_SKILL_FILES),
148
+ skill_path="skills/atlas",
149
+ )
150
+
151
+ bumped = dict(_REPO)
152
+ bumped["skills/atlas/SKILL.md"] = "---\nname: atlas\nversion: 1.1.0\n---\n# v2"
153
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(bumped))
154
+ store.update(
155
+ slug="atlas", version="1.1.0", commit_sha="b",
156
+ repo_url="https://github.com/x/atlas-cli.git",
157
+ manifest=_manifest({**_SKILL_FILES, "SKILL.md": bumped["skills/atlas/SKILL.md"]}),
158
+ )
159
+
160
+ store_dir = tmp_path / "store" / "atlas"
161
+ assert (store_dir / "SKILL.md").read_text(encoding="utf-8").endswith("# v2")
162
+ assert not (store_dir / "pyproject.toml").exists(), "корень монорепо налип"
163
+ assert not (store_dir / "src").exists()
164
+ assert not (store_dir / "skills").exists(), "копия навыка внутри навыка"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes