s-skillkit 0.3.6__tar.gz → 0.3.7__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 (81) hide show
  1. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/CHANGELOG.md +27 -0
  2. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/PKG-INFO +2 -2
  3. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/pyproject.toml +1 -1
  4. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/__init__.py +3 -1
  5. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/config.py +14 -2
  6. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/errors.py +34 -0
  7. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/mcp_register.py +211 -7
  8. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/path_store.py +114 -6
  9. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/tooling.py +51 -3
  10. s_skillkit-0.3.7/tests/conftest.py +40 -0
  11. s_skillkit-0.3.7/tests/test_artifact_ownership_2272.py +411 -0
  12. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_tooling.py +3 -3
  13. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/uv.lock +1 -1
  14. s_skillkit-0.3.6/tests/conftest.py +0 -21
  15. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/.gitignore +0 -0
  16. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/.gitlab-ci.yml +0 -0
  17. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/AGENTS.md +0 -0
  18. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/LICENSE +0 -0
  19. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/README.md +0 -0
  20. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/binary_installer.py +0 -0
  21. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/bootstrap.py +0 -0
  22. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/collections.py +0 -0
  23. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/deps_installer.py +0 -0
  24. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/filter.py +0 -0
  25. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/hook_register.py +0 -0
  26. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/__init__.py +0 -0
  27. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/differ.py +0 -0
  28. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/fetchers.py +0 -0
  29. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/fsops.py +0 -0
  30. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/meta.py +0 -0
  31. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/models.py +0 -0
  32. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/ownership.py +0 -0
  33. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/linker.py +0 -0
  34. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/manifest.py +0 -0
  35. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/paths.py +0 -0
  36. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/pkg_manager.py +0 -0
  37. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/proc.py +0 -0
  38. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/project.py +0 -0
  39. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/__init__.py +0 -0
  40. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/antigravity.py +0 -0
  41. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/base.py +0 -0
  42. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/claude_code.py +0 -0
  43. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/codex.py +0 -0
  44. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/detect.py +0 -0
  45. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/opencode.py +0 -0
  46. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/telemetry.py +0 -0
  47. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/uv_bootstrap.py +0 -0
  48. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_agent_skill_layouts_2026_07_21.py +0 -0
  49. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_binary_installer.py +0 -0
  50. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_bootstrap.py +0 -0
  51. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_collections.py +0 -0
  52. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_config.py +0 -0
  53. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_deps_binary.py +0 -0
  54. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_deps_installer.py +0 -0
  55. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_destructive_ownership_gate_2026_07_21.py +0 -0
  56. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_errors.py +0 -0
  57. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_filter.py +0 -0
  58. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_git_install.py +0 -0
  59. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_hooks.py +0 -0
  60. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_installer.py +0 -0
  61. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_legacy_paths_no_orphans_2026_07_21.py +0 -0
  62. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_manifest.py +0 -0
  63. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_mcp_register.py +0 -0
  64. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_no_raw_subprocess.py +0 -0
  65. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_packaging_lock.py +0 -0
  66. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_path_store.py +0 -0
  67. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_paths.py +0 -0
  68. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_pkg_manager.py +0 -0
  69. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_pkgsrc_not_in_agent_zone.py +0 -0
  70. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_proc.py +0 -0
  71. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_project.py +0 -0
  72. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_remove_ownership_guard_2026_07_21.py +0 -0
  73. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_stub_guard.py +0 -0
  74. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_targets.py +0 -0
  75. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_telemetry_adapter.py +0 -0
  76. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_transactional_install.py +0 -0
  77. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_traversal_guard.py +0 -0
  78. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_typed_fields.py +0 -0
  79. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_uv_bootstrap.py +0 -0
  80. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_w268_subdir_skill.py +0 -0
  81. {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_w272_empty_manifest_guard.py +0 -0
@@ -4,6 +4,33 @@
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 — гейт принадлежности у CLI-команд и MCP-серверов (#2272)
10
+
11
+ - **Навык больше не забирает чужую команду.** `path_store.add_cli` снимал ЛЮБОЙ
12
+ существующий shim и переписывал sidecar, не сверяя владельца: второй навык с
13
+ тем же `command_name` молча забирал команду первого. Теперь владелец читается
14
+ из sidecar'а (`cli_owner` / `cli_ownership`), и попытка занять чужое —
15
+ `ArtifactConflict`, а не перезапись.
16
+ - **Снятие навыка не уносит чужое.** `remove_cli(command_name, skill_slug=…)`
17
+ удаляет запись только при совпадении владельца. Без `skill_slug`
18
+ (административное снятие «по имени») удаляется лишь то, у чего есть НАШ
19
+ sidecar: файл, которого кит не клал, не трогается никогда.
20
+ - **MCP-сервер, настроенный пользователем РУКАМИ, защищён.** `register_mcp` /
21
+ `unregister_mcp` принимают `skill_slug` и сверяются с реестром владения
22
+ `<config_dir>/mcp_owners.json` (маркер держим СНАРУЖИ чужого конфига агента —
23
+ дописывать служебные ключи в запись сервера значит рисковать её валидностью).
24
+ Чужая или безмаркерная запись → `status=conflict`, конфиг не меняется.
25
+ - **Отчёты.** `apply_tooling_artifacts` отдаёт `status=conflict` (с `owner`) по
26
+ затронутому артефакту, `revert_tooling_artifacts` — список `conflicts`.
27
+ Установка/снятие самого навыка при этом не ломаются.
28
+ - **Совместимость.** Sidecar старого формата (без `skill_slug`) считается нашим
29
+ и получает владельца при первой перезаписи; MCP-запись, сделанная до
30
+ появления реестра, «усыновляется», если её содержимое совпадает с тем, что
31
+ положил бы этот же навык сейчас. Повторная установка ТОГО ЖЕ навыка (в т.ч. с
32
+ новым entrypoint/config) проходит как раньше.
33
+
7
34
  ## [0.3.6]
8
35
 
9
36
  ### Changed — своя политика запуска подпроцессов, s-librarykit убран (#1644)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: s-skillkit
3
- Version: 0.3.6
3
+ Version: 0.3.7
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.3.6"
3
+ version = "0.3.7"
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" }
@@ -67,6 +67,7 @@ from skillkit.bootstrap import bootstrap_skill, run_self_check
67
67
  from skillkit.collections import LocalCollectionError
68
68
  from skillkit.config import SkillkitConfig, configure, reset_to_defaults
69
69
  from skillkit.errors import (
70
+ ArtifactConflict,
70
71
  CloneError,
71
72
  ForeignPathError,
72
73
  MaterializeError,
@@ -119,7 +120,7 @@ from skillkit.targets import (
119
120
  )
120
121
  from skillkit.uv_bootstrap import ensure_uv
121
122
 
122
- __version__ = "0.3.5"
123
+ __version__ = "0.3.7"
123
124
 
124
125
  __all__ = [
125
126
  "__version__",
@@ -152,6 +153,7 @@ __all__ = [
152
153
  "Source",
153
154
  # доменные исключения (иерархия SkillkitError)
154
155
  "SkillkitError",
156
+ "ArtifactConflict",
155
157
  "CloneError",
156
158
  "ScopeConflict",
157
159
  "ForeignPathError",
@@ -93,7 +93,14 @@ def configure(config: SkillkitConfig) -> None:
93
93
  дефолтного ``SkillkitConfig()`` эквивалентна ``reset_to_defaults()``.
94
94
  """
95
95
  # Импорт внутри функции — избегаем циклов на этапе загрузки пакета.
96
- from skillkit import collections, installer, path_store, paths, project
96
+ from skillkit import (
97
+ collections,
98
+ installer,
99
+ mcp_register,
100
+ path_store,
101
+ paths,
102
+ project,
103
+ )
97
104
 
98
105
  # 1. platformdirs-имя приложения (Paths.default → config/data/bin каталоги).
99
106
  paths.set_app_name(config.app_name)
@@ -122,7 +129,12 @@ def configure(config: SkillkitConfig) -> None:
122
129
  config.config_dir_provider or collections._default_config_dir
123
130
  )
124
131
 
125
- # 5. Установщик: брендо-строки (clone-префикс + владелец управляемых папок).
132
+ # 5. Реестр владения MCP-записями (#2272) — лежит в config-каталоге бренда.
133
+ mcp_register.set_state_dir_provider(
134
+ config.config_dir_provider or mcp_register._default_state_dir
135
+ )
136
+
137
+ # 6. Установщик: брендо-строки (clone-префикс + владелец управляемых папок).
126
138
  installer.set_clone_prefix(config.resolved_clone_prefix())
127
139
  installer.set_managed_by(config.resolved_managed_by())
128
140
 
@@ -58,3 +58,37 @@ class ScopeUnsupported(SkillkitError): # noqa: N818 — доменное имя
58
58
  него был только глобальный каталог. Затем раскладку навыков agy владелец свёл в
59
59
  общий ``.agents/skills`` с поддержкой project-scope.
60
60
  """
61
+
62
+
63
+ class ArtifactConflict(SkillkitError): # noqa: N818 — доменное имя без -Error суффикса
64
+ """Исполняемый артефакт уже принадлежит ДРУГОМУ владельцу.
65
+
66
+ Класс дефекта (2026-08-25, #2272): CLI-команда навыка (``[[cli]]``) и
67
+ MCP-сервер (``[[mcp]]``) пишутся в ОБЩЕЕ пространство имён — bin-каталог и
68
+ конфиг агента. Без сверки владельца второй навык молча забирал команду
69
+ первого, а его снятие уносило чужую запись; MCP-сервер, заведённый
70
+ пользователем РУКАМИ, перетирался и удалялся вместе с навыком. Это потеря
71
+ пользовательских данных, а не «конфликт имён».
72
+
73
+ Гейт принадлежности здесь — свойство самой операции (как ``ForeignPathError``
74
+ у путей): владелец пишется рядом с артефактом при установке, и запись
75
+ занимается/снимается ТОЛЬКО при совпадении владельца. Чужое (в т.ч. запись
76
+ БЕЗ нашего маркера — то есть заведомо не наша) не трогается никогда.
77
+
78
+ ``artifact`` — ``"cli"`` | ``"mcp"``; ``name`` — command_name/server_name;
79
+ ``owner`` — slug текущего владельца или ``None``, если владелец неизвестен
80
+ (запись сделана вне кита — вручную пользователем или другим инструментом).
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ message: str,
86
+ *,
87
+ artifact: str,
88
+ name: str,
89
+ owner: str | None = None,
90
+ ) -> None:
91
+ super().__init__(message)
92
+ self.artifact = artifact
93
+ self.name = name
94
+ self.owner = owner
@@ -12,8 +12,11 @@
12
12
  - агент с НЕИЗВЕСТНЫМ форматом (нет дескриптора) → не падаем: возвращаем
13
13
  ``status=manual`` + готовая инструкция (graceful degradation).
14
14
 
15
- Идемпотентно (ключ = ``server_name``, перезапись, не дубль). ``unregister_mcp``
16
- снимает запись при disable/remove навыка. ``list_mcp`` — что зарегистрировано
15
+ Идемпотентно (ключ = ``server_name``, перезапись СВОЕЙ записи, не дубль).
16
+ Перезаписать/снять запись ДРУГОГО навыка или заведённую пользователем вручную
17
+ нельзя — владение фиксируется в реестре кита, чужое отдаёт ``status=conflict``
18
+ (#2272). ``unregister_mcp`` снимает запись при disable/remove навыка.
19
+ ``list_mcp`` — что зарегистрировано
17
20
  (для doctor/status). Запись атомарная (tmp + ``os.replace``). Кроссплатформа:
18
21
  пути берутся от ``Path.home()`` / project, TOML через ``tomli_w``.
19
22
 
@@ -25,11 +28,14 @@ from __future__ import annotations
25
28
  import contextlib
26
29
  import json
27
30
  import os
31
+ from collections.abc import Callable
28
32
  from pathlib import Path
29
33
  from typing import Any
30
34
 
31
35
  import tomli_w
32
36
 
37
+ from skillkit.paths import Paths
38
+
33
39
  try: # py311+ stdlib; не должен отсутствовать, но мягко
34
40
  import tomllib
35
41
  except ModuleNotFoundError: # pragma: no cover
@@ -78,6 +84,143 @@ def _server_name(server: dict[str, Any]) -> str:
78
84
  return str(server.get("server_name") or "").strip()
79
85
 
80
86
 
87
+ # --------------------------------------------------------------------------
88
+ # Реестр владения MCP-записями (#2272)
89
+ # --------------------------------------------------------------------------
90
+ # ЗАЧЕМ ОТДЕЛЬНЫЙ РЕЕСТР, А НЕ МАРКЕР ВНУТРИ ЗАПИСИ.
91
+ #
92
+ # Конфиг агента — ЧУЖОЙ файл со своей схемой (``~/.claude.json``,
93
+ # ``~/.codex/config.toml``). Дописать в запись сервера наш служебный ключ значит
94
+ # рискнуть тем, что агент её отвергнет как невалидную. Поэтому владелец хранится
95
+ # СНАРУЖИ — в собственном файле кита ``<config_dir>/mcp_owners.json``, ключом
96
+ # служит пара «путь конфига + имя сервера».
97
+ #
98
+ # Отсюда же и правило совместимости: у записей, сделанных ДО появления реестра
99
+ # (и у всего, что пользователь завёл руками), записи владельца нет. Такая запись
100
+ # считается НЕ нашей — кроме случая, когда её содержимое ПОБАЙТОВО совпадает с
101
+ # тем, что положил бы этот же навык сейчас: тогда владение «усыновляется» (это
102
+ # заведомо наша прежняя установка, и перезапись/снятие ничего не меняют).
103
+
104
+ #: Классы владения (те же строки, что у ``path_store`` — контракт отчётов един).
105
+ OWNERSHIP_FREE = "free"
106
+ OWNERSHIP_OURS = "ours"
107
+ OWNERSHIP_FOREIGN = "foreign"
108
+
109
+
110
+ _OWNERS_FILE = "mcp_owners.json"
111
+ _OWNERS_VERSION = 1
112
+
113
+
114
+ def _default_state_dir() -> Path:
115
+ """Standalone-дефолт каталога состояния кита (нативный config-каталог)."""
116
+ return Paths.default().config_dir
117
+
118
+
119
+ _state_dir_provider: Callable[[], Path] = _default_state_dir
120
+
121
+
122
+ def set_state_dir_provider(provider: Callable[[], Path]) -> None:
123
+ """Задать каталог, где кит держит реестр владения MCP (инъекция потребителя)."""
124
+ global _state_dir_provider
125
+ _state_dir_provider = provider
126
+
127
+
128
+ def _owners_file() -> Path:
129
+ return _state_dir_provider() / _OWNERS_FILE
130
+
131
+
132
+ def _owner_key(config_path: Path, server_name: str) -> str:
133
+ norm = os.path.normcase(os.path.normpath(str(config_path)))
134
+ return f"{norm}::{server_name}"
135
+
136
+
137
+ def _read_owners() -> dict[str, str]:
138
+ """Реестр владения. Никогда не бросает: битый/отсутствующий → пустой."""
139
+ try:
140
+ data = json.loads(_owners_file().read_text(encoding="utf-8"))
141
+ except (OSError, ValueError, UnicodeDecodeError):
142
+ return {}
143
+ entries = data.get("entries") if isinstance(data, dict) else None
144
+ if not isinstance(entries, dict):
145
+ return {}
146
+ return {str(k): str(v or "") for k, v in entries.items()}
147
+
148
+
149
+ def _write_owners(entries: dict[str, str]) -> None:
150
+ """Атомарно сохранить реестр. Best-effort: сбой записи не ломает установку."""
151
+ with contextlib.suppress(OSError, ValueError):
152
+ path = _owners_file()
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ payload = json.dumps(
155
+ {"version": _OWNERS_VERSION, "entries": entries},
156
+ indent=2,
157
+ ensure_ascii=False,
158
+ ) + '\n'
159
+ tmp = path.with_name(path.name + ".tmp")
160
+ tmp.write_text(payload, encoding="utf-8")
161
+ os.replace(tmp, path)
162
+
163
+
164
+ def mcp_owner(config_path: Path, server_name: str) -> str | None:
165
+ """Владелец записи сервера по реестру, либо None (владение не зафиксировано)."""
166
+ return _read_owners().get(_owner_key(config_path, server_name))
167
+
168
+
169
+ def _record_owner(config_path: Path, server_name: str, skill_slug: str) -> None:
170
+ entries = _read_owners()
171
+ entries[_owner_key(config_path, server_name)] = str(skill_slug or "")
172
+ _write_owners(entries)
173
+
174
+
175
+ def _drop_owner(config_path: Path, server_name: str) -> None:
176
+ entries = _read_owners()
177
+ if entries.pop(_owner_key(config_path, server_name), None) is not None:
178
+ _write_owners(entries)
179
+
180
+
181
+ def _same_entry(current: Any, expected: dict[str, Any] | None) -> bool:
182
+ """Совпадает ли запись в конфиге с тем, что положил бы навык сейчас."""
183
+ return expected is not None and current == expected
184
+
185
+
186
+ def mcp_ownership(
187
+ config_path: Path,
188
+ server_name: str,
189
+ *,
190
+ skill_slug: str | None,
191
+ current: Any = None,
192
+ expected: dict[str, Any] | None = None,
193
+ present: bool = True,
194
+ ) -> str:
195
+ """Кому принадлежит запись сервера: ``free`` | ``ours`` | ``foreign``.
196
+
197
+ ``present=False`` (записи в конфиге нет) → ``free``. Иначе решает реестр:
198
+ владелец совпал → ``ours``, не совпал → ``foreign``. Владельца в реестре
199
+ нет → запись «дореестровая» или ручная: ``ours`` лишь при полном совпадении
200
+ содержимого с ``expected`` (усыновление), иначе ``foreign``.
201
+ """
202
+ if not present:
203
+ return OWNERSHIP_FREE
204
+ owner = mcp_owner(config_path, server_name)
205
+ want = str(skill_slug or "").strip()
206
+ if owner is not None:
207
+ return OWNERSHIP_OURS if owner == want else OWNERSHIP_FOREIGN
208
+ return OWNERSHIP_OURS if _same_entry(current, expected) else OWNERSHIP_FOREIGN
209
+
210
+
211
+ def _conflict_reason(server_name: str, owner: str | None) -> str:
212
+ if owner:
213
+ return (
214
+ f"MCP-сервер «{server_name}» уже принадлежит навыку «{owner}» — "
215
+ "чужую запись не перезаписываем и не удаляем"
216
+ )
217
+ return (
218
+ f"MCP-сервер «{server_name}» в конфиге агента заведён не китом "
219
+ "(вручную или другим инструментом) — чужую запись не перезаписываем "
220
+ "и не удаляем"
221
+ )
222
+
223
+
81
224
  # --------------------------------------------------------------------------
82
225
  # Дескриптор формата конфига — ИНЪЕКЦИЯ от самого агента (не хардкод-мапа)
83
226
  # --------------------------------------------------------------------------
@@ -152,12 +295,19 @@ def _manual_instruction(server: dict[str, Any], agent_name: str) -> str:
152
295
  # Public: register / unregister / list
153
296
  # --------------------------------------------------------------------------
154
297
  def register_mcp(
155
- server: dict[str, Any], *, agent_target: Any, project: Path | None = None
298
+ server: dict[str, Any],
299
+ *,
300
+ agent_target: Any,
301
+ project: Path | None = None,
302
+ skill_slug: str | None = None,
156
303
  ) -> dict[str, Any]:
157
304
  """Зарегистрировать MCP-сервер навыка в конфиге агента. Не бросает.
158
305
 
159
306
  Возврат ``status`` ∈:
160
- - ``registered`` — записан (added/перезаписан, idempotent);
307
+ - ``registered`` — записан (added/перезаписан своим навыком, idempotent);
308
+ - ``conflict`` — имя занято ДРУГИМ навыком или ручной записью пользователя
309
+ (#2272): чужое НЕ трогаем, в ``reason`` — объяснение, в ``owner`` — slug
310
+ владельца, если он известен;
161
311
  - ``manual`` — формат агента неизвестен (есть ``instruction``);
162
312
  - ``error`` — пустой ``server_name`` или сбой записи (есть ``reason``).
163
313
  """
@@ -181,21 +331,54 @@ def register_mcp(
181
331
  servers = data.get(key)
182
332
  if not isinstance(servers, dict):
183
333
  servers = {}
184
- servers[name] = _build_entry(server)
334
+ entry = _build_entry(server)
335
+ # ГЕЙТ ПРИНАДЛЕЖНОСТИ (#2272): перезаписываем только СВОЮ запись.
336
+ if (
337
+ mcp_ownership(
338
+ path,
339
+ name,
340
+ skill_slug=skill_slug,
341
+ current=servers.get(name),
342
+ expected=entry,
343
+ present=name in servers,
344
+ )
345
+ == OWNERSHIP_FOREIGN
346
+ ):
347
+ owner = mcp_owner(path, name)
348
+ return {
349
+ "status": "conflict",
350
+ "server_name": name,
351
+ "owner": owner,
352
+ "config_path": str(path),
353
+ "reason": _conflict_reason(name, owner),
354
+ }
355
+ servers[name] = entry
185
356
  data[key] = servers
186
357
  _write_config(path, fmt, data)
187
358
  except (OSError, ValueError) as exc:
188
359
  return {"status": "error", "server_name": name, "reason": str(exc)}
360
+ _record_owner(path, name, str(skill_slug or ""))
189
361
  return {"status": "registered", "server_name": name, "config_path": str(path)}
190
362
 
191
363
 
192
364
  def unregister_mcp(
193
- server_name: str, *, agent_target: Any, project: Path | None = None
365
+ server_name: str,
366
+ *,
367
+ agent_target: Any,
368
+ project: Path | None = None,
369
+ skill_slug: str | None = None,
370
+ expected: dict[str, Any] | None = None,
194
371
  ) -> dict[str, Any]:
195
372
  """Снять MCP-сервер из конфига агента (disable/remove навыка). Не бросает.
196
373
 
374
+ ГЕЙТ ПРИНАДЛЕЖНОСТИ (#2272): удаляем ТОЛЬКО свою запись. ``skill_slug`` —
375
+ навык, который снимается; ``expected`` — его объявление сервера
376
+ (``[[mcp]]``-словарь), нужное для «усыновления» дореестровых записей: если
377
+ в конфиге лежит ровно то, что положил бы этот навык, запись считается нашей
378
+ и снимается, иначе (ручная правка пользователя) остаётся на месте.
379
+
197
380
  Возврат ``status`` ∈ ``unregistered`` | ``absent`` (нечего снимать) |
198
- ``manual`` (формат неизвестен) | ``error``.
381
+ ``conflict`` (запись чужая — не тронули) | ``manual`` | ``error``.
199
382
  """
200
383
  name = str(server_name or "").strip()
201
384
  if not name:
@@ -213,12 +396,33 @@ def unregister_mcp(
213
396
  data = _read_config(path, fmt)
214
397
  servers = data.get(key)
215
398
  if not isinstance(servers, dict) or name not in servers:
399
+ _drop_owner(path, name)
216
400
  return {"status": "absent", "server_name": name}
401
+ if (
402
+ mcp_ownership(
403
+ path,
404
+ name,
405
+ skill_slug=skill_slug,
406
+ current=servers.get(name),
407
+ expected=_build_entry(expected) if expected else None,
408
+ present=True,
409
+ )
410
+ == OWNERSHIP_FOREIGN
411
+ ):
412
+ owner = mcp_owner(path, name)
413
+ return {
414
+ "status": "conflict",
415
+ "server_name": name,
416
+ "owner": owner,
417
+ "config_path": str(path),
418
+ "reason": _conflict_reason(name, owner),
419
+ }
217
420
  del servers[name]
218
421
  data[key] = servers
219
422
  _write_config(path, fmt, data)
220
423
  except (OSError, ValueError) as exc:
221
424
  return {"status": "error", "server_name": name, "reason": str(exc)}
425
+ _drop_owner(path, name)
222
426
  return {"status": "unregistered", "server_name": name, "config_path": str(path)}
223
427
 
224
428
 
@@ -13,11 +13,14 @@ skillkit НЕ читает env/config: bin-каталог приходит от
13
13
  Модель (симметрично linker/installer):
14
14
  - ``bin_dir()`` — каталог шимов (через провайдер; CLI инъектирует свой,
15
15
  учитывающий env ``SKILLS_HUB_BIN_DIR`` / config);
16
- - ``add_cli(command_name, entrypoint, *, skill_slug)`` — кладёт shim (см. ниже
16
+ - ``add_cli(command_name, entrypoint, *, skill_slug)`` — кладёт shim, если
17
+ команда свободна или уже принадлежит этому навыку; чужую не занимает
18
+ (``ArtifactConflict``, #2272). См. ниже
17
19
  «Учёт вызова»). Рядом пишется sidecar ``<command>.json`` с привязкой к навыку
18
20
  и **прямым** entrypoint (для ``list_clis``, снятия при disable/remove и —
19
21
  главное — для запуска раннером без рекурсии);
20
- - ``remove_cli(command_name)`` — удаляет shim + sidecar (disable/remove навыка);
22
+ - ``remove_cli(command_name, *, skill_slug=None)`` — удаляет shim + sidecar
23
+ (disable/remove навыка) ТОЛЬКО если команда принадлежит этому навыку (#2272);
21
24
  - ``ensure_on_path()`` — idempotent: bin в PATH? нет → добавить без admin
22
25
  (Windows: User PATH через ``HKCU\\Environment``; POSIX: ``export PATH`` строкой
23
26
  в rc-файл). Возвращает ``{"status": added|already|manual-needed, ...}``;
@@ -71,6 +74,7 @@ import sys
71
74
  from collections.abc import Callable
72
75
  from pathlib import Path
73
76
 
77
+ from skillkit.errors import ArtifactConflict
74
78
  from skillkit.paths import Paths
75
79
 
76
80
  IS_WINDOWS = sys.platform == "win32"
@@ -171,6 +175,81 @@ def _shim_path(command_name: str) -> Path:
171
175
  return bin_dir() / (f"{command_name}.cmd" if IS_WINDOWS else command_name)
172
176
 
173
177
 
178
+ # --------------------------------------------------------------------------
179
+ # Владение командой (#2272) — sidecar рядом с шимом и есть маркер владельца
180
+ # --------------------------------------------------------------------------
181
+ #: Классы владения командой: свободна | наша | чужая.
182
+ OWNERSHIP_FREE = "free"
183
+ OWNERSHIP_OURS = "ours"
184
+ OWNERSHIP_FOREIGN = "foreign"
185
+
186
+
187
+ def read_cli_sidecar(command_name: str) -> dict[str, str] | None:
188
+ """Sidecar команды (``<bin>/<command>.json``) или None, если его нет/битый."""
189
+ sc = _sidecar_path(command_name)
190
+ try:
191
+ data = json.loads(sc.read_text(encoding="utf-8"))
192
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
193
+ return None
194
+ return data if isinstance(data, dict) else None
195
+
196
+
197
+ def cli_owner(command_name: str) -> str | None:
198
+ """Slug навыка-владельца команды, либо None (нет sidecar / нет поля).
199
+
200
+ None здесь — НЕ «свободно»: это «владелец неизвестен». Отличать свободную
201
+ команду от занятой-без-маркера нужно по наличию файлов (см.
202
+ :func:`cli_ownership`).
203
+ """
204
+ data = read_cli_sidecar(command_name)
205
+ if data is None:
206
+ return None
207
+ owner = str(data.get("skill_slug") or "").strip()
208
+ return owner or None
209
+
210
+
211
+ def _shim_exists(command_name: str) -> bool:
212
+ shim = _shim_path(command_name)
213
+ try:
214
+ return shim.is_symlink() or shim.exists()
215
+ except OSError:
216
+ return False
217
+
218
+
219
+ def cli_ownership(command_name: str, *, skill_slug: str | None) -> str:
220
+ """Кому принадлежит команда с точки зрения навыка ``skill_slug``.
221
+
222
+ - ``free`` — команды нет (ни шима, ни sidecar) → занимать можно;
223
+ - ``ours`` — sidecar говорит, что владелец именно этот навык;
224
+ - ``foreign`` — владелец ДРУГОЙ навык, ЛИБО файл лежит без нашего sidecar
225
+ (значит, его положил не кит — вручную пользователь или иной инструмент).
226
+
227
+ ОБРАТНАЯ СОВМЕСТИМОСТЬ: sidecar СТАРОГО формата (без ``skill_slug``) —
228
+ наш по факту: его писал кит, просто версией, где владельца ещё не
229
+ фиксировали. Такой sidecar считается ``ours`` и при первой же перезаписи
230
+ получает владельца. Ломать уже установленные навыки строгостью нельзя.
231
+ """
232
+ data = read_cli_sidecar(command_name)
233
+ if data is None:
234
+ # Sidecar'а нет. Файл рядом есть → он не наш (кит всегда пишет пару).
235
+ return OWNERSHIP_FOREIGN if _shim_exists(command_name) else OWNERSHIP_FREE
236
+ owner = str(data.get("skill_slug") or "").strip()
237
+ if not owner:
238
+ return OWNERSHIP_OURS # старый sidecar без владельца — наш
239
+ return OWNERSHIP_OURS if owner == str(skill_slug or "").strip() else OWNERSHIP_FOREIGN
240
+
241
+
242
+ def _conflict(command_name: str, owner: str | None) -> ArtifactConflict:
243
+ who = f"навыку «{owner}»" if owner else "постороннему файлу (не кит его положил)"
244
+ return ArtifactConflict(
245
+ f"команда «{command_name}» уже принадлежит {who} — "
246
+ "занять её нельзя, чужое не перезаписываем",
247
+ artifact="cli",
248
+ name=command_name,
249
+ owner=owner,
250
+ )
251
+
252
+
174
253
  # --------------------------------------------------------------------------
175
254
  # add_cli
176
255
  # --------------------------------------------------------------------------
@@ -257,8 +336,13 @@ def add_cli(command_name: str, entrypoint: str, *, skill_slug: str) -> Path:
257
336
  """Положить исполняемый shim ``command_name`` в bin-каталог стора.
258
337
 
259
338
  ``entrypoint`` — что выполнять: команда (``python -m pkg.cli``) ИЛИ путь к
260
- исполняемому console-script. Идемпотентно: повторный вызов перезаписывает
261
- shim и sidecar. Возвращает путь к созданному шиму.
339
+ исполняемому console-script. Идемпотентно: повторный вызов ТОГО ЖЕ навыка
340
+ перезаписывает shim и sidecar. Возвращает путь к созданному шиму.
341
+
342
+ Занять команду, принадлежащую ДРУГОМУ навыку (или лежащую в bin-каталоге
343
+ без нашего sidecar — то есть заведённую вручную), нельзя:
344
+ ``ArtifactConflict`` (#2272). Вызывающий (``tooling``) превращает это в
345
+ ``status=conflict`` в отчёте установки, а не в потерю чужой команды.
262
346
 
263
347
  Сам shim зовёт РАННЕР (учёт вызова, #1221), а прямой вызов entrypoint
264
348
  остаётся его fallback-веткой. В sidecar пишется именно ПРЯМОЙ entrypoint —
@@ -270,6 +354,12 @@ def add_cli(command_name: str, entrypoint: str, *, skill_slug: str) -> Path:
270
354
  if not entrypoint or not entrypoint.strip():
271
355
  raise ValueError("entrypoint не может быть пустым")
272
356
 
357
+ # ГЕЙТ ПРИНАДЛЕЖНОСТИ (#2272). Своё перезаписываем (идемпотентность), чужое —
358
+ # никогда: раньше add_cli снимал ЛЮБОЙ существующий shim и переписывал
359
+ # sidecar, и второй навык с тем же command_name молча забирал команду.
360
+ if cli_ownership(command_name, skill_slug=skill_slug) == OWNERSHIP_FOREIGN:
361
+ raise _conflict(command_name, cli_owner(command_name))
362
+
273
363
  d = bin_dir()
274
364
  d.mkdir(parents=True, exist_ok=True)
275
365
  shim = _shim_path(command_name)
@@ -315,8 +405,26 @@ def _unlink_shim(shim: Path) -> None:
315
405
  # --------------------------------------------------------------------------
316
406
  # remove_cli
317
407
  # --------------------------------------------------------------------------
318
- def remove_cli(command_name: str) -> bool:
319
- """Снять shim (+ sidecar) команды. True если что-то удалили, иначе False."""
408
+ def remove_cli(command_name: str, *, skill_slug: str | None = None) -> bool:
409
+ """Снять shim (+ sidecar) команды. True если что-то удалили, иначе False.
410
+
411
+ ГЕЙТ ПРИНАДЛЕЖНОСТИ (#2272). Удаляем ТОЛЬКО своё:
412
+
413
+ - ``skill_slug`` задан → снимаем, лишь если sidecar называет владельцем
414
+ именно его (или sidecar старого формата, без владельца). Иначе False и
415
+ чужая команда остаётся на месте — снятие навыка B не имеет права уносить
416
+ команду навыка A;
417
+ - ``skill_slug`` не задан (административное снятие «по имени») → снимаем
418
+ только то, у чего ЕСТЬ наш sidecar. Файл без sidecar кит не клал —
419
+ значит, он не наш, и трогать его нельзя ни при каких условиях.
420
+ """
421
+ if skill_slug is None:
422
+ # Административный режим: sidecar (наш) есть → снимаем; нет — не наше.
423
+ if read_cli_sidecar(command_name) is None and _shim_exists(command_name):
424
+ return False
425
+ elif cli_ownership(command_name, skill_slug=skill_slug) == OWNERSHIP_FOREIGN:
426
+ return False
427
+
320
428
  shim = _shim_path(command_name)
321
429
  sidecar = _sidecar_path(command_name)
322
430
  removed = False