s-skillkit 0.1.2__tar.gz → 0.1.4__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 (41) hide show
  1. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/PKG-INFO +1 -1
  2. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/pyproject.toml +48 -48
  3. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/deps_installer.py +27 -6
  4. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/installer.py +35 -9
  5. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/manifest.py +30 -1
  6. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/tooling.py +75 -7
  7. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_manifest.py +39 -0
  8. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_tooling.py +356 -262
  9. s_skillkit-0.1.4/tests/test_w268_subdir_skill.py +111 -0
  10. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/uv.lock +1 -1
  11. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/.gitignore +0 -0
  12. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/LICENSE +0 -0
  13. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/README.md +0 -0
  14. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/__init__.py +0 -0
  15. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/collections.py +0 -0
  16. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/filter.py +0 -0
  17. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/linker.py +0 -0
  18. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/mcp_register.py +0 -0
  19. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/path_store.py +0 -0
  20. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/paths.py +0 -0
  21. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/project.py +0 -0
  22. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/__init__.py +0 -0
  23. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/antigravity.py +0 -0
  24. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/base.py +0 -0
  25. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/claude_code.py +0 -0
  26. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/codex.py +0 -0
  27. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/detect.py +0 -0
  28. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/conftest.py +0 -0
  29. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_collections.py +0 -0
  30. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_deps_installer.py +0 -0
  31. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_filter.py +0 -0
  32. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_git_install.py +0 -0
  33. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_installer.py +0 -0
  34. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_mcp_register.py +0 -0
  35. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_path_store.py +0 -0
  36. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_paths.py +0 -0
  37. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_project.py +0 -0
  38. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_stub_guard.py +0 -0
  39. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_targets.py +0 -0
  40. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_traversal_guard.py +0 -0
  41. {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_w272_empty_manifest_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-skillkit
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.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
+ [project]
2
+ name = "s-skillkit"
3
+ version = "0.1.4"
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 = ["."]
@@ -68,6 +68,8 @@ def _run(cmd: list[str]) -> int:
68
68
  # --------------------------------------------------------------------------
69
69
  def install_runtime_dependencies(
70
70
  deps: list[dict[str, Any]] | tuple[dict[str, Any], ...] | None,
71
+ *,
72
+ python_executable: str | None = None,
71
73
  ) -> dict[str, list[dict[str, Any]]]:
72
74
  """Поставить runtime-зависимости навыка. Никогда не бросает.
73
75
 
@@ -75,6 +77,12 @@ def install_runtime_dependencies(
75
77
  ``runtime_dependencies`` / ``aggregated_runtime_dependencies``). Кривые
76
78
  элементы (не-dict, без kind/spec, неизвестный kind) пропускаются/идут в
77
79
  skipped. Возвращает отчёт ``{installed, skipped, failed}``.
80
+
81
+ ``python_executable`` — целевой интерпретатор для pip-веток (выделенный
82
+ venv инструмента). Задан → ставим ИМЕННО в него (``uv pip install --python
83
+ <exe>`` / ``<exe> -m pip``), чтобы цель установки совпадала с интерпретатором
84
+ CLI-шима и не зависела от cwd/активного venv. None → прежнее ambient-поведение
85
+ (uv по своему правилу выбора окружения / текущий ``sys.executable``).
78
86
  """
79
87
  report: dict[str, list[dict[str, Any]]] = {
80
88
  "installed": [],
@@ -103,16 +111,19 @@ def install_runtime_dependencies(
103
111
  }
104
112
  )
105
113
  continue
106
- _dispatch(kind, spec, report)
114
+ _dispatch(kind, spec, report, python_executable)
107
115
  return report
108
116
 
109
117
 
110
118
  def _dispatch(
111
- kind: str, spec: str, report: dict[str, list[dict[str, Any]]]
119
+ kind: str,
120
+ spec: str,
121
+ report: dict[str, list[dict[str, Any]]],
122
+ python_executable: str | None = None,
112
123
  ) -> None:
113
124
  """Маршрутизация одной зависимости по kind в нужную ветку установки."""
114
125
  if kind == "pip":
115
- _install_pip(spec, report)
126
+ _install_pip(spec, report, python_executable)
116
127
  elif kind == "npm":
117
128
  _install_npm(spec, report)
118
129
  else: # system
@@ -132,16 +143,26 @@ def _dispatch(
132
143
  # --------------------------------------------------------------------------
133
144
  # pip: uv pip install → python -m pip install
134
145
  # --------------------------------------------------------------------------
135
- def _install_pip(spec: str, report: dict[str, list[dict[str, Any]]]) -> None:
146
+ def _install_pip(
147
+ spec: str,
148
+ report: dict[str, list[dict[str, Any]]],
149
+ python_executable: str | None = None,
150
+ ) -> None:
136
151
  entry = {"kind": "pip", "spec": spec}
137
152
  # ``--upgrade``: при обновлении навыка повторный apply «pkg>=X» должен
138
153
  # ПОДНЯТЬ уже стоящий пакет до новой версии (без флага pip/uv считают
139
154
  # требование удовлетворённым и ничего не делают). Идемпотентность сохраняется
140
155
  # — для уже-последней версии --upgrade это no-op.
156
+ # ``python_executable`` (выделенный venv инструмента) → ставим строго в него:
157
+ # uv → ``--python <exe>`` (uv не уходит в cwd/.venv);
158
+ # pip → запускаем САМ <exe> -m pip (а не sys.executable хоста).
141
159
  if _which("uv") is not None:
142
- cmd = ["uv", "pip", "install", "--upgrade", spec]
160
+ cmd = ["uv", "pip", "install"]
161
+ if python_executable:
162
+ cmd += ["--python", python_executable]
163
+ cmd += ["--upgrade", spec]
143
164
  elif _pip_available():
144
- cmd = [sys.executable, "-m", "pip", "install", "--upgrade", spec]
165
+ cmd = [python_executable or sys.executable, "-m", "pip", "install", "--upgrade", spec]
145
166
  else:
146
167
  report["skipped"].append(
147
168
  {
@@ -409,6 +409,7 @@ class SkillStore:
409
409
  skill_id: str | int | None = None,
410
410
  local_src: Path | None = None,
411
411
  git_ref: str | None = None,
412
+ skill_path: str | None = None,
412
413
  ) -> InstallResult:
413
414
  """Материализует навык в стор и линкует в scope агента.
414
415
 
@@ -449,6 +450,7 @@ class SkillStore:
449
450
  slug=slug, dir_name=dir_name, version=version, commit_sha=commit_sha,
450
451
  repo_url=repo_url, manifest=manifest, scope=scope, project=project,
451
452
  skill_id=skill_id_str, local_src=local_src, git_ref=git_ref,
453
+ skill_path=skill_path,
452
454
  )
453
455
  # git-url источник мог уточнить версию из frontmatter клона (фикс l2).
454
456
  effective_version = mat.get("version") or version
@@ -518,10 +520,12 @@ class SkillStore:
518
520
  skill_id: str | int | None = None,
519
521
  local_src: Path | None = None,
520
522
  git_ref: str | None = None,
523
+ skill_path: str | None = None,
521
524
  ) -> dict[str, Any]:
522
525
  """Материализует контент навыка в стор БЕЗ линковки в scope.
523
526
 
524
- Возвращает {is_update, filter_result, update_diff, version}.
527
+ ``skill_path`` (#268) подпапка навыка в репо (``skills/<name>/``);
528
+ None ⇒ корень репо. Возвращает {is_update, filter_result, update_diff, version}.
525
529
  """
526
530
  scope = "project" if project is not None else "global"
527
531
  dir_name = skill_dir_name(slug, skill_id)
@@ -530,12 +534,13 @@ class SkillStore:
530
534
  slug=slug, dir_name=dir_name, version=version, commit_sha=commit_sha,
531
535
  repo_url=repo_url, manifest=manifest, scope=scope, project=project,
532
536
  skill_id=skill_id_str, local_src=local_src, git_ref=git_ref,
537
+ skill_path=skill_path,
533
538
  )
534
539
 
535
540
  def _materialize_store(
536
541
  self, *, slug, dir_name, version, commit_sha, repo_url, manifest,
537
542
  scope, project, skill_id, local_src: Path | None = None,
538
- git_ref: str | None = None,
543
+ git_ref: str | None = None, skill_path: str | None = None,
539
544
  ) -> dict[str, Any]:
540
545
  """Кладёт контент навыка в store_dir. Возвращает {is_update, filter_result, update_diff}."""
541
546
  store_dir = self._store_path(dir_name)
@@ -550,6 +555,7 @@ class SkillStore:
550
555
  slug=slug, version=version, commit_sha=commit_sha, repo_url=repo_url,
551
556
  manifest=manifest, scope=scope, project=project, slug_dir=store_dir,
552
557
  skill_id=skill_id, local_src=local_src, git_ref=git_ref,
558
+ skill_path=skill_path,
553
559
  )
554
560
  return {"is_update": True, "filter_result": up.filter_result,
555
561
  "update_diff": up.update_diff, "version": up.version}
@@ -558,7 +564,9 @@ class SkillStore:
558
564
  filter_result: dict[str, int] | None = None
559
565
  source = "hub"
560
566
  if repo_url:
561
- with self._clone_version(repo_url, version, ref=git_ref) as cloned:
567
+ with self._clone_version(
568
+ repo_url, version, ref=git_ref, skill_path=skill_path
569
+ ) as cloned:
562
570
  safe_copy_tree(cloned, store_dir)
563
571
  filter_result = apply_skill_filter(store_dir)
564
572
  if git_ref is not None:
@@ -684,6 +692,7 @@ class SkillStore:
684
692
  skill_id: str | None = None,
685
693
  local_src: Path | None = None,
686
694
  git_ref: str | None = None,
695
+ skill_path: str | None = None,
687
696
  ) -> InstallResult:
688
697
  """Докачивает diff между установленной и новой версией.
689
698
 
@@ -714,7 +723,9 @@ class SkillStore:
714
723
  src_label = "local-path"
715
724
  meta_repo = None
716
725
  else:
717
- with self._clone_version(repo_url, version, ref=git_ref) as cloned:
726
+ with self._clone_version(
727
+ repo_url, version, ref=git_ref, skill_path=skill_path
728
+ ) as cloned:
718
729
  safe_copy_tree(cloned, slug_dir)
719
730
  src_label = "git-url"
720
731
  meta_repo = repo_url
@@ -755,7 +766,7 @@ class SkillStore:
755
766
  # версии (как git-url путь), сохраняя preserved-пути, вместо diff.
756
767
  if not manifest.get("files"):
757
768
  preserved = _preserved_for(manifest, self._target)
758
- with self._clone_version(repo_url, version) as cloned:
769
+ with self._clone_version(repo_url, version, skill_path=skill_path) as cloned:
759
770
  safe_copy_tree(cloned, slug_dir)
760
771
  version = _version_from_skill_md(slug_dir, version)
761
772
  filter_result = apply_skill_filter(slug_dir, extra_preserved=preserved)
@@ -776,7 +787,7 @@ class SkillStore:
776
787
  preserved = _preserved_for(manifest, self._target)
777
788
 
778
789
  filter_result: dict[str, int] | None = None
779
- with self._clone_version(repo_url, version) as cloned:
790
+ with self._clone_version(repo_url, version, skill_path=skill_path) as cloned:
780
791
  # 4. Копируем added + changed.
781
792
  for rel in [*added, *changed]:
782
793
  src_file = cloned / rel
@@ -913,7 +924,10 @@ class SkillStore:
913
924
  # helpers
914
925
  # ------------------------------------------------------------------
915
926
  @contextlib.contextmanager
916
- def _clone_version(self, repo_url: str, version: str, *, ref: str | None = None):
927
+ def _clone_version(
928
+ self, repo_url: str, version: str, *, ref: str | None = None,
929
+ skill_path: str | None = None,
930
+ ):
917
931
  """git clone во временную папку (yield Path).
918
932
 
919
933
  Поведение по ``ref``:
@@ -921,7 +935,11 @@ class SkillStore:
921
935
  - ``""`` (пусто) → git-url без явного ref: клон дефолтной ветки репо
922
936
  (``--branch`` опускается — ``--branch HEAD`` git не принимает);
923
937
  - иначе → дословный ref (ветка/тег/sha).
924
- Папка удаляется при выходе из контекста.
938
+
939
+ ``skill_path`` (subdir-навык, #268): если задан — yield-им ПОДПАПКУ
940
+ ``<clone>/<skill_path>`` (с traversal-guard), а не корень репо. Так навык
941
+ может лежать подкаталогом CLI-репо (``skills/<name>/``), а материализуется
942
+ только он. Папка удаляется при выходе из контекста.
925
943
  """
926
944
  use_default_branch = ref == ""
927
945
  ref = ref if ref is not None else f"v{version}"
@@ -949,7 +967,15 @@ class SkillStore:
949
967
  if secret:
950
968
  stderr = stderr.replace(secret, "***")
951
969
  raise RuntimeError(f"git clone failed: {stderr}") from e
952
- yield clone_dir
970
+ # subdir-навык (#268): материализуем подпапку, не корень репо.
971
+ target = clone_dir
972
+ if skill_path:
973
+ target = _assert_within(clone_dir, clone_dir / skill_path)
974
+ if not target.is_dir():
975
+ raise RuntimeError(
976
+ f"skill_path '{skill_path}' не найден в репо {repo_url}"
977
+ )
978
+ yield target
953
979
  finally:
954
980
  _force_rmtree(tmp)
955
981
 
@@ -11,7 +11,7 @@ import hashlib
11
11
  import re
12
12
  import subprocess
13
13
  import tomllib
14
- from dataclasses import dataclass
14
+ from dataclasses import dataclass, field
15
15
  from pathlib import Path
16
16
  from typing import Any
17
17
 
@@ -47,6 +47,12 @@ class BuiltManifest:
47
47
  files: list[dict] # [{path, sha256, size}, ...]
48
48
  dependencies: list[dict]
49
49
  preserved_paths: list[str]
50
+ # Tooling-поля из _skill_meta.toml (kind=tooling несёт CLI/MCP/deps). Без них
51
+ # publish терял бы CLI-метаданные → install не ставил бы сам инструмент.
52
+ kind: str | None = None
53
+ cli: list[dict] = field(default_factory=list) # [{command_name, entrypoint}, ...]
54
+ mcp: list[dict] = field(default_factory=list)
55
+ runtime_dependencies: list[dict] = field(default_factory=list) # [{kind, spec}, ...]
50
56
 
51
57
 
52
58
  def _sha256_of(path: Path) -> str:
@@ -159,6 +165,25 @@ def build_manifest(skill_dir: Path, *, version: str) -> BuiltManifest:
159
165
  if isinstance(d, dict) and "slug" in d:
160
166
  deps.append({"slug": d["slug"], "min_version": d.get("min_version", "0.0.0")})
161
167
 
168
+ # Tooling-поля: kind=tooling несёт CLI-инструмент(ы) + runtime-зависимости.
169
+ # Нормализуем под DTO бэкенда (cli: command_name/entrypoint; runtime_dependencies:
170
+ # kind/spec), чтобы publish сохранил их и install смог поставить сам инструмент.
171
+ kind = meta_toml.get("kind") or None
172
+ cli_tools: list[dict] = []
173
+ for c in meta_toml.get("cli", []):
174
+ if isinstance(c, dict) and c.get("command_name") and c.get("entrypoint"):
175
+ cli_tools.append(
176
+ {"command_name": str(c["command_name"]), "entrypoint": str(c["entrypoint"])}
177
+ )
178
+ mcp_servers: list[dict] = []
179
+ for srv in meta_toml.get("mcp", []):
180
+ if isinstance(srv, dict):
181
+ mcp_servers.append({k: v for k, v in srv.items()})
182
+ runtime_deps: list[dict] = []
183
+ for rd in meta_toml.get("runtime_dependencies", []):
184
+ if isinstance(rd, dict) and rd.get("kind") and rd.get("spec"):
185
+ runtime_deps.append({"kind": str(rd["kind"]), "spec": str(rd["spec"])})
186
+
162
187
  # Walk file tree
163
188
  files: list[dict] = []
164
189
  for p in skill_dir.rglob("*"):
@@ -188,4 +213,8 @@ def build_manifest(skill_dir: Path, *, version: str) -> BuiltManifest:
188
213
  files=sorted(files, key=lambda f: f["path"]),
189
214
  dependencies=deps,
190
215
  preserved_paths=["_local/", "browser_profiles/"],
216
+ kind=kind,
217
+ cli=cli_tools,
218
+ mcp=mcp_servers,
219
+ runtime_dependencies=runtime_deps,
191
220
  )
@@ -29,6 +29,8 @@
29
29
  from __future__ import annotations
30
30
 
31
31
  import shlex
32
+ import shutil
33
+ import subprocess
32
34
  import sys
33
35
  from pathlib import Path
34
36
  from typing import Any
@@ -83,13 +85,20 @@ def _has_artifacts(manifest: dict[str, Any]) -> bool:
83
85
  # --------------------------------------------------------------------------
84
86
  # entrypoint → исполняемая команда для shim
85
87
  # --------------------------------------------------------------------------
86
- def _entrypoint_to_command(entrypoint: str | None, *, store_dir: Path | None) -> str | None:
88
+ def _entrypoint_to_command(
89
+ entrypoint: str | None,
90
+ *,
91
+ store_dir: Path | None,
92
+ python_executable: str | None = None,
93
+ ) -> str | None:
87
94
  """Привести ``entrypoint`` к команде для ``path_store.add_cli``.
88
95
 
89
96
  - ``None`` / пусто → ``None`` (CLI без энтрипоинта пропускаем);
90
97
  - ``module:callable`` (console-script спецификация) → python-команда,
91
- вызывающая callable текущим интерпретатором (кроссплатформенно, без
92
- зависимости от того, поставлен ли console-script в venv);
98
+ вызывающая callable ИНТЕРПРЕТАТОРОМ ``python_executable`` (выделенный venv
99
+ инструмента, куда поставлены runtime-зависимости) иначе ``sys.executable``
100
+ хоста, где пакета может не быть. Кроссплатформенно, без зависимости от того,
101
+ поставлен ли console-script в venv;
93
102
  - всё прочее (``python -m pkg`` / путь к exe) → как есть (path_store сам
94
103
  различит путь-к-файлу и команду).
95
104
  """
@@ -106,13 +115,61 @@ def _entrypoint_to_command(entrypoint: str | None, *, store_dir: Path | None) ->
106
115
  ):
107
116
  module, _, func = ep.partition(":")
108
117
  if module and func:
109
- py = _quote(sys.executable)
118
+ py = _quote(python_executable or sys.executable)
110
119
  # sys.exit(func()) — корректный код возврата CLI.
111
120
  code = f"import sys; from {module} import {func}; sys.exit({func}())"
112
121
  return f'{py} -c "{code}"'
113
122
  return ep
114
123
 
115
124
 
125
+ # --------------------------------------------------------------------------
126
+ # выделенный venv инструмента (изоляция runtime-зависимостей)
127
+ # --------------------------------------------------------------------------
128
+ def _venv_python(venv_dir: Path) -> Path:
129
+ """Путь к python внутри venv (Scripts на Windows, bin на POSIX)."""
130
+ if sys.platform == "win32":
131
+ return venv_dir / "Scripts" / "python.exe"
132
+ return venv_dir / "bin" / "python"
133
+
134
+
135
+ def _ensure_tool_venv(store_dir: Path | None) -> str | None:
136
+ """Гарантировать выделенный venv для CLI-инструмента в ``<store_dir>/.venv``.
137
+
138
+ Зачем: runtime-зависимости навыка ставятся в ИЗОЛИРОВАННЫЙ venv (не в хост-
139
+ интерпретатор ``skills-hub`` и не в случайный cwd/.venv), а CLI-шим целится в
140
+ тот же venv. Это убирает конфликт версий с хостом и делает установку
141
+ детерминированной.
142
+
143
+ Идемпотентно: venv уже есть → переиспользуем (deps накатятся через --upgrade).
144
+ Предпочитаем ``uv venv`` (быстро, без pip внутри — uv ставит снаружи); нет uv
145
+ → ``python -m venv``. Никогда не бросает: не вышло создать → ``None`` (вызвавший
146
+ откатится на ambient ``sys.executable``-поведение).
147
+ """
148
+ if store_dir is None:
149
+ return None
150
+ try:
151
+ venv_dir = Path(store_dir) / ".venv"
152
+ py = _venv_python(venv_dir)
153
+ if py.exists():
154
+ return str(py)
155
+ venv_dir.parent.mkdir(parents=True, exist_ok=True)
156
+ if shutil.which("uv") is not None:
157
+ rc = subprocess.run(
158
+ ["uv", "venv", str(venv_dir)], check=False, capture_output=True
159
+ ).returncode
160
+ else:
161
+ rc = subprocess.run(
162
+ [sys.executable, "-m", "venv", str(venv_dir)],
163
+ check=False,
164
+ capture_output=True,
165
+ ).returncode
166
+ if rc == 0 and py.exists():
167
+ return str(py)
168
+ except Exception:
169
+ return None
170
+ return None
171
+
172
+
116
173
  def _quote(value: str) -> str:
117
174
  """Кавычки для пути с пробелами (POSIX shlex; на Windows — двойные)."""
118
175
  if " " not in value:
@@ -145,11 +202,20 @@ def apply_tooling_artifacts(
145
202
  store_dir = getattr(result, "store_dir", None)
146
203
  skill_ref = _skill_ref(result)
147
204
 
148
- # 1. runtime-зависимости (deps_installer сам graceful, но обернём на всякий).
205
+ # Выделенный venv инструмента: если есть pip-зависимости и известна store_dir —
206
+ # создаём <store_dir>/.venv и ставим deps ИМЕННО в него, а CLI-шим целим туда
207
+ # же. Без этого deps уходили в cwd/.venv или хост-интерпретатор, а шим целился в
208
+ # sys.executable — рассинхрон ⇒ «ModuleNotFoundError» при запуске CLI.
149
209
  deps = _deps_list(man)
210
+ has_pip = any(str(d.get("kind") or "") == "pip" for d in deps)
211
+ tool_python = _ensure_tool_venv(store_dir) if has_pip else None
212
+
213
+ # 1. runtime-зависимости (deps_installer сам graceful, но обернём на всякий).
150
214
  if deps:
151
215
  try:
152
- report["deps"] = deps_installer.install_runtime_dependencies(deps)
216
+ report["deps"] = deps_installer.install_runtime_dependencies(
217
+ deps, python_executable=tool_python
218
+ )
153
219
  except Exception as exc:
154
220
  report["deps"] = {"error": str(exc)}
155
221
 
@@ -161,7 +227,9 @@ def apply_tooling_artifacts(
161
227
  if not command_name:
162
228
  report["cli"].append({"status": "error", "reason": "command_name пуст"})
163
229
  continue
164
- command = _entrypoint_to_command(tool.get("entrypoint"), store_dir=store_dir)
230
+ command = _entrypoint_to_command(
231
+ tool.get("entrypoint"), store_dir=store_dir, python_executable=tool_python
232
+ )
165
233
  if command is None:
166
234
  report["cli"].append(
167
235
  {
@@ -69,3 +69,42 @@ def test_read_meta_toml(tmp_path: Path) -> None:
69
69
  (d / "SKILL.md").write_text("---\nname: s\nversion: 1.0.0\n---\n# s\n", encoding="utf-8")
70
70
  m = build_manifest(d, version="1.0.0")
71
71
  assert m.description == "from toml"
72
+
73
+
74
+ def test_build_manifest_tooling_fields(tmp_path: Path) -> None:
75
+ """tooling-навык: kind/cli/runtime_dependencies из _skill_meta.toml попадают
76
+ в манифест (без них publish терял бы CLI → install не ставил инструмент)."""
77
+ d = tmp_path / "tool"
78
+ d.mkdir()
79
+ (d / "SKILL.md").write_text("---\nname: tool\n---\n# tool\n", encoding="utf-8")
80
+ # ВАЖНО: runtime_dependencies (top-level массив) — ДО заголовка [[cli]],
81
+ # иначе tomllib относит ключ внутрь cli[0], а не на верхний уровень.
82
+ (d / "_skill_meta.toml").write_text(
83
+ 'version = "0.2.1"\n'
84
+ 'description = "tool skill"\n'
85
+ 'kind = "tooling"\n'
86
+ 'runtime_dependencies = [\n'
87
+ ' { kind = "pip", spec = "mytool @ git+https://example/x.git@v1" },\n'
88
+ ']\n'
89
+ "[[cli]]\n"
90
+ 'command_name = "mytool"\n'
91
+ 'entrypoint = "mytool.cli:app"\n',
92
+ encoding="utf-8",
93
+ )
94
+ m = build_manifest(d, version="0.2.1")
95
+ assert m.kind == "tooling"
96
+ assert m.cli == [{"command_name": "mytool", "entrypoint": "mytool.cli:app"}]
97
+ assert m.runtime_dependencies == [
98
+ {"kind": "pip", "spec": "mytool @ git+https://example/x.git@v1"}
99
+ ]
100
+ assert m.mcp == []
101
+
102
+
103
+ def test_build_manifest_no_tooling_fields_defaults(tmp_path: Path) -> None:
104
+ """Не-tooling навык: tooling-поля пусты (kind=None, списки пустые)."""
105
+ d = _skill(tmp_path)
106
+ m = build_manifest(d, version="1.0.0")
107
+ assert m.kind is None
108
+ assert m.cli == []
109
+ assert m.mcp == []
110
+ assert m.runtime_dependencies == []