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,939 @@
1
+ """Exercise preservation, upgrade boundaries, ignore merging, and write failures."""
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from types import SimpleNamespace
12
+ import unittest
13
+ from unittest.mock import patch
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
16
+ import install
17
+
18
+
19
+ class PathSafetyTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-path-")
22
+ self.addCleanup(self.temp.cleanup)
23
+ self.target = Path(self.temp.name).resolve()
24
+
25
+ def test_link_probe_treats_non_directory_ancestor_as_absent(self):
26
+ with patch.object(Path, "lstat", side_effect=NotADirectoryError("file ancestor")):
27
+ self.assertFalse(install.is_link(self.target / "marker/rule.md"))
28
+
29
+ def test_nested_detection_markers_under_a_file_are_absent(self):
30
+ (self.target / ".github").write_bytes(b"Existing file\n")
31
+ original_lstat = Path.lstat
32
+
33
+ def posix_lstat(path, *args, **kwargs):
34
+ if self.target / ".github" in path.parents:
35
+ raise NotADirectoryError("The .github ancestor is a regular file")
36
+ return original_lstat(path, *args, **kwargs)
37
+
38
+ with patch.object(Path, "lstat", posix_lstat):
39
+ self.assertEqual(install.detect_agents(self.target, install.load_agent_registry()), {})
40
+
41
+ def test_selected_adapter_refuses_a_file_parent_before_writing(self):
42
+ marker = self.target / ".clinerules"
43
+ marker.write_bytes(b"Existing Cline rules\n")
44
+ with self.assertRaises(NotADirectoryError):
45
+ install.build_plan(self.target, agents=["codex", "cline"])
46
+ errors = io.StringIO()
47
+ with contextlib.redirect_stderr(errors):
48
+ self.assertEqual(install.main([str(self.target), "--agent", "codex", "--agent", "cline",
49
+ "--apply"]), 1)
50
+ self.assertIn("Installation parent is not a directory", errors.getvalue())
51
+ self.assertNotIn("Traceback", errors.getvalue())
52
+ self.assertEqual(marker.read_bytes(), b"Existing Cline rules\n")
53
+ self.assertEqual(list(self.target.iterdir()), [marker])
54
+
55
+ def test_link_probe_does_not_hide_permission_errors(self):
56
+ with patch.object(Path, "lstat", side_effect=PermissionError("Access denied")):
57
+ with self.assertRaises(PermissionError):
58
+ install.is_link(self.target / "marker")
59
+
60
+ def test_unselected_adapter_does_not_hide_a_linked_parent(self):
61
+ marker = self.target / ".clinerules"
62
+ marker.write_bytes(b"Existing Cline rules\n")
63
+ original_is_link = install.is_link
64
+
65
+ def linked_parent(path):
66
+ return path == marker or original_is_link(path)
67
+
68
+ with patch.object(install, "is_link", linked_parent):
69
+ with self.assertRaisesRegex(ValueError, "Refusing linked installation path"):
70
+ install.build_plan(self.target)
71
+ self.assertEqual(list(self.target.iterdir()), [marker])
72
+
73
+
74
+ class InstallerTests(unittest.TestCase):
75
+ def setUp(self):
76
+ self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-project-")
77
+ self.addCleanup(self.temp.cleanup)
78
+ # Windows TEMP can use an 8.3 alias; match the installer's resolved paths.
79
+ self.base = Path(self.temp.name).resolve()
80
+ self.target = self.base / "project with spaces"
81
+ self.source = self.base / "reference"
82
+ shutil.copytree(install.ROOT, self.source, ignore=shutil.ignore_patterns("__pycache__", ".git"))
83
+ self.initial_version = (self.source / "VERSION").read_text(encoding="utf-8").strip()
84
+
85
+ def plan(self, **options):
86
+ return install.build_plan(self.target, root=self.source, **options)
87
+
88
+ def apply(self, **options):
89
+ plan = self.plan(**options)
90
+ self.assertTrue(install.apply_plan(plan))
91
+ return plan
92
+
93
+ def write(self, path, data):
94
+ destination = self.target / path
95
+ destination.parent.mkdir(parents=True, exist_ok=True)
96
+ destination.write_bytes(data)
97
+
98
+ def change_source(self):
99
+ core = self.source / "template/ai-kit/CORE.md"
100
+ core.write_bytes(core.read_bytes() + b"\nUpstream clarification.\n")
101
+ version_path = self.source / "VERSION"
102
+ major, minor, patch_version = version_path.read_text(encoding="utf-8").strip().split(".")
103
+ version_path.write_text(f"{major}.{minor}.{int(patch_version) + 1}\n", encoding="utf-8")
104
+
105
+ def test_preview_creates_nothing(self):
106
+ plan = self.plan()
107
+ self.assertTrue(plan["actions"])
108
+ self.assertFalse(self.target.exists())
109
+
110
+ def test_fresh_install_and_second_run_are_stable(self):
111
+ self.apply()
112
+ state = (self.target / "ai-kit/.install-state.json").read_bytes()
113
+ plan = self.plan()
114
+ self.assertFalse(plan["actions"])
115
+ self.assertFalse(plan["conflicts"])
116
+ self.assertTrue(install.apply_plan(plan))
117
+ self.assertEqual(state, (self.target / "ai-kit/.install-state.json").read_bytes())
118
+
119
+ def test_all_project_owned_documents_are_preserved(self):
120
+ for path in install.OWNED:
121
+ self.write(path, ("Existing " + path + "\n").encode())
122
+ self.apply()
123
+ for path in install.OWNED:
124
+ self.assertEqual((self.target / path).read_bytes(), ("Existing " + path + "\n").encode())
125
+
126
+ def test_unmanaged_instruction_conflict_applies_only_candidates(self):
127
+ self.write("AGENTS.md", b"Existing instructions\n")
128
+ plan = self.plan()
129
+ self.assertIn("AGENTS.md", plan["conflicts"])
130
+ self.assertFalse(install.apply_plan(plan))
131
+ self.assertEqual((self.target / "AGENTS.md").read_bytes(), b"Existing instructions\n")
132
+ self.assertFalse((self.target / "ai-kit/CORE.md").exists())
133
+ self.assertFalse((self.target / "ai-kit/.install-state.json").exists())
134
+ candidate = self.target / plan["candidates"]["AGENTS.md"]["path"]
135
+ self.assertTrue(candidate.exists())
136
+ manifest = json.loads((self.target / plan["candidates"]["AGENTS.md"]["manifest"]).read_text())
137
+ self.assertEqual(manifest["relative_path"], "AGENTS.md")
138
+ self.assertEqual(manifest["source_hash"], install.digest(candidate.read_bytes()))
139
+ self.assertEqual(manifest["local_hash"], install.digest(b"Existing instructions\n"))
140
+
141
+ def test_unchanged_baseline_upgrade_and_backup(self):
142
+ self.apply()
143
+ old = (self.target / "ai-kit/CORE.md").read_bytes()
144
+ self.change_source()
145
+ self.apply()
146
+ self.assertNotEqual(old, (self.target / "ai-kit/CORE.md").read_bytes())
147
+ backups = list((self.target / "ai-kit/.upstream-cache/backups").glob("*/ai-kit/CORE.md"))
148
+ self.assertEqual(len(backups), 1)
149
+ self.assertEqual(backups[0].read_bytes(), old)
150
+
151
+ def test_local_edit_survives_repeat_and_blocks_new_upstream(self):
152
+ self.apply()
153
+ self.write("ai-kit/CORE.md", b"Local adapted core\n")
154
+ self.assertFalse(self.plan()["conflicts"])
155
+ old_state = (self.target / "ai-kit/.install-state.json").read_bytes()
156
+ self.change_source()
157
+ plan = self.plan()
158
+ self.assertIn("ai-kit/CORE.md", plan["conflicts"])
159
+ self.assertFalse(install.apply_plan(plan))
160
+ self.assertEqual(old_state, (self.target / "ai-kit/.install-state.json").read_bytes())
161
+ self.assertEqual((self.target / "ai-kit/CORE.md").read_bytes(), b"Local adapted core\n")
162
+ self.assertEqual((self.target / "ai-kit/VERSION").read_text().strip(), self.initial_version)
163
+
164
+ def test_explicit_semantic_acceptance_preserves_future_adaptations(self):
165
+ self.apply()
166
+ self.change_source()
167
+ self.write("ai-kit/CORE.md", b"Reviewed semantic merge\n")
168
+ self.apply(accept_local=["ai-kit/CORE.md"])
169
+ self.assertFalse(self.plan()["actions"])
170
+ state = json.loads((self.target / "ai-kit/.install-state.json").read_text())
171
+ self.assertTrue(state["files"]["ai-kit/CORE.md"]["local_adaptation"])
172
+ self.change_source()
173
+ self.assertIn("ai-kit/CORE.md", self.plan()["conflicts"])
174
+ self.assertEqual((self.target / "ai-kit/CORE.md").read_bytes(), b"Reviewed semantic merge\n")
175
+
176
+ def test_cannot_accept_unknown_or_project_owned_path(self):
177
+ for path in ["../escape", "README.md", "ai-kit/settings.json"]:
178
+ with self.subTest(path=path), self.assertRaises(ValueError):
179
+ self.plan(accept_local=[path])
180
+
181
+ def test_ignore_block_preserves_other_lines_without_duplicates(self):
182
+ self.write(".gitignore", b"custom.log\n")
183
+ self.apply()
184
+ first = (self.target / ".gitignore").read_bytes()
185
+ self.apply()
186
+ self.assertEqual((self.target / ".gitignore").read_bytes(), first)
187
+ self.assertTrue(first.startswith(b"custom.log\n"))
188
+ self.assertEqual(first.count(install.START.encode()), 1)
189
+
190
+ def test_team_change_replaces_only_managed_policy(self):
191
+ self.write(".gitignore", b"custom.log\n")
192
+ self.apply()
193
+ self.apply(mode="team", language="English", conventions="standard")
194
+ text = (self.target / ".gitignore").read_text()
195
+ self.assertIn("custom.log", text)
196
+ self.assertNotIn("\n/AGENTS.md\n", text)
197
+ settings = json.loads((self.target / "ai-kit/settings.json").read_text())
198
+ self.assertEqual(settings["sharing_mode"], "team")
199
+ self.assertEqual(settings["chat_language"], "English")
200
+ self.assertEqual(settings["conventions"], "standard")
201
+ self.assertFalse(self.plan()["actions"])
202
+
203
+ def test_legacy_team_exclusions_are_reported_not_removed(self):
204
+ self.write(".gitignore", b"/AGENTS.md\ncustom.log\n")
205
+ plan = self.plan(mode="team")
206
+ self.assertIn(".gitignore", plan["conflicts"])
207
+ self.assertFalse(install.apply_plan(plan))
208
+ self.assertEqual((self.target / ".gitignore").read_bytes(), b"/AGENTS.md\ncustom.log\n")
209
+
210
+ def test_php_vendor_rule_is_scoped_and_go_vendor_is_preserved(self):
211
+ self.write("web/composer.json", b"{}\n")
212
+ self.write("mixed/composer.json", b"{}\n")
213
+ self.write("mixed/go.mod", b"module example.test/mixed\n")
214
+ self.write("vendored/composer.json", b"{}\n")
215
+ self.write("vendored/vendor/modules.txt", b"# module v1\n")
216
+ self.write("go/go.mod", b"module example.test/go\n")
217
+ self.assertEqual(install.module_ignores(self.target), ["/web/vendor/"])
218
+
219
+ def test_moodle_config_rules_require_verified_module(self):
220
+ self.write("moodle/config-dist.php", b"<?php\n")
221
+ self.write("moodle/lib/moodlelib.php", b"<?php\n")
222
+ self.write("other/config-dist.php", b"<?php\n")
223
+ self.assertEqual(install.module_ignores(self.target), ["/moodle/behat.yml", "/moodle/config.php"])
224
+
225
+ def test_node_and_python_rules_are_scoped_to_verified_modules(self):
226
+ self.write("app/package.json", b"{}\n")
227
+ self.write("svc/pyproject.toml", b'[project]\nname = "svc"\n')
228
+ self.write("plain/notes.txt", b"nothing detected\n")
229
+ rules = install.module_ignores(self.target)
230
+ self.assertIn("app/**/node_modules/", rules)
231
+ self.assertIn("app/**/.yarn/cache/", rules)
232
+ self.assertIn("svc/**/__pycache__/", rules)
233
+ self.assertIn("svc/**/.venv/", rules)
234
+ self.assertIn("/svc/.cache/pip/", rules)
235
+ self.assertFalse(any(rule.startswith("plain/") for rule in rules))
236
+
237
+ def test_no_manifests_produce_no_generated_path_rules(self):
238
+ self.write("src/app.go", b"package main\n")
239
+ self.assertEqual(install.module_ignores(self.target), [])
240
+
241
+ def test_detected_profiles_feed_fresh_project_context_draft(self):
242
+ self.write("go.mod", b"module example.test/app\n")
243
+ self.write("package.json", b"{}\n")
244
+ plan = self.plan()
245
+ self.assertEqual(plan["detected_profiles"], {"FRONTEND": [""], "GO": [""]})
246
+ self.apply()
247
+ context = (self.target / "PROJECT_CONTEXT.md").read_text(encoding="utf-8")
248
+ self.assertIn("| / | go.mod, package.json (installer-detected) | Go (suggested) | "
249
+ "FRONTEND, GO (suggested, confirm at bootstrap) | "
250
+ "build: go build ./...; lint: go vet ./...; test: go test ./... |", context)
251
+
252
+ def test_detected_profiles_are_scoped_to_module_prefixes(self):
253
+ self.write("api/go.mod", b"module example.test/api\n")
254
+ self.write("web/composer.json", b"{}\n")
255
+ self.write("web/artisan", b"#!/usr/bin/env php\n")
256
+ self.assertEqual(self.plan()["detected_profiles"],
257
+ {"GO": ["api/"], "LARAVEL": ["web/"], "PHP": ["web/"]})
258
+
259
+ def test_new_stack_profiles_are_detected_with_facts(self):
260
+ self.write("core/Cargo.toml", b'[package]\nname = "core"\nrust-version = "1.75"\n')
261
+ self.write("api/api.csproj",
262
+ b"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>")
263
+ self.write("svc/pom.xml", b"<project><properties><java.version>21</java.version></properties></project>")
264
+ self.write("infra/main.tf", b'resource "null_resource" "fixture" {}\n')
265
+ self.write("chart/Chart.yaml", b"apiVersion: v2\nname: chart\n")
266
+ facts = {module["path"]: module for module in install.detect_facts(self.target)["modules"]}
267
+ self.assertEqual(facts["/core/"]["profiles"], ["RUST"])
268
+ self.assertEqual(facts["/core/"]["language"], "Rust")
269
+ self.assertEqual(facts["/core/"]["version"], "1.75")
270
+ self.assertEqual(facts["/core/"]["commands"]["test"], "cargo test")
271
+ self.assertEqual(facts["/api/"]["profiles"], ["DOTNET"])
272
+ self.assertEqual(facts["/api/"]["language"], ".NET")
273
+ self.assertEqual(facts["/api/"]["version"], "net8.0")
274
+ self.assertEqual(facts["/svc/"]["profiles"], ["JAVA"])
275
+ self.assertEqual(facts["/svc/"]["version"], "21")
276
+ self.assertIn("test", facts["/svc/"]["commands"])
277
+ self.assertEqual(facts["/infra/"]["profiles"], ["INFRA"])
278
+ self.assertEqual(facts["/infra/"]["language"], "Terraform")
279
+ self.assertEqual(facts["/chart/"]["profiles"], ["INFRA"])
280
+ self.assertEqual(facts["/chart/"]["language"], "Helm")
281
+ self.assertEqual(self.plan()["detected_profiles"],
282
+ {"DOTNET": ["api/"], "INFRA": ["chart/", "infra/"],
283
+ "JAVA": ["svc/"], "RUST": ["core/"]})
284
+
285
+ def test_java_wrappers_select_wrapper_commands(self):
286
+ self.write("legacy/mvnw", b"#!/bin/sh\n")
287
+ self.write("gradle-app/build.gradle.kts", b"plugins { java }\n")
288
+ self.write("gradle-app/gradlew", b"#!/bin/sh\n")
289
+ facts = {module["path"]: module for module in install.detect_facts(self.target)["modules"]}
290
+ self.assertEqual(facts["/legacy/"]["commands"]["test"], "./mvnw test")
291
+ self.assertEqual(facts["/gradle-app/"]["profiles"], ["JAVA"])
292
+ self.assertEqual(facts["/gradle-app/"]["commands"]["test"], "./gradlew test")
293
+ self.assertEqual(facts["/gradle-app/"]["commands"]["build"], "./gradlew build")
294
+
295
+ def test_detection_and_placeholder_persist_without_manifests(self):
296
+ self.assertEqual(self.plan()["detected_profiles"], {})
297
+ self.apply()
298
+ context = (self.target / "PROJECT_CONTEXT.md").read_text(encoding="utf-8")
299
+ self.assertIn("| Not established | Not established | Not established | None confirmed | Not established |",
300
+ context)
301
+
302
+ def test_existing_project_context_is_preserved_against_detection(self):
303
+ self.write("PROJECT_CONTEXT.md", b"Existing verified context\n")
304
+ self.write("go.mod", b"module example.test/app\n")
305
+ self.apply()
306
+ self.assertEqual((self.target / "PROJECT_CONTEXT.md").read_bytes(), b"Existing verified context\n")
307
+
308
+ def test_session_start_extra_installs_hook_and_claude_wiring(self):
309
+ plan = self.plan(agents=["codex", "claude"], extras=["session-start"])
310
+ self.assertEqual(plan["extras"], ["session-start"])
311
+ self.assertIn(".agents/hooks/session-start.md", plan["actions"])
312
+ # Private mode keeps AI-KIT client entries in the personal local settings file.
313
+ self.assertIn(".claude/settings.local.json", plan["actions"])
314
+ self.assertNotIn(".claude/settings.json", plan["actions"])
315
+ self.apply(agents=["codex", "claude"], extras=["session-start"])
316
+ self.assertIn("SessionStart", (self.target / ".claude/settings.local.json").read_text())
317
+ settings = json.loads((self.target / "ai-kit/settings.json").read_text())
318
+ self.assertIs(settings["session_start"], True)
319
+
320
+ def test_guards_extra_installs_ask_rules_and_persists(self):
321
+ # Shared-file merge semantics run in team mode, where settings.json is the target.
322
+ self.apply(agents=["claude"], extras=["guards"], mode="team")
323
+ claude_settings = json.loads((self.target / ".claude/settings.json").read_text())
324
+ # Core permits an explicitly requested commit, so the guard asks instead of denying.
325
+ self.assertIn("Bash(git push *)", claude_settings["permissions"]["ask"])
326
+ self.assertIn("Bash(git commit)", claude_settings["permissions"]["ask"])
327
+ self.assertNotIn("deny", claude_settings["permissions"])
328
+ plan = self.plan()
329
+ self.assertEqual(plan["extras"], ["guards"])
330
+ self.assertFalse(plan["actions"])
331
+
332
+ def user_claude_settings(self):
333
+ return {"model": "user-choice", "permissions": {"allow": ["Bash(make test)"]},
334
+ "hooks": {"SessionStart": [{"matcher": "startup",
335
+ "hooks": [{"type": "command", "command": "echo user"}]}]}}
336
+
337
+ def test_existing_claude_settings_are_merged_by_key(self):
338
+ self.write(".claude/settings.json", (json.dumps(self.user_claude_settings(), indent=4) + "\n").encode())
339
+ plan = self.apply(agents=["claude"], extras=["guards", "session-start"], mode="team")
340
+ self.assertFalse(plan["conflicts"])
341
+ merged = json.loads((self.target / ".claude/settings.json").read_text())
342
+ self.assertEqual(merged["model"], "user-choice")
343
+ self.assertEqual(merged["permissions"]["allow"], ["Bash(make test)"])
344
+ self.assertIn("Bash(git commit *)", merged["permissions"]["ask"])
345
+ commands = [h["command"] for group in merged["hooks"]["SessionStart"] for h in group["hooks"]]
346
+ self.assertEqual(commands, ["echo user", install.SESSION_START_COMMAND])
347
+ state = json.loads((self.target / "ai-kit/.install-state.json").read_text())
348
+ self.assertNotIn(".claude/settings.json", state["files"])
349
+ self.assertEqual(state["managed_json"][".claude/settings.json"]["hooks.SessionStart"],
350
+ [install.SESSION_START_COMMAND])
351
+ # The original file was backed up before the merge.
352
+ backups = list((self.target / "ai-kit/.upstream-cache/backups").glob("*/.claude/settings.json"))
353
+ self.assertEqual(len(backups), 1)
354
+ self.assertFalse(self.plan()["actions"])
355
+
356
+ def test_user_edits_to_merged_settings_survive_reruns(self):
357
+ self.apply(agents=["claude"], extras=["guards"], mode="team")
358
+ path = self.target / ".claude/settings.json"
359
+ settings = json.loads(path.read_text())
360
+ settings["permissions"]["deny"] = ["Read(./.env)"]
361
+ settings["env"] = {"EXAMPLE": "1"}
362
+ path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
363
+ plan = self.plan()
364
+ self.assertFalse(plan["actions"])
365
+ self.assertFalse(plan["conflicts"])
366
+
367
+ def test_disabling_an_extra_removes_only_its_managed_entries(self):
368
+ self.write(".claude/settings.json", (json.dumps(self.user_claude_settings()) + "\n").encode())
369
+ self.apply(agents=["claude"], extras=["guards", "session-start"], mode="team")
370
+ settings_path = self.target / "ai-kit/settings.json"
371
+ settings = json.loads(settings_path.read_text())
372
+ settings["guards"] = False
373
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
374
+ self.apply()
375
+ merged = json.loads((self.target / ".claude/settings.json").read_text())
376
+ self.assertEqual(merged["permissions"], {"allow": ["Bash(make test)"]})
377
+ self.assertEqual(len(merged["hooks"]["SessionStart"]), 2)
378
+ state = json.loads((self.target / "ai-kit/.install-state.json").read_text())
379
+ self.assertEqual(list(state["managed_json"][".claude/settings.json"]), ["hooks.SessionStart"])
380
+
381
+ def test_legacy_whole_file_claude_settings_migrate_to_managed_entries(self):
382
+ self.apply(agents=["claude"])
383
+ legacy = {"permissions": {"deny": ["Bash(git commit *)", "Bash(git push *)"]},
384
+ "hooks": {"SessionStart": [{"hooks": [{"type": "command",
385
+ "command": "cat .agents/hooks/session-start.md"}]}]}}
386
+ data = (json.dumps(legacy, indent=2) + "\n").encode()
387
+ self.write(".claude/settings.json", data)
388
+ state_path = self.target / "ai-kit/.install-state.json"
389
+ state = json.loads(state_path.read_text())
390
+ state["files"][".claude/settings.json"] = {"installed_hash": install.digest(data),
391
+ "source_hash": install.digest(data)}
392
+ state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
393
+ settings_path = self.target / "ai-kit/settings.json"
394
+ settings = json.loads(settings_path.read_text())
395
+ settings.update(guards=True, session_start=True)
396
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
397
+ plan = self.apply()
398
+ self.assertFalse(any("retained without a current source" in w for w in plan["warnings"]))
399
+ # Legacy entries leave the shared file; private mode re-adds them to the local file.
400
+ self.assertEqual(json.loads((self.target / ".claude/settings.json").read_text()), {})
401
+ merged = json.loads((self.target / ".claude/settings.local.json").read_text())
402
+ self.assertNotIn("deny", merged["permissions"])
403
+ self.assertIn("Bash(git push *)", merged["permissions"]["ask"])
404
+ commands = [h["command"] for group in merged["hooks"]["SessionStart"] for h in group["hooks"]]
405
+ self.assertEqual(commands, [install.SESSION_START_COMMAND])
406
+ state = json.loads(state_path.read_text())
407
+ self.assertNotIn(".claude/settings.json", state["files"])
408
+ self.assertEqual(sorted(state["managed_json"]), [".claude/settings.local.json"])
409
+ self.assertFalse(self.plan()["actions"])
410
+
411
+ def test_invalid_claude_settings_become_a_reviewed_conflict(self):
412
+ self.write(".claude/settings.json", b"{not json\n")
413
+ plan = self.plan(agents=["claude"], extras=["guards"], mode="team")
414
+ self.assertIn(".claude/settings.json", plan["conflicts"])
415
+ self.assertTrue(any("Cannot merge AI-KIT entries" in w for w in plan["warnings"]))
416
+ self.assertFalse(install.apply_plan(plan))
417
+ self.assertEqual((self.target / ".claude/settings.json").read_bytes(), b"{not json\n")
418
+ candidate = json.loads((self.target / plan["candidates"][".claude/settings.json"]["path"]).read_text())
419
+ self.assertIn("Bash(git push *)", candidate["permissions"]["ask"])
420
+
421
+ def test_session_start_hook_injects_context_and_tolerates_crlf(self):
422
+ self.apply(agents=["claude"], extras=["session-start"])
423
+ claude_settings = json.loads((self.target / ".claude/settings.local.json").read_text())
424
+ command = claude_settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
425
+ self.assertIn("$CLAUDE_PROJECT_DIR", command)
426
+ sh = shutil.which("sh")
427
+ if sh is None:
428
+ self.skipTest("POSIX sh is unavailable for executing the hook")
429
+ script = self.target / ".agents/hooks/session-start.sh"
430
+ script.write_bytes(script.read_bytes().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n"))
431
+ context = self.target / "PROJECT_CONTEXT.md"
432
+ context.write_bytes(context.read_bytes() + b"Hook marker fact.\n")
433
+ result = subprocess.run([sh, "-c", command], cwd=self.base, capture_output=True, text=True,
434
+ env={**os.environ, "CLAUDE_PROJECT_DIR": str(self.target)})
435
+ self.assertEqual(result.returncode, 0, result.stderr)
436
+ self.assertIn("# Session start", result.stdout)
437
+ self.assertIn("Hook marker fact.", result.stdout)
438
+ self.assertNotIn("Truncated", result.stdout)
439
+ context.write_bytes(context.read_bytes() + b"x" * 9000 + b"\n")
440
+ result = subprocess.run([sh, "-c", command], cwd=self.base, capture_output=True, text=True,
441
+ env={**os.environ, "CLAUDE_PROJECT_DIR": str(self.target)})
442
+ self.assertIn("Truncated at 8000", result.stdout)
443
+
444
+ def test_detected_clients_are_suggested_without_changing_selection(self):
445
+ self.write("CLAUDE.md", b"Existing Claude notes\n")
446
+ self.write(".cursor/rules/team.mdc", b"rule\n")
447
+ self.write(".clinerules", b"rules\n")
448
+ original_lstat = Path.lstat
449
+
450
+ def posix_lstat(path, *args, **kwargs):
451
+ if path == self.target / ".clinerules/ai-kit.md":
452
+ raise NotADirectoryError("The .clinerules ancestor is a regular file")
453
+ return original_lstat(path, *args, **kwargs)
454
+
455
+ # Windows can report a missing path here instead of POSIX ENOTDIR.
456
+ with patch.object(Path, "lstat", posix_lstat):
457
+ plan = self.plan()
458
+ explicit = self.plan(agents=["codex"])
459
+ self.assertEqual(plan["detected_agents"], {"claude": ["CLAUDE.md"], "cline": [".clinerules"],
460
+ "cursor": [".cursor/"]})
461
+ self.assertEqual(plan["suggested_agents"], ["claude", "cline", "codex", "cursor"])
462
+ self.assertNotIn("CLAUDE.md", plan["actions"])
463
+ self.assertTrue(any("--agent claude --agent cline --agent codex --agent cursor" in w
464
+ for w in plan["warnings"]))
465
+ self.assertFalse(any("Detected client files" in w for w in explicit["warnings"]))
466
+ self.assertFalse((self.target / "ai-kit").exists())
467
+ self.assertTrue(install.apply_plan(plan))
468
+ self.assertEqual((self.target / ".clinerules").read_bytes(), b"rules\n")
469
+ self.assertEqual((self.target / "CLAUDE.md").read_bytes(), b"Existing Claude notes\n")
470
+ with patch.object(Path, "lstat", posix_lstat):
471
+ self.assertFalse(self.plan()["actions"])
472
+
473
+ def test_registry_refuses_unsafe_detect_markers(self):
474
+ registry_path = self.source / install.AGENT_REGISTRY_PATH
475
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
476
+ for marker in ("../outside", "/abs", ".claude/../x", "a\\b"):
477
+ with self.subTest(marker=marker):
478
+ registry["agents"]["claude"]["detect"] = [marker]
479
+ registry_path.write_text(json.dumps(registry), encoding="utf-8")
480
+ with self.assertRaises(ValueError):
481
+ install.load_agent_registry(self.source)
482
+
483
+ def test_invalid_managed_json_state_is_refused(self):
484
+ self.apply()
485
+ state_path = self.target / "ai-kit/.install-state.json"
486
+ valid = json.loads(state_path.read_text())
487
+ for managed in ([], {".claude/settings.json": []}, {".claude/settings.json": {"other.key": ["x"]}},
488
+ {".claude/settings.json": {"permissions.ask": "x"}}, {"../x": {}}):
489
+ with self.subTest(managed=managed):
490
+ state_path.write_text(json.dumps({**valid, "managed_json": managed}), encoding="utf-8")
491
+ with self.assertRaises(ValueError):
492
+ self.plan()
493
+
494
+ def test_session_start_and_guards_merge_into_one_settings_file(self):
495
+ self.apply(agents=["claude"], extras=["session-start", "guards"])
496
+ claude_settings = json.loads((self.target / ".claude/settings.local.json").read_text())
497
+ self.assertIn("hooks", claude_settings)
498
+ self.assertIn("permissions", claude_settings)
499
+
500
+ def test_guards_without_claude_warns_and_skips_wiring(self):
501
+ plan = self.plan(extras=["guards"])
502
+ self.assertNotIn(".claude/settings.json", plan["actions"])
503
+ self.assertTrue(any("requires the claude agent selection" in warning for warning in plan["warnings"]))
504
+
505
+ def test_ci_extra_installs_workflow_and_warns_in_private_mode(self):
506
+ plan = self.plan(extras=["ci"])
507
+ self.assertIn(".github/workflows/ai-kit.yml", plan["actions"])
508
+ self.assertTrue(any("team mode is the intended companion" in warning for warning in plan["warnings"]))
509
+ self.apply(extras=["ci"])
510
+ self.assertIn("AI-KIT project check", (self.target / ".github/workflows/ai-kit.yml").read_text())
511
+
512
+ def test_unknown_extra_is_refused(self):
513
+ with self.assertRaises(ValueError):
514
+ self.plan(extras=["unknown"])
515
+
516
+ def test_non_boolean_settings_extra_is_refused(self):
517
+ settings = {"project_language": "English", "chat_language": "Russian",
518
+ "sharing_mode": "private", "conventions": "owner", "ci": "yes"}
519
+ self.write("ai-kit/settings.json", (json.dumps(settings) + "\n").encode())
520
+ with self.assertRaises(ValueError):
521
+ self.plan()
522
+
523
+ def test_new_adapters_are_opt_in_and_use_native_paths(self):
524
+ plan = self.plan()
525
+ for path in (".windsurf/rules/ai-kit.md", ".clinerules/ai-kit.md",
526
+ ".roo/rules/ai-kit.md", "GEMINI.md"):
527
+ self.assertNotIn(path, plan["actions"])
528
+ self.apply(agents=["codex", "gemini", "windsurf", "cline", "roo"])
529
+ for path in ("GEMINI.md", ".windsurf/rules/ai-kit.md", ".clinerules/ai-kit.md",
530
+ ".roo/rules/ai-kit.md"):
531
+ self.assertTrue((self.target / path).is_file(), path)
532
+ self.assertIn("PROJECT_CONTEXT", (self.target / path).read_text(encoding="utf-8"))
533
+
534
+ def test_claude_and_optional_entries_have_native_paths(self):
535
+ self.apply(agents=["codex", "claude", "copilot", "cursor", "aider"])
536
+ self.assertIn("@AGENTS.md", (self.target / "CLAUDE.md").read_text())
537
+ for original in (self.target / ".agents/skills").glob("*/SKILL.md"):
538
+ copied = self.target / ".claude/skills" / original.parent.name / "SKILL.md"
539
+ self.assertEqual(original.read_bytes(), copied.read_bytes())
540
+ self.assertTrue((self.target / ".github/copilot-instructions.md").exists())
541
+ self.assertTrue((self.target / ".cursor/rules/ai-kit.mdc").exists())
542
+ self.assertTrue((self.target / "CONVENTIONS.md").exists())
543
+ self.assertFalse((self.target / ".claude/settings.json").exists())
544
+
545
+ def test_settings_preserve_unrecognized_local_fields(self):
546
+ self.apply()
547
+ path = self.target / "ai-kit/settings.json"
548
+ settings = json.loads(path.read_text())
549
+ settings["local_option"] = "keep"
550
+ self.write("ai-kit/settings.json", (json.dumps(settings) + "\n").encode())
551
+ self.apply()
552
+ self.assertEqual(json.loads(path.read_text())["local_option"], "keep")
553
+
554
+ def test_refuse_reference_target(self):
555
+ for target in [self.source, self.source / "nested"]:
556
+ with self.subTest(target=target), self.assertRaises(ValueError):
557
+ install.build_plan(target, root=self.source)
558
+
559
+ def test_refuse_path_traversal_and_directory_destinations(self):
560
+ for path in ["../outside", "/absolute", "C:/absolute", "a/../outside"]:
561
+ with self.subTest(path=path), self.assertRaises(ValueError):
562
+ install.safe_path(self.target, path)
563
+ (self.target / "AGENTS.md").mkdir(parents=True)
564
+ with self.assertRaises(ValueError):
565
+ self.plan()
566
+
567
+ def test_refuse_linked_destination(self):
568
+ self.target.mkdir()
569
+ outside = self.base / "outside"
570
+ outside.mkdir()
571
+ try:
572
+ (self.target / "ai-kit").symlink_to(outside, target_is_directory=True)
573
+ except OSError as exc:
574
+ self.skipTest("Symlinks unavailable: " + str(exc))
575
+ with self.assertRaises(ValueError):
576
+ self.plan()
577
+ self.assertFalse(list(outside.iterdir()))
578
+
579
+ def test_refuse_malformed_ignore_markers(self):
580
+ for text in [install.START + "\n", install.END + "\n" + install.START,
581
+ (install.START + "\n" + install.END + "\n") * 2]:
582
+ with self.subTest(text=text), self.assertRaises(ValueError):
583
+ install.merge_ignore(text, "foo\n", [])
584
+
585
+ def test_file_changed_after_preview_refuses_all_instruction_writes(self):
586
+ self.write(".gitignore", b"initial\n")
587
+ plan = self.plan()
588
+ self.write(".gitignore", b"concurrent edit\n")
589
+ with self.assertRaises(ValueError):
590
+ install.apply_plan(plan)
591
+ self.assertFalse((self.target / "AGENTS.md").exists())
592
+ self.assertEqual((self.target / ".gitignore").read_bytes(), b"concurrent edit\n")
593
+
594
+ def test_write_failure_rolls_back_completed_instruction_writes(self):
595
+ plan = self.plan()
596
+ real_write = install.atomic_write
597
+
598
+ def failing_write(path, data):
599
+ if path == self.target / "ai-kit/CORE.md":
600
+ raise OSError("Injected failure")
601
+ real_write(path, data)
602
+
603
+ with patch.object(install, "atomic_write", side_effect=failing_write), self.assertRaises(OSError):
604
+ install.apply_plan(plan)
605
+ self.assertFalse((self.target / "AGENTS.md").exists())
606
+ self.assertFalse((self.target / "ai-kit/.install-state.json").exists())
607
+
608
+ def test_cli_preview_and_conflict_exit_codes(self):
609
+ with contextlib.redirect_stdout(io.StringIO()), patch.object(install, "ROOT", self.source):
610
+ # Default argument binds the distribution root; CLI is exercised against real sources.
611
+ self.assertEqual(install.main([str(self.target), "--dry-run"]), 0)
612
+ self.assertFalse(self.target.exists())
613
+ self.write("AGENTS.md", b"Existing\n")
614
+ self.assertEqual(install.main([str(self.target), "--apply"]), 2)
615
+
616
+ def test_repeated_candidate_conflict_preserves_draft_and_manifest(self):
617
+ self.write("AGENTS.md", b"Project instructions\n")
618
+ plan = self.plan()
619
+ info = plan["candidates"]["AGENTS.md"]
620
+ self.assertFalse(self.target.joinpath(info["path"]).exists())
621
+ self.assertFalse(install.apply_plan(plan))
622
+ self.write(info["path"], b"Human reviewed draft\n")
623
+ old_manifest = self.target.joinpath(info["manifest"]).read_bytes()
624
+ self.write("AGENTS.md", b"Another local edit\n")
625
+ again = self.plan()
626
+ self.assertFalse(again["candidates"]["AGENTS.md"]["matches_source"])
627
+ self.assertFalse(install.apply_plan(again))
628
+ self.assertEqual(self.target.joinpath(info["path"]).read_bytes(), b"Human reviewed draft\n")
629
+ self.assertEqual(self.target.joinpath(info["manifest"]).read_bytes(), old_manifest)
630
+ self.assertFalse((self.target / "ai-kit/.install-state.json").exists())
631
+
632
+ def test_new_source_candidate_preserves_previous_and_legacy_drafts(self):
633
+ self.write("AGENTS.md", b"Project instructions\n")
634
+ legacy = "ai-kit/.upstream-cache/candidates/AGENTS.md"
635
+ self.write(legacy, b"Legacy review draft\n")
636
+ first = self.plan()
637
+ install.apply_plan(first)
638
+ old_path = first["candidates"]["AGENTS.md"]["path"]
639
+ self.write(old_path, b"Current review draft\n")
640
+ source = self.source / "template/AGENTS.md"
641
+ source.write_bytes(source.read_bytes() + b"\nNew upstream instructions.\n")
642
+ next_plan = self.plan()
643
+ new_path = next_plan["candidates"]["AGENTS.md"]["path"]
644
+ self.assertNotEqual(old_path, new_path)
645
+ self.assertFalse(self.target.joinpath(new_path).exists())
646
+ install.apply_plan(next_plan)
647
+ self.assertEqual(self.target.joinpath(new_path).read_bytes(), source.read_bytes())
648
+ self.assertEqual(self.target.joinpath(old_path).read_bytes(), b"Current review draft\n")
649
+ self.assertEqual(self.target.joinpath(legacy).read_bytes(), b"Legacy review draft\n")
650
+
651
+ def test_candidate_created_after_preview_is_never_overwritten(self):
652
+ self.write("AGENTS.md", b"Project instructions\n")
653
+ plan = self.plan()
654
+ path = plan["candidates"]["AGENTS.md"]["path"]
655
+ self.write(path, b"Draft created after preview\n")
656
+ self.assertFalse(install.apply_plan(plan))
657
+ self.assertEqual(self.target.joinpath(path).read_bytes(), b"Draft created after preview\n")
658
+
659
+ def test_agent_retirement_conflict_preserves_active_files_and_state(self):
660
+ self.apply(agents=["codex", "claude"])
661
+ state_path = self.target / "ai-kit/.install-state.json"
662
+ original_state = state_path.read_bytes()
663
+ self.write("CLAUDE.md", b"Locally adapted Claude instructions\n")
664
+ plan = self.plan(agents=["codex", "copilot"])
665
+ self.assertIn("CLAUDE.md", plan["adapter_conflicts"])
666
+ native_skills = {".claude/skills/" + path.parent.name + "/SKILL.md"
667
+ for path in (self.target / ".agents/skills").glob("*/SKILL.md")}
668
+ self.assertEqual(set(plan["adapter_conflicts"]), {"CLAUDE.md", *native_skills})
669
+ self.assertTrue(install.needs_review(plan))
670
+ self.assertFalse(install.apply_plan(plan))
671
+ self.assertEqual(state_path.read_bytes(), original_state)
672
+ self.assertFalse((self.target / ".github/copilot-instructions.md").exists())
673
+ self.assertEqual((self.target / "CLAUDE.md").read_bytes(), b"Locally adapted Claude instructions\n")
674
+ self.assertIn("CLAUDE.md", json.loads(original_state)["files"])
675
+ self.assertFalse(self.plan(agents=["codex", "claude"])["adapter_conflicts"])
676
+
677
+ def test_reviewed_manual_retirement_allows_new_selection(self):
678
+ self.apply(agents=["codex", "claude"])
679
+ plan = self.plan(agents=["codex", "copilot"])
680
+ for relative in plan["adapter_conflicts"]:
681
+ path = install.safe_path(self.target, relative)
682
+ path.resolve().relative_to(self.target.resolve())
683
+ path.unlink()
684
+ self.apply(agents=["codex", "copilot"])
685
+ state = json.loads((self.target / "ai-kit/.install-state.json").read_text())
686
+ self.assertEqual(state["agents"], ["codex", "copilot"])
687
+ self.assertNotIn("CLAUDE.md", state["files"])
688
+ self.assertTrue((self.target / ".github/copilot-instructions.md").exists())
689
+
690
+ def test_untracked_unselected_adapter_and_skills_are_reported(self):
691
+ self.write("CLAUDE.md", b"Custom Claude instructions\n")
692
+ self.write(".claude/skills/custom/SKILL.md", b"Custom native skill\n")
693
+ plan = self.apply()
694
+ self.assertTrue(any("Untracked unselected claude" in warning for warning in plan["warnings"]))
695
+ self.assertTrue(any("Unselected .claude/skills" in warning for warning in plan["warnings"]))
696
+ self.assertEqual((self.target / "CLAUDE.md").read_bytes(), b"Custom Claude instructions\n")
697
+ self.assertEqual((self.target / ".claude/skills/custom/SKILL.md").read_bytes(), b"Custom native skill\n")
698
+
699
+ def test_state_validation_refuses_shapes_before_planning(self):
700
+ valid = {"schema": 1, "accepted_version": "0.2.1", "agents": ["codex"], "files": {}}
701
+ cases = [{}, {**valid, "schema": 99}, {**valid, "schema": True},
702
+ {**valid, "accepted_version": []}, {**valid, "agents": "codex"},
703
+ {**valid, "agents": ["codex", 1]}, {**valid, "agents": ["codex", "codex"]},
704
+ {**valid, "agents": ["unknown"]}, {**valid, "files": []},
705
+ {**valid, "files": {"AGENTS.md": "bad"}},
706
+ {**valid, "files": {"AGENTS.md": {"source_hash": "bad", "installed_hash": "a" * 64}}},
707
+ {**valid, "files": {"AGENTS.md": {"source_hash": "a" * 64, "installed_hash": None}}},
708
+ {**valid, "files": {"AGENTS.md": {"source_hash": "a" * 64, "installed_hash": "b" * 64,
709
+ "local_adaptation": 1}}},
710
+ {**valid, "files": {"../outside": {"source_hash": "a" * 64, "installed_hash": "b" * 64}}}]
711
+ for state in cases:
712
+ with self.subTest(state=state):
713
+ self.write("ai-kit/.install-state.json", json.dumps(state).encode())
714
+ with self.assertRaises(ValueError):
715
+ self.plan()
716
+ self.assertFalse((self.target / "AGENTS.md").exists())
717
+
718
+ def test_cli_corrupt_state_reports_refusal_without_traceback(self):
719
+ self.write("ai-kit/.install-state.json",
720
+ json.dumps({"schema": 99, "agents": ["codex", 1], "files": {}}).encode())
721
+ errors = io.StringIO()
722
+ with contextlib.redirect_stderr(errors), contextlib.redirect_stdout(io.StringIO()):
723
+ self.assertEqual(install.main([str(self.target), "--apply"]), 1)
724
+ self.assertIn("Installation refused: Unsupported installer state schema", errors.getvalue())
725
+ self.assertNotIn("Traceback", errors.getvalue())
726
+ self.assertFalse((self.target / "AGENTS.md").exists())
727
+
728
+ def test_chat_language_accepts_non_latin_names(self):
729
+ settings = json.loads((self.source / "template/ai-kit/settings.json").read_text())
730
+ settings["chat_language"] = "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430"
731
+ install.validate_settings(settings)
732
+ settings["chat_language"] = "\u4e2d\u6587"
733
+ install.validate_settings(settings)
734
+ for bad in (" ", "x" * 61, "line\nbreak"):
735
+ with self.subTest(bad=bad), self.assertRaises(ValueError):
736
+ settings["chat_language"] = bad
737
+ install.validate_settings(settings)
738
+
739
+ def test_settings_type_validation_is_controlled(self):
740
+ valid = json.loads((self.source / "template/ai-kit/settings.json").read_text())
741
+ for field, value in [("project_language", 1), ("chat_language", []), ("sharing_mode", {}),
742
+ ("conventions", False), ("upstream", "not-an-object"),
743
+ ("upstream", {"ref": 1})]:
744
+ with self.subTest(field=field, value=value):
745
+ self.write("ai-kit/settings.json", json.dumps({**valid, field: value}).encode())
746
+ with self.assertRaises(ValueError):
747
+ self.plan()
748
+ self.assertFalse((self.target / "AGENTS.md").exists())
749
+
750
+ def test_previous_bundle_state_and_project_data_survive_upgrade(self):
751
+ (self.source / "VERSION").write_text("0.2.1\n", encoding="utf-8")
752
+ self.write("README.md", b"Existing application documentation\n")
753
+ self.write(".gitignore", b"custom.cache\n")
754
+ self.apply()
755
+ self.write("ai-kit/.upstream-cache/candidates/AGENTS.md", b"Old review work\n")
756
+ (self.source / "VERSION").write_text(self.initial_version + "\n", encoding="utf-8")
757
+ self.apply()
758
+ state = json.loads((self.target / "ai-kit/.install-state.json").read_text())
759
+ self.assertEqual(state["schema"], 1)
760
+ self.assertEqual(state["accepted_version"], self.initial_version)
761
+ self.assertEqual((self.target / "README.md").read_bytes(), b"Existing application documentation\n")
762
+ self.assertTrue((self.target / ".gitignore").read_bytes().startswith(b"custom.cache\n"))
763
+ self.assertEqual((self.target / "ai-kit/.upstream-cache/candidates/AGENTS.md").read_bytes(),
764
+ b"Old review work\n")
765
+
766
+ def test_new_profile_upgrade_keeps_project_facts_selection_and_measurements(self):
767
+ # Synthetic older layout; this is not evidence of a real application upgrade.
768
+ additions = ["template/ai-kit/METRICS.md", "template/ai-kit/stacks/FILAMENT.md",
769
+ "template/.agents/skills/filament-work/SKILL.md"]
770
+ originals = {name: (self.source / name).read_bytes() for name in additions}
771
+ for name in additions:
772
+ (self.source / name).unlink()
773
+ for path in (self.source / "template").rglob("*.md"):
774
+ data = path.read_bytes()
775
+ lines = data.decode("utf-8").splitlines(keepends=True)
776
+ filtered = "".join(line for line in lines if "FILAMENT.md" not in line and
777
+ "filament-work" not in line and "METRICS.md" not in line).encode("utf-8")
778
+ if filtered != data:
779
+ originals[path.relative_to(self.source).as_posix()] = data
780
+ path.write_bytes(filtered)
781
+ (self.source / "VERSION").write_text("0.4.7\n", encoding="utf-8")
782
+ self.write("admin/composer.json", b'{"require":{"filament/filament":"^5.0"}}\n')
783
+ self.write("admin/artisan", b"<?php\n")
784
+ self.apply(agents=["codex", "claude"], mode="team")
785
+ preserved = {"PROJECT_CONTEXT.md": b"# Reviewed application facts\n",
786
+ "ai-kit/project.json": b'{"schema":1,"modules":[],"operations":{}}\n',
787
+ "ai-kit/.metrics/tasks.jsonl": b"Private measurement data\n"}
788
+ for name, data in preserved.items():
789
+ self.write(name, data)
790
+ selection_path = "ai-kit/router/selection.json"
791
+ selection_data = json.loads((self.target / selection_path).read_text())
792
+ selection_data["default_provider"] = "local"
793
+ selection_data["local_models"]["work"] = "owner-model"
794
+ preserved[selection_path] = (json.dumps(selection_data) + "\n").encode()
795
+ self.write(selection_path, preserved[selection_path])
796
+ for name, data in originals.items():
797
+ (self.source / name).write_bytes(data)
798
+ (self.source / "VERSION").write_text(self.initial_version + "\n", encoding="utf-8")
799
+ plan = self.plan()
800
+ self.assertFalse(plan["conflicts"])
801
+ self.assertEqual(plan["detected_profiles"]["FILAMENT"], ["admin/"])
802
+ self.assertTrue(install.apply_plan(plan))
803
+ for name, data in preserved.items():
804
+ self.assertEqual((self.target / name).read_bytes(), data)
805
+ original = self.target / ".agents/skills/filament-work/SKILL.md"
806
+ native = self.target / ".claude/skills/filament-work/SKILL.md"
807
+ self.assertEqual(original.read_bytes(), native.read_bytes())
808
+ self.assertFalse(self.plan()["actions"])
809
+
810
+ def test_existing_mixed_project_and_installed_git_visibility(self):
811
+ git = os.environ.get("AI_KIT_GIT") or shutil.which("git")
812
+ if not git:
813
+ self.skipTest("Git unavailable for installed-project visibility check")
814
+ files = {"AGENTS.md": b"Reviewed application instructions\n",
815
+ "README.md": b"Existing application documentation\n",
816
+ ".gitignore": b"custom.cache\n",
817
+ "web/composer.json": b'{"require": {}}\n', "web/composer.lock": b"{}\n",
818
+ "web/vendor/autoload.php": b"<?php\n",
819
+ "api/go.mod": b"module example.test/api\n", "api/go.sum": b"fixture checksum\n",
820
+ "api/vendor/modules.txt": b"# intentional vendor fixture\n",
821
+ "tools/pyproject.toml": b'[project]\nname = "fixture-tools"\n',
822
+ "tools/uv.lock": b"version = 1\n", "tools/requirements.txt": b"# fixture\n",
823
+ "tools/__pycache__/task.pyc": b"cache", ".env": b"FIXTURE=local\n",
824
+ ".env.example": b"FIXTURE=example\n", "api/go-cache/item": b"cache",
825
+ "api/result.out": b"output", "custom.cache": b"local"}
826
+ for path, data in files.items():
827
+ self.write(path, data)
828
+ pending = self.plan(agents=["codex", "claude"])
829
+ self.assertFalse(install.apply_plan(pending))
830
+ self.assertFalse((self.target / "ai-kit/.install-state.json").exists())
831
+ subprocess.run([git, "-C", str(self.target), "init", "--quiet"], check=True,
832
+ capture_output=True, timeout=20)
833
+
834
+ def ignored(path):
835
+ result = subprocess.run([git, "-C", str(self.target), "-c", "core.excludesFile=",
836
+ "check-ignore", "--no-index", "-q", path],
837
+ capture_output=True, timeout=20)
838
+ self.assertIn(result.returncode, (0, 1), result.stderr.decode(errors="replace"))
839
+ return result.returncode == 0
840
+
841
+ shared = ["AGENTS.md", "ai-kit/CORE.md", ".agents/skills/go-work/SKILL.md",
842
+ "CLAUDE.md", ".claude/skills/go-work/SKILL.md", "ai-kit/stacks/FILAMENT.md",
843
+ ".agents/skills/filament-work/SKILL.md", ".claude/skills/filament-work/SKILL.md"]
844
+ visible = ["README.md", "CHANGELOG.md", ".env.example", "web/composer.json",
845
+ "web/composer.lock", "api/go.mod", "api/go.sum", "api/vendor/modules.txt",
846
+ "tools/pyproject.toml", "tools/uv.lock", "tools/requirements.txt"]
847
+ hidden = [".env", "custom.cache", "web/vendor/autoload.php", "api/go-cache/item",
848
+ "api/result.out", "tools/__pycache__/task.pyc", "ai-kit/.install-state.json",
849
+ "ai-kit/.metrics/tasks.jsonl", "ai-kit/.metrics/write.lock",
850
+ pending["candidates"]["AGENTS.md"]["path"]]
851
+ for mode in ("private", "team"):
852
+ with self.subTest(mode=mode):
853
+ self.apply(mode=mode, agents=["codex", "claude"], accept_local=["AGENTS.md"])
854
+ for path in visible:
855
+ self.assertFalse(ignored(path), path)
856
+ for path in hidden:
857
+ self.assertTrue(ignored(path), path)
858
+ for path in shared:
859
+ self.assertEqual(ignored(path), mode == "private", path)
860
+ self.assertEqual((self.target / "AGENTS.md").read_bytes(), files["AGENTS.md"])
861
+ self.assertEqual((self.target / "README.md").read_bytes(), files["README.md"])
862
+ self.assertTrue((self.target / ".gitignore").read_bytes().startswith(files[".gitignore"]))
863
+ for path in ["web/composer.lock", "api/go.mod", "api/go.sum", "tools/uv.lock"]:
864
+ self.assertEqual((self.target / path).read_bytes(), files[path])
865
+ self.assertFalse(self.plan()["actions"])
866
+
867
+ @unittest.skipUnless(os.name == "nt", "Windows junction fixture")
868
+ def test_windows_junctions_refused_and_excluded_from_module_scan(self):
869
+ shell = shutil.which("powershell") or shutil.which("pwsh")
870
+ if not shell:
871
+ self.skipTest("PowerShell unavailable for junction fixture")
872
+ self.target.mkdir()
873
+ outside = self.base / "outside"
874
+ outside.mkdir()
875
+ (outside / "composer.json").write_bytes(b"{}\n")
876
+ sentinel = outside / "preserved.txt"
877
+ sentinel.write_bytes(b"Keep outside data\n")
878
+ helper = install.ROOT / "tests/fixtures/create-junction.ps1"
879
+
880
+ def junction(link):
881
+ # Both paths are confined to this disposable test fixture.
882
+ link.absolute().relative_to(self.base.resolve())
883
+ outside.resolve().relative_to(self.base.resolve())
884
+ result = subprocess.run([shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
885
+ "-File", str(helper),
886
+ "-LinkPath", str(link), "-TargetPath", str(outside)],
887
+ capture_output=True, timeout=30)
888
+ if result.returncode == 77:
889
+ self.skipTest("Junction creation unavailable: " + result.stderr.decode(errors="replace"))
890
+ self.assertEqual(result.returncode, 0, result.stderr.decode(errors="replace"))
891
+ self.assertTrue(install.is_link(link))
892
+ self.addCleanup(link.rmdir)
893
+
894
+ nested = self.target / "ai-kit"
895
+ junction(nested)
896
+ with self.assertRaises(ValueError):
897
+ self.plan()
898
+ root_link = self.base / "linked root"
899
+ junction(root_link)
900
+ with self.assertRaisesRegex(ValueError, "linked target root"):
901
+ install.build_plan(root_link, root=self.source)
902
+ module_link = self.target / "linked module"
903
+ junction(module_link)
904
+ self.assertEqual(install.module_ignores(self.target), [])
905
+ self.assertEqual(sentinel.read_bytes(), b"Keep outside data\n")
906
+ self.assertFalse((outside / "settings.json").exists())
907
+
908
+
909
+ class NoncanonicalTempPathTests(unittest.TestCase):
910
+ """Run the two affected scenarios with another spelling of the temp root."""
911
+
912
+ plan = InstallerTests.plan
913
+ test_write_failure_rolls_back_completed_instruction_writes = (
914
+ InstallerTests.test_write_failure_rolls_back_completed_instruction_writes)
915
+ test_windows_junctions_refused_and_excluded_from_module_scan = (
916
+ InstallerTests.test_windows_junctions_refused_and_excluded_from_module_scan)
917
+
918
+ def setUp(self):
919
+ temporary_directory = tempfile.TemporaryDirectory
920
+
921
+ def noncanonical_directory(*args, **kwargs):
922
+ directory = temporary_directory(*args, **kwargs)
923
+ self.addCleanup(directory.cleanup)
924
+ actual = Path(directory.name).resolve()
925
+ route = temporary_directory(dir=actual.parent, prefix="ai-kit-alias-route-")
926
+ self.addCleanup(route.cleanup)
927
+ # This real '..' route has a different prefix, like an 8.3 alias.
928
+ # Cleanup stays bound to each original directory, never the alias.
929
+ return SimpleNamespace(name=str(Path(route.name) / ".." / actual.name),
930
+ cleanup=directory.cleanup)
931
+
932
+ replacement = patch.object(tempfile, "TemporaryDirectory", side_effect=noncanonical_directory)
933
+ replacement.start()
934
+ self.addCleanup(replacement.stop)
935
+ InstallerTests.setUp(self)
936
+
937
+
938
+ if __name__ == "__main__":
939
+ unittest.main()