s-skillkit 0.1.2__tar.gz → 0.1.3__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.3}/PKG-INFO +1 -1
  2. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/pyproject.toml +48 -48
  3. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/installer.py +35 -9
  4. s_skillkit-0.1.3/tests/test_w268_subdir_skill.py +111 -0
  5. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/uv.lock +1 -1
  6. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/.gitignore +0 -0
  7. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/LICENSE +0 -0
  8. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/README.md +0 -0
  9. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/__init__.py +0 -0
  10. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/collections.py +0 -0
  11. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/deps_installer.py +0 -0
  12. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/filter.py +0 -0
  13. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/linker.py +0 -0
  14. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/manifest.py +0 -0
  15. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/mcp_register.py +0 -0
  16. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/path_store.py +0 -0
  17. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/paths.py +0 -0
  18. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/project.py +0 -0
  19. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/__init__.py +0 -0
  20. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/antigravity.py +0 -0
  21. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/base.py +0 -0
  22. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/claude_code.py +0 -0
  23. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/codex.py +0 -0
  24. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/targets/detect.py +0 -0
  25. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/skillkit/tooling.py +0 -0
  26. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/conftest.py +0 -0
  27. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_collections.py +0 -0
  28. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_deps_installer.py +0 -0
  29. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_filter.py +0 -0
  30. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_git_install.py +0 -0
  31. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_installer.py +0 -0
  32. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_manifest.py +0 -0
  33. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_mcp_register.py +0 -0
  34. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_path_store.py +0 -0
  35. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_paths.py +0 -0
  36. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_project.py +0 -0
  37. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_stub_guard.py +0 -0
  38. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_targets.py +0 -0
  39. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_tooling.py +0 -0
  40. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/tests/test_traversal_guard.py +0 -0
  41. {s_skillkit-0.1.2 → s_skillkit-0.1.3}/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.3
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.3"
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 = ["."]
@@ -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
 
@@ -0,0 +1,111 @@
1
+ """#268 — subdir-навык: материализуется ПОДПАПКА репо (skills/<name>/), не корень.
2
+
3
+ Позволяет навыку жить подкаталогом CLI-репо: install клонирует репо, но в стор
4
+ кладёт только skill_path-подпапку (SKILL.md + agents + references), а сам CLI
5
+ ставится через runtime_dependency.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from skillkit import ClaudeCodeTarget, SkillStore
14
+ from skillkit import installer as installer_mod
15
+
16
+
17
+ def _sha(text: str) -> str:
18
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def _manifest(files: dict[str, str]) -> dict[str, Any]:
22
+ return {
23
+ "version": "1.0.0", "description": "x",
24
+ "files": [
25
+ {"path": rel, "sha256": _sha(c), "size": len(c)} for rel, c in files.items()
26
+ ],
27
+ "preserved_paths": ["_local/"],
28
+ }
29
+
30
+
31
+ def _make_fake_clone(layout: dict[str, str]):
32
+ """Фейковый git clone: пишет ВЕСЬ layout репо (корень + подпапки) в target."""
33
+ def fake_run(cmd: list[str], *a: Any, **k: Any):
34
+ assert cmd[0] == "git" and cmd[1] == "clone"
35
+ target = Path(cmd[-1])
36
+ target.mkdir(parents=True, exist_ok=True)
37
+ for rel, content in layout.items():
38
+ p = target / rel
39
+ p.parent.mkdir(parents=True, exist_ok=True)
40
+ p.write_text(content, encoding="utf-8")
41
+
42
+ class _R:
43
+ returncode = 0
44
+ stdout = ""
45
+ stderr = ""
46
+
47
+ return _R()
48
+
49
+ return fake_run
50
+
51
+
52
+ # Репо CLI-проекта: код в корне + навык в skills/atlas/
53
+ _REPO = {
54
+ "pyproject.toml": "[project]\nname='atlas'\n",
55
+ "src/atlas/cli.py": "# CLI",
56
+ "skills/atlas/SKILL.md": "---\nname: atlas\nversion: 1.0.0\n---\n# atlas skill",
57
+ "skills/atlas/agents/x.md": "AGENT",
58
+ "skills/atlas/references/r.md": "REF",
59
+ }
60
+ _SKILL_FILES = {
61
+ "SKILL.md": _REPO["skills/atlas/SKILL.md"],
62
+ "agents/x.md": "AGENT",
63
+ "references/r.md": "REF",
64
+ }
65
+
66
+
67
+ def test_subdir_materialize_takes_only_skill_path(tmp_path: Path, monkeypatch) -> None:
68
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(_REPO))
69
+ store = SkillStore(ClaudeCodeTarget(root=tmp_path / ".claude"), tmp_path / "store")
70
+ store.materialize(
71
+ slug="atlas", version="1.0.0", commit_sha="a",
72
+ repo_url="https://github.com/x/atlas-cli.git",
73
+ manifest=_manifest(_SKILL_FILES),
74
+ skill_path="skills/atlas",
75
+ )
76
+ store_dir = tmp_path / "store" / "atlas"
77
+ # В сторе — содержимое ПОДПАПКИ навыка:
78
+ assert (store_dir / "SKILL.md").exists()
79
+ assert (store_dir / "agents" / "x.md").read_text(encoding="utf-8") == "AGENT"
80
+ assert (store_dir / "references" / "r.md").exists()
81
+ # НЕ корневой код репо:
82
+ assert not (store_dir / "pyproject.toml").exists()
83
+ assert not (store_dir / "src").exists()
84
+ assert not (store_dir / "skills").exists()
85
+
86
+
87
+ def test_subdir_missing_path_errors(tmp_path: Path, monkeypatch) -> None:
88
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(_REPO))
89
+ store = SkillStore(ClaudeCodeTarget(root=tmp_path / ".claude"), tmp_path / "store")
90
+ import pytest
91
+ with pytest.raises(RuntimeError, match="skill_path"):
92
+ store.materialize(
93
+ slug="atlas", version="1.0.0", commit_sha="a",
94
+ repo_url="https://github.com/x/atlas-cli.git",
95
+ manifest=_manifest(_SKILL_FILES),
96
+ skill_path="skills/nonexistent",
97
+ )
98
+
99
+
100
+ def test_no_skill_path_uses_repo_root(tmp_path: Path, monkeypatch) -> None:
101
+ """Без skill_path (None) поведение прежнее — материализуется корень репо."""
102
+ root_layout = {"SKILL.md": "---\nname: x\nversion: 1.0.0\n---\n# x", "a.md": "A"}
103
+ monkeypatch.setattr(installer_mod.subprocess, "run", _make_fake_clone(root_layout))
104
+ store = SkillStore(ClaudeCodeTarget(root=tmp_path / ".claude"), tmp_path / "store")
105
+ store.materialize(
106
+ slug="x", version="1.0.0", commit_sha="a",
107
+ repo_url="https://github.com/x/x.git", manifest=_manifest(root_layout),
108
+ )
109
+ store_dir = tmp_path / "store" / "x"
110
+ assert (store_dir / "SKILL.md").exists()
111
+ assert (store_dir / "a.md").exists()
@@ -96,7 +96,7 @@ wheels = [
96
96
 
97
97
  [[package]]
98
98
  name = "s-skillkit"
99
- version = "0.1.1"
99
+ version = "0.1.2"
100
100
  source = { editable = "." }
101
101
  dependencies = [
102
102
  { name = "pathspec" },
File without changes
File without changes
File without changes
File without changes
File without changes