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.
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/CHANGELOG.md +26 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/PKG-INFO +1 -1
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/pyproject.toml +1 -1
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/__init__.py +1 -1
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/deps_installer.py +12 -16
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/__init__.py +27 -2
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/manifest.py +20 -1
- s_skillkit-0.2.2/skillkit/pkg_manager.py +190 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_manifest.py +55 -0
- s_skillkit-0.2.2/tests/test_pkg_manager.py +117 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_w268_subdir_skill.py +53 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/.gitignore +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/AGENTS.md +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/LICENSE +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/README.md +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/binary_installer.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/bootstrap.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/collections.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/config.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/errors.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/filter.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/differ.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/fetchers.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/fsops.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/meta.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/installer/models.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/linker.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/mcp_register.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/path_store.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/paths.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/project.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/__init__.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/antigravity.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/base.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/claude_code.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/codex.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/targets/detect.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/tooling.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/skillkit/uv_bootstrap.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/conftest.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_binary_installer.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_bootstrap.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_collections.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_config.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_deps_binary.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_deps_installer.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_errors.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_filter.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_git_install.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_installer.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_mcp_register.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_path_store.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_paths.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_project.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_stub_guard.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_targets.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_tooling.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_transactional_install.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_traversal_guard.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_typed_fields.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_uv_bootstrap.py +0 -0
- {s_skillkit-0.2.1 → s_skillkit-0.2.2}/tests/test_w272_empty_manifest_guard.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|