s-agentskit 0.1.0__py3-none-any.whl

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.
agentskit/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """agentskit — AI-агенты как сущности: автономный онбординг инструкций.
2
+
3
+ Переиспользуемый слой поверх ``librarykit``. Делает любой навык/инструмент
4
+ **автономным онбордером**: сам прописывает свои инструкции (managed-блок) в
5
+ конфиг-файлы любого AI-агента (``.claude``/``.cursor``/``.codex``/``.gemini``/
6
+ ``.github``/…) идемпотентно и аддитивно — чужой текст не затирается.
7
+
8
+ Механизм generic: реестр агент→файл как ДАННЫЕ (``data/agents.json``), детект по
9
+ ФС, инъекция managed-блока между параметризуемыми маркерами, резолв global/project.
10
+ Контент (тело инструкции + namespace) приносит потребитель — это НЕ код кита.
11
+
12
+ Роль «агент-как-адаптер» (драйв агента из комбайна) — зона ``adapterkit``, не
13
+ этого кита (agentskit отвечает только за онбординг-данные и инъекцию).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ from .detect import DetectedAgent, detect_agents
20
+ from .inject import (
21
+ has_managed_block,
22
+ inject_managed_block,
23
+ managed_block,
24
+ strip_managed_block,
25
+ )
26
+ from .markers import begin_marker, end_marker
27
+ from .onboard import ApplyResult, onboard, uninstall
28
+ from .spec import (
29
+ AgentSpec,
30
+ SkillLayout,
31
+ agent_registry,
32
+ get_agent_spec,
33
+ list_agents,
34
+ register_agent_spec,
35
+ resolve_agent_keys,
36
+ )
37
+ from .targets import Target, global_path_for, resolve_targets
38
+
39
+ __all__ = [
40
+ "__version__",
41
+ # инъекция / маркеры
42
+ "begin_marker",
43
+ "end_marker",
44
+ "managed_block",
45
+ "has_managed_block",
46
+ "inject_managed_block",
47
+ "strip_managed_block",
48
+ # реестр агентов (данные)
49
+ "AgentSpec",
50
+ "SkillLayout",
51
+ "agent_registry",
52
+ "get_agent_spec",
53
+ "list_agents",
54
+ "register_agent_spec",
55
+ "resolve_agent_keys",
56
+ # детект / резолв целей / онбординг
57
+ "DetectedAgent",
58
+ "detect_agents",
59
+ "Target",
60
+ "global_path_for",
61
+ "resolve_targets",
62
+ "ApplyResult",
63
+ "onboard",
64
+ "uninstall",
65
+ ]
agentskit/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI кита: ``agentskit detect / agents / onboard / uninstall`` (на clikit).
2
+
3
+ Кит — прежде всего библиотека (потребитель зовёт ``agentskit.onboard(...)``), но
4
+ даёт тонкий автономный CLI для самостоятельного использования и для раздачи как
5
+ tooling-навык. json-by-default (clikit); ``--text`` — человекочитаемо.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from clikit import CliError, build_root_app, command, emit_data
14
+
15
+ from . import __version__
16
+ from .detect import detect_agents
17
+ from .onboard import onboard as _onboard
18
+ from .onboard import uninstall as _uninstall
19
+ from .spec import list_agents, resolve_agent_keys
20
+
21
+ app = build_root_app(
22
+ "agentskit",
23
+ version=__version__,
24
+ help="AI-агенты как сущности: онбординг инструкций в агентские файлы (--json по умолчанию).",
25
+ )
26
+
27
+
28
+ def _keys(agents: str) -> list[str] | None:
29
+ if not agents.strip():
30
+ return None
31
+ try:
32
+ return resolve_agent_keys(agents)
33
+ except ValueError as exc:
34
+ raise CliError("bad_agents", str(exc)) from exc
35
+
36
+
37
+ @app.command("agents")
38
+ @command
39
+ def agents_cmd() -> None:
40
+ """Весь канонический реестр агентов (ключ/label/config_dir/файлы)."""
41
+ data = [
42
+ {
43
+ "key": s.key,
44
+ "label": s.label,
45
+ "config_dir": s.config_dir,
46
+ "repo_file": s.repo_file,
47
+ "global": list(s.global_subpath) if s.global_subpath else None,
48
+ "aliases": list(s.aliases),
49
+ }
50
+ for s in list_agents()
51
+ ]
52
+ emit_data(data, text_renderer=lambda rows: [
53
+ print(f"{r['key']:<12} {r['config_dir']:<10} "
54
+ f"{r['repo_file'] or '—':<28} {r['label']}") for r in rows
55
+ ])
56
+
57
+
58
+ @app.command("detect")
59
+ @command
60
+ def detect_cmd(
61
+ glob: bool = typer.Option(False, "--global", help="Считать и $HOME-агентов."),
62
+ include_absent: bool = typer.Option(False, "--all", help="Показать и ненайденных."),
63
+ ) -> None:
64
+ """Какие AI-агенты присутствуют (config-папка в cwd и/или $HOME)."""
65
+ found = detect_agents(include_absent=include_absent)
66
+ data = [{"key": d.key, "label": d.label, "present": d.present, "scope": d.scope}
67
+ for d in found]
68
+ emit_data(data, text_renderer=lambda rows: (
69
+ [print(f"{r['key']:<12} {r['scope']:<8} {r['label']}") for r in rows]
70
+ or [print("агентов не найдено — трактуй как 'all'")] if not rows else None
71
+ ))
72
+
73
+
74
+ @app.command("onboard")
75
+ @command
76
+ def onboard_cmd(
77
+ namespace: str = typer.Option(..., "--namespace", "-n", help="namespace плагина"),
78
+ body_file: str | None = typer.Option(None, "--body-file", help="Файл с телом (markdown)."),
79
+ body: str | None = typer.Option(None, "--body", help="Тело инструкции строкой ('-' — stdin)."),
80
+ scope: str = typer.Option("all", "--scope", help="global | repo | all."),
81
+ agents: str = typer.Option("", "--agents", help="CSV ключей/алиасов или 'all'."),
82
+ create: bool = typer.Option(False, "--create", help="Создать файлы выбранных агентов."),
83
+ dry_run: bool = typer.Option(False, "--dry-run", help="Показать, что изменится, без записи."),
84
+ ) -> None:
85
+ """Прописать managed-блок плагина в выбранных AI-агентов (reference-режим)."""
86
+ if scope not in ("global", "repo", "all"):
87
+ raise CliError("bad_scope", f"--scope: global|repo|all, не '{scope}'.")
88
+ text = _read_body(body, body_file)
89
+ results = _onboard(
90
+ namespace=namespace, body=text, scope=scope, agents=_keys(agents),
91
+ create=create, dry_run=dry_run,
92
+ )
93
+ emit_data(
94
+ [{"path": r.path, "action": r.action, "agent": r.agent_key, "reason": r.reason}
95
+ for r in results],
96
+ text_renderer=lambda rows: [
97
+ print(f" {r['action']:<12} {r['agent']:<10} {r['path']}"
98
+ + (f" ({r['reason']})" if r['reason'] else "")) for r in rows
99
+ ] or [print("целей нет")],
100
+ )
101
+
102
+
103
+ @app.command("uninstall")
104
+ @command
105
+ def uninstall_cmd(
106
+ namespace: str = typer.Option(..., "--namespace", "-n", help="namespace плагина."),
107
+ scope: str = typer.Option("all", "--scope", help="global | repo | all."),
108
+ agents: str = typer.Option("", "--agents", help="CSV ключей/алиасов или 'all'."),
109
+ dry_run: bool = typer.Option(False, "--dry-run", help="Показать без записи."),
110
+ ) -> None:
111
+ """Снять managed-блок плагина (чужие блоки не трогаются)."""
112
+ results = _uninstall(
113
+ namespace=namespace, scope=scope, agents=_keys(agents), dry_run=dry_run,
114
+ )
115
+ emit_data(
116
+ [{"path": r.path, "action": r.action, "agent": r.agent_key, "reason": r.reason}
117
+ for r in results],
118
+ text_renderer=lambda rows: [
119
+ print(f" {r['action']:<12} {r['agent']:<10} {r['path']}") for r in rows
120
+ ] or [print("целей нет")],
121
+ )
122
+
123
+
124
+ def _read_body(body: str | None, body_file: str | None) -> str:
125
+ if body_file:
126
+ return Path(body_file).read_text(encoding="utf-8")
127
+ if body == "-":
128
+ return sys.stdin.read()
129
+ if body is not None:
130
+ return body
131
+ raise CliError("no_body", "Нужно --body '<текст>' / --body - (stdin) / --body-file <md>.")
132
+
133
+
134
+ def main() -> None:
135
+ app()
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
@@ -0,0 +1,149 @@
1
+ {
2
+ "_schema": "agentskit.agents/1 — реестр AI-агентов как ДАННЫЕ (не код). config_dir → детект; repo_file/global_subpath → reference-инъекция; skill_layout → full-материализация (фаза 2). Источник канона папок: ui-ux-pro-max AI_FOLDERS + global-память Atlas.",
3
+ "agents": {
4
+ "claude": {
5
+ "label": "Claude Code",
6
+ "config_dir": ".claude",
7
+ "repo_file": "CLAUDE.md",
8
+ "global_subpath": [".claude", "CLAUDE.md"],
9
+ "aliases": ["claude-code", "claudecode"],
10
+ "skill_layout": {"subdir": ["skills", "{skill}"], "filename": "SKILL.md", "install_type": "full", "frontmatter": true}
11
+ },
12
+ "codex": {
13
+ "label": "Codex / AGENTS.md",
14
+ "config_dir": ".codex",
15
+ "repo_file": "AGENTS.md",
16
+ "global_subpath": [".codex", "AGENTS.md"],
17
+ "aliases": ["agents", "agents.md"],
18
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
19
+ },
20
+ "gemini": {
21
+ "label": "Gemini CLI",
22
+ "config_dir": ".gemini",
23
+ "repo_file": "GEMINI.md",
24
+ "global_subpath": [".gemini", "GEMINI.md"],
25
+ "aliases": ["gemini-cli"],
26
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
27
+ },
28
+ "cursor": {
29
+ "label": "Cursor",
30
+ "config_dir": ".cursor",
31
+ "repo_file": ".cursorrules",
32
+ "global_subpath": null,
33
+ "aliases": [],
34
+ "skill_layout": {"subdir": [".shared"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
35
+ },
36
+ "copilot": {
37
+ "label": "GitHub Copilot",
38
+ "config_dir": ".github",
39
+ "repo_file": ".github/copilot-instructions.md",
40
+ "global_subpath": null,
41
+ "aliases": ["copilot-instructions"],
42
+ "skill_layout": {"subdir": ["prompts"], "filename": "{skill}.prompt.md", "install_type": "full", "frontmatter": true}
43
+ },
44
+ "windsurf": {
45
+ "label": "Windsurf",
46
+ "config_dir": ".windsurf",
47
+ "repo_file": ".windsurfrules",
48
+ "global_subpath": null,
49
+ "aliases": [],
50
+ "skill_layout": {"subdir": ["rules"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
51
+ },
52
+ "antigravity": {
53
+ "label": "Antigravity",
54
+ "config_dir": ".agents",
55
+ "repo_file": "AGENTS.md",
56
+ "global_subpath": null,
57
+ "aliases": ["agent"],
58
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
59
+ },
60
+ "kiro": {
61
+ "label": "Kiro",
62
+ "config_dir": ".kiro",
63
+ "repo_file": null,
64
+ "global_subpath": null,
65
+ "aliases": [],
66
+ "skill_layout": {"subdir": ["steering"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
67
+ },
68
+ "roocode": {
69
+ "label": "Roo Code",
70
+ "config_dir": ".roo",
71
+ "repo_file": null,
72
+ "global_subpath": null,
73
+ "aliases": ["roo"],
74
+ "skill_layout": {"subdir": ["rules"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
75
+ },
76
+ "qoder": {
77
+ "label": "Qoder",
78
+ "config_dir": ".qoder",
79
+ "repo_file": null,
80
+ "global_subpath": null,
81
+ "aliases": [],
82
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
83
+ },
84
+ "trae": {
85
+ "label": "Trae",
86
+ "config_dir": ".trae",
87
+ "repo_file": null,
88
+ "global_subpath": null,
89
+ "aliases": [],
90
+ "skill_layout": {"subdir": ["rules"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
91
+ },
92
+ "opencode": {
93
+ "label": "OpenCode",
94
+ "config_dir": ".opencode",
95
+ "repo_file": null,
96
+ "global_subpath": null,
97
+ "aliases": [],
98
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
99
+ },
100
+ "continue": {
101
+ "label": "Continue",
102
+ "config_dir": ".continue",
103
+ "repo_file": null,
104
+ "global_subpath": null,
105
+ "aliases": [],
106
+ "skill_layout": {"subdir": ["rules"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
107
+ },
108
+ "codebuddy": {
109
+ "label": "CodeBuddy",
110
+ "config_dir": ".codebuddy",
111
+ "repo_file": null,
112
+ "global_subpath": null,
113
+ "aliases": [],
114
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
115
+ },
116
+ "droid": {
117
+ "label": "Factory Droid",
118
+ "config_dir": ".factory",
119
+ "repo_file": null,
120
+ "global_subpath": null,
121
+ "aliases": ["factory"],
122
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
123
+ },
124
+ "kilocode": {
125
+ "label": "Kilo Code",
126
+ "config_dir": ".kilocode",
127
+ "repo_file": null,
128
+ "global_subpath": null,
129
+ "aliases": ["kilo"],
130
+ "skill_layout": {"subdir": ["rules"], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
131
+ },
132
+ "warp": {
133
+ "label": "Warp",
134
+ "config_dir": ".warp",
135
+ "repo_file": null,
136
+ "global_subpath": null,
137
+ "aliases": [],
138
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
139
+ },
140
+ "augment": {
141
+ "label": "Augment",
142
+ "config_dir": ".augment",
143
+ "repo_file": null,
144
+ "global_subpath": null,
145
+ "aliases": [],
146
+ "skill_layout": {"subdir": [], "filename": "{skill}.md", "install_type": "full", "frontmatter": false}
147
+ }
148
+ }
149
+ }
agentskit/detect.py ADDED
@@ -0,0 +1,55 @@
1
+ """Детект AI-агентов по файловой системе (перенос ui-ux-pro-max ``detectAIType``).
2
+
3
+ Структурный детект: для каждого ``AgentSpec`` проверяем наличие его ``config_dir``
4
+ в проекте (cwd) и/или в $HOME. Пусто → caller трактует как 'all'.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .spec import AgentSpec, list_agents
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DetectedAgent:
16
+ """Найденный агент + где (project/global/both)."""
17
+
18
+ key: str
19
+ label: str
20
+ present: bool # config_dir существует (cwd и/или $HOME)
21
+ scope: str # "project" | "global" | "both" | "none"
22
+
23
+
24
+ def _scope_of(spec: AgentSpec, cwd: Path, home: Path) -> str:
25
+ in_proj = (cwd / spec.config_dir).exists()
26
+ in_home = (home / spec.config_dir).exists()
27
+ if in_proj and in_home:
28
+ return "both"
29
+ if in_proj:
30
+ return "project"
31
+ if in_home:
32
+ return "global"
33
+ return "none"
34
+
35
+
36
+ def detect_agents(
37
+ cwd: Path | None = None,
38
+ *,
39
+ home: Path | None = None,
40
+ include_absent: bool = False,
41
+ ) -> list[DetectedAgent]:
42
+ """Просканировать ФС по реестру. ``include_absent=False`` — только найденные.
43
+
44
+ Возвращает список в порядке реестра. Пустой список найденных → caller
45
+ трактует как 'all' (как ``suggested='all'`` в ui-ux-pro-max).
46
+ """
47
+ base = Path(cwd) if cwd is not None else Path.cwd()
48
+ h = Path(home) if home is not None else Path.home()
49
+ out: list[DetectedAgent] = []
50
+ for spec in list_agents():
51
+ scope = _scope_of(spec, base, h)
52
+ present = scope != "none"
53
+ if present or include_absent:
54
+ out.append(DetectedAgent(spec.key, spec.label, present, scope))
55
+ return out
agentskit/inject.py ADDED
@@ -0,0 +1,84 @@
1
+ """Идемпотентная инъекция managed-блока в текст агентского файла.
2
+
3
+ Чистый текст-ин → текст-аут (без I/O) — тестируемо; запись/детект файлов — в
4
+ ``onboard``/``targets``. Алгоритм перенесён из Atlas ``agent_discipline`` 1:1
5
+ (выстраданные тонкости сохранены — см. ниже) и параметризован ``namespace``.
6
+
7
+ Выстраданные тонкости (НЕ переписывать):
8
+ - ``pattern.sub(lambda _m: managed, ...)`` — lambda во избежание интерпретации
9
+ ``\\``-групп в теле блока как backreference (RUF-safe).
10
+ - логика разделителя при дописывании (`""` / `"\\n"` / `"\\n\\n"`).
11
+ - ``re.DOTALL`` — блок может быть многострочным.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from .markers import begin_marker, end_marker
18
+
19
+
20
+ def managed_block(body: str, *, namespace: str) -> str:
21
+ """Полный managed-блок с маркерами ``namespace`` (то, что вставляется)."""
22
+ return f"{begin_marker(namespace)}\n{body}\n{end_marker(namespace)}"
23
+
24
+
25
+ def has_managed_block(text: str, *, namespace: str) -> bool:
26
+ """Есть ли уже наш managed-блок (оба маркера ``namespace``) в тексте."""
27
+ return begin_marker(namespace) in text and end_marker(namespace) in text
28
+
29
+
30
+ def inject_managed_block(
31
+ text: str,
32
+ body: str | None = None,
33
+ *,
34
+ namespace: str,
35
+ block: str | None = None,
36
+ begin: str | None = None,
37
+ end: str | None = None,
38
+ ) -> str:
39
+ """Вставить/обновить managed-блок ``namespace`` в ``text``. Чужое не затирает.
40
+
41
+ - маркеры уже есть → заменяем СОДЕРЖИМОЕ между ними (обновление);
42
+ - маркеров нет → дописываем блок в КОНЕЦ (с корректным разделителем).
43
+
44
+ ``body`` — тело блока (без маркеров); ``block`` — целиком готовый блок (тогда
45
+ ``body`` игнорируется). Идемпотентно: повторный вызов с тем же содержимым →
46
+ no-op по смыслу (текст совпадает).
47
+ """
48
+ b = begin if begin is not None else begin_marker(namespace)
49
+ e = end if end is not None else end_marker(namespace)
50
+ if block is not None:
51
+ managed = block
52
+ else:
53
+ if body is None:
54
+ raise ValueError("inject_managed_block: нужен body или block.")
55
+ managed = managed_block(body, namespace=namespace)
56
+
57
+ pattern = re.compile(re.escape(b) + r".*?" + re.escape(e), re.DOTALL)
58
+ if pattern.search(text):
59
+ # lambda во избежание интерпретации \-групп в managed как backreference.
60
+ return pattern.sub(lambda _m: managed, text, count=1)
61
+ if not text.strip():
62
+ return managed + "\n"
63
+ sep = "\n" if text.endswith("\n") else "\n\n"
64
+ if text.endswith("\n\n"):
65
+ sep = ""
66
+ return text + sep + managed + "\n"
67
+
68
+
69
+ def strip_managed_block(text: str, *, namespace: str) -> str:
70
+ """Удалить managed-блок ``namespace`` из текста (для uninstall).
71
+
72
+ Чужие блоки других namespace не трогаются. Если блока нет — текст возвращается
73
+ как есть. Подчищает лишний разделитель, оставшийся после вырезания.
74
+ """
75
+ b = begin_marker(namespace)
76
+ e = end_marker(namespace)
77
+ pattern = re.compile(
78
+ r"\n*" + re.escape(b) + r".*?" + re.escape(e) + r"\n*", re.DOTALL
79
+ )
80
+ if not pattern.search(text):
81
+ return text
82
+ out = pattern.sub("\n", text, count=1)
83
+ # не оставляем ведущий перевод строки, если блок был в начале файла.
84
+ return out.lstrip("\n") if not text[: text.find(b)].strip() else out
agentskit/markers.py ADDED
@@ -0,0 +1,23 @@
1
+ """Managed-маркеры онбординг-блока, параметризованные namespace плагина.
2
+
3
+ Один движок инъекции обслуживает N плагинов в ОДНОМ файле без коллизий: каждый
4
+ плагин владеет своей парой маркеров, выведенной из его namespace. Например
5
+ ``atlas init`` пишет ``<!-- ATLAS:BEGIN managed -->``, гипотетический ``uipro
6
+ init`` — ``<!-- UIPRO:BEGIN managed -->``; оба блока мирно сосуществуют в одном
7
+ ``CLAUDE.md``.
8
+
9
+ Обратная совместимость: ``begin_marker("atlas")`` == исторический Atlas-маркер
10
+ ``<!-- ATLAS:BEGIN managed -->`` (был хардкодом ``BEGIN_MARKER``), поэтому уже
11
+ прописанные ATLAS-блоки находятся и обновляются как раньше.
12
+ """
13
+ from __future__ import annotations
14
+
15
+
16
+ def begin_marker(namespace: str) -> str:
17
+ """Открывающий маркер managed-блока для ``namespace`` (UPPER-регистр)."""
18
+ return f"<!-- {namespace.upper()}:BEGIN managed -->"
19
+
20
+
21
+ def end_marker(namespace: str) -> str:
22
+ """Закрывающий маркер managed-блока для ``namespace`` (UPPER-регистр)."""
23
+ return f"<!-- {namespace.upper()}:END -->"
agentskit/onboard.py ADDED
@@ -0,0 +1,104 @@
1
+ """Оркестратор автономного онбординга — главная точка роли A.
2
+
3
+ Для каждой цели (``resolve_targets``) применяет managed-блок плагина:
4
+ - **reference** (MVP): идемпотентная инъекция блока в memory-файл агента
5
+ (перенос Atlas ``_apply_one``: детект exists, granular-verbs, dry-run);
6
+ - **full** (фаза 2): материализация контента в per-agent layout — пока заглушка.
7
+
8
+ ``uninstall`` снимает managed-блок namespace (чужие блоки не трогает).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from .inject import has_managed_block, inject_managed_block, strip_managed_block
16
+ from .targets import Target, resolve_targets
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ApplyResult:
21
+ """Результат применения к одной цели."""
22
+
23
+ path: str
24
+ action: str # created|appended|updated|unchanged|skipped|removed|would-<verb>
25
+ agent_key: str
26
+ reason: str = ""
27
+
28
+
29
+ def _apply_one(
30
+ t: Target, body: str, *, namespace: str, dry_run: bool
31
+ ) -> ApplyResult:
32
+ if t.mode != "reference":
33
+ return ApplyResult(
34
+ str(t.path), "skipped", t.agent_key,
35
+ reason="full-режим пока не реализован (фаза 2)",
36
+ )
37
+ exists = t.path.exists()
38
+ if not exists and not t.create_if_missing:
39
+ return ApplyResult(str(t.path), "skipped", t.agent_key, reason="файла нет")
40
+ existing = t.path.read_text(encoding="utf-8") if exists else ""
41
+ updated = inject_managed_block(existing, body, namespace=namespace)
42
+ if updated == existing:
43
+ return ApplyResult(str(t.path), "unchanged", t.agent_key)
44
+ had = has_managed_block(existing, namespace=namespace)
45
+ verb = ("updated" if had else "appended") if exists else "created"
46
+ if dry_run:
47
+ return ApplyResult(str(t.path), f"would-{verb}", t.agent_key)
48
+ t.path.parent.mkdir(parents=True, exist_ok=True)
49
+ t.path.write_text(updated, encoding="utf-8")
50
+ return ApplyResult(str(t.path), verb, t.agent_key)
51
+
52
+
53
+ def onboard(
54
+ *,
55
+ namespace: str,
56
+ body: str,
57
+ scope: str = "all",
58
+ agents: list[str] | None = None,
59
+ mode: str = "reference",
60
+ create: bool = False,
61
+ dry_run: bool = False,
62
+ force: bool = False, # full-режим (фаза 2); reference идемпотентен и так
63
+ cwd: Path | None = None,
64
+ ) -> list[ApplyResult]:
65
+ """Прописать managed-блок ``namespace`` (тело ``body``) выбранным агентам.
66
+
67
+ ``agents=None`` → легаси (существующие агентские файлы); список ключей →
68
+ точечный выбор (``resolve_agent_keys`` на стороне caller). Идемпотентно:
69
+ повторный вызов с тем же ``body`` → ``unchanged``.
70
+ """
71
+ targets = resolve_targets(
72
+ scope=scope, agents=agents, mode=mode, create=create, cwd=cwd,
73
+ )
74
+ return [_apply_one(t, body, namespace=namespace, dry_run=dry_run) for t in targets]
75
+
76
+
77
+ def uninstall(
78
+ *,
79
+ namespace: str,
80
+ scope: str = "all",
81
+ agents: list[str] | None = None,
82
+ dry_run: bool = False,
83
+ cwd: Path | None = None,
84
+ ) -> list[ApplyResult]:
85
+ """Снять managed-блок ``namespace`` (reference). Чужие блоки не трогаются."""
86
+ targets = resolve_targets(
87
+ scope=scope, agents=agents, mode="reference", create=False, cwd=cwd,
88
+ )
89
+ results: list[ApplyResult] = []
90
+ for t in targets:
91
+ if not t.path.exists():
92
+ results.append(ApplyResult(str(t.path), "skipped", t.agent_key, reason="файла нет"))
93
+ continue
94
+ existing = t.path.read_text(encoding="utf-8")
95
+ if not has_managed_block(existing, namespace=namespace):
96
+ results.append(ApplyResult(str(t.path), "skipped", t.agent_key, reason="нет блока"))
97
+ continue
98
+ stripped = strip_managed_block(existing, namespace=namespace)
99
+ if dry_run:
100
+ results.append(ApplyResult(str(t.path), "would-removed", t.agent_key))
101
+ continue
102
+ t.path.write_text(stripped, encoding="utf-8")
103
+ results.append(ApplyResult(str(t.path), "removed", t.agent_key))
104
+ return results
agentskit/spec.py ADDED
@@ -0,0 +1,151 @@
1
+ """Реестр AI-агентов: ``AgentSpec`` как ДАННЫЕ (загружаются из ``data/agents.json``).
2
+
3
+ Принцип (из эталона ui-ux-pro-max): layout у каждого агента СВОЙ и описан
4
+ декларативно — добавить агента = добавить запись в ``agents.json`` (или
5
+ ``register_agent_spec`` / entry-points ``agentskit.agent_specs``), без правки кода.
6
+ ``agents.json`` — единый language-neutral источник правды (его же прочитает будущий
7
+ npm/npx-CLI).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from functools import lru_cache
14
+ from importlib import resources
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class SkillLayout:
19
+ """Per-agent layout для full-материализации (фаза 2) — ДАННЫЕ, не код."""
20
+
21
+ subdir: tuple[str, ...] # claude: ("skills","{skill}"); copilot: ("prompts",)
22
+ filename: str # "SKILL.md" | "{skill}.prompt.md"
23
+ install_type: str = "full" # "full" | "reference"
24
+ frontmatter: bool = True
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AgentSpec:
29
+ """Один AI-агент как ДАННЫЕ: куда и как писать инструкции."""
30
+
31
+ key: str # "claude" | "cursor" | ...
32
+ label: str # "Claude Code"
33
+ config_dir: str # корневая config-папка: ".claude"
34
+ repo_file: str | None = None # reference: файл инструкций в корне репо
35
+ global_subpath: tuple[str, ...] | None = None # reference: путь под $HOME
36
+ skill_layout: SkillLayout | None = None # full: layout (фаза 2)
37
+ aliases: tuple[str, ...] = () # дружественные алиасы для --agents
38
+
39
+
40
+ def _spec_from_dict(key: str, d: dict) -> AgentSpec:
41
+ sl = d.get("skill_layout")
42
+ layout = (
43
+ SkillLayout(
44
+ subdir=tuple(sl.get("subdir") or ()),
45
+ filename=sl["filename"],
46
+ install_type=sl.get("install_type", "full"),
47
+ frontmatter=bool(sl.get("frontmatter", True)),
48
+ )
49
+ if sl
50
+ else None
51
+ )
52
+ gs = d.get("global_subpath")
53
+ return AgentSpec(
54
+ key=key,
55
+ label=d.get("label", key),
56
+ config_dir=d["config_dir"],
57
+ repo_file=d.get("repo_file"),
58
+ global_subpath=tuple(gs) if gs else None,
59
+ skill_layout=layout,
60
+ aliases=tuple(d.get("aliases") or ()),
61
+ )
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def _builtin_registry() -> dict[str, AgentSpec]:
66
+ """Канон из ``data/agents.json`` + расширения из entry-points ``agentskit.agent_specs``."""
67
+ raw = resources.files("agentskit").joinpath("data", "agents.json").read_text("utf-8")
68
+ data = json.loads(raw)
69
+ reg: dict[str, AgentSpec] = {
70
+ key: _spec_from_dict(key, d) for key, d in data["agents"].items()
71
+ }
72
+ # внешние пакеты добавляют агентов через entry-points (не правя кит).
73
+ try:
74
+ from importlib.metadata import entry_points
75
+
76
+ for ep in entry_points(group="agentskit.agent_specs"):
77
+ try:
78
+ obj = ep.load()
79
+ spec = obj() if callable(obj) else obj
80
+ if isinstance(spec, AgentSpec):
81
+ reg[spec.key] = spec
82
+ except Exception: # pragma: no cover — битый плагин не валит реестр
83
+ continue
84
+ except Exception: # pragma: no cover
85
+ pass
86
+ return reg
87
+
88
+
89
+ #: In-tree ручные расширения (перебивают встроенный канон). Заполняется
90
+ #: ``register_agent_spec``; собирается в финальный реестр в ``agent_registry()``.
91
+ _OVERRIDES: dict[str, AgentSpec] = {}
92
+
93
+
94
+ def register_agent_spec(spec: AgentSpec) -> None:
95
+ """Зарегистрировать/переопределить агента в процессе (перебивает встроенный)."""
96
+ _OVERRIDES[spec.key] = spec
97
+ agent_registry.cache_clear()
98
+
99
+
100
+ @lru_cache(maxsize=1)
101
+ def agent_registry() -> dict[str, AgentSpec]:
102
+ """Итоговый реестр: встроенный канон + entry-points + ручные override."""
103
+ reg = dict(_builtin_registry())
104
+ reg.update(_OVERRIDES)
105
+ return reg
106
+
107
+
108
+ def _alias_index() -> dict[str, str]:
109
+ idx: dict[str, str] = {}
110
+ for key, spec in agent_registry().items():
111
+ idx[key] = key
112
+ for a in spec.aliases:
113
+ idx[a.lower()] = key
114
+ return idx
115
+
116
+
117
+ def list_agents() -> list[AgentSpec]:
118
+ """Весь канонический реестр (для меню/CLI ``agents``)."""
119
+ return list(agent_registry().values())
120
+
121
+
122
+ def get_agent_spec(key: str) -> AgentSpec | None:
123
+ """AgentSpec по ключу или алиасу (или ``None``)."""
124
+ return agent_registry().get(_alias_index().get(key.strip().lower(), key))
125
+
126
+
127
+ def resolve_agent_keys(raw: str) -> list[str]:
128
+ """Разобрать строку ``--agents`` в список валидных ключей реестра.
129
+
130
+ Принимает ``"all"`` или CSV ключей/алиасов (``"claude,gemini"`` /
131
+ ``"agents, cursor"``). Дубликаты схлопываются с сохранением порядка.
132
+ Неизвестный ключ → ``ValueError`` со списком валидных.
133
+ """
134
+ raw = (raw or "").strip()
135
+ idx = _alias_index()
136
+ if raw.lower() == "all":
137
+ return list(agent_registry().keys())
138
+ out: list[str] = []
139
+ for tok in raw.split(","):
140
+ name = tok.strip().lower()
141
+ if not name:
142
+ continue
143
+ key = idx.get(name)
144
+ if key is None:
145
+ valid = ", ".join([*agent_registry().keys(), "all"])
146
+ raise ValueError(f"Неизвестный агент '{tok.strip()}'. Доступно: {valid}.")
147
+ if key not in out:
148
+ out.append(key)
149
+ if not out:
150
+ raise ValueError("Пустой список --agents.")
151
+ return out
agentskit/targets.py ADDED
@@ -0,0 +1,94 @@
1
+ """Резолв целевых файлов онбординга под scope (global/project) и выбор агентов.
2
+
3
+ Перенос Atlas ``commands/init._resolve_targets`` + ``_global_path_for``,
4
+ обобщённый на реестр ``agentskit.spec``. ``_global_claude_md`` — единая
5
+ override-точка (мокается в тестах), как в Atlas.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .spec import AgentSpec, agent_registry, list_agents
13
+
14
+
15
+ def _global_claude_md() -> Path:
16
+ """Глобальный CLAUDE.md (override-точка для тестов)."""
17
+ return Path.home() / ".claude" / "CLAUDE.md"
18
+
19
+
20
+ def global_path_for(spec: AgentSpec) -> Path | None:
21
+ """Глобальный memory-файл агента под $HOME (claude — через override-точку)."""
22
+ if spec.key == "claude":
23
+ return _global_claude_md()
24
+ if spec.global_subpath is None:
25
+ return None
26
+ return Path.home().joinpath(*spec.global_subpath)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Target:
31
+ """Цель онбординга: путь + создавать-ли-если-нет + агент + режим."""
32
+
33
+ path: Path
34
+ create_if_missing: bool
35
+ agent_key: str
36
+ mode: str = "reference"
37
+
38
+
39
+ def _reference_repo_files() -> list[tuple[str, str]]:
40
+ """[(agent_key, repo_file)] для агентов с reference-файлом (порядок реестра)."""
41
+ return [(s.key, s.repo_file) for s in list_agents() if s.repo_file]
42
+
43
+
44
+ def resolve_targets(
45
+ *,
46
+ scope: str = "all",
47
+ agents: list[str] | None = None,
48
+ mode: str = "reference",
49
+ create: bool = False,
50
+ cwd: Path | None = None,
51
+ ) -> list[Target]:
52
+ """Список целей под scope.
53
+
54
+ - ``agents is None`` — ЛЕГАСИ (как Atlas): global → ~/.claude/CLAUDE.md; repo →
55
+ существующие reference-файлы агентов в cwd (или AGENTS.md при --create).
56
+ - ``agents`` задан — точечный выбор: для каждого выбранного агента его
57
+ global-файл (создаётся всегда — выбор явный) и/или repo-файл (--create).
58
+ Reference-режим: агенты без ``repo_file`` пропускаются в repo-scope.
59
+ """
60
+ base = Path(cwd) if cwd is not None else Path.cwd()
61
+ targets: list[Target] = []
62
+
63
+ if agents is not None:
64
+ for key in agents:
65
+ spec = agent_registry().get(key)
66
+ if spec is None:
67
+ continue
68
+ if scope in ("global", "all"):
69
+ gp = global_path_for(spec)
70
+ if gp is not None:
71
+ targets.append(Target(gp, True, key, mode))
72
+ if scope in ("repo", "all") and spec.repo_file:
73
+ targets.append(Target(base / spec.repo_file, create, key, mode))
74
+ return targets
75
+
76
+ # Легаси (без --agents) — прежнее поведение Atlas.
77
+ if scope in ("global", "all"):
78
+ targets.append(Target(_global_claude_md(), True, "claude", mode))
79
+ if scope in ("repo", "all"):
80
+ seen: set[Path] = set()
81
+ found: list[Target] = []
82
+ for key, rf in _reference_repo_files():
83
+ p = base / rf
84
+ if p in seen:
85
+ continue
86
+ if p.exists():
87
+ seen.add(p)
88
+ found.append(Target(p, False, key, mode))
89
+ if found:
90
+ targets.extend(found)
91
+ elif create:
92
+ # дефолт Atlas: создать AGENTS.md (codex-конвенция).
93
+ targets.append(Target(base / "AGENTS.md", True, "codex", mode))
94
+ return targets
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: s-agentskit
3
+ Version: 0.1.0
4
+ Summary: AI-агенты как сущности: автономный онбординг инструкций (идемпотентные managed-блоки в .claude/.cursor/.codex/.gemini/.github/…) поверх единого реестра агентов-данных. Тонкий слой на clikit.
5
+ Author: Dmitry
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: s-clikit
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.3; extra == 'dev'
12
+ Requires-Dist: ruff>=0.8; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # agentskit
16
+
17
+ **AI-агенты как сущности: автономный онбординг инструкций.** Переиспользуемый
18
+ слой (dist `s-agentskit` / import `agentskit`) поверх `clikit`. Делает любой
19
+ навык/инструмент **автономным онбордером**: он сам прописывает свои инструкции
20
+ (managed-блок) в конфиг-файлы любого AI-агента — `.claude` / `.cursor` / `.codex`
21
+ / `.gemini` / `.github` / … — идемпотентно и аддитивно (чужой текст не затирается).
22
+
23
+ ```
24
+ agentskit (реестр агентов = данные) → onboard(namespace, body) → managed-блок в N агентов
25
+ ```
26
+
27
+ ## Зачем
28
+
29
+ Знание «где живёт каждый AI-агент» (его config-папка и memory-файл) не должно
30
+ дублироваться в каждом инструменте. agentskit держит **единый реестр агентов как
31
+ ДАННЫЕ** (`data/agents.json`, 18 агентов) — потребитель приносит только контент
32
+ (тело инструкции + свой namespace), а механизм (детект, инъекция, резолв
33
+ global/project) общий.
34
+
35
+ > Роль «агент-как-адаптер» (драйв агента из комбайна gateway/bublictr) — зона
36
+ > **adapterkit**, не этого кита. agentskit отвечает только за онбординг.
37
+
38
+ ## Установка
39
+
40
+ ```bash
41
+ pip install s-agentskit # import agentskit (dist-имя ≠ import-имя)
42
+ # dev: uv sync --extra dev
43
+ ```
44
+
45
+ ## Библиотека (основной способ)
46
+
47
+ ```python
48
+ from agentskit import onboard, resolve_agent_keys
49
+
50
+ onboard(
51
+ namespace="atlas", # маркеры <!-- ATLAS:BEGIN/END -->
52
+ body="## Работай в Atlas\n- atlas task …",
53
+ scope="all", # global | repo | all
54
+ agents=resolve_agent_keys("claude,gemini"), # или None — все существующие файлы
55
+ create=True,
56
+ )
57
+ ```
58
+
59
+ Идемпотентно: повторный вызов с тем же `body` → `unchanged`. Несколько плагинов
60
+ (разные `namespace`) сосуществуют в одном `CLAUDE.md` без коллизий.
61
+
62
+ ## CLI
63
+
64
+ ```bash
65
+ agentskit agents # весь реестр агентов
66
+ agentskit detect # какие агенты есть в проекте/$HOME
67
+ agentskit onboard -n mytool --body-file INSTRUCTIONS.md --agents claude,cursor --create
68
+ agentskit uninstall -n mytool --agents all
69
+ ```
70
+
71
+ ## Публичный API
72
+
73
+ `onboard` / `uninstall` · `detect_agents` / `list_agents` · `resolve_agent_keys`
74
+ · `resolve_targets` · `inject_managed_block` / `has_managed_block` /
75
+ `strip_managed_block` / `managed_block` · `begin_marker` / `end_marker` ·
76
+ `AgentSpec` / `SkillLayout` / `register_agent_spec` / `agent_registry`.
77
+
78
+ ## Расширение реестра
79
+
80
+ Добавить агента без правки кита: запись в `data/agents.json`, либо
81
+ `register_agent_spec(AgentSpec(...))`, либо entry-points группа
82
+ `agentskit.agent_specs` во внешнем пакете.
83
+
84
+ ## Режимы
85
+
86
+ - **reference** (MVP): managed-блок-указатель в memory-файл агента (`CLAUDE.md` и т.п.).
87
+ - **full** (фаза 2): материализация контента целиком в per-agent layout (как
88
+ `uipro`-инсталлеры).
89
+
90
+ ## Лицензия
91
+
92
+ [MIT](LICENSE).
@@ -0,0 +1,14 @@
1
+ agentskit/__init__.py,sha256=Bdads3VJIReyYFOsu4UxaMvt2zreJVUpMmgrHsbTEdk,2419
2
+ agentskit/cli.py,sha256=6zRkodoCsRdS8tz-A6QLKUoR0BX49U-Ky1uw6PbqAc4,5721
3
+ agentskit/detect.py,sha256=sJefxFEYF7cFhY2r9OZ4N_JIHngMOSYDVS5ByJoXiK0,1965
4
+ agentskit/inject.py,sha256=Tq7uV3kFK2Nt6Op4jv0tTJg45b6O4NDMOhjwvxmjHvc,4126
5
+ agentskit/markers.py,sha256=WVqGCbepeqHASLRmX_OYKpK5w6ipxP7tt-4jVbtzhNE,1390
6
+ agentskit/onboard.py,sha256=1pkMaFiA4NrrHotrhrM3jHmr5qm_XIrZNwP-vorI2nc,4400
7
+ agentskit/spec.py,sha256=zlCuBvlYvaHexvCg5iiezgHT8ppphQbaHgBmxI2UDAQ,6177
8
+ agentskit/targets.py,sha256=MN_I7x1F5nq-oN6OMAIiiozdV1xiVMkCkorlI7dWyg0,3717
9
+ agentskit/data/agents.json,sha256=mJrSnYGujmzLWY8syOe8gfDkaOT6fIsIiTgqS5xO0tI,5517
10
+ s_agentskit-0.1.0.dist-info/METADATA,sha256=_Kouwd-9wNuodwWqoJXOA_54fBDSfRCrK5uIWsNdL50,4274
11
+ s_agentskit-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ s_agentskit-0.1.0.dist-info/entry_points.txt,sha256=w7lzzIEhSjceWC-Nxjk07GoslPm1qowVrmwmpGIy6Ck,49
13
+ s_agentskit-0.1.0.dist-info/licenses/LICENSE,sha256=j9GKJmUNdQuKRUbKhbpv0uyMaL99xsxE6L2TDtXuaZ4,1063
14
+ s_agentskit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentskit = agentskit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.