ai-kit-python 1.4.2__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.
Files changed (278) hide show
  1. ai_kit_python-1.4.2.dist-info/METADATA +132 -0
  2. ai_kit_python-1.4.2.dist-info/RECORD +278 -0
  3. ai_kit_python-1.4.2.dist-info/WHEEL +5 -0
  4. ai_kit_python-1.4.2.dist-info/entry_points.txt +2 -0
  5. ai_kit_python-1.4.2.dist-info/licenses/LICENSE +21 -0
  6. ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/security-audit/LICENSE +21 -0
  7. ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/strix/LICENSE +201 -0
  8. ai_kit_python-1.4.2.dist-info/top_level.txt +1 -0
  9. aikit/.agents/skills/go-work/SKILL.md +8 -0
  10. aikit/.agents/skills/laravel-work/SKILL.md +8 -0
  11. aikit/.agents/skills/moodle-work/SKILL.md +8 -0
  12. aikit/.agents/skills/project-continuity/SKILL.md +34 -0
  13. aikit/.agents/skills/python-work/SKILL.md +8 -0
  14. aikit/.dockerignore +90 -0
  15. aikit/.gitattributes +3 -0
  16. aikit/.github/workflows/check-kit.yml +70 -0
  17. aikit/.github/workflows/publish-pypi.yml +108 -0
  18. aikit/.gitignore +60 -0
  19. aikit/BOOTSTRAP_PROMPT.md +18 -0
  20. aikit/CHANGELOG.md +85 -0
  21. aikit/CLAUDE.md +3 -0
  22. aikit/CONTRIBUTING.md +46 -0
  23. aikit/Dockerfile +44 -0
  24. aikit/KIMI.md +3 -0
  25. aikit/LICENSE +21 -0
  26. aikit/MANIFEST.in +22 -0
  27. aikit/MANUS.md +3 -0
  28. aikit/Makefile +70 -0
  29. aikit/README.md +1161 -0
  30. aikit/SECURITY.md +8 -0
  31. aikit/SKILL.md +105 -0
  32. aikit/VERSION +1 -0
  33. aikit/__init__.py +1 -0
  34. aikit/ai-kit/ADAPTERS.md +5 -0
  35. aikit/ai-kit/CONTAINERS.md +5 -0
  36. aikit/ai-kit/ENGINEERING.md +5 -0
  37. aikit/ai-kit/INTENT.md +5 -0
  38. aikit/ai-kit/MODELS.md +5 -0
  39. aikit/ai-kit/SKILLS.md +5 -0
  40. aikit/ai-kit/stacks/GO.md +5 -0
  41. aikit/ai-kit/stacks/LARAVEL.md +5 -0
  42. aikit/ai-kit/stacks/MOODLE.md +5 -0
  43. aikit/ai-kit/stacks/PYTHON.md +5 -0
  44. aikit/aikit_cli.py +59 -0
  45. aikit/deploy/k8s/install-preview-job.yaml +56 -0
  46. aikit/docker/build-requirements.txt +4 -0
  47. aikit/docker/check_version.py +29 -0
  48. aikit/docker/pypi-requirements.txt +3 -0
  49. aikit/docs/ADAPTER_PATH_FIX.md +23 -0
  50. aikit/docs/BENCHMARK.md +106 -0
  51. aikit/docs/CONTEXT7.md +33 -0
  52. aikit/docs/DECISIONS.md +22 -0
  53. aikit/docs/DEVELOPMENT_PLAN.md +16 -0
  54. aikit/docs/DIALOGUE_MEMORY.md +70 -0
  55. aikit/docs/DOCKER.md +74 -0
  56. aikit/docs/DOCKER_HUB_OVERVIEW.md +311 -0
  57. aikit/docs/EVALUATION.md +30 -0
  58. aikit/docs/IMPLEMENTATION.md +134 -0
  59. aikit/docs/MANUS_REVIEW.md +46 -0
  60. aikit/docs/PYPI.md +87 -0
  61. aikit/docs/PYPI_DESCRIPTION.md +103 -0
  62. aikit/docs/SECURITY_AUDIT.md +26 -0
  63. aikit/docs/STRIX.md +28 -0
  64. aikit/docs/V0_5_0.md +27 -0
  65. aikit/docs/V0_6_4.md +25 -0
  66. aikit/docs/V1_4_0.md +73 -0
  67. aikit/docs/V1_4_1.md +55 -0
  68. aikit/docs/V1_4_2.md +33 -0
  69. aikit/docs/WEB_DESIGN.md +72 -0
  70. aikit/docs/WINDOWS_CI_FIX.md +39 -0
  71. aikit/docs/adr/0000-template.md +24 -0
  72. aikit/docs/adr/0001-adaptive-model-routing.md +30 -0
  73. aikit/docs/adr/0002-template-separation.md +24 -0
  74. aikit/docs/adr/0003-agent-registry-and-router.md +24 -0
  75. aikit/docs/adr/0004-onboarding-and-project-facts.md +24 -0
  76. aikit/docs/adr/0005-client-settings-ownership.md +30 -0
  77. aikit/docs/adr/0006-routing-evidence-and-local-measurements.md +26 -0
  78. aikit/docs/adr/0007-generated-client-configuration-and-presets.md +34 -0
  79. aikit/docs/adr/0008-unified-cli-and-fact-drift.md +20 -0
  80. aikit/docs/adr/0009-provider-review-doctor-fix-and-ci-self-install.md +20 -0
  81. aikit/docs/adr/0010-metrics-analysis-and-gemini-verification.md +20 -0
  82. aikit/docs/adr/0011-evidence-lifecycle-and-evaluation.md +20 -0
  83. aikit/docs/adr/0012-measurement-led-routing.md +31 -0
  84. aikit/docs/adr/0013-docker-cli-image.md +24 -0
  85. aikit/docs/adr/0014-local-dialogue-memory.md +22 -0
  86. aikit/docs/history/original-bundle-checks.md +11 -0
  87. aikit/docs/history/v0.1-changelog.txt +37 -0
  88. aikit/docs/history/v0.10-changelog.md +12 -0
  89. aikit/docs/history/v0.2-changelog.md +35 -0
  90. aikit/docs/history/v0.3-changelog.md +25 -0
  91. aikit/docs/history/v0.4-changelog.md +31 -0
  92. aikit/docs/history/v0.5-changelog.md +14 -0
  93. aikit/docs/history/v0.6.4-changelog.md +12 -0
  94. aikit/docs/history/v0.6.5-v0.9-changelog.md +35 -0
  95. aikit/docs/history/v1.1.0-changelog.md +12 -0
  96. aikit/docs/history/v1.2.0-changelog.md +11 -0
  97. aikit/docs/history/verification-before-measurements.md +53 -0
  98. aikit/integrations/CONVENTIONS.md +3 -0
  99. aikit/integrations/README.md +7 -0
  100. aikit/integrations/agents.json +136 -0
  101. aikit/integrations/ai-kit-check.yml +66 -0
  102. aikit/integrations/ai-kit-cline.md +4 -0
  103. aikit/integrations/ai-kit-roo.md +4 -0
  104. aikit/integrations/ai-kit-windsurf.md +4 -0
  105. aikit/integrations/ai-kit.mdc +6 -0
  106. aikit/integrations/claude-settings.data-ask.json +23 -0
  107. aikit/integrations/claude-settings.git-ask.json +10 -0
  108. aikit/integrations/copilot-instructions.md +3 -0
  109. aikit/integrations/security-audit.json +135 -0
  110. aikit/integrations/strix.json +69 -0
  111. aikit/pyproject.toml +60 -0
  112. aikit/scripts/__init__.py +1 -0
  113. aikit/scripts/adapters.py +184 -0
  114. aikit/scripts/adaptive.py +167 -0
  115. aikit/scripts/adr.py +61 -0
  116. aikit/scripts/benchmark.py +543 -0
  117. aikit/scripts/changelog.py +55 -0
  118. aikit/scripts/check_distribution.py +178 -0
  119. aikit/scripts/check_kit.py +406 -0
  120. aikit/scripts/context.py +184 -0
  121. aikit/scripts/docker_test_usage.py +55 -0
  122. aikit/scripts/doctor.py +313 -0
  123. aikit/scripts/evaluate.py +125 -0
  124. aikit/scripts/install.py +1584 -0
  125. aikit/scripts/learning.py +260 -0
  126. aikit/scripts/memory.py +344 -0
  127. aikit/scripts/memory_import.py +177 -0
  128. aikit/scripts/memory_store.py +125 -0
  129. aikit/scripts/metrics.py +351 -0
  130. aikit/scripts/router.py +399 -0
  131. aikit/template/.agents/hooks/session-start.md +3 -0
  132. aikit/template/.agents/hooks/session-start.sh +21 -0
  133. aikit/template/.agents/skills/ai-kit-bootstrap/SKILL.md +14 -0
  134. aikit/template/.agents/skills/ai-kit-review/SKILL.md +11 -0
  135. aikit/template/.agents/skills/ai-kit-sync-context/SKILL.md +12 -0
  136. aikit/template/.agents/skills/context7-docs/SKILL.md +11 -0
  137. aikit/template/.agents/skills/design-extract/SKILL.md +9 -0
  138. aikit/template/.agents/skills/dotnet-work/SKILL.md +9 -0
  139. aikit/template/.agents/skills/filament-work/SKILL.md +9 -0
  140. aikit/template/.agents/skills/frontend-work/SKILL.md +9 -0
  141. aikit/template/.agents/skills/go-work/SKILL.md +9 -0
  142. aikit/template/.agents/skills/infra-work/SKILL.md +9 -0
  143. aikit/template/.agents/skills/java-work/SKILL.md +9 -0
  144. aikit/template/.agents/skills/laravel-blade-ui/SKILL.md +9 -0
  145. aikit/template/.agents/skills/laravel-work/SKILL.md +10 -0
  146. aikit/template/.agents/skills/library-work/SKILL.md +9 -0
  147. aikit/template/.agents/skills/moodle-work/SKILL.md +9 -0
  148. aikit/template/.agents/skills/php-work/SKILL.md +9 -0
  149. aikit/template/.agents/skills/project-continuity/SKILL.md +21 -0
  150. aikit/template/.agents/skills/python-work/SKILL.md +9 -0
  151. aikit/template/.agents/skills/rust-work/SKILL.md +9 -0
  152. aikit/template/.agents/skills/security-audit/SKILL.md +16 -0
  153. aikit/template/.agents/skills/security-audit/agents/openai.yaml +2 -0
  154. aikit/template/.agents/skills/strix/SKILL.md +16 -0
  155. aikit/template/.agents/skills/strix/agents/openai.yaml +2 -0
  156. aikit/template/.agents/skills/ui-review/SKILL.md +9 -0
  157. aikit/template/.agents/skills/web-design/SKILL.md +9 -0
  158. aikit/template/AGENTS.md +16 -0
  159. aikit/template/CHANGELOG.md +6 -0
  160. aikit/template/CLAUDE.md +5 -0
  161. aikit/template/GEMINI.md +4 -0
  162. aikit/template/KIMI.md +4 -0
  163. aikit/template/MANUS.md +4 -0
  164. aikit/template/PROJECT_CONTEXT.md +35 -0
  165. aikit/template/README.md +6 -0
  166. aikit/template/WIKI.md +37 -0
  167. aikit/template/ai-kit/ADAPTERS.md +100 -0
  168. aikit/template/ai-kit/BOOTSTRAP.md +15 -0
  169. aikit/template/ai-kit/CONTAINERS.md +63 -0
  170. aikit/template/ai-kit/CONTEXT7.md +96 -0
  171. aikit/template/ai-kit/CORE.md +23 -0
  172. aikit/template/ai-kit/ENGINEERING.md +34 -0
  173. aikit/template/ai-kit/MCP.md +17 -0
  174. aikit/template/ai-kit/METRICS.md +83 -0
  175. aikit/template/ai-kit/SECURITY-AUDIT.md +21 -0
  176. aikit/template/ai-kit/SECURITY.md +34 -0
  177. aikit/template/ai-kit/SKILLS.md +37 -0
  178. aikit/template/ai-kit/STRIX.md +44 -0
  179. aikit/template/ai-kit/UPSTREAM.md +38 -0
  180. aikit/template/ai-kit/WEB-DESIGN.md +37 -0
  181. aikit/template/ai-kit/profiles/OWNER.md +19 -0
  182. aikit/template/ai-kit/project.json +14 -0
  183. aikit/template/ai-kit/router/POLICY.md +79 -0
  184. aikit/template/ai-kit/router/providers/anthropic.json +44 -0
  185. aikit/template/ai-kit/router/providers/anthropic.md +14 -0
  186. aikit/template/ai-kit/router/providers/gemini.json +13 -0
  187. aikit/template/ai-kit/router/providers/gemini.md +10 -0
  188. aikit/template/ai-kit/router/providers/kimi.json +26 -0
  189. aikit/template/ai-kit/router/providers/kimi.md +13 -0
  190. aikit/template/ai-kit/router/providers/local.json +18 -0
  191. aikit/template/ai-kit/router/providers/local.md +10 -0
  192. aikit/template/ai-kit/router/providers/openai.json +48 -0
  193. aikit/template/ai-kit/router/providers/openai.md +14 -0
  194. aikit/template/ai-kit/router/routing.json +11 -0
  195. aikit/template/ai-kit/router/selection.json +14 -0
  196. aikit/template/ai-kit/settings.json +11 -0
  197. aikit/template/ai-kit/stacks/DOTNET.md +13 -0
  198. aikit/template/ai-kit/stacks/FILAMENT.md +18 -0
  199. aikit/template/ai-kit/stacks/FRONTEND.md +9 -0
  200. aikit/template/ai-kit/stacks/GO.md +19 -0
  201. aikit/template/ai-kit/stacks/INFRA.md +12 -0
  202. aikit/template/ai-kit/stacks/JAVA.md +13 -0
  203. aikit/template/ai-kit/stacks/LARAVEL.md +15 -0
  204. aikit/template/ai-kit/stacks/LIBRARY.md +9 -0
  205. aikit/template/ai-kit/stacks/MOODLE.md +12 -0
  206. aikit/template/ai-kit/stacks/PHP.md +11 -0
  207. aikit/template/ai-kit/stacks/PYTHON.md +11 -0
  208. aikit/template/ai-kit/stacks/RUST.md +18 -0
  209. aikit/template/ai-kit/vendor/security-audit/AI-AND-LLM.md +83 -0
  210. aikit/template/ai-kit/vendor/security-audit/ATTACK-CLASSES.md +130 -0
  211. aikit/template/ai-kit/vendor/security-audit/CLIENT-SIDE.md +83 -0
  212. aikit/template/ai-kit/vendor/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
  213. aikit/template/ai-kit/vendor/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
  214. aikit/template/ai-kit/vendor/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
  215. aikit/template/ai-kit/vendor/security-audit/HUNTING.md +251 -0
  216. aikit/template/ai-kit/vendor/security-audit/LICENSE +21 -0
  217. aikit/template/ai-kit/vendor/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
  218. aikit/template/ai-kit/vendor/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
  219. aikit/template/ai-kit/vendor/security-audit/RECONNAISSANCE.md +156 -0
  220. aikit/template/ai-kit/vendor/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
  221. aikit/template/ai-kit/vendor/security-audit/SKILL.md +192 -0
  222. aikit/template/ai-kit/vendor/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
  223. aikit/template/ai-kit/vendor/security-audit/VALIDATION-AND-REPORTING.md +186 -0
  224. aikit/template/ai-kit/vendor/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
  225. aikit/template/ai-kit/vendor/security-audit/report-schema.json +461 -0
  226. aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.cjs +872 -0
  227. aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.test.cjs +740 -0
  228. aikit/template/ai-kit/vendor/security-audit/validate-findings.cjs +773 -0
  229. aikit/template/ai-kit/vendor/security-audit/validate-findings.test.cjs +652 -0
  230. aikit/template/ai-kit/vendor/strix/LICENSE +201 -0
  231. aikit/template/ai-kit/vendor/strix/api-security-testing/SKILL.md +61 -0
  232. aikit/template/ai-kit/vendor/strix/application-security-testing/SKILL.md +66 -0
  233. aikit/template/ai-kit/vendor/strix/ci-security-scanning-with-strix/SKILL.md +149 -0
  234. aikit/template/ai-kit/vendor/strix/find-security-vulnerabilities-in-code/SKILL.md +62 -0
  235. aikit/template/ai-kit/vendor/strix/fix-security-vulnerabilities-with-strix/SKILL.md +77 -0
  236. aikit/template/ai-kit/vendor/strix/managed-pentesting-with-strix/SKILL.md +321 -0
  237. aikit/template/ai-kit/vendor/strix/owasp-top-10-testing/SKILL.md +64 -0
  238. aikit/template/ai-kit/vendor/strix/penetration-testing-with-strix/SKILL.md +162 -0
  239. aikit/template/ai-kit/vendor/strix/web-app-penetration-testing/SKILL.md +54 -0
  240. aikit/template/ai-kit/web-design/LARAVEL.md +51 -0
  241. aikit/template/ai-kit/web-design/LIBRARIES.md +39 -0
  242. aikit/template/ai-kit/web-design/REVIEW.md +37 -0
  243. aikit/template/ai-kit/web-design/TOOLS.md +43 -0
  244. aikit/template/docs/DECISIONS.md +7 -0
  245. aikit/template/docs/adr/0000-template.md +20 -0
  246. aikit/template/docs/design/ACCEPTANCE.md +24 -0
  247. aikit/template/docs/design/BRIEF.md +26 -0
  248. aikit/template/docs/design/SYSTEM.md +30 -0
  249. aikit/templates/agent-secrets.ignore +19 -0
  250. aikit/templates/gitignore.private +160 -0
  251. aikit/templates/gitignore.team +139 -0
  252. aikit/tests/build_distribution.py +73 -0
  253. aikit/tests/docker_smoke.py +172 -0
  254. aikit/tests/fixtures/create-junction.ps1 +18 -0
  255. aikit/tests/fixtures/golden/claude-settings-local.json +58 -0
  256. aikit/tests/test_adapters.py +105 -0
  257. aikit/tests/test_aikit.py +79 -0
  258. aikit/tests/test_benchmark.py +241 -0
  259. aikit/tests/test_check_kit.py +203 -0
  260. aikit/tests/test_context.py +76 -0
  261. aikit/tests/test_context7.py +153 -0
  262. aikit/tests/test_context_selection.py +70 -0
  263. aikit/tests/test_distribution.py +181 -0
  264. aikit/tests/test_docker_image.py +63 -0
  265. aikit/tests/test_docker_test_usage.py +54 -0
  266. aikit/tests/test_doctor.py +148 -0
  267. aikit/tests/test_evaluate.py +66 -0
  268. aikit/tests/test_install.py +939 -0
  269. aikit/tests/test_learning.py +247 -0
  270. aikit/tests/test_memory.py +371 -0
  271. aikit/tests/test_metrics.py +209 -0
  272. aikit/tests/test_native.py +342 -0
  273. aikit/tests/test_onboarding.py +301 -0
  274. aikit/tests/test_router.py +336 -0
  275. aikit/tests/test_security_audit.py +237 -0
  276. aikit/tests/test_strix.py +245 -0
  277. aikit/tests/test_web_design.py +219 -0
  278. aikit/tests/wheel_smoke.py +104 -0
@@ -0,0 +1,1584 @@
1
+ """Conservative, offline AI-KIT installation and unchanged-baseline upgrades."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import stat
12
+ import sys
13
+ import uuid
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ START = "# BEGIN AI-KIT MANAGED"
17
+ END = "# END AI-KIT MANAGED"
18
+ OWNED = {"README.md", "PROJECT_CONTEXT.md", "WIKI.md", "CHANGELOG.md", "docs/DECISIONS.md",
19
+ "docs/adr/0000-template.md", "ai-kit/project.json"}
20
+ AGENT_REGISTRY_PATH = "integrations/agents.json"
21
+ PROFILE_MANIFESTS = {
22
+ "GO": ("go.mod",),
23
+ "PYTHON": ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg"),
24
+ "FRONTEND": ("package.json",),
25
+ }
26
+ FILAMENT_PACKAGES = {"filament/filament", "filament/forms", "filament/tables", "filament/schemas",
27
+ "filament/actions", "filament/widgets", "filament/infolists", "filament/notifications"}
28
+ EXTRAS = {"session-start", "guards", "data-guards", "native-settings", "scoped-rules", "ci", "security-audit", "strix",
29
+ "context7", "web-design"}
30
+ EXTRA_TEMPLATE = {"session-start": (".agents/hooks/session-start.md", ".agents/hooks/session-start.sh")}
31
+ EXTRA_INTEGRATIONS = {"ci": ("ai-kit-check.yml", ".github/workflows/ai-kit.yml")}
32
+ EXTRA_SETTINGS_KEYS = {"session-start": "session_start", "guards": "guards", "data-guards": "data_guards",
33
+ "native-settings": "native_settings", "scoped-rules": "scoped_rules", "ci": "ci",
34
+ "security-audit": "security_audit", "strix": "strix", "context7": "context7",
35
+ "web-design": "web_design"}
36
+ SECURITY_AUDIT_SKILL = ".agents/skills/security-audit/"
37
+ SECURITY_AUDIT_PACKAGE = "ai-kit/vendor/security-audit/"
38
+ SECURITY_AUDIT_MANIFEST = "integrations/security-audit.json"
39
+ STRIX_SKILLS = ("api-security-testing", "application-security-testing", "ci-security-scanning-with-strix",
40
+ "find-security-vulnerabilities-in-code", "fix-security-vulnerabilities-with-strix",
41
+ "managed-pentesting-with-strix", "owasp-top-10-testing", "penetration-testing-with-strix",
42
+ "web-app-penetration-testing")
43
+ OPTIONAL_SKILLS = {
44
+ "security-audit": {"repository": "https://github.com/cloudflare/security-audit-skill",
45
+ "source_directory": "skills/security-audit", "license": "MIT", "depth": 1,
46
+ "required": {"SKILL.md", "LICENSE", "RECONNAISSANCE.md", "HUNTING.md", "ATTACK-CLASSES.md",
47
+ "VALIDATION-AND-REPORTING.md", "report-schema.json", "validate-findings.cjs",
48
+ "validate-coverage-ledger.cjs"}},
49
+ "strix": {"repository": "https://github.com/usestrix/strix", "source_directory": "skills",
50
+ "license": "Apache-2.0", "depth": 2,
51
+ "required": {"LICENSE", *(name + "/SKILL.md" for name in STRIX_SKILLS)}},
52
+ }
53
+ # Local documentation workflows share selection/preservation behavior, without an upstream payload.
54
+ OPTIONAL_ENTRY_POINTS = {**{name: (name,) for name in OPTIONAL_SKILLS}, "context7": ("context7-docs",),
55
+ "web-design": ("web-design", "laravel-blade-ui", "ui-review", "design-extract")}
56
+ OPTIONAL_DOCUMENTS = {"web-design": ("docs/design/BRIEF.md", "docs/design/SYSTEM.md", "docs/design/ACCEPTANCE.md")}
57
+ OPTIONAL_RESOURCES = {"web-design": ("ai-kit/WEB-DESIGN.md", "ai-kit/web-design/LARAVEL.md",
58
+ "ai-kit/web-design/LIBRARIES.md", "ai-kit/web-design/TOOLS.md",
59
+ "ai-kit/web-design/REVIEW.md")}
60
+ # Presets set every extra they manage on or off; explicit --with-* flags add on top. CI is left
61
+ # to an explicit choice, as are optional integrations. A preset mode of None keeps the sharing mode.
62
+ PRESET_EXTRAS = ("session-start", "guards", "data-guards", "native-settings", "scoped-rules")
63
+ PRESETS = {
64
+ "minimal": {"mode": None, "extras": ()},
65
+ "solo": {"mode": "private", "extras": ("session-start", "native-settings", "scoped-rules")},
66
+ "team": {"mode": "team", "extras": ("session-start", "guards", "native-settings", "scoped-rules")},
67
+ "strict": {"mode": None, "extras": PRESET_EXTRAS},
68
+ }
69
+ CLAUDE_SETTINGS = ".claude/settings.json"
70
+ CLAUDE_LOCAL_SETTINGS = ".claude/settings.local.json"
71
+ GUARDS_SOURCE = "claude-settings.git-ask.json"
72
+ DATA_GUARDS_SOURCE = "claude-settings.data-ask.json"
73
+ SECRETS_SOURCE = "agent-secrets.ignore"
74
+ SCOPED_FORMATS = {"claude-paths": ".md", "cline-paths": ".md", "cursor-globs": ".mdc",
75
+ "copilot-applyto": ".instructions.md", "windsurf-trigger": ".md"}
76
+ MODULE_PATH = re.compile(r"/(?:[A-Za-z0-9_-][A-Za-z0-9._-]*/)*")
77
+ # Recorded commands become allow rules only when they are one plain command.
78
+ UNSAFE_COMMAND = re.compile(r"[;&|<>`$()*\\\"'\r\n]")
79
+ # tr strips CR so a CRLF checkout of the hook script still runs under sh.
80
+ SESSION_START_COMMAND = "tr -d '\\r' < \"$CLAUDE_PROJECT_DIR/.agents/hooks/session-start.sh\" | sh"
81
+ # Entries written by v0.3.6-v0.4.6, which managed the whole client settings file.
82
+ LEGACY_CLAUDE_ENTRIES = {"permissions.deny": ["Bash(git commit *)", "Bash(git push *)"],
83
+ "hooks.SessionStart": ["cat .agents/hooks/session-start.md"]}
84
+ NODE_MANAGERS = ("npm", "pnpm", "yarn", "bun")
85
+ NODE_LOCKFILES = (("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lock", "bun"),
86
+ ("bun.lockb", "bun"), ("package-lock.json", "npm"), ("npm-shrinkwrap.json", "npm"))
87
+ PYTHON_RUNNERS = (("uv.lock", "uv run "), ("poetry.lock", "poetry run "))
88
+ PYTHON_TOOL_FILES = ("pyproject.toml", "setup.cfg", "tox.ini", "requirements.txt", "requirements-dev.txt",
89
+ "requirements_dev.txt", "dev-requirements.txt", "requirements-test.txt",
90
+ "test-requirements.txt")
91
+ NODE_IGNORES = ("node_modules/", ".npm/", ".pnpm-store/", ".yarn/cache/", ".yarn/unplugged/",
92
+ ".yarn/install-state.gz", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*",
93
+ "pnpm-debug.log*", ".next/", ".nuxt/", ".svelte-kit/", ".turbo/", ".parcel-cache/")
94
+ PYTHON_IGNORES = (".venv/", "venv/", "__pypackages__/", "__pycache__/", "*.py[cod]", "*$py.class",
95
+ ".pytest_cache/", ".mypy_cache/", ".ruff_cache/", ".pyre/", ".pytype/",
96
+ ".hypothesis/", ".tox/", ".nox/", ".ipynb_checkpoints/", ".coverage", ".coverage.*",
97
+ "htmlcov/", "coverage.xml", "*.egg-info/", ".eggs/", "*.egg", "*.whl",
98
+ "pip-wheel-metadata/", "pip-log.txt", "pip-delete-this-directory.txt", ".pypirc")
99
+ PYTHON_ROOT_IGNORES = (".cache/pip/", ".cache/uv/", ".cache/pypoetry/")
100
+
101
+
102
+ def digest(data: bytes) -> str:
103
+ return hashlib.sha256(data).hexdigest()
104
+
105
+
106
+ def is_link(path: Path) -> bool:
107
+ """Recognize symlinks and Windows junctions on Python 3.10 and later."""
108
+ try:
109
+ info = path.lstat()
110
+ except (FileNotFoundError, NotADirectoryError):
111
+ return False
112
+ return stat.S_ISLNK(info.st_mode) or getattr(info, "st_reparse_tag", None) in {
113
+ getattr(stat, "IO_REPARSE_TAG_SYMLINK", -1),
114
+ getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", -2),
115
+ }
116
+
117
+
118
+ def safe_path(target: Path, relative: str, *, allow_obstructions: bool = False) -> Path:
119
+ """Guard lexical/link boundaries; only read-only audits may allow file/directory obstructions."""
120
+ parts = relative.replace("\\", "/").split("/")
121
+ if not parts or any(p in {"", ".", ".."} or ":" in p for p in parts):
122
+ raise ValueError(f"Unsafe relative path: {relative}")
123
+ path = target.joinpath(*parts)
124
+ path.resolve().relative_to(target.resolve())
125
+ obstruction = None
126
+ for candidate in [path, *path.parents]:
127
+ if candidate == target.parent:
128
+ break
129
+ if is_link(candidate):
130
+ raise ValueError(f"Refusing linked installation path: {candidate}")
131
+ if candidate != path and candidate.exists() and not candidate.is_dir():
132
+ obstruction = candidate
133
+ if obstruction is not None and not allow_obstructions:
134
+ raise NotADirectoryError(f"Installation parent is not a directory: {obstruction}")
135
+ if path.exists() and not path.is_file() and not allow_obstructions:
136
+ raise ValueError(f"Destination is not a regular file: {relative}")
137
+ return path
138
+
139
+
140
+ def read_json(path: Path, default: dict) -> dict:
141
+ if not path.exists():
142
+ return default
143
+ value = json.loads(path.read_text(encoding="utf-8"))
144
+ if not isinstance(value, dict):
145
+ raise ValueError(f"Expected JSON object: {path}")
146
+ return value
147
+
148
+
149
+ def load_agent_registry(root: Path = ROOT) -> dict:
150
+ """Load and validate the data-driven agent registry (integrations/agents.json)."""
151
+ data = json.loads((root / AGENT_REGISTRY_PATH).read_text(encoding="utf-8"))
152
+ if not isinstance(data, dict) or type(data.get("schema")) is not int or data["schema"] != 1:
153
+ raise ValueError("Unsupported agent registry schema; expected schema 1")
154
+ agents = data.get("agents")
155
+ if not isinstance(agents, dict) or not agents:
156
+ raise ValueError("Agent registry must declare a nonempty agents object")
157
+ names: set[str] = set()
158
+ entries: dict[str, str] = {}
159
+ optional: dict[str, list[tuple[str, str]]] = {}
160
+ skills_copies: dict[str, str] = {}
161
+ detect: dict[str, list[str]] = {}
162
+ scoped_rules: dict[str, dict[str, str]] = {}
163
+ ignore_files: dict[str, str] = {}
164
+ metadata: dict[str, dict] = {}
165
+ for name, spec in agents.items():
166
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", name):
167
+ raise ValueError(f"Invalid agent name: {name}")
168
+ if not isinstance(spec, dict):
169
+ raise ValueError(f"Invalid agent spec for {name}")
170
+ names.add(name)
171
+ for entry in spec.get("entries", []):
172
+ if not isinstance(entry, str) or not entry:
173
+ raise ValueError(f"Invalid entry for {name}")
174
+ if entry in entries:
175
+ raise ValueError(f"Entry owned by multiple agents: {entry}")
176
+ entries[entry] = name
177
+ for item in spec.get("optional", []):
178
+ if not isinstance(item, dict):
179
+ raise ValueError(f"Invalid optional entry for {name}")
180
+ source, destination = item.get("source"), item.get("destination")
181
+ if not isinstance(source, str) or not source or not isinstance(destination, str) or not destination:
182
+ raise ValueError(f"Invalid optional source/destination for {name}")
183
+ optional.setdefault(name, []).append((source, destination))
184
+ prefix = spec.get("skills_copy")
185
+ if prefix is not None:
186
+ if not isinstance(prefix, str) or not prefix.startswith(".") or not prefix.endswith("/"):
187
+ raise ValueError(f"Invalid skills_copy prefix for {name}")
188
+ skills_copies[name] = prefix
189
+ markers = spec.get("detect", [])
190
+ if not isinstance(markers, list) or any(
191
+ not isinstance(marker, str) or
192
+ not re.fullmatch(r"[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*/?", marker) or
193
+ any(part in {".", ".."} for part in marker.rstrip("/").split("/")) for marker in markers):
194
+ raise ValueError(f"Invalid detect markers for {name}")
195
+ if markers:
196
+ detect[name] = markers
197
+ scoped = spec.get("scoped_rules")
198
+ if scoped is not None:
199
+ directory = scoped.get("directory") if isinstance(scoped, dict) else None
200
+ if (not isinstance(directory, str) or not re.fullmatch(r"\.[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*/",
201
+ directory)
202
+ or ".." in directory.split("/") or scoped.get("format") not in SCOPED_FORMATS):
203
+ raise ValueError(f"Invalid scoped_rules for {name}")
204
+ scoped_rules[name] = {"directory": directory, "format": scoped["format"]}
205
+ ignore_file = spec.get("ignore_file")
206
+ if ignore_file is not None:
207
+ if not isinstance(ignore_file, str) or not re.fullmatch(r"\.[A-Za-z0-9._-]+", ignore_file):
208
+ raise ValueError(f"Invalid ignore_file for {name}")
209
+ ignore_files[name] = ignore_file
210
+ documentation = spec.get("documentation")
211
+ if not isinstance(documentation, dict) or set(documentation) != {
212
+ "status", "verified_documentation_date", "sources", "activation"}:
213
+ raise ValueError(f"Invalid documentation metadata for {name}")
214
+ status = documentation["status"]
215
+ verified = documentation["verified_documentation_date"]
216
+ sources = documentation["sources"]
217
+ activation = documentation["activation"]
218
+ if status not in {"verified", "unverified"}:
219
+ raise ValueError(f"Invalid documentation status for {name}")
220
+ if verified is not None and (not isinstance(verified, str) or
221
+ not re.fullmatch(r"\d{4}-\d{2}-\d{2}", verified)):
222
+ raise ValueError(f"Invalid documentation date for {name}")
223
+ if not isinstance(sources, list) or any(not isinstance(source, str) or not source.startswith("https://")
224
+ for source in sources):
225
+ raise ValueError(f"Invalid documentation sources for {name}")
226
+ if activation not in {"verified", "unverified", "not-applicable"}:
227
+ raise ValueError(f"Invalid activation status for {name}")
228
+ if status == "verified" and (verified is None or not sources):
229
+ raise ValueError(f"Verified documentation needs date and sources for {name}")
230
+ metadata[name] = {"status": status, "verified_documentation_date": verified,
231
+ "sources": sources, "activation": activation}
232
+ return {"names": names, "entries": entries, "optional": optional,
233
+ "skills_copies": skills_copies, "detect": detect, "scoped_rules": scoped_rules,
234
+ "ignore_files": ignore_files, "metadata": metadata}
235
+
236
+
237
+ def detect_agents(target: Path, registry: dict) -> dict[str, list[str]]:
238
+ """Map clients to existing native files/directories suggesting they are in use (unverified)."""
239
+ found: dict[str, list[str]] = {}
240
+ for agent, markers in sorted(registry["detect"].items()):
241
+ evidence = []
242
+ for marker in markers:
243
+ path = target.joinpath(*marker.rstrip("/").split("/"))
244
+ if is_link(path):
245
+ continue
246
+ # A trailing slash marks a directory; other markers may be files or directories.
247
+ if path.is_dir() if marker.endswith("/") else path.exists():
248
+ evidence.append(marker)
249
+ if evidence:
250
+ found[agent] = evidence
251
+ return found
252
+
253
+
254
+ def agent_selection(value: object, agents: set[str], *, unique: bool = False) -> list[str]:
255
+ if not isinstance(value, list) or not value or any(not isinstance(item, str) for item in value):
256
+ raise ValueError("Agents must be a nonempty list of supported strings")
257
+ if set(value) - agents:
258
+ raise ValueError("Unknown agent selection")
259
+ if unique and len(value) != len(set(value)):
260
+ raise ValueError("State agents must be unique")
261
+ return sorted(set(value))
262
+
263
+
264
+ def validate_settings(settings: dict) -> None:
265
+ for field in ("project_language", "chat_language", "sharing_mode", "conventions"):
266
+ if not isinstance(settings.get(field), str):
267
+ raise ValueError(f"Settings {field} must be a string")
268
+ if settings["project_language"] != "English":
269
+ raise ValueError("This distribution requires English project content")
270
+ chat_language = settings["chat_language"]
271
+ if not chat_language.strip() or len(chat_language) > 60 or any(ord(ch) < 32 for ch in chat_language):
272
+ raise ValueError("Chat language must be a nonempty name up to 60 characters without control characters")
273
+ if settings["sharing_mode"] not in {"private", "team"} or settings["conventions"] not in {"owner", "standard"}:
274
+ raise ValueError("Invalid sharing mode or conventions")
275
+ for field in EXTRA_SETTINGS_KEYS.values():
276
+ if field in settings and not isinstance(settings[field], bool):
277
+ raise ValueError(f"Settings {field} must be a boolean")
278
+ upstream = settings.get("upstream")
279
+ if upstream is not None:
280
+ if not isinstance(upstream, dict):
281
+ raise ValueError("Settings upstream must be an object or null")
282
+ for field in ("url", "ref", "source_directory"):
283
+ if field in upstream and not isinstance(upstream[field], str):
284
+ raise ValueError(f"Settings upstream.{field} must be a string")
285
+
286
+
287
+ def security_audit_files(root: Path = ROOT) -> dict[str, bytes]:
288
+ """Return the verified Cloudflare payload (retained for existing callers)."""
289
+ return upstream_skill_files("security-audit", root)
290
+
291
+
292
+ def upstream_skill_files(name: str, root: Path = ROOT) -> dict[str, bytes]:
293
+ """Verify a bounded upstream reference tree and return its offline install payload."""
294
+ spec = OPTIONAL_SKILLS[name]
295
+ package_prefix = "ai-kit/vendor/" + name + "/"
296
+ with safe_path(root, "integrations/" + name + ".json").open("rb") as stream:
297
+ manifest_bytes = stream.read(1024 * 1024 + 1)
298
+ if len(manifest_bytes) > 1024 * 1024:
299
+ raise ValueError(f"Oversized {name} upstream manifest")
300
+ manifest = json.loads(manifest_bytes)
301
+ if (not isinstance(manifest, dict) or type(manifest.get("schema")) is not int
302
+ or manifest["schema"] != 1
303
+ or manifest.get("repository") != spec["repository"]
304
+ or not isinstance(manifest.get("revision"), str)
305
+ or not re.fullmatch(r"[0-9a-f]{40}", manifest["revision"])
306
+ or manifest.get("source_directory") != spec["source_directory"]
307
+ or manifest.get("license") != spec["license"]):
308
+ raise ValueError(f"Invalid {name} upstream identity")
309
+ files = manifest.get("files")
310
+ if not isinstance(files, dict) or not spec["required"] <= files.keys() or len(files) > 100:
311
+ raise ValueError(f"Incomplete {name} upstream manifest")
312
+ prefixes: dict[str, str] = {}
313
+ reserved = {"con", "prn", "aux", "nul", *("com" + str(i) for i in range(1, 10)),
314
+ *("lpt" + str(i) for i in range(1, 10))}
315
+ for filename in files:
316
+ parts = filename.split("/") if isinstance(filename, str) else []
317
+ if (not parts or len(parts) > spec["depth"] or filename.casefold() == "upstream.json"
318
+ or any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", part)
319
+ or part.endswith(".") or part.split(".")[0].casefold() in reserved for part in parts)):
320
+ raise ValueError(f"Unsafe {name} upstream filename")
321
+ for i in range(1, len(parts) + 1):
322
+ prefix = "/".join(parts[:i])
323
+ folded = prefix.casefold()
324
+ if folded in prefixes and prefixes[folded] != prefix:
325
+ raise ValueError(f"Unsafe {name} upstream filename alias")
326
+ prefixes[folded] = prefix
327
+ package = safe_path(root, "template/" + package_prefix + "LICENSE").parent
328
+ actual = set()
329
+ pending = [package]
330
+ while pending:
331
+ directory = pending.pop()
332
+ for path in directory.iterdir():
333
+ relative = path.relative_to(package).as_posix()
334
+ if is_link(path):
335
+ raise ValueError(f"Linked {name} upstream entry: {relative}")
336
+ if path.is_dir() and len(path.relative_to(package).parts) < spec["depth"]:
337
+ if not any(filename.startswith(relative + "/") for filename in files):
338
+ raise ValueError(f"{name} upstream directories differ from the manifest")
339
+ pending.append(path)
340
+ elif path.is_file():
341
+ actual.add(relative)
342
+ else:
343
+ raise ValueError(f"Non-regular {name} upstream entry: {relative}")
344
+ if actual != set(files):
345
+ raise ValueError(f"{name} upstream files differ from the manifest")
346
+ incoming: dict[str, bytes] = {}
347
+ total = 0
348
+ for filename, record in sorted(files.items()):
349
+ if (not isinstance(record, dict) or type(record.get("size")) is not int
350
+ or not 0 <= record["size"] <= 1024 * 1024
351
+ or record.get("git_mode") not in ("100644", "100755")
352
+ or any(not isinstance(record.get(field), str)
353
+ or not re.fullmatch(r"[0-9a-f]{" + str(length) + "}", record[field])
354
+ for field, length in (("sha256", 64), ("git_blob", 40)))):
355
+ raise ValueError(f"Invalid {name} upstream record: {filename}")
356
+ source = safe_path(root, "template/" + package_prefix + filename)
357
+ with source.open("rb") as stream:
358
+ data = stream.read(1024 * 1024 + 1)
359
+ total += len(data)
360
+ blob = hashlib.sha1(b"blob " + str(len(data)).encode("ascii") + b"\0" + data,
361
+ usedforsecurity=False).hexdigest()
362
+ if (total > 5 * 1024 * 1024 or len(data) != record["size"]
363
+ or digest(data) != record["sha256"] or blob != record["git_blob"]):
364
+ raise ValueError(f"{name} upstream integrity mismatch: {filename}")
365
+ incoming[package_prefix + filename] = data
366
+ incoming[package_prefix + "UPSTREAM.json"] = manifest_bytes
367
+ return incoming
368
+
369
+
370
+ def validate_state(state: dict, target: Path, agents: set[str] | None = None, *, allow_obstructions: bool = False) -> None:
371
+ if type(state.get("schema")) is not int or state["schema"] != 1:
372
+ raise ValueError("Unsupported installer state schema; expected schema 1")
373
+ if not isinstance(state.get("accepted_version"), str) or not state["accepted_version"].strip():
374
+ raise ValueError("State accepted_version must be a nonempty string")
375
+ agent_selection(state.get("agents"), agents if agents is not None else load_agent_registry()["names"],
376
+ unique=True)
377
+ records = state.get("files")
378
+ if not isinstance(records, dict):
379
+ raise ValueError("State files must be an object")
380
+ for relative, record in records.items():
381
+ if not isinstance(relative, str) or not isinstance(record, dict):
382
+ raise ValueError("State files must map relative paths to objects")
383
+ if allow_obstructions:
384
+ safe_path(target, relative, allow_obstructions=True)
385
+ else:
386
+ safe_path(target, relative)
387
+ for field in ("installed_hash", "source_hash"):
388
+ value = record.get(field)
389
+ if not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value):
390
+ raise ValueError(f"Invalid state {field} for {relative}")
391
+ if "local_adaptation" in record and type(record["local_adaptation"]) is not bool:
392
+ raise ValueError(f"State local_adaptation must be boolean for {relative}")
393
+ managed = state.get("managed_json", {})
394
+ if not isinstance(managed, dict):
395
+ raise ValueError("State managed_json must be an object")
396
+ for relative, entries in managed.items():
397
+ if not isinstance(relative, str) or not isinstance(entries, dict):
398
+ raise ValueError("State managed_json must map relative paths to objects")
399
+ if allow_obstructions:
400
+ safe_path(target, relative, allow_obstructions=True)
401
+ else:
402
+ safe_path(target, relative)
403
+ for key, values in entries.items():
404
+ if (not isinstance(key, str) or not re.fullmatch(r"(?:permissions|hooks)\.[A-Za-z]+", key) or
405
+ not isinstance(values, list) or any(not isinstance(value, str) for value in values)):
406
+ raise ValueError(f"Invalid state managed_json entry for {relative}")
407
+
408
+
409
+ def merge_managed_json(current: dict, remove: dict[str, list[str]], add: dict[str, list[str]]) -> dict:
410
+ """Drop stale AI-KIT-owned entries and add missing ones; all other content is preserved.
411
+
412
+ Keys name a settings list: permissions.<list> holds rule strings, hooks.<event> holds
413
+ command hook groups identified by their command.
414
+ """
415
+ result = json.loads(json.dumps(current))
416
+ for key, values in remove.items():
417
+ section, name = key.split(".", 1)
418
+ stale = [value for value in values if value not in add.get(key, [])]
419
+ container = result.get(section)
420
+ if not stale or not isinstance(container, dict) or not isinstance(container.get(name), list):
421
+ continue
422
+ items = container[name]
423
+ kept = []
424
+ for item in items:
425
+ if section != "hooks":
426
+ if item not in stale:
427
+ kept.append(item)
428
+ continue
429
+ handlers = item.get("hooks") if isinstance(item, dict) else None
430
+ if isinstance(handlers, list):
431
+ remaining = [h for h in handlers if not (isinstance(h, dict) and h.get("command") in stale)]
432
+ if len(remaining) != len(handlers):
433
+ if not remaining:
434
+ continue
435
+ item = {**item, "hooks": remaining}
436
+ kept.append(item)
437
+ if kept == items:
438
+ continue
439
+ container[name] = kept
440
+ if not kept:
441
+ del container[name]
442
+ if not container:
443
+ del result[section]
444
+ for key, values in add.items():
445
+ section, name = key.split(".", 1)
446
+ container = result.setdefault(section, {})
447
+ if not isinstance(container, dict):
448
+ raise ValueError(f"{section} must be an object")
449
+ items = container.setdefault(name, [])
450
+ if not isinstance(items, list):
451
+ raise ValueError(f"{key} must be a list")
452
+ if section == "hooks":
453
+ present = set()
454
+ for group in items:
455
+ handlers = group.get("hooks") if isinstance(group, dict) else None
456
+ for handler in handlers if isinstance(handlers, list) else []:
457
+ if isinstance(handler, dict):
458
+ present.add(handler.get("command"))
459
+ for value in values:
460
+ if value not in present:
461
+ items.append({"hooks": [{"type": "command", "command": value}]})
462
+ else:
463
+ for value in values:
464
+ if value not in items:
465
+ items.append(value)
466
+ return result
467
+
468
+
469
+ def project_modules(target: Path, facts: dict) -> tuple[list[dict], list[str]]:
470
+ """Module facts for generated client configuration: project.json when filled, else detection."""
471
+ warnings: list[str] = []
472
+ source = facts["modules"]
473
+ path = target / "ai-kit" / "project.json"
474
+ if path.is_file() and not is_link(path):
475
+ try:
476
+ value = json.loads(path.read_text(encoding="utf-8"))
477
+ if isinstance(value, dict) and isinstance(value.get("modules"), list) and value["modules"]:
478
+ source = value["modules"]
479
+ except (OSError, ValueError, UnicodeError):
480
+ warnings.append("ai-kit/project.json is unreadable; generated client configuration uses detection.")
481
+ modules = []
482
+ for item in source:
483
+ module_path = item.get("path") if isinstance(item, dict) else None
484
+ if isinstance(module_path, str) and not module_path.endswith("/"):
485
+ module_path += "/"
486
+ if not isinstance(module_path, str) or not MODULE_PATH.fullmatch(module_path):
487
+ warnings.append(f"Skipping module path unsupported by generated client configuration: {module_path!r}")
488
+ continue
489
+ profiles = item.get("profiles") if isinstance(item.get("profiles"), list) else []
490
+ commands = item.get("commands") if isinstance(item.get("commands"), dict) else {}
491
+ modules.append({"path": module_path,
492
+ "profiles": sorted({p for p in profiles if isinstance(p, str) and re.fullmatch(r"[A-Z]+", p)}),
493
+ "commands": {k: v for k, v in commands.items() if isinstance(k, str) and isinstance(v, str)}})
494
+ return modules, warnings
495
+
496
+
497
+ def secret_patterns(root: Path, modules: list[dict]) -> list[str]:
498
+ """Gitignore-style secret patterns: the shared list plus secrets of detected stacks."""
499
+ text = (root / "templates" / SECRETS_SOURCE).read_text(encoding="utf-8")
500
+ patterns = [line.strip() for line in text.splitlines() if line.strip() and not line.startswith("#")]
501
+ for module in modules:
502
+ prefix = module["path"][1:]
503
+ profiles = set(module["profiles"])
504
+ extra = []
505
+ if profiles & {"PHP", "LARAVEL", "MOODLE"}:
506
+ extra.append("auth.json")
507
+ if "LARAVEL" in profiles:
508
+ extra.append("/" + prefix + "storage/*.key")
509
+ if "MOODLE" in profiles:
510
+ extra.append("/" + prefix + "config.php")
511
+ if "PYTHON" in profiles:
512
+ extra.append(".pypirc")
513
+ patterns.extend(pattern for pattern in extra if pattern not in patterns)
514
+ return patterns
515
+
516
+
517
+ def claude_read_rules(patterns: list[str]) -> list[str]:
518
+ """Convert gitignore patterns to Claude Code Read rules; negations keep their order."""
519
+ rules = []
520
+ for pattern in patterns:
521
+ negated = pattern.startswith("!")
522
+ body = pattern[1:] if negated else pattern
523
+ if body.endswith("/"):
524
+ body += "**"
525
+ rules.append("Read(" + ("!" if negated else "") + body + ")")
526
+ return rules
527
+
528
+
529
+ def claude_allow_rules(modules: list[dict]) -> list[str]:
530
+ rules = []
531
+ for module in modules:
532
+ for key in ("test", "lint", "build"):
533
+ command = module["commands"].get(key)
534
+ if command and command == command.strip() and not UNSAFE_COMMAND.search(command):
535
+ # A trailing " *" also matches the bare command.
536
+ rule = f"Bash({command} *)"
537
+ if rule not in rules:
538
+ rules.append(rule)
539
+ return rules
540
+
541
+
542
+ def claude_managed_entries(root: Path, extras: set[str], modules: list[dict] | None = None) -> dict[str, list[str]]:
543
+ """Client settings entries AI-KIT owns for the enabled extras."""
544
+ entries: dict[str, list[str]] = {}
545
+
546
+ def add(key: str, values: list[str]) -> None:
547
+ bucket = entries.setdefault(key, [])
548
+ bucket.extend(value for value in values if value not in bucket)
549
+
550
+ modules = modules or []
551
+ if "guards" in extras:
552
+ guards = read_json(root / "integrations" / GUARDS_SOURCE, {})
553
+ for kind, rules in guards.get("permissions", {}).items():
554
+ add("permissions." + kind, list(rules))
555
+ if "data-guards" in extras:
556
+ data_guards = read_json(root / "integrations" / DATA_GUARDS_SOURCE, {})
557
+ for kind, rules in data_guards.get("permissions", {}).items():
558
+ add("permissions." + kind, list(rules))
559
+ stacks = data_guards.get("stacks", {})
560
+ for profile in sorted({p for module in modules for p in module["profiles"]}):
561
+ add("permissions.ask", list(stacks.get(profile, [])))
562
+ if "native-settings" in extras:
563
+ add("permissions.allow", claude_allow_rules(modules))
564
+ add("permissions.deny", claude_read_rules(secret_patterns(root, modules)))
565
+ if "session-start" in extras:
566
+ entries["hooks.SessionStart"] = [SESSION_START_COMMAND]
567
+ return {key: values for key, values in entries.items() if values}
568
+
569
+
570
+ def _module_slugs(modules: list[dict]) -> dict[str, str]:
571
+ slugs: dict[str, str] = {}
572
+ for module in modules:
573
+ path = module["path"]
574
+ slug = re.sub(r"[^a-z0-9]+", "-", path.strip("/").lower()).strip("-") or "root"
575
+ if slug in slugs.values():
576
+ slug += "-" + digest(path.encode())[:6]
577
+ slugs[path] = slug
578
+ return slugs
579
+
580
+
581
+ def render_scoped_rule(fmt: str, relative: str, module: dict) -> bytes:
582
+ """One module rule pointing to canonical profiles; the root module uses each client's always-on form."""
583
+ path = module["path"]
584
+ root_module = path == "/"
585
+ glob = "**" if root_module else path[1:] + "**"
586
+ up = "../" * relative.count("/")
587
+ links = ", ".join(f"[{profile}]({up}ai-kit/stacks/{profile}.md)" for profile in module["profiles"])
588
+ scope = "this repository" if root_module else f"files under `{path[1:]}`"
589
+ if fmt in {"claude-paths", "cline-paths"}:
590
+ header = "" if root_module else f"---\npaths:\n - {json.dumps(glob)}\n---\n\n"
591
+ elif fmt == "cursor-globs":
592
+ header = "---\nalwaysApply: true\n---\n\n" if root_module else f"---\nglobs: {glob}\nalwaysApply: false\n---\n\n"
593
+ elif fmt == "copilot-applyto":
594
+ header = f"---\napplyTo: {json.dumps(glob)}\n---\n\n"
595
+ else:
596
+ header = "---\ntrigger: always_on\n---\n\n" if root_module else f"---\ntrigger: glob\nglobs: {glob}\n---\n\n"
597
+ body = (f"# AI-KIT module rules: {path}\n\n"
598
+ "Generated by the AI-KIT installer from ai-kit/project.json; update the module facts there and rerun "
599
+ "the installer instead of editing this file.\n"
600
+ f"For {scope} apply {links} with the project's actual versions and conventions.\n")
601
+ if module["commands"]:
602
+ body += "Recorded module commands: " + "; ".join(
603
+ f"{key} `{value}`" for key, value in sorted(module["commands"].items())) + ".\n"
604
+ return (header + body).encode("utf-8")
605
+
606
+
607
+ def scoped_rule_files(root: Path, registry: dict, agents: list[str], modules: list[dict]) -> dict[str, bytes]:
608
+ """Generate path-scoped rules per module for selected clients that support them."""
609
+ usable = []
610
+ for module in modules:
611
+ profiles = [p for p in module["profiles"] if (root / "template/ai-kit/stacks" / (p + ".md")).is_file()]
612
+ if profiles:
613
+ usable.append({**module, "profiles": profiles})
614
+ slugs = _module_slugs(usable)
615
+ files: dict[str, bytes] = {}
616
+ for agent in agents:
617
+ spec = registry["scoped_rules"].get(agent)
618
+ if spec is None:
619
+ continue
620
+ for module in usable:
621
+ relative = spec["directory"] + "ai-kit-" + slugs[module["path"]] + SCOPED_FORMATS[spec["format"]]
622
+ files[relative] = render_scoped_rule(spec["format"], relative, module)
623
+ return files
624
+
625
+
626
+ def adapter_owner(relative: str, registry: dict) -> str | None:
627
+ entries = registry["entries"]
628
+ skills_copies = registry["skills_copies"]
629
+ optional = registry["optional"]
630
+ if relative in entries:
631
+ return entries[relative]
632
+ for agent, prefix in skills_copies.items():
633
+ if relative.startswith(prefix):
634
+ return agent
635
+ for agent, items in optional.items():
636
+ if any(destination == relative for _, destination in items):
637
+ return agent
638
+ for agent, spec in registry.get("scoped_rules", {}).items():
639
+ if relative.startswith(spec["directory"] + "ai-kit-"):
640
+ return agent
641
+ return None
642
+
643
+
644
+ def split_ignore(current: str) -> tuple[str, str]:
645
+ if current.count(START) != current.count(END) or current.count(START) > 1:
646
+ raise ValueError("Malformed or repeated AI-KIT ignore markers")
647
+ if START not in current:
648
+ return current, ""
649
+ a, b = current.index(START), current.index(END)
650
+ if b < a:
651
+ raise ValueError("Reversed AI-KIT ignore markers")
652
+ # Markers must occupy complete lines, avoiding matches inside unrelated comments.
653
+ lines = current.splitlines(keepends=True)
654
+ if sum(line.strip() == START for line in lines) != 1 or sum(line.strip() == END for line in lines) != 1:
655
+ raise ValueError("Ignore markers must occupy complete lines")
656
+ end = b + len(END)
657
+ if current[end:end + 2] == "\r\n":
658
+ end += 2
659
+ elif current[end:end + 1] == "\n":
660
+ end += 1
661
+ return current[:a], current[end:]
662
+
663
+
664
+ def module_ignores(target: Path) -> list[str]:
665
+ rules = []
666
+ excluded = {".git", "node_modules", "vendor", "ai-kit", ".agents", ".claude", ".venv"}
667
+ for directory, dirs, files in os.walk(target, followlinks=False):
668
+ dirs[:] = [d for d in dirs if d not in excluded and not is_link(Path(directory) / d)]
669
+ module = Path(directory)
670
+ prefix = module.relative_to(target).as_posix()
671
+ prefix = "" if prefix == "." else prefix + "/"
672
+ # Deliberate Go vendoring wins over a generic Composer exclusion.
673
+ if "composer.json" in files and "go.mod" not in files and not (module / "vendor/modules.txt").exists():
674
+ rules.append("/" + prefix + "vendor/")
675
+ if "config-dist.php" in files and (module / "lib/moodlelib.php").is_file():
676
+ rules.extend(["/" + prefix + "config.php", "/" + prefix + "behat.yml"])
677
+ # Node/Python generated paths are scoped to modules with verified manifests.
678
+ if "package.json" in files:
679
+ rules.extend(prefix + "**/" + name for name in NODE_IGNORES)
680
+ if any(name in files for name in PROFILE_MANIFESTS["PYTHON"]):
681
+ rules.extend(prefix + "**/" + name for name in PYTHON_IGNORES)
682
+ rules.extend("/" + prefix + name for name in PYTHON_ROOT_IGNORES)
683
+ return sorted(set(rules))
684
+
685
+
686
+ def _manifest_json(path: Path) -> dict | None:
687
+ try:
688
+ value = json.loads(path.read_text(encoding="utf-8"))
689
+ except (OSError, ValueError, UnicodeError):
690
+ return None
691
+ return value if isinstance(value, dict) else None
692
+
693
+
694
+ def _go_version(path: Path) -> str | None:
695
+ try:
696
+ for line in path.read_text(encoding="utf-8").splitlines():
697
+ match = re.match(r"\s*go\s+(\d+\.\d+(?:\.\d+)?)\s*$", line)
698
+ if match:
699
+ return match.group(1)
700
+ except (OSError, UnicodeError):
701
+ pass
702
+ return None
703
+
704
+
705
+ def _python_requires(path: Path) -> str | None:
706
+ try:
707
+ match = re.search(r'requires-python\s*=\s*"([^"]+)"', path.read_text(encoding="utf-8"))
708
+ except (OSError, UnicodeError):
709
+ return None
710
+ return match.group(1) if match else None
711
+
712
+
713
+ def _upward(module: Path, target: Path):
714
+ """Yield the module directory and its ancestors up to the project root (workspace roots)."""
715
+ current = module
716
+ while True:
717
+ yield current
718
+ if current == target or target not in current.parents:
719
+ return
720
+ current = current.parent
721
+
722
+
723
+ def _node_manager(module: Path, target: Path) -> str:
724
+ """Prefer a declared packageManager, then a lockfile, searching up to the workspace root."""
725
+ for directory in _upward(module, target):
726
+ declared = (_manifest_json(directory / "package.json") or {}).get("packageManager")
727
+ if isinstance(declared, str) and declared.split("@", 1)[0] in NODE_MANAGERS:
728
+ return declared.split("@", 1)[0]
729
+ for lockfile, manager in NODE_LOCKFILES:
730
+ if (directory / lockfile).is_file():
731
+ return manager
732
+ return "npm"
733
+
734
+
735
+ def _package_facts(pkg: dict, manager: str = "npm") -> dict:
736
+ version = None
737
+ engines = pkg.get("engines")
738
+ if isinstance(engines, dict) and isinstance(engines.get("node"), str):
739
+ version = engines["node"]
740
+ scripts = pkg.get("scripts") if isinstance(pkg.get("scripts"), dict) else {}
741
+ commands = {}
742
+ if "test" in scripts:
743
+ # "bun test" starts Bun's own runner rather than the package script.
744
+ commands["test"] = f"{manager} run test" if manager == "bun" else f"{manager} test"
745
+ if "build" in scripts:
746
+ commands["build"] = f"{manager} run build"
747
+ if "lint" in scripts:
748
+ commands["lint"] = f"{manager} run lint"
749
+ return {"language": "Node", "version": version, "commands": commands}
750
+
751
+
752
+ def _python_commands(module: Path, target: Path, files: list[str]) -> dict:
753
+ """Suggest pytest/ruff only when manifests or config files reference them."""
754
+ texts = []
755
+ for name in PYTHON_TOOL_FILES:
756
+ if name in files:
757
+ try:
758
+ texts.append((module / name).read_text(encoding="utf-8"))
759
+ except (OSError, UnicodeError):
760
+ pass
761
+ evidence = "\n".join(texts)
762
+ runner = next((prefix for directory in _upward(module, target)
763
+ for lockfile, prefix in PYTHON_RUNNERS if (directory / lockfile).is_file()), "")
764
+ commands = {}
765
+ if re.search(r"\bpytest\b", evidence) or "pytest.ini" in files or "conftest.py" in files:
766
+ commands["test"] = runner + "pytest"
767
+ if re.search(r"\bruff\b", evidence) or "ruff.toml" in files or ".ruff.toml" in files:
768
+ commands["lint"] = runner + "ruff check ."
769
+ return commands
770
+
771
+
772
+ def _composer_facts(composer: dict, *, laravel: bool) -> dict:
773
+ require = composer.get("require") if isinstance(composer.get("require"), dict) else {}
774
+ version = require.get("php") if isinstance(require.get("php"), str) else None
775
+ if laravel and isinstance(require.get("laravel/framework"), str):
776
+ version = require["laravel/framework"]
777
+ scripts = composer.get("scripts") if isinstance(composer.get("scripts"), dict) else {}
778
+ commands = {}
779
+ if "test" in scripts:
780
+ commands["test"] = "composer test"
781
+ if laravel:
782
+ commands["test"] = "php artisan test"
783
+ return {"language": "PHP", "version": version, "commands": commands}
784
+
785
+
786
+ def _filament_manifests(module: Path, composer: dict) -> list[str]:
787
+ """Suggest a profile from known first-party dependencies, never from a wrapper or directory name."""
788
+ evidence = []
789
+ for section in ("require", "require-dev"):
790
+ dependencies = composer.get(section, {})
791
+ if isinstance(dependencies, dict) and FILAMENT_PACKAGES.intersection(dependencies):
792
+ evidence.append("composer.json")
793
+ break
794
+ lock_path = module / "composer.lock"
795
+ lock = (_manifest_json(lock_path) if not is_link(lock_path) else None) or {}
796
+ for section in ("packages", "packages-dev"):
797
+ packages = lock.get(section, [])
798
+ if isinstance(packages, list) and any(isinstance(package, dict) and
799
+ isinstance(package.get("name"), str) and
800
+ package["name"] in FILAMENT_PACKAGES for package in packages):
801
+ evidence.append("composer.lock")
802
+ break
803
+ return evidence
804
+
805
+
806
+ def _cargo_facts(module: Path) -> dict:
807
+ version = None
808
+ try:
809
+ match = re.search(r'^\s*rust-version\s*=\s*"([^"]+)"',
810
+ (module / "Cargo.toml").read_text(encoding="utf-8"), re.M)
811
+ if match:
812
+ version = match.group(1)
813
+ except (OSError, UnicodeError):
814
+ pass
815
+ return {"language": "Rust", "version": version,
816
+ "commands": {"test": "cargo test", "build": "cargo build", "lint": "cargo clippy"}}
817
+
818
+
819
+ def _dotnet_facts(module: Path, files: list[str]) -> dict:
820
+ project_file = next((name for name in sorted(files)
821
+ if name.endswith((".csproj", ".fsproj", ".vbproj"))), None)
822
+ version = None
823
+ if project_file:
824
+ try:
825
+ match = re.search(r"<TargetFrameworks?>([^<]+)</TargetFrameworks?>",
826
+ (module / project_file).read_text(encoding="utf-8"))
827
+ if match:
828
+ version = match.group(1).split(";")[0].strip()
829
+ except (OSError, UnicodeError):
830
+ pass
831
+ return {"language": ".NET", "version": version,
832
+ "commands": {"test": "dotnet test", "build": "dotnet build"}}
833
+
834
+
835
+ def _java_facts(module: Path, files: list[str]) -> dict:
836
+ if "pom.xml" in files or "mvnw" in files:
837
+ version = None
838
+ if "pom.xml" in files:
839
+ try:
840
+ match = re.search(r"<(maven.compiler.release|java.version|maven.compiler.source)>([^<]+)</",
841
+ (module / "pom.xml").read_text(encoding="utf-8"))
842
+ if match:
843
+ version = match.group(2).strip()
844
+ except (OSError, UnicodeError):
845
+ pass
846
+ runner = "./mvnw" if "mvnw" in files else "mvn"
847
+ return {"language": "Java", "version": version,
848
+ "commands": {"test": runner + " test", "build": runner + " compile"}}
849
+ runner = "./gradlew" if "gradlew" in files else "gradle"
850
+ return {"language": "Java", "version": None,
851
+ "commands": {"test": runner + " test", "build": runner + " build"}}
852
+
853
+
854
+ def _infra_facts(module: Path, files: list[str]) -> tuple[list[str], dict]:
855
+ if "Chart.yaml" in files:
856
+ return ["Chart.yaml"], {"language": "Helm", "version": None,
857
+ "commands": {"lint": "helm lint ."}}
858
+ terraform = sorted(name for name in files if name.endswith(".tf"))
859
+ if terraform:
860
+ return terraform, {"language": "Terraform", "version": None,
861
+ "commands": {"lint": "terraform fmt -check", "test": "terraform validate"}}
862
+ return [], {"language": None, "version": None, "commands": {}}
863
+
864
+
865
+ def detect_facts(target: Path) -> dict:
866
+ """Detect per-module manifests, language, version, and suggested commands (unverified draft)."""
867
+ modules: dict[str, dict] = {}
868
+ excluded = {".git", "node_modules", "vendor", "ai-kit", ".agents", ".claude", ".venv"}
869
+ for directory, dirs, files in os.walk(target, followlinks=False):
870
+ dirs[:] = [d for d in dirs if d not in excluded and not is_link(Path(directory) / d)]
871
+ module = Path(directory)
872
+ prefix = module.relative_to(target).as_posix()
873
+ prefix = "" if prefix == "." else prefix + "/"
874
+ facts: dict = {"path": "/" + prefix if prefix else "/", "manifests": [],
875
+ "language": None, "version": None, "profiles": [], "commands": {}}
876
+ if "go.mod" in files:
877
+ facts["manifests"].append("go.mod")
878
+ facts["profiles"].append("GO")
879
+ facts["language"] = "Go"
880
+ facts["version"] = _go_version(module / "go.mod")
881
+ facts["commands"].update({"test": "go test ./...", "build": "go build ./...",
882
+ "lint": "go vet ./..."})
883
+ py_manifest = next((name for name in PROFILE_MANIFESTS["PYTHON"] if name in files), None)
884
+ if py_manifest:
885
+ facts["manifests"].append(py_manifest)
886
+ facts["profiles"].append("PYTHON")
887
+ if facts["language"] is None:
888
+ facts["language"] = "Python"
889
+ if facts["version"] is None:
890
+ facts["version"] = _python_requires(module / py_manifest)
891
+ facts["commands"].update(_python_commands(module, target, files))
892
+ if "package.json" in files:
893
+ facts["manifests"].append("package.json")
894
+ facts["profiles"].append("FRONTEND")
895
+ package = _package_facts(_manifest_json(module / "package.json") or {},
896
+ _node_manager(module, target))
897
+ if facts["language"] is None:
898
+ facts["language"] = package["language"]
899
+ if facts["version"] is None:
900
+ facts["version"] = package["version"]
901
+ facts["commands"].update(package["commands"])
902
+ if "composer.json" in files:
903
+ facts["manifests"].append("composer.json")
904
+ facts["profiles"].append("PHP")
905
+ laravel = (module / "artisan").is_file()
906
+ if laravel:
907
+ facts["profiles"].append("LARAVEL")
908
+ manifest = _manifest_json(module / "composer.json") or {}
909
+ filament = _filament_manifests(module, manifest)
910
+ if filament:
911
+ facts["profiles"].append("FILAMENT")
912
+ if "composer.lock" in filament:
913
+ facts["manifests"].append("composer.lock")
914
+ composer = _composer_facts(manifest, laravel=laravel)
915
+ if facts["language"] is None:
916
+ facts["language"] = composer["language"]
917
+ if facts["version"] is None:
918
+ facts["version"] = composer["version"]
919
+ facts["commands"].update(composer["commands"])
920
+ if "config-dist.php" in files and (module / "lib/moodlelib.php").is_file():
921
+ facts["manifests"].append("config-dist.php")
922
+ facts["profiles"].append("MOODLE")
923
+ if facts["language"] is None:
924
+ facts["language"] = "PHP"
925
+ facts["commands"].setdefault("test", "vendor/bin/phpunit")
926
+ if "Cargo.toml" in files:
927
+ facts["manifests"].append("Cargo.toml")
928
+ facts["profiles"].append("RUST")
929
+ cargo = _cargo_facts(module)
930
+ if facts["language"] is None:
931
+ facts["language"] = cargo["language"]
932
+ if facts["version"] is None:
933
+ facts["version"] = cargo["version"]
934
+ facts["commands"].update(cargo["commands"])
935
+ if any(name.endswith((".csproj", ".fsproj", ".vbproj")) for name in files) or "global.json" in files:
936
+ project_files = [name for name in sorted(files)
937
+ if name.endswith((".csproj", ".fsproj", ".vbproj"))]
938
+ facts["manifests"].extend(project_files or ["global.json"])
939
+ facts["profiles"].append("DOTNET")
940
+ dotnet = _dotnet_facts(module, files)
941
+ if facts["language"] is None:
942
+ facts["language"] = dotnet["language"]
943
+ if facts["version"] is None:
944
+ facts["version"] = dotnet["version"]
945
+ facts["commands"].update(dotnet["commands"])
946
+ if any(name in files for name in ("pom.xml", "mvnw", "build.gradle", "build.gradle.kts", "gradlew")):
947
+ java_manifest = next(name for name in ("pom.xml", "mvnw", "build.gradle", "build.gradle.kts", "gradlew")
948
+ if name in files)
949
+ facts["manifests"].append(java_manifest)
950
+ facts["profiles"].append("JAVA")
951
+ java = _java_facts(module, files)
952
+ if facts["language"] is None:
953
+ facts["language"] = java["language"]
954
+ if facts["version"] is None:
955
+ facts["version"] = java["version"]
956
+ facts["commands"].update(java["commands"])
957
+ infra_manifests, infra = _infra_facts(module, files)
958
+ if infra_manifests:
959
+ facts["manifests"].extend(infra_manifests)
960
+ facts["profiles"].append("INFRA")
961
+ if facts["language"] is None:
962
+ facts["language"] = infra["language"]
963
+ facts["commands"].update(infra["commands"])
964
+ if facts["manifests"]:
965
+ facts["profiles"] = sorted(set(facts["profiles"]))
966
+ modules[prefix] = facts
967
+ return {"modules": [modules[key] for key in sorted(modules)]}
968
+
969
+
970
+ def project_fact_evidence(target: Path, facts: dict) -> dict:
971
+ """Return stable, path-safe manifest digests for doctor freshness checks."""
972
+ modules = []
973
+ for module in facts["modules"]:
974
+ prefix = "" if module["path"] == "/" else module["path"].strip("/") + "/"
975
+ manifests = {}
976
+ for name in sorted(module["manifests"]):
977
+ path = safe_path(target, prefix + name)
978
+ if is_link(path):
979
+ raise ValueError("Refusing linked project manifest: " + prefix + name)
980
+ if path.is_file():
981
+ manifests[name] = digest(path.read_bytes())
982
+ modules.append({"path": module["path"], "manifests": manifests,
983
+ "profiles": list(module["profiles"]), "commands": dict(module["commands"])})
984
+ return {"schema": 1, "modules": modules}
985
+
986
+
987
+ def profiles_from_facts(facts: dict) -> dict[str, list[str]]:
988
+ found: dict[str, set[str]] = {}
989
+ for module in facts["modules"]:
990
+ prefix = "" if module["path"] == "/" else module["path"][1:]
991
+ for profile in module["profiles"]:
992
+ found.setdefault(profile, set()).add(prefix)
993
+ return {profile: sorted(prefixes) for profile, prefixes in sorted(found.items())}
994
+
995
+
996
+ def detect_profiles(target: Path) -> dict[str, list[str]]:
997
+ """Map stack profiles to module prefixes evidenced by manifests (unverified draft)."""
998
+ return profiles_from_facts(detect_facts(target))
999
+
1000
+
1001
+ def project_context_draft(data: bytes, facts: dict) -> bytes:
1002
+ """Turn installer-detected manifests into draft module-map rows for a fresh context."""
1003
+ modules = facts["modules"]
1004
+ if not modules:
1005
+ return data
1006
+ text = data.decode("utf-8")
1007
+ placeholder = "| Not established | Not established | Not established | None confirmed | Not established |"
1008
+ if placeholder not in text:
1009
+ return data
1010
+ rows = []
1011
+ for module in modules:
1012
+ manifests = ", ".join(module["manifests"]) + " (installer-detected)"
1013
+ language = module["language"] or "not established"
1014
+ if module["version"]:
1015
+ language += " " + module["version"]
1016
+ language += " (suggested)"
1017
+ profiles = ", ".join(module["profiles"]) + " (suggested, confirm at bootstrap)"
1018
+ commands = module["commands"]
1019
+ if commands:
1020
+ command_text = "; ".join(f"{key}: {value}" for key, value in sorted(commands.items()))
1021
+ else:
1022
+ command_text = "not established"
1023
+ rows.append(f"| {module['path']} | {manifests} | {language} | {profiles} | {command_text} |")
1024
+ return text.replace(placeholder, "\n".join(rows), 1).encode("utf-8")
1025
+
1026
+
1027
+ def project_json_draft(data: bytes, facts: dict, target: Path) -> bytes:
1028
+ """Fill the machine-readable project.json modules from detected manifests (unverified draft)."""
1029
+ if not facts["modules"]:
1030
+ return data
1031
+ value = json.loads(data.decode("utf-8"))
1032
+ value["modules"] = [{"path": m["path"], "manifests": m["manifests"], "language": m["language"],
1033
+ "version": m["version"], "profiles": m["profiles"], "commands": m["commands"]}
1034
+ for m in facts["modules"]]
1035
+ value["evidence"] = project_fact_evidence(target, facts)
1036
+ return (json.dumps(value, indent=2) + "\n").encode("utf-8")
1037
+
1038
+
1039
+ def merge_ignore(current: str, policy: str, extra: list[str]) -> tuple[str, str]:
1040
+ before, after = split_ignore(current)
1041
+ outside = before + after
1042
+ block = START + "\n" + policy.rstrip() + "\n"
1043
+ if extra:
1044
+ block += "\n# Verified module-specific generated paths\n" + "\n".join(extra) + "\n"
1045
+ block += END + "\n"
1046
+ if before and not before.endswith(("\n", "\r")):
1047
+ before += "\n"
1048
+ return before + block + after, outside
1049
+
1050
+
1051
+ def project_documents(settings: dict) -> set[str]:
1052
+ """Project-owned documents are created once, including those of enabled modules."""
1053
+ return OWNED | {path for extra, paths in OPTIONAL_DOCUMENTS.items()
1054
+ if settings.get(EXTRA_SETTINGS_KEYS[extra]) is True for path in paths}
1055
+
1056
+
1057
+ def build_plan(target: Path, *, root: Path = ROOT, mode: str | None = None,
1058
+ language: str | None = None, conventions: str | None = None,
1059
+ agents: list[str] | None = None, accept_local: list[str] | None = None,
1060
+ extras: list[str] | None = None, preset: str | None = None) -> dict:
1061
+ if preset is not None and preset not in PRESETS:
1062
+ raise ValueError(f"Unknown preset: {preset}; choose one of {', '.join(sorted(PRESETS))}")
1063
+ target = target.absolute()
1064
+ if is_link(target):
1065
+ raise ValueError("Refusing a linked target root")
1066
+ target = target.resolve()
1067
+ if target == root.resolve() or root.resolve() in target.parents:
1068
+ raise ValueError("Cannot install an application kit inside the reference distribution")
1069
+ if target.exists() and not target.is_dir():
1070
+ raise ValueError("Target must be a directory")
1071
+ registry = load_agent_registry(root)
1072
+ settings_path = safe_path(target, "ai-kit/settings.json")
1073
+ settings = read_json(settings_path, read_json(root / "template/ai-kit/settings.json", {}))
1074
+ validate_settings(settings)
1075
+ state_path = safe_path(target, "ai-kit/.install-state.json")
1076
+ state = read_json(state_path, {})
1077
+ if state_path.exists():
1078
+ validate_state(state, target, registry["names"])
1079
+ preset_disabled = []
1080
+ if preset is not None:
1081
+ mode = mode or PRESETS[preset]["mode"]
1082
+ for name in PRESET_EXTRAS:
1083
+ key = EXTRA_SETTINGS_KEYS[name]
1084
+ enabled = name in PRESETS[preset]["extras"]
1085
+ if settings.get(key) is True and not enabled and name not in (extras or []):
1086
+ preset_disabled.append(name)
1087
+ settings[key] = enabled
1088
+ mode = mode or settings.get("sharing_mode", "private")
1089
+ language = language or settings.get("chat_language", "Russian")
1090
+ conventions = conventions or settings.get("conventions", "owner")
1091
+ explicit_agents = agents is not None
1092
+ agents = agent_selection(agents if agents is not None else state.get("agents", ["codex"]),
1093
+ registry["names"])
1094
+ detected_agents = detect_agents(target, registry)
1095
+ suggested_agents = sorted(set(agents) | set(detected_agents))
1096
+ if accept_local is not None and (not isinstance(accept_local, list) or
1097
+ any(not isinstance(path, str) for path in accept_local)):
1098
+ raise ValueError("Accept-local paths must be a list of strings")
1099
+ accept = set(accept_local or [])
1100
+ enabled_extras = set(extras or [])
1101
+ if enabled_extras - EXTRAS:
1102
+ raise ValueError("Unknown install extras: " + ", ".join(sorted(enabled_extras - EXTRAS)))
1103
+ for name, key in EXTRA_SETTINGS_KEYS.items():
1104
+ if name not in enabled_extras and settings.get(key) is True:
1105
+ enabled_extras.add(name)
1106
+ for name, key in EXTRA_SETTINGS_KEYS.items():
1107
+ if name in enabled_extras:
1108
+ settings[key] = True
1109
+ settings.update(sharing_mode=mode, chat_language=language, conventions=conventions)
1110
+ validate_settings(settings)
1111
+ owned_documents = project_documents(settings)
1112
+ facts = detect_facts(target)
1113
+ detected = profiles_from_facts(facts)
1114
+ modules, module_warnings = project_modules(target, facts)
1115
+ upstream_files = {}
1116
+ for name in OPTIONAL_SKILLS:
1117
+ if name in enabled_extras:
1118
+ upstream_files.update(upstream_skill_files(name, root))
1119
+ incoming: dict[str, bytes] = {}
1120
+ for src in sorted((root / "template").rglob("*")):
1121
+ if is_link(src):
1122
+ raise ValueError(f"Linked source path: {src}")
1123
+ if not src.is_file():
1124
+ continue
1125
+ relative = src.relative_to(root / "template").as_posix()
1126
+ if any(relative.startswith("ai-kit/vendor/" + name + "/") for name in OPTIONAL_SKILLS):
1127
+ # Only the manifest-verified bytes may enter an optional upstream payload.
1128
+ continue
1129
+ if any(relative.startswith(".agents/skills/" + skill + "/") and name not in enabled_extras
1130
+ for name, skills in OPTIONAL_ENTRY_POINTS.items() for skill in skills):
1131
+ continue
1132
+ if any(relative in paths and name not in enabled_extras
1133
+ for mapping in (OPTIONAL_RESOURCES, OPTIONAL_DOCUMENTS) for name, paths in mapping.items()):
1134
+ continue
1135
+ if relative in registry["entries"] and registry["entries"][relative] not in agents:
1136
+ continue
1137
+ if any(relative in paths for paths in EXTRA_TEMPLATE.values()):
1138
+ continue
1139
+ incoming[relative] = src.read_bytes()
1140
+ incoming.update(upstream_files)
1141
+ for name, skills in OPTIONAL_ENTRY_POINTS.items():
1142
+ if name not in enabled_extras:
1143
+ registry_path = "ai-kit/SKILLS.md"
1144
+ incoming[registry_path] = b"\n".join(
1145
+ line for line in incoming[registry_path].split(b"\n")
1146
+ if not any(("(../.agents/skills/" + skill + "/SKILL.md)").encode() in line for skill in skills))
1147
+ if "web-design" not in enabled_extras:
1148
+ # These navigation lines only exist when their optional destinations are installed.
1149
+ for navigation in ("WIKI.md", "ai-kit/ADAPTERS.md"):
1150
+ incoming[navigation] = b"\n".join(line for line in incoming[navigation].split(b"\n")
1151
+ if b"WEB-DESIGN.md)" not in line)
1152
+ incoming["ai-kit/settings.json"] = (json.dumps(settings, indent=2) + "\n").encode()
1153
+ incoming["ai-kit/VERSION"] = (root / "VERSION").read_bytes()
1154
+ incoming["ai-kit/LICENSE"] = (root / "LICENSE").read_bytes()
1155
+ for agent, prefix in registry["skills_copies"].items():
1156
+ if agent in agents:
1157
+ for relative, data in list(incoming.items()):
1158
+ if relative.startswith(".agents/skills/"):
1159
+ if agent == "claude" and relative in {
1160
+ ".agents/skills/" + name + "/SKILL.md" for name in OPTIONAL_SKILLS}:
1161
+ newline = b"\r\n" if data.startswith(b"---\r\n") else b"\n"
1162
+ header = b"---" + newline
1163
+ if not data.startswith(header):
1164
+ raise ValueError("Optional upstream entry point lacks frontmatter")
1165
+ data = header + b"disable-model-invocation: true" + newline + data[len(header):]
1166
+ incoming[relative.replace(".agents/skills/", prefix, 1)] = data
1167
+ for agent, items in registry["optional"].items():
1168
+ if agent in agents:
1169
+ for source, destination in items:
1170
+ incoming[destination] = (root / "integrations" / source).read_bytes()
1171
+ for name in sorted(enabled_extras):
1172
+ if name in EXTRA_TEMPLATE:
1173
+ for relative in EXTRA_TEMPLATE[name]:
1174
+ incoming[relative] = (root / "template" / relative).read_bytes()
1175
+ elif name in EXTRA_INTEGRATIONS:
1176
+ src, dst = EXTRA_INTEGRATIONS[name]
1177
+ incoming[dst] = (root / "integrations" / src).read_bytes()
1178
+ if "scoped-rules" in enabled_extras:
1179
+ # Generated module rules are managed files: unchanged ones follow project.json, edited ones conflict.
1180
+ incoming.update(scoped_rule_files(root, registry, agents, modules))
1181
+ if accept - incoming.keys() or accept & (owned_documents | {"ai-kit/settings.json"}):
1182
+ raise ValueError("Accept-local paths must name selected managed instructions")
1183
+ records = state.get("files", {})
1184
+ if not isinstance(records, dict):
1185
+ raise ValueError("Invalid installer state")
1186
+ actions, conflicts, preserved, accepted = {}, {}, [], {}
1187
+ conflict_local_hashes = {}
1188
+ for relative, data in incoming.items():
1189
+ path = safe_path(target, relative)
1190
+ current = path.read_bytes() if path.exists() else None
1191
+ source_hash = digest(data)
1192
+ previous = records.get(relative, {})
1193
+ if not isinstance(previous, dict):
1194
+ raise ValueError("Invalid baseline record")
1195
+ if relative in owned_documents:
1196
+ if current is None:
1197
+ if relative == "PROJECT_CONTEXT.md":
1198
+ data = project_context_draft(data, facts)
1199
+ elif relative == "ai-kit/project.json":
1200
+ data = project_json_draft(data, facts, target)
1201
+ actions[relative] = {"data": data, "old": None}
1202
+ else:
1203
+ preserved.append(relative)
1204
+ continue
1205
+ if relative in accept:
1206
+ if current is None:
1207
+ raise ValueError(f"Cannot accept a missing file: {relative}")
1208
+ accepted[relative] = {"installed_hash": digest(current), "source_hash": source_hash,
1209
+ "local_adaptation": True}
1210
+ continue
1211
+ if relative == "ai-kit/settings.json" or current is None or current == data:
1212
+ accepted[relative] = {"installed_hash": source_hash, "source_hash": source_hash}
1213
+ if current != data:
1214
+ actions[relative] = {"data": data, "old": current}
1215
+ elif previous.get("source_hash") == source_hash:
1216
+ # No upstream change: retain local adaptations without rewriting them.
1217
+ accepted[relative] = previous
1218
+ preserved.append(relative)
1219
+ elif not previous.get("local_adaptation") and previous.get("installed_hash") == digest(current):
1220
+ actions[relative] = {"data": data, "old": current}
1221
+ accepted[relative] = {"installed_hash": source_hash, "source_hash": source_hash}
1222
+ else:
1223
+ conflicts[relative] = data
1224
+ conflict_local_hashes[relative] = digest(current)
1225
+ ignore_path = safe_path(target, ".gitignore")
1226
+ old_ignore = ignore_path.read_bytes() if ignore_path.exists() else None
1227
+ merged, outside = merge_ignore((old_ignore or b"").decode("utf-8-sig"),
1228
+ (root / "templates" / ("gitignore." + mode)).read_text(encoding="utf-8"),
1229
+ module_ignores(target))
1230
+ # Recognized legacy private exclusions cannot be silently removed for team mode.
1231
+ legacy = {"AGENTS.md", "/AGENTS.md", "/ai-kit/", "ai-kit/", "/.agents/", "/.agents/skills/",
1232
+ "PROJECT_CONTEXT.md", "/PROJECT_CONTEXT.md", "/.claude/", "SKILL.md"}
1233
+ warnings = ["Existing ignore rules outside the managed block need project-specific visibility review."]
1234
+ if "ci" in enabled_extras and mode == "private":
1235
+ warnings.append("The CI check reads installer state that private sharing mode excludes from Git; "
1236
+ "team mode is the intended companion.")
1237
+ if "claude" not in agents and ({"guards", "session-start"} & enabled_extras):
1238
+ warnings.append("guards/session-start client wiring requires the claude agent selection; "
1239
+ "only the shared files were installed.")
1240
+ if mode == "team" and legacy.intersection(line.strip() for line in outside.splitlines()):
1241
+ conflicts[".gitignore"] = merged.encode()
1242
+ conflict_local_hashes[".gitignore"] = digest(old_ignore) if old_ignore is not None else None
1243
+ warnings.append("Remove or reconcile legacy private exclusions explicitly before applying team mode.")
1244
+ elif old_ignore != merged.encode():
1245
+ actions[".gitignore"] = {"data": merged.encode(), "old": old_ignore}
1246
+ missing_agents = sorted(set(detected_agents) - set(agents))
1247
+ if missing_agents and not explicit_agents:
1248
+ evidence = "; ".join(f"{agent} ({', '.join(detected_agents[agent])})" for agent in missing_agents)
1249
+ warnings.append(f"Detected client files for {evidence}; preview with "
1250
+ + " ".join("--agent " + agent for agent in suggested_agents) + " to wire them.")
1251
+ warnings.extend(module_warnings)
1252
+ if preset_disabled:
1253
+ warnings.append(f"Preset {preset} turns off previously enabled extras: {', '.join(preset_disabled)}; "
1254
+ "add their --with-* flags to keep them.")
1255
+ # AI-KIT owns only its own entries in the client settings file; other settings survive.
1256
+ # Private mode keeps them in the personal local file, team mode in the shared one.
1257
+ managed_json = {relative: dict(entries) for relative, entries in state.get("managed_json", {}).items()}
1258
+ claude_target = CLAUDE_LOCAL_SETTINGS if mode == "private" else CLAUDE_SETTINGS
1259
+ if "claude" in agents:
1260
+ wanted_entries = claude_managed_entries(root, enabled_extras, modules)
1261
+ for relative in (CLAUDE_SETTINGS, CLAUDE_LOCAL_SETTINGS):
1262
+ wanted = wanted_entries if relative == claude_target else {}
1263
+ previous = {key: list(values) for key, values in managed_json.get(relative, {}).items()}
1264
+ if relative == CLAUDE_SETTINGS and CLAUDE_SETTINGS in records:
1265
+ for key, values in LEGACY_CLAUDE_ENTRIES.items():
1266
+ previous.setdefault(key, []).extend(values)
1267
+ if not (wanted or previous):
1268
+ continue
1269
+ settings_file = safe_path(target, relative)
1270
+ old_settings = settings_file.read_bytes() if settings_file.exists() else None
1271
+ try:
1272
+ current = json.loads(old_settings.decode("utf-8-sig")) if old_settings is not None else {}
1273
+ if not isinstance(current, dict):
1274
+ raise ValueError("expected a JSON object")
1275
+ merged_settings = merge_managed_json(current, previous, wanted)
1276
+ except (ValueError, UnicodeError) as exc:
1277
+ fragment = merge_managed_json({}, {}, wanted)
1278
+ conflicts[relative] = (json.dumps(fragment, indent=2) + "\n").encode()
1279
+ conflict_local_hashes[relative] = digest(old_settings)
1280
+ warnings.append(f"Cannot merge AI-KIT entries into {relative} ({exc}); fix the file "
1281
+ "or merge the candidate fragment manually, then rerun the preview.")
1282
+ continue
1283
+ if (old_settings is None and wanted) or (old_settings is not None and merged_settings != current):
1284
+ data = (json.dumps(merged_settings, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
1285
+ actions[relative] = {"data": data, "old": old_settings}
1286
+ if wanted:
1287
+ managed_json[relative] = wanted
1288
+ else:
1289
+ managed_json.pop(relative, None)
1290
+ else:
1291
+ for relative in (CLAUDE_SETTINGS, CLAUDE_LOCAL_SETTINGS):
1292
+ if relative in managed_json:
1293
+ warnings.append(f"AI-KIT entries remain in {relative} for the unselected claude client; "
1294
+ "review them before retirement.")
1295
+ # Client ignore files get one marked secret block, like .gitignore; other lines survive.
1296
+ secret_block = None
1297
+ if "native-settings" in enabled_extras:
1298
+ secret_block = ("# Secret files AI clients must not read\n"
1299
+ + "\n".join(secret_patterns(root, modules)) + "\n")
1300
+ for agent, relative in sorted(registry["ignore_files"].items()):
1301
+ ignore_file = safe_path(target, relative)
1302
+ old_text_bytes = ignore_file.read_bytes() if ignore_file.exists() else None
1303
+ old_text = (old_text_bytes or b"").decode("utf-8-sig")
1304
+ if secret_block is not None and agent in agents:
1305
+ new_text, _ = merge_ignore(old_text, secret_block, [])
1306
+ elif old_text_bytes is not None and START in old_text:
1307
+ before, after = split_ignore(old_text)
1308
+ new_text = before + after
1309
+ else:
1310
+ continue
1311
+ if new_text.encode("utf-8") != old_text_bytes:
1312
+ actions[relative] = {"data": new_text.encode("utf-8"), "old": old_text_bytes}
1313
+ retirements = []
1314
+ for relative, record in records.items():
1315
+ if relative in incoming or relative in owned_documents:
1316
+ continue
1317
+ if relative == CLAUDE_SETTINGS and "claude" in agents:
1318
+ # Earlier versions managed the whole file; its entries now migrate to managed_json.
1319
+ continue
1320
+ if safe_path(target, relative).exists():
1321
+ # Keep old baseline information even when a path leaves the bundle.
1322
+ accepted[relative] = record
1323
+ owner = adapter_owner(relative, registry)
1324
+ if owner is not None and owner not in agents:
1325
+ retirements.append(relative)
1326
+ else:
1327
+ warnings.append(f"Previously managed path retained without a current source: {relative}")
1328
+ if retirements:
1329
+ warnings.append("Agent selection replaces the recorded list, but tracked unselected adapters "
1330
+ "remain on disk and may still load. Review/retire the listed files or retain "
1331
+ "their agent selection; accepted files/state will not change.")
1332
+ unselected = dict(registry["entries"])
1333
+ for agent, items in registry["optional"].items():
1334
+ for _, destination in items:
1335
+ unselected[destination] = agent
1336
+ for relative, owner in unselected.items():
1337
+ if owner in agents or relative in records:
1338
+ continue
1339
+ try:
1340
+ exists = safe_path(target, relative).exists()
1341
+ except NotADirectoryError:
1342
+ # An existing native rule file can occupy a directory adapter's parent.
1343
+ continue
1344
+ if exists:
1345
+ warnings.append(f"Untracked unselected {owner} entry may still load: {relative}")
1346
+ if "claude" not in agents and (target / ".claude/skills").exists():
1347
+ warnings.append("Unselected .claude/skills remains on disk; verify actual client loading "
1348
+ "and review custom/untracked skills before retirement.")
1349
+ candidates = {}
1350
+ for relative, data in conflicts.items():
1351
+ source_hash = digest(data)
1352
+ path = "ai-kit/.upstream-cache/candidates/" + source_hash + "/" + relative
1353
+ candidate = safe_path(target, path)
1354
+ manifest = path + ".metadata.json"
1355
+ safe_path(target, manifest)
1356
+ exists = candidate.exists()
1357
+ matches = digest(candidate.read_bytes()) == source_hash if exists else None
1358
+ candidates[relative] = {"path": path, "manifest": manifest, "source_hash": source_hash,
1359
+ "local_hash": conflict_local_hashes[relative],
1360
+ "exists": exists, "matches_source": matches}
1361
+ if exists and not matches:
1362
+ warnings.append(f"Existing candidate has local edits and will be preserved: {path}")
1363
+ new_state = dict(state)
1364
+ new_state.update(schema=1, accepted_version=(root / "VERSION").read_text().strip(),
1365
+ agents=agents, files=accepted)
1366
+ if managed_json:
1367
+ new_state["managed_json"] = managed_json
1368
+ else:
1369
+ new_state.pop("managed_json", None)
1370
+ state_data = (json.dumps(new_state, indent=2, sort_keys=True) + "\n").encode()
1371
+ old_state = state_path.read_bytes() if state_path.exists() else None
1372
+ if old_state != state_data:
1373
+ actions["ai-kit/.install-state.json"] = {"data": state_data, "old": old_state}
1374
+ return {"target": target, "mode": mode, "version": new_state["accepted_version"],
1375
+ "actions": actions, "conflicts": conflicts, "candidates": candidates,
1376
+ "adapter_conflicts": sorted(retirements), "preserved": preserved,
1377
+ "detected_profiles": detected, "detected_agents": detected_agents,
1378
+ "suggested_agents": suggested_agents, "extras": sorted(enabled_extras), "preset": preset,
1379
+ "warnings": warnings}
1380
+
1381
+
1382
+ def atomic_write(path: Path, data: bytes) -> None:
1383
+ path.parent.mkdir(parents=True, exist_ok=True)
1384
+ temp = path.with_name(path.name + ".ai-kit-" + uuid.uuid4().hex + ".tmp")
1385
+ try:
1386
+ with temp.open("xb") as stream:
1387
+ stream.write(data)
1388
+ os.replace(temp, path)
1389
+ finally:
1390
+ if temp.exists():
1391
+ temp.unlink()
1392
+
1393
+
1394
+ def write_once(path: Path, data: bytes) -> bool:
1395
+ """Create exclusively; preserve every existing file, including an edited draft."""
1396
+ path.parent.mkdir(parents=True, exist_ok=True)
1397
+ try:
1398
+ stream = path.open("xb")
1399
+ except FileExistsError:
1400
+ return False
1401
+ with stream:
1402
+ stream.write(data)
1403
+ return True
1404
+
1405
+
1406
+ def needs_review(plan: dict) -> bool:
1407
+ return bool(plan["conflicts"] or plan["adapter_conflicts"])
1408
+
1409
+
1410
+ def apply_plan(plan: dict) -> bool:
1411
+ target: Path = plan["target"]
1412
+ if needs_review(plan):
1413
+ for relative, data in plan["conflicts"].items():
1414
+ info = plan["candidates"][relative]
1415
+ write_once(safe_path(target, info["path"]), data)
1416
+ manifest = {"relative_path": relative, "source_hash": info["source_hash"],
1417
+ "local_hash": info["local_hash"], "bundle_version": plan["version"],
1418
+ "created_at": datetime.now(timezone.utc).isoformat()}
1419
+ write_once(safe_path(target, info["manifest"]),
1420
+ (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode())
1421
+ return False
1422
+ # Recheck every planned write before modifying any project file.
1423
+ for relative, action in plan["actions"].items():
1424
+ path = safe_path(target, relative)
1425
+ actual = path.read_bytes() if path.exists() else None
1426
+ if actual != action["old"]:
1427
+ raise ValueError(f"File changed after preview: {relative}")
1428
+ if not plan["actions"]:
1429
+ return True
1430
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
1431
+ # Prepare all existing-file backups before writing any instruction.
1432
+ for relative, action in plan["actions"].items():
1433
+ if action["old"] is not None:
1434
+ backup = safe_path(target, "ai-kit/.upstream-cache/backups/" + stamp + "/" + relative)
1435
+ atomic_write(backup, action["old"])
1436
+ completed = []
1437
+ try:
1438
+ for relative, action in plan["actions"].items():
1439
+ path = safe_path(target, relative)
1440
+ actual = path.read_bytes() if path.exists() else None
1441
+ if actual != action["old"]:
1442
+ raise ValueError(f"Concurrent modification: {relative}")
1443
+ atomic_write(path, action["data"])
1444
+ completed.append(relative)
1445
+ except (OSError, ValueError):
1446
+ # Restore only files that still contain our exact write.
1447
+ for relative in reversed(completed):
1448
+ path = safe_path(target, relative)
1449
+ action = plan["actions"][relative]
1450
+ if path.read_bytes() == action["data"]:
1451
+ if action["old"] is None:
1452
+ path.unlink()
1453
+ else:
1454
+ atomic_write(path, action["old"])
1455
+ raise
1456
+ return True
1457
+
1458
+
1459
+ def interactive_selections(read=input, *, agents_available: set[str], facts: dict,
1460
+ detected_agents: dict[str, list[str]] | None = None) -> dict | None:
1461
+ """Prompt for selections; returns None when the user declines to continue."""
1462
+ print("AI-KIT interactive installer")
1463
+ for module in facts["modules"]:
1464
+ print(f" Detected: {module['path']} ({', '.join(module['profiles']) or 'no profile'})"
1465
+ f" from {', '.join(module['manifests'])}")
1466
+ detected_agents = detected_agents or {}
1467
+ for agent, evidence in sorted(detected_agents.items()):
1468
+ print(f" Detected client: {agent} ({', '.join(evidence)})")
1469
+ default_agents = sorted({"codex"} | set(detected_agents))
1470
+ preset = read(f"Preset [{'/'.join(PRESETS)}] (none): ").strip().lower() or None
1471
+ spec = PRESETS.get(preset or "", {"mode": None, "extras": ()})
1472
+ default_mode = spec["mode"] or "private"
1473
+ mode = (read(f"Sharing mode [private/team] ({default_mode}): ").strip() or default_mode).lower()
1474
+ language = read("Chat language (Russian): ").strip() or "Russian"
1475
+ conventions = (read("Conventions [owner/standard] (owner): ").strip() or "owner").lower()
1476
+ agent_text = read(f"Agents, comma-separated ({', '.join(sorted(agents_available))}) "
1477
+ f"[{','.join(default_agents)}]: ").strip()
1478
+ agents = [a.strip() for a in agent_text.split(",") if a.strip()] or default_agents
1479
+ default_extras = list(spec["extras"])
1480
+ extra_text = read(f"Extras, comma-separated [{','.join(sorted(EXTRAS))}] "
1481
+ f"({','.join(default_extras) or 'none'}): ").strip()
1482
+ extras = [e.strip() for e in extra_text.split(",") if e.strip()] or default_extras
1483
+ print("\nSummary:")
1484
+ print(f" preset={preset or 'none'}, mode={mode}, language={language}, conventions={conventions}")
1485
+ print(f" agents={', '.join(agents)}, extras={', '.join(extras) or 'none'}")
1486
+ confirm = read("Proceed? [y/N]: ").strip().lower()
1487
+ if confirm not in {"y", "yes"}:
1488
+ print("Aborted.")
1489
+ return None
1490
+ return {"preset": preset, "mode": mode, "language": language, "conventions": conventions,
1491
+ "agents": agents, "extras": extras}
1492
+
1493
+
1494
+ def main(argv: list[str] | None = None) -> int:
1495
+ parser = argparse.ArgumentParser(description=__doc__)
1496
+ parser.add_argument("target", type=Path)
1497
+ group = parser.add_mutually_exclusive_group()
1498
+ group.add_argument("--apply", action="store_true")
1499
+ group.add_argument("--dry-run", action="store_true")
1500
+ parser.add_argument("--mode", choices=["private", "team"])
1501
+ parser.add_argument("--chat-language")
1502
+ parser.add_argument("--conventions", choices=["owner", "standard"])
1503
+ parser.add_argument("--agent", choices=sorted(load_agent_registry()["names"]), action="append")
1504
+ parser.add_argument("--interactive", action="store_true",
1505
+ help="Prompt for sharing mode, language, conventions, agents, and extras")
1506
+ parser.add_argument("--check", action="store_true",
1507
+ help="Run the project doctor after a successful --apply")
1508
+ parser.add_argument("--accept-local", action="append", default=[],
1509
+ help="Record an explicitly reviewed semantic merge of a managed path")
1510
+ parser.add_argument("--with-session-start", action="store_true",
1511
+ help="Install the session-start hook file and client wiring")
1512
+ parser.add_argument("--with-guards", action="store_true",
1513
+ help="Install client confirmation (ask) rules for Git commit/push")
1514
+ parser.add_argument("--with-ci", action="store_true",
1515
+ help="Install the self-contained AI-KIT project check workflow")
1516
+ parser.add_argument("--with-data-guards", action="store_true",
1517
+ help="Ask before destructive file, Docker, and detected migration commands (Claude Code)")
1518
+ parser.add_argument("--with-native-settings", action="store_true",
1519
+ help="Allow recorded checks, deny secret reads, and write client ignore files")
1520
+ parser.add_argument("--with-scoped-rules", action="store_true",
1521
+ help="Generate path-scoped rules per module for clients that support them")
1522
+ parser.add_argument("--with-security-audit", action="store_true",
1523
+ help="Install the pinned, user-invoked security-audit skill without running an audit")
1524
+ parser.add_argument("--with-strix", action="store_true",
1525
+ help="Install pinned Strix skills and a user-invoked entry point without installing or running Strix")
1526
+ parser.add_argument("--with-context7", action="store_true",
1527
+ help="Install the Context7 documentation skill; MCP connection/authentication stays separate")
1528
+ parser.add_argument("--preset", choices=list(PRESETS),
1529
+ help="Apply a selection bundle; explicit --mode and --with-* flags take precedence")
1530
+ parser.add_argument("--with-web-design", action="store_true",
1531
+ help="Install Web Design workflows and project documents; no external tool setup")
1532
+ args = parser.parse_args(argv)
1533
+ extras = [name for name, flag in (("session-start", args.with_session_start),
1534
+ ("guards", args.with_guards),
1535
+ ("ci", args.with_ci),
1536
+ ("data-guards", args.with_data_guards),
1537
+ ("native-settings", args.with_native_settings),
1538
+ ("scoped-rules", args.with_scoped_rules),
1539
+ ("security-audit", args.with_security_audit), ("strix", args.with_strix),
1540
+ ("context7", args.with_context7),
1541
+ ("web-design", args.with_web_design)) if flag]
1542
+ mode, language, conventions, agents = args.mode, args.chat_language, args.conventions, args.agent
1543
+ preset = args.preset
1544
+ if args.interactive:
1545
+ target = args.target.absolute().resolve()
1546
+ registry = load_agent_registry()
1547
+ selections = interactive_selections(agents_available=registry["names"], facts=detect_facts(target),
1548
+ detected_agents=detect_agents(target, registry))
1549
+ if selections is None:
1550
+ return 0
1551
+ mode = mode or selections["mode"]
1552
+ language = language or selections["language"]
1553
+ conventions = conventions or selections["conventions"]
1554
+ agents = agents or selections["agents"]
1555
+ extras = sorted(set(extras) | set(selections["extras"]))
1556
+ preset = preset or selections["preset"]
1557
+ try:
1558
+ plan = build_plan(args.target, mode=mode, language=language,
1559
+ conventions=conventions, agents=agents, accept_local=args.accept_local,
1560
+ extras=extras, preset=preset)
1561
+ print(json.dumps({"target": str(plan["target"]), "version": plan["version"], "mode": plan["mode"],
1562
+ "preset": plan["preset"],
1563
+ "preview": not args.apply, "writes": sorted(plan["actions"]),
1564
+ "conflicts": sorted(plan["conflicts"]), "preserved": sorted(plan["preserved"]),
1565
+ "candidates": plan["candidates"], "adapter_conflicts": plan["adapter_conflicts"],
1566
+ "detected_profiles": plan["detected_profiles"],
1567
+ "detected_agents": plan["detected_agents"],
1568
+ "suggested_agents": plan["suggested_agents"], "extras": plan["extras"],
1569
+ "warnings": plan["warnings"]}, indent=2))
1570
+ if args.apply:
1571
+ applied = apply_plan(plan)
1572
+ if applied and args.check:
1573
+ import doctor
1574
+ report = doctor.examine(plan["target"], root=ROOT)
1575
+ print(json.dumps({"doctor": report}, indent=2))
1576
+ return 1 if report["errors"] else 0
1577
+ return 2 if needs_review(plan) else 0
1578
+ except (OSError, ValueError, UnicodeError, json.JSONDecodeError) as exc:
1579
+ print(f"Installation refused: {exc}", file=sys.stderr)
1580
+ return 1
1581
+
1582
+
1583
+ if __name__ == "__main__":
1584
+ raise SystemExit(main())