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.
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/PKG-INFO +1 -1
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/pyproject.toml +48 -48
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/deps_installer.py +27 -6
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/installer.py +35 -9
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/manifest.py +30 -1
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/tooling.py +75 -7
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_manifest.py +39 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_tooling.py +356 -262
- s_skillkit-0.1.4/tests/test_w268_subdir_skill.py +111 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/uv.lock +1 -1
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/.gitignore +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/LICENSE +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/README.md +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/__init__.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/collections.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/filter.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/linker.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/mcp_register.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/path_store.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/paths.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/project.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/__init__.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/antigravity.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/base.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/claude_code.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/codex.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/skillkit/targets/detect.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/conftest.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_collections.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_deps_installer.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_filter.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_git_install.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_installer.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_mcp_register.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_path_store.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_paths.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_project.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_stub_guard.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_targets.py +0 -0
- {s_skillkit-0.1.2 → s_skillkit-0.1.4}/tests/test_traversal_guard.py +0 -0
- {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.
|
|
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.
|
|
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,
|
|
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(
|
|
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"
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
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(
|
|
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 == []
|