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.
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/CHANGELOG.md +27 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/PKG-INFO +2 -2
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/pyproject.toml +1 -1
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/__init__.py +3 -1
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/config.py +14 -2
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/errors.py +34 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/mcp_register.py +211 -7
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/path_store.py +114 -6
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/tooling.py +51 -3
- s_skillkit-0.3.7/tests/conftest.py +40 -0
- s_skillkit-0.3.7/tests/test_artifact_ownership_2272.py +411 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_tooling.py +3 -3
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/uv.lock +1 -1
- s_skillkit-0.3.6/tests/conftest.py +0 -21
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/.gitignore +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/.gitlab-ci.yml +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/AGENTS.md +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/LICENSE +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/README.md +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/binary_installer.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/bootstrap.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/collections.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/deps_installer.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/filter.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/hook_register.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/__init__.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/differ.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/fetchers.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/fsops.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/meta.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/models.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/installer/ownership.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/linker.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/manifest.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/paths.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/pkg_manager.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/proc.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/project.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/__init__.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/antigravity.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/base.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/claude_code.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/codex.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/detect.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/targets/opencode.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/telemetry.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/skillkit/uv_bootstrap.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_agent_skill_layouts_2026_07_21.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_binary_installer.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_bootstrap.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_collections.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_config.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_deps_binary.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_deps_installer.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_destructive_ownership_gate_2026_07_21.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_errors.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_filter.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_git_install.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_hooks.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_installer.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_legacy_paths_no_orphans_2026_07_21.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_manifest.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_mcp_register.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_no_raw_subprocess.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_packaging_lock.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_path_store.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_paths.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_pkg_manager.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_pkgsrc_not_in_agent_zone.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_proc.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_project.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_remove_ownership_guard_2026_07_21.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_stub_guard.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_targets.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_telemetry_adapter.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_transactional_install.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_traversal_guard.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_typed_fields.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_uv_bootstrap.py +0 -0
- {s_skillkit-0.3.6 → s_skillkit-0.3.7}/tests/test_w268_subdir_skill.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: s-skillkit
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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``,
|
|
16
|
-
|
|
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],
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
``
|
|
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
|
|
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
|