s-skillkit 0.3.2__tar.gz → 0.3.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 (76) hide show
  1. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/CHANGELOG.md +36 -0
  2. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/PKG-INFO +6 -3
  3. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/README.md +4 -2
  4. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/pyproject.toml +11 -4
  5. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/bootstrap.py +35 -20
  6. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/deps_installer.py +28 -9
  7. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/__init__.py +40 -6
  8. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/fetchers.py +32 -22
  9. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/fsops.py +26 -5
  10. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/models.py +17 -1
  11. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/linker.py +34 -7
  12. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/manifest.py +10 -4
  13. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/tooling.py +15 -6
  14. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/uv_bootstrap.py +19 -5
  15. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_errors.py +12 -3
  16. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_installer.py +36 -1
  17. s_skillkit-0.3.3/tests/test_no_raw_subprocess.py +77 -0
  18. s_skillkit-0.3.3/tests/test_pkgsrc_not_in_agent_zone.py +102 -0
  19. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_tooling.py +3 -3
  20. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_uv_bootstrap.py +3 -2
  21. s_skillkit-0.3.3/uv.lock +569 -0
  22. s_skillkit-0.3.2/uv.lock +0 -139
  23. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/.gitignore +0 -0
  24. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/.gitlab-ci.yml +0 -0
  25. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/AGENTS.md +0 -0
  26. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/LICENSE +0 -0
  27. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/__init__.py +0 -0
  28. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/binary_installer.py +0 -0
  29. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/collections.py +0 -0
  30. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/config.py +0 -0
  31. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/errors.py +0 -0
  32. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/filter.py +0 -0
  33. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/hook_register.py +0 -0
  34. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/differ.py +0 -0
  35. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/meta.py +0 -0
  36. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/installer/ownership.py +0 -0
  37. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/mcp_register.py +0 -0
  38. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/path_store.py +0 -0
  39. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/paths.py +0 -0
  40. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/pkg_manager.py +0 -0
  41. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/project.py +0 -0
  42. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/__init__.py +0 -0
  43. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/antigravity.py +0 -0
  44. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/base.py +0 -0
  45. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/claude_code.py +0 -0
  46. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/codex.py +0 -0
  47. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/detect.py +0 -0
  48. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/skillkit/targets/opencode.py +0 -0
  49. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/conftest.py +0 -0
  50. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_agent_skill_layouts_2026_07_21.py +0 -0
  51. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_binary_installer.py +0 -0
  52. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_bootstrap.py +0 -0
  53. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_collections.py +0 -0
  54. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_config.py +0 -0
  55. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_deps_binary.py +0 -0
  56. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_deps_installer.py +0 -0
  57. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_destructive_ownership_gate_2026_07_21.py +0 -0
  58. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_filter.py +0 -0
  59. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_git_install.py +0 -0
  60. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_hooks.py +0 -0
  61. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_legacy_paths_no_orphans_2026_07_21.py +0 -0
  62. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_manifest.py +0 -0
  63. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_mcp_register.py +0 -0
  64. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_packaging_lock.py +0 -0
  65. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_path_store.py +0 -0
  66. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_paths.py +0 -0
  67. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_pkg_manager.py +0 -0
  68. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_project.py +0 -0
  69. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_remove_ownership_guard_2026_07_21.py +0 -0
  70. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_stub_guard.py +0 -0
  71. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_targets.py +0 -0
  72. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_transactional_install.py +0 -0
  73. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_traversal_guard.py +0 -0
  74. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_typed_fields.py +0 -0
  75. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_w268_subdir_skill.py +0 -0
  76. {s_skillkit-0.3.2 → s_skillkit-0.3.3}/tests/test_w272_empty_manifest_guard.py +0 -0
@@ -4,6 +4,42 @@
4
4
  [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версионирование —
5
5
  [SemVer](https://semver.org/lang/ru/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Fixed — окна консоли, `.pkgsrc` в зоне агента, потеря происхождения снапшота
10
+
11
+ - **#1144 — прямые `subprocess` выкошены.** Демон стартует DETACHED (своей
12
+ консоли нет), поэтому КАЖДЫЙ консольный ребёнок (`uv`, `git`, `powershell`,
13
+ `cmd`) без `CREATE_NO_WINDOW` получал от Windows своё окно — пользователь
14
+ видел `uv.EXE` поверх браузера во время фоновой установки навыка.
15
+ `capture_output=True` окно НЕ подавляет (stdio и аллокация консоли в Win32 —
16
+ разные механизмы), и флаг стоял ровно в ОДНОМ из девяти мест. Все запуски
17
+ переведены на `librarykit.proc.run` (единая политика окон + обязательный
18
+ таймаут + захват вывода + маскировка секретов в логе). Появилась зависимость
19
+ `s-librarykit>=0.7.1`. Стережёт AST-гейт `tests/test_no_raw_subprocess.py`:
20
+ прямой `subprocess.run|Popen|call|check_output|check_call` в `skillkit/**`
21
+ роняет тесты.
22
+ - **#1147 — `.pkgsrc` больше не утекает в папку агента.** При copy-fallback
23
+ (линковка стор→scope не удалась) копировался ВЕСЬ стор: `safe_copy_tree`
24
+ фильтровал только `.git`. Фильтр стал параметром (`skip_roots`), в зону агента
25
+ едет `AGENT_ZONE_SKIP_ROOTS` (`.git`, `.pkgsrc`). Копия стор→staging (перенос
26
+ версии) фильтр НЕ применяет — `.pkgsrc` обязана переезжать между версиями,
27
+ иначе теряется имя дистрибутива для `uv tool uninstall`. Инвариант: **стор —
28
+ источник истины; в зоне агента только артефакты навыка**. Плюс диагностика
29
+ первопричины: причина fallback'а логируется и различает «канон занят обычной
30
+ папкой» (`FileExistsError`) от «ОС/ФС не дала ссылку», а на Windows перед
31
+ сдачей в copy теперь пробуется `os.symlink` (Developer Mode), которого раньше
32
+ не пробовали вовсе.
33
+ - **#1145 — снапшот-установка метится `source="hub"`.** Ядро нормализует
34
+ `SnapshotSource` → `LocalPathSource` (архив распакован во временную папку) ДО
35
+ записи меты, и происхождение терялось: 7 из 8 навыков с хаба получали
36
+ `source="local-path"` и МОЛЧА выпадали из автообновления CLI (оно берёт только
37
+ `source == "hub"`). Происхождение теперь едет явной меткой
38
+ (`SnapshotSource.origin` → `InstallRequest.source_label`) и перебивает вывод по
39
+ типу источника — и на установке, и на обновлении. Семантика реального
40
+ `install_from_path` не изменилась. Миграция уже установленных сторов — на
41
+ стороне CLI (признак: `source == "local-path"` при непустом `skill_id`).
42
+
7
43
  ## [0.3.1] — 2026-07-21
8
44
 
9
45
  Круг 3 независимой проверки нашёл два настоящих дефекта в правках 0.3.0 и один
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: s-skillkit
3
- Version: 0.3.2
3
+ Version: 0.3.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
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
9
  Requires-Dist: pathspec>=0.12.0
10
10
  Requires-Dist: platformdirs>=4.0
11
+ Requires-Dist: s-librarykit>=0.7.1
11
12
  Requires-Dist: tomli-w>=1.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
@@ -16,8 +17,10 @@ Description-Content-Type: text/markdown
16
17
 
17
18
  # s-skillkit
18
19
 
19
- Локальное ядро управления навыками (skills) — **SIBLING** `librarykit`
20
- (не зависит от него, от httpx, auth или сети). Только локальная ФС-механика.
20
+ Локальное ядро управления навыками (skills): только локальная ФС-механика — ни
21
+ сети, ни auth. Из `librarykit` берётся ровно один модуль — `librarykit.proc`
22
+ (единый запуск подпроцессов: на Windows без всплывающих консольных окон и с
23
+ обязательным таймаутом); `uv`, `git` и `npm` кит зовёт только через него.
21
24
 
22
25
  ## Что внутри
23
26
 
@@ -1,7 +1,9 @@
1
1
  # s-skillkit
2
2
 
3
- Локальное ядро управления навыками (skills) — **SIBLING** `librarykit`
4
- (не зависит от него, от httpx, auth или сети). Только локальная ФС-механика.
3
+ Локальное ядро управления навыками (skills): только локальная ФС-механика — ни
4
+ сети, ни auth. Из `librarykit` берётся ровно один модуль — `librarykit.proc`
5
+ (единый запуск подпроцессов: на Windows без всплывающих консольных окон и с
6
+ обязательным таймаутом); `uv`, `git` и `npm` кит зовёт только через него.
5
7
 
6
8
  ## Что внутри
7
9
 
@@ -1,21 +1,28 @@
1
1
  [project]
2
2
  name = "s-skillkit"
3
- version = "0.3.2"
3
+ version = "0.3.3"
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" }
7
7
  requires-python = ">=3.11"
8
8
  authors = [{ name = "Dmitry" }]
9
- # skillkit — SIBLING librarykit (НЕ зависит от него, httpx, auth, сети).
10
- # Только локальная ФС-механика навыков. Core deps:
9
+ # skillkit — локальная ФС-механика навыков (НЕ ходит в сеть, не знает auth).
10
+ # Core deps:
11
11
  # tomli-w — запись TOML (project-манифест .skills-hub/skills.toml,
12
12
  # _skill_meta.toml ридеры используют stdlib tomllib на чтение);
13
13
  # pathspec — gitignore-стиль .skillignore / files-allowlist фильтр;
14
- # platformdirs — нативные дефолтные пути (Paths.default) по ОС.
14
+ # platformdirs — нативные дефолтные пути (Paths.default) по ОС;
15
+ # s-librarykit — ТОЛЬКО ради ``librarykit.proc`` (#1144): единая политика запуска
16
+ # подпроцессов. Кит запускает uv/git/npm/powershell, а на Windows консольный
17
+ # ребёнок демона (у него нет своей консоли) без CREATE_NO_WINDOW получает
18
+ # ВИДИМОЕ окно поверх браузера; ``capture_output`` его не подавляет. Своей
19
+ # второй реализации «как правильно на Windows» в ките быть не должно —
20
+ # поэтому прежний статус «sibling, ни от чего не зависим» осознанно снят.
15
21
  dependencies = [
16
22
  "tomli-w>=1.0",
17
23
  "pathspec>=0.12.0",
18
24
  "platformdirs>=4.0",
25
+ "s-librarykit>=0.7.1",
19
26
  ]
20
27
 
21
28
  [project.optional-dependencies]
@@ -22,23 +22,36 @@ s-xray-gate). Кит НЕ зависит от него жёстко: это ``Ca
22
22
  запускает ``scripts/self_check.py`` (venv-python навыка → ``uv run`` → текущий
23
23
  интерпретатор), возвращает ``(ok, output)``.
24
24
 
25
- Мокабельность: subprocess идёт через ``_run_cmd`` (uv sync) и ``_run_capture``
25
+ Мокабельность: запуск идёт через ``_run_cmd`` (uv sync) и ``_run_capture``
26
26
  (self_check); поиск uv — через ``_which``; ОС — константа ``IS_WINDOWS``.
27
+ Оба примитива — обёртки над ``librarykit.proc.run`` (#1144): на Windows он
28
+ всегда ставит ``CREATE_NO_WINDOW`` (bootstrap зовёт демон, у которого нет своей
29
+ консоли — иначе окна поверх браузера) и требует явный таймаут.
27
30
  """
28
31
  from __future__ import annotations
29
32
 
33
+ import logging
30
34
  import shutil
31
- import subprocess
32
35
  import sys
33
36
  from collections.abc import Callable
34
37
  from pathlib import Path
35
38
  from typing import Any
36
39
 
40
+ from librarykit import proc
41
+ from librarykit.proc import ProcResult
42
+
37
43
  from skillkit import deps_installer, uv_bootstrap
38
44
  from skillkit.deps_installer import _BINARY_KINDS
39
45
 
40
46
  IS_WINDOWS = sys.platform == "win32"
41
47
 
48
+ _LOG = logging.getLogger(__name__)
49
+
50
+ #: ``uv sync`` тянет всё дерево зависимостей навыка — это минуты на узком канале.
51
+ _SYNC_TIMEOUT = 900.0
52
+ #: self_check навыка — проверка окружения, а не сборка; висеть ей незачем.
53
+ _SELF_CHECK_TIMEOUT = 300.0
54
+
42
55
 
43
56
  # --------------------------------------------------------------------------
44
57
  # Подменяемые в тестах примитивы окружения
@@ -50,27 +63,28 @@ def _which(name: str) -> str | None:
50
63
  def _run_cmd(cmd: list[str], *, cwd: Path | None = None) -> int:
51
64
  """Запустить команду, вернуть returncode (не бросает на OSError)."""
52
65
  try:
53
- return subprocess.run(
54
- cmd, cwd=str(cwd) if cwd else None, check=False
55
- ).returncode
66
+ return proc.run(cmd, cwd=cwd, timeout=_SYNC_TIMEOUT, log=_LOG).returncode
56
67
  except OSError:
57
68
  return 127
58
69
 
59
70
 
60
- def _run_capture(
61
- cmd: list[str], *, cwd: Path | None = None
62
- ) -> subprocess.CompletedProcess:
63
- """Запустить команду с захватом вывода (не бросает на OSError)."""
71
+ def _run_capture(cmd: list[str], *, cwd: Path | None = None) -> ProcResult:
72
+ """Запустить команду с захватом вывода (не бросает на OSError).
73
+
74
+ Возвращает ``ProcResult`` — по полям (``returncode``/``stdout``/``stderr``)
75
+ совместим с прежним ``CompletedProcess``, но дополнительно несёт ``timed_out``.
76
+ """
64
77
  try:
65
- return subprocess.run(
66
- cmd,
67
- cwd=str(cwd) if cwd else None,
68
- check=False,
69
- capture_output=True,
70
- text=True,
71
- )
78
+ return proc.run(cmd, cwd=cwd, timeout=_SELF_CHECK_TIMEOUT, log=_LOG)
72
79
  except OSError as exc:
73
- return subprocess.CompletedProcess(cmd, 127, "", str(exc))
80
+ return ProcResult(
81
+ cmd=tuple(str(c) for c in cmd),
82
+ returncode=127,
83
+ stdout="",
84
+ stderr=str(exc),
85
+ duration=0.0,
86
+ timed_out=False,
87
+ )
74
88
 
75
89
 
76
90
  # --------------------------------------------------------------------------
@@ -131,9 +145,10 @@ def run_self_check(skill_dir: Path | str) -> tuple[bool, str]:
131
145
  script = skill_dir / "scripts" / "self_check.py"
132
146
  if not script.exists():
133
147
  return (False, f"scripts/self_check.py не найден в {skill_dir}")
134
- proc = _run_capture(_self_check_command(skill_dir, script), cwd=skill_dir)
135
- output = (proc.stdout or "") + (proc.stderr or "")
136
- return (proc.returncode == 0, output)
148
+ # Имя ``result``, а не ``proc``: модульный ``proc`` — это librarykit.proc.
149
+ result = _run_capture(_self_check_command(skill_dir, script), cwd=skill_dir)
150
+ output = (result.stdout or "") + (result.stderr or "")
151
+ return (result.returncode == 0, output)
137
152
 
138
153
 
139
154
  # Алиас для использования внутри bootstrap_skill (там параметр ``run_self_check``
@@ -27,19 +27,34 @@
27
27
 
28
28
  Кроссплатформенность: выбор менеджера — через ``shutil.which`` (обёрнут в
29
29
  ``_which`` для мокабельности) + ``python -m pip`` (текущий интерпретатор,
30
- работает на win/mac/linux). subprocess-примитив ``_run`` тоже подменяем в тестах.
30
+ работает на win/mac/linux). Примитив запуска ``_run`` тоже подменяем в тестах.
31
+
32
+ Запуск идёт ТОЛЬКО через ``librarykit.proc.run`` (#1144): на Windows он всегда
33
+ ставит ``CREATE_NO_WINDOW`` (иначе фоновая установка навыка демоном рисует
34
+ пользователю окна ``uv.EXE`` поверх браузера) и требует явный таймаут — висящий
35
+ ``uv`` в демоне некому прервать. Заодно снят прежний дефект: ``uv pip install``
36
+ запускался без захвата вывода и сыпал прогресс-бары в stdout потребителя.
31
37
  """
32
38
  from __future__ import annotations
33
39
 
40
+ import logging
34
41
  import shutil
35
- import subprocess
36
42
  import sys
37
43
  from pathlib import Path
38
44
  from typing import Any
39
45
 
46
+ from librarykit import proc
47
+
40
48
  from skillkit import binary_installer
41
49
  from skillkit.pkg_manager import detect_project, missing_manager_report
42
50
 
51
+ _LOG = logging.getLogger(__name__)
52
+
53
+ #: Установка пакета — минуты (компиляция колёс, узкий канал), а не секунды.
54
+ _INSTALL_TIMEOUT = 900.0
55
+ #: Проба «есть ли pip» обязана отвечать быстро; вис здесь = вис установки.
56
+ _PROBE_TIMEOUT = 60.0
57
+
43
58
  # kinds внешних бинарей (GitHub Releases) — делегируют binary_installer.
44
59
  _BINARY_KINDS = frozenset({"binary", "github-release"})
45
60
 
@@ -58,20 +73,24 @@ def _which(name: str) -> str | None:
58
73
  def _pip_available() -> bool:
59
74
  """Доступен ли ``pip`` для текущего интерпретатора (``python -m pip``)."""
60
75
  try:
61
- proc = subprocess.run(
76
+ return proc.run(
62
77
  [sys.executable, "-m", "pip", "--version"],
63
- capture_output=True,
64
- check=False,
65
- )
66
- return proc.returncode == 0
78
+ timeout=_PROBE_TIMEOUT,
79
+ log=_LOG,
80
+ ).ok
67
81
  except OSError:
68
82
  return False
69
83
 
70
84
 
71
85
  def _run(cmd: list[str]) -> int:
72
- """Запустить команду установки, вернуть returncode (не бросает на OSError)."""
86
+ """Запустить команду установки, вернуть returncode (не бросает на OSError).
87
+
88
+ Таймаут — не ошибка вызывающего, а такой же неуспех: ``ProcResult.ok`` уже
89
+ False, а ``returncode`` при убийстве по таймауту равен -1 (отличимо от кода
90
+ менеджера в отчёте ``failed``).
91
+ """
73
92
  try:
74
- return subprocess.run(cmd, check=False).returncode
93
+ return proc.run(cmd, timeout=_INSTALL_TIMEOUT, log=_LOG).returncode
75
94
  except OSError:
76
95
  return 127
77
96
 
@@ -27,7 +27,12 @@ skillkit-инъекция: store_dir передаётся явно (Paths.store_
27
27
  from __future__ import annotations
28
28
 
29
29
  import contextlib
30
- import subprocess # noqa: F401 # re-export seam: тесты патчат installer.subprocess.run
30
+ import logging
31
+
32
+ # re-export seam: тесты патчат installer.subprocess.run. Сам кит подпроцессы
33
+ # НЕ запускает напрямую (#1144) — их гоняет ``librarykit.proc.run``, который
34
+ # внутри зовёт этот же stdlib-``subprocess``, поэтому патч продолжает работать.
35
+ import subprocess # noqa: F401
31
36
  import tarfile
32
37
  import tempfile
33
38
  from dataclasses import replace
@@ -52,6 +57,7 @@ from skillkit.installer.differ import _file_index, _manifest_diff # noqa: F401
52
57
  from skillkit.installer.fetchers import ContentFetcher, GitContentFetcher
53
58
  from skillkit.installer.fsops import (
54
59
  _COPY_SKIP_ROOT, # noqa: F401 # re-export seam
60
+ AGENT_ZONE_SKIP_ROOTS,
55
61
  PathTraversalError,
56
62
  _assert_within,
57
63
  _force_rmtree, # noqa: F401 # re-export seam (звать ЗДЕСЬ нельзя — только PathGuard)
@@ -132,6 +138,9 @@ __all__ = [
132
138
  ]
133
139
 
134
140
 
141
+ _LOG = logging.getLogger(__name__)
142
+
143
+
135
144
  # Брендо-нейтральные дефолты (само-имя кита); потребитель переопределяет через
136
145
  # ``set_managed_by`` / ``set_clone_prefix`` (обычно из ``config.configure`` —
137
146
  # выводятся из ``app_name``).
@@ -282,9 +291,13 @@ class SkillStore:
282
291
  # папку и ставим её как локальный источник (как прежний
283
292
  # ``install_from_snapshot`` → ``install_from_path``).
284
293
  if isinstance(request.source, SnapshotSource):
294
+ # #1145: происхождение архива НЕ теряется при нормализации —
295
+ # оно едет дальше явной меткой и перебивает вывод по типу источника
296
+ # (иначе мета получит "local-path" и навык выпадет из автоапдейта).
297
+ origin = request.source_label or request.source.origin
285
298
  with self._extract_snapshot(request.source.archive_path) as root:
286
299
  return self._install(
287
- replace(request, source=LocalPathSource(root))
300
+ replace(request, source=LocalPathSource(root), source_label=origin)
288
301
  )
289
302
 
290
303
  slug = request.slug
@@ -531,7 +544,9 @@ class SkillStore:
531
544
  self._build_meta(
532
545
  request.slug, version, request.commit_sha, request.manifest, scope,
533
546
  request.project, self._skill_id_str(request),
534
- source=source_label, repo_url=repo_url_meta,
547
+ # Явная метка (снапшот хаба) сильнее вывода по типу источника.
548
+ source=request.source_label or source_label,
549
+ repo_url=repo_url_meta,
535
550
  skill_path=_source_skill_path(src),
536
551
  ),
537
552
  )
@@ -560,9 +575,24 @@ class SkillStore:
560
575
  try:
561
576
  kind = linker.create_link(link, store_dir)
562
577
  return True, kind
563
- except OSError:
578
+ except OSError as exc:
564
579
  # Нет прав / ФС не поддерживает ссылки → копируем стор в scope.
565
- safe_copy_tree(store_dir, link)
580
+ # #1147: причину fallback'а раньше никто не видел — а лечатся эти
581
+ # случаи по-разному. FileExistsError = канон занят ОБЫЧНОЙ папкой
582
+ # (её должен был освободить гейт, значит проблема выше по стеку);
583
+ # прочий OSError = среда (нет прав на symlink, ФС без ссылок).
584
+ reason = (
585
+ "канон занят обычной папкой"
586
+ if isinstance(exc, FileExistsError)
587
+ else "ОС/ФС не дала создать ссылку"
588
+ )
589
+ _LOG.warning(
590
+ "линковка %s → %s не удалась (%s: %s); копирую стор в scope",
591
+ link, store_dir, reason, exc,
592
+ )
593
+ # В зону агента едут ТОЛЬКО артефакты навыка: служебные каталоги
594
+ # (``.pkgsrc``) остаются в сторе — источнике истины.
595
+ safe_copy_tree(store_dir, link, skip_roots=AGENT_ZONE_SKIP_ROOTS)
566
596
  return False, "copy"
567
597
 
568
598
  def link_existing(
@@ -747,7 +777,11 @@ class SkillStore:
747
777
  slug_dir,
748
778
  self._build_meta(
749
779
  request.slug, version, request.commit_sha, request.manifest, scope,
750
- request.project, skill_id, source=src_label, repo_url=meta_repo,
780
+ request.project, skill_id,
781
+ # #1145: и на ветке ОБНОВЛЕНИЯ метка снапшота обязана уцелеть —
782
+ # иначе навык выпадал из автоапдейта после первого же update.
783
+ source=request.source_label or src_label,
784
+ repo_url=meta_repo,
751
785
  skill_path=_source_skill_path(request.source),
752
786
  ),
753
787
  )
@@ -2,28 +2,38 @@
2
2
 
3
3
  ``ContentFetcher`` — протокол «достань репозиторий навыка на диск как контекст-
4
4
  менеджер, отдай Path на корень клона, по выходу — прибери за собой». Дефолт —
5
- ``GitContentFetcher`` (прежнее поведение: ``git clone --depth 1`` через
6
- ``subprocess``). Инъекция позволяет подменить получение источника (офлайн-
7
- зеркало, кеш, фейк в тестах) НЕ трогая оркестратор ``SkillStore``.
8
-
9
- Важно про seam ``subprocess``: ``GitContentFetcher`` зовёт ``subprocess.run`` из
10
- общего модульного объекта, поэтому существующий в тестах
11
- ``monkeypatch.setattr(installer.subprocess, "run", ...)`` продолжает
12
- перехватывать клон (патчится атрибут того же модуля).
5
+ ``GitContentFetcher`` (прежнее поведение: ``git clone --depth 1``). Инъекция
6
+ позволяет подменить получение источника (офлайн-зеркало, кеш, фейк в тестах)
7
+ НЕ трогая оркестратор ``SkillStore``.
8
+
9
+ Запуск git идёт через ``librarykit.proc.run`` (#1144) — единая политика окон и
10
+ таймаутов кита. Здесь исторически стоял ЕДИНСТВЕННЫЙ во всём ките
11
+ ``CREATE_NO_WINDOW``; теперь флаг не дублируется в вызове, а приходит из ``proc``.
12
+
13
+ Важно про seam: ``proc.run`` внутри зовёт ``subprocess.run``, поэтому
14
+ существующий в тестах ``monkeypatch.setattr(installer.subprocess, "run", ...)``
15
+ (патч атрибута общего stdlib-модуля) продолжает перехватывать клон.
13
16
  """
14
17
  from __future__ import annotations
15
18
 
16
19
  import contextlib
17
- import subprocess
18
- import sys
20
+ import logging
19
21
  import tempfile
20
22
  from collections.abc import Iterator
21
23
  from pathlib import Path
22
24
  from typing import Protocol, runtime_checkable
23
25
 
26
+ from librarykit import proc
27
+
24
28
  from skillkit.errors import CloneError
25
29
  from skillkit.installer.fsops import _force_rmtree
26
30
 
31
+ _LOG = logging.getLogger(__name__)
32
+
33
+ #: ``git clone --depth 1`` навыка: обычно секунды, но узкий канал/большой репо —
34
+ #: минуты. Без таймаута зависший git держал бы фоновую установку вечно.
35
+ _CLONE_TIMEOUT = 900.0
36
+
27
37
 
28
38
  @runtime_checkable
29
39
  class ContentFetcher(Protocol):
@@ -78,24 +88,24 @@ class GitContentFetcher:
78
88
  if not use_default_branch:
79
89
  cmd += ["--branch", ref]
80
90
  cmd += [url, str(clone_dir)]
81
- # #1028: без окна на Windows. Когда git clone спавнит ДЕМОН (без
82
- # консоли), Windows отдаёт git собственное окно git-bash → мигающая
83
- # вкладка. CREATE_NO_WINDOW подавляет её. Вне Windows флага нет.
84
- _run_kw: dict = {"check": True, "capture_output": True, "text": True}
85
- if sys.platform == "win32":
86
- _run_kw["creationflags"] = 0x08000000 # CREATE_NO_WINDOW
87
91
  try:
88
- try:
89
- subprocess.run(cmd, **_run_kw)
90
- except subprocess.CalledProcessError as e:
91
- stderr = e.stderr or e.stdout or ""
92
+ # #1028/#1144: без окна на Windows. Когда git clone спавнит ДЕМОН
93
+ # (без консоли), Windows отдаёт git собственное окно — мигающая
94
+ # вкладка поверх браузера. CREATE_NO_WINDOW ставит сам ``proc.run``.
95
+ # ``log=`` маскирует креды в URL, но stderr самого git может нести
96
+ # токен дословно — эту редакцию делаем ниже сами.
97
+ result = proc.run(cmd, timeout=_CLONE_TIMEOUT, log=_LOG)
98
+ if not result.ok:
99
+ stderr = result.stderr or result.stdout or ""
100
+ if result.timed_out:
101
+ stderr = f"таймаут {_CLONE_TIMEOUT:.0f}s. {stderr}"
92
102
  # Маскируем секрет из URL в сообщении об ошибке: если резолвер
93
- # добавил токен в URL, не утечь его в текст RuntimeError.
103
+ # добавил токен в URL, не утечь его в текст CloneError.
94
104
  if url != repo_url and "@" in url:
95
105
  secret = url.split("//", 1)[-1].split("@", 1)[0]
96
106
  if secret:
97
107
  stderr = stderr.replace(secret, "***")
98
- raise CloneError(f"git clone failed: {stderr}") from e
108
+ raise CloneError(f"git clone failed: {stderr}")
99
109
  yield clone_dir
100
110
  finally:
101
111
  _force_rmtree(tmp)
@@ -22,6 +22,16 @@ class PathTraversalError(SkillkitError):
22
22
  # Внутри source-репо НЕ копируем в slug_dir (state репозитория, не skill).
23
23
  _COPY_SKIP_ROOT = frozenset({".git"})
24
24
 
25
+ #: Что НЕ имеет права попасть в зону агента (``~/.claude/skills/<slug>/``) при
26
+ #: copy-fallback (#1147). Инвариант: **стор — источник истины; в зоне агента
27
+ #: только артефакты навыка**. ``.pkgsrc`` — служебный исходник пакета навыка,
28
+ #: он нужен ``uv tool uninstall`` при снятии и живёт в СТОРЕ; скопированный
29
+ #: рядом со ``SKILL.md`` он выглядел «лишней папкой, дублирующей снапшот».
30
+ #: ВАЖНО: этот набор применяется ТОЛЬКО к копии в зону агента. Копия
31
+ #: стор→staging (перенос версии) обязана нести ``.pkgsrc`` дальше, иначе имя
32
+ #: дистрибутива для uninstall теряется навсегда.
33
+ AGENT_ZONE_SKIP_ROOTS = frozenset({".git", ".pkgsrc"})
34
+
25
35
 
26
36
  def _on_rm_error(func, path, exc_info):
27
37
  """Чинит read-only-файлы в Windows (.git/objects/...) перед удалением."""
@@ -111,9 +121,16 @@ def _assert_within(base: Path, candidate: Path) -> Path:
111
121
  return Path(cand_abs)
112
122
 
113
123
 
114
- def safe_copy_tree(src: Path, dst: Path) -> None:
124
+ def safe_copy_tree(
125
+ src: Path, dst: Path, *, skip_roots: frozenset[str] | set[str] | None = None
126
+ ) -> None:
115
127
  """Копирует содержимое src → dst, отвергая path-traversal (ТЗ §10).
116
128
 
129
+ ``skip_roots`` — какие каталоги ВЕРХНЕГО уровня не копировать; по умолчанию
130
+ ``_COPY_SKIP_ROOT`` (только ``.git``). Копирование в зону агента передаёт
131
+ сюда ``AGENT_ZONE_SKIP_ROOTS`` (плюс ``.pkgsrc``, см. #1147) — фильтр стал
132
+ параметром именно потому, что он РАЗНЫЙ для стора и для зоны агента.
133
+
117
134
  Правила безопасности:
118
135
  - `.git/` source-репо пропускается (это state репо, не содержимое skill).
119
136
  - Ссылки НЕ следуются вслепую; если цель ссылки резолвится наружу dst →
@@ -129,6 +146,7 @@ def safe_copy_tree(src: Path, dst: Path) -> None:
129
146
  """
130
147
  from skillkit import linker
131
148
 
149
+ skips = frozenset(skip_roots) if skip_roots is not None else _COPY_SKIP_ROOT
132
150
  src = Path(src)
133
151
  dst = Path(dst)
134
152
  dst.mkdir(parents=True, exist_ok=True)
@@ -140,11 +158,12 @@ def safe_copy_tree(src: Path, dst: Path) -> None:
140
158
  for root, dirnames, filenames in os.walk(src, followlinks=False):
141
159
  root_path = Path(root)
142
160
  rel_root = root_path.relative_to(src)
143
- # Пропускаем .git/ и не спускаемся внутрь.
144
- if rel_root.parts and rel_root.parts[0] in _COPY_SKIP_ROOT:
161
+ # Пропускаем корневые skip-каталоги (.git, при копии в зону агента —
162
+ # ещё и .pkgsrc) и не спускаемся внутрь.
163
+ if rel_root.parts and rel_root.parts[0] in skips:
145
164
  dirnames[:] = []
146
165
  continue
147
- dirnames[:] = [d for d in dirnames if d not in _COPY_SKIP_ROOT]
166
+ dirnames[:] = [d for d in dirnames if d not in skips]
148
167
 
149
168
  linked_dirs: list[str] = []
150
169
  for name in list(dirnames):
@@ -160,7 +179,9 @@ def safe_copy_tree(src: Path, dst: Path) -> None:
160
179
  # он НЕ отсекает (followlinks=False ловит только symlink) и
161
180
  # спустился бы внутрь, дублируя/обходя guard.
162
181
  target.mkdir(parents=True, exist_ok=True)
163
- safe_copy_tree(child, target)
182
+ # Тот же набор исключений и внутри материализуемой ссылки —
183
+ # иначе фильтр «в зону агента» обходился бы через junction.
184
+ safe_copy_tree(child, target, skip_roots=skips)
164
185
  linked_dirs.append(name)
165
186
  else:
166
187
  target.mkdir(parents=True, exist_ok=True)
@@ -89,9 +89,20 @@ class LocalPathSource:
89
89
 
90
90
  @dataclass(frozen=True)
91
91
  class SnapshotSource:
92
- """Snapshot-архив навыка (``tar.gz``): распаковывается и ставится как local."""
92
+ """Snapshot-архив навыка (``tar.gz``): распаковывается и ставится как local.
93
+
94
+ ``origin`` (#1145) — ЧЕМ этот архив является по происхождению; попадает в
95
+ ``_skill_meta.json`` как ``source``. Дефолт ``"hub"``: снапшот — это раздача
96
+ хаба (``GET /skills/{slug}/versions/{semver}/snapshot``), а не локальная
97
+ папка пользователя. Поле нужно потому, что ядро нормализует ``SnapshotSource``
98
+ → ``LocalPathSource`` (архив распакован во временную папку) ДО записи меты, и
99
+ без него происхождение терялось: мета получала ``"local-path"``, а авто-
100
+ обновление CLI берёт только ``source == "hub"`` — установленные снапшотом
101
+ навыки молча выпадали из автоапдейта.
102
+ """
93
103
 
94
104
  archive_path: Path
105
+ origin: Source = "hub"
95
106
 
96
107
 
97
108
  @dataclass(frozen=True)
@@ -140,6 +151,11 @@ class InstallRequest:
140
151
  project: Path | None = None
141
152
  force: bool = False
142
153
  skill_id: str | int | None = None
154
+ #: Явная метка происхождения для ``_skill_meta.json`` (#1145). None ⇒ метка
155
+ #: выводится из типа ``source`` (как раньше). Задаётся, когда вывод по типу
156
+ #: соврал бы: снапшот хаба ставится ЧЕРЕЗ ``LocalPathSource`` (распакованная
157
+ #: временная папка), но по происхождению это "hub".
158
+ source_label: Source | None = None
143
159
 
144
160
 
145
161
  @dataclass
@@ -13,14 +13,22 @@
13
13
  """
14
14
  from __future__ import annotations
15
15
 
16
+ import logging
16
17
  import os
17
18
  import stat
18
- import subprocess
19
19
  import sys
20
20
  from pathlib import Path
21
21
 
22
+ from librarykit import proc
23
+
22
24
  IS_WINDOWS = sys.platform == "win32"
23
25
 
26
+ _LOG = logging.getLogger(__name__)
27
+
28
+ #: ``mklink /J`` — одна операция ФС; секунды. Запуск через ``librarykit.proc.run``
29
+ #: (#1144): ``cmd.exe`` из демона без консоли иначе мигает окном пользователю.
30
+ _MKLINK_TIMEOUT = 30.0
31
+
24
32
  # Тип результата create_link: "junction" | "symlink".
25
33
  LinkKind = str
26
34
 
@@ -67,12 +75,21 @@ def _create_junction(link: Path, target: Path) -> None:
67
75
  return
68
76
  except (ImportError, AttributeError, OSError):
69
77
  pass
70
- subprocess.run(
78
+ result = proc.run(
71
79
  ["cmd", "/c", "mklink", "/J", str(link), str(target)],
72
- check=True,
73
- capture_output=True,
74
- text=True,
80
+ timeout=_MKLINK_TIMEOUT,
81
+ log=_LOG,
75
82
  )
83
+ if not result.ok:
84
+ # Наружу отдаём именно OSError: контракт create_link — «ОС не дала
85
+ # создать ссылку» ловится вызывающим как OSError (copy-fallback).
86
+ # Прежний ``check=True`` бросал CalledProcessError, которая мимо этого
87
+ # except проскакивала наверх необработанной.
88
+ detail = (result.stderr or result.stdout or "").strip()
89
+ raise OSError(
90
+ f"mklink /J не создал junction (код {result.returncode})"
91
+ + (f": {detail}" if detail else "")
92
+ )
76
93
 
77
94
 
78
95
  def create_link(link_path: Path, target_dir: Path) -> LinkKind:
@@ -99,8 +116,18 @@ def create_link(link_path: Path, target_dir: Path) -> LinkKind:
99
116
 
100
117
  link_path.parent.mkdir(parents=True, exist_ok=True)
101
118
  if IS_WINDOWS:
102
- _create_junction(link_path, target_dir)
103
- return "junction"
119
+ try:
120
+ _create_junction(link_path, target_dir)
121
+ return "junction"
122
+ except OSError as exc:
123
+ # #1147: раньше сдавались сразу в copy — а копия стора в зону агента
124
+ # и есть источник «лишних папок» там. Symlink на Windows работает
125
+ # при включённом Developer Mode / привилегии SeCreateSymbolicLink,
126
+ # то есть покрывает часть случаев, где junction не вышел. Не помог и
127
+ # он → OSError наверх, вызывающий делает copy-fallback осознанно.
128
+ _LOG.debug("junction не создан (%s) — пробую symlink: %s", exc, link_path)
129
+ os.symlink(target_dir, link_path, target_is_directory=True)
130
+ return "symlink"
104
131
  os.symlink(target_dir, link_path, target_is_directory=True)
105
132
  return "symlink"
106
133