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,209 @@
1
+ """Local measurement accounting, preservation, validation, and path guards."""
2
+ import contextlib
3
+ import copy
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest import mock
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
13
+ import metrics
14
+
15
+
16
+ def task(task_id="pair-01", **overrides):
17
+ value = {"schema": 1, "task_id": task_id, "experiment": "trial", "arm": "kit",
18
+ "kit_version": "test-fixture", "outcome": "correct", "checks": "passed",
19
+ "rework": 0, "escalations": 0,
20
+ "attempts": [{"provider": "test-provider", "model": "test-model", "effort": "low",
21
+ "duration_seconds": 10, "input_tokens": 100, "cached_input_tokens": 20,
22
+ "output_tokens": 40, "reasoning_tokens": 10,
23
+ "cost": {"amount": "0.10", "currency": "USD", "source": "provider-usage"}}]}
24
+ value.update(overrides)
25
+ return value
26
+
27
+
28
+ class AccountingTests(unittest.TestCase):
29
+ def test_failed_and_recovery_costs_are_included(self):
30
+ successful = task(rework=1, escalations=1)
31
+ successful["attempts"].append(copy.deepcopy(successful["attempts"][0]))
32
+ failed = task("pair-02", outcome="incorrect", checks="failed")
33
+ group = metrics.summarize([successful, failed])["groups"][0]
34
+ self.assertEqual(group["correct"], 1)
35
+ self.assertEqual(group["attempts"], 3)
36
+ self.assertEqual(group["cost_per_correct_result"], "0.30")
37
+ self.assertEqual(group["duration_seconds"], 30)
38
+ self.assertEqual(group["tokens"]["input_tokens"]["known_total"], 300)
39
+ self.assertEqual((group["rework"], group["escalations"]), (1, 1))
40
+
41
+ def test_unknown_cost_time_and_tokens_are_not_zero(self):
42
+ value = task()
43
+ value["attempts"].append({})
44
+ group = metrics.summarize([value])["groups"][0]
45
+ self.assertIsNone(group["cost_per_correct_result"])
46
+ self.assertIsNone(group["duration_seconds"])
47
+ self.assertEqual(group["cost_coverage"], {"known": 1, "total": 2, "estimated": 0})
48
+ self.assertEqual(group["tokens"]["input_tokens"]["known_attempts"], 1)
49
+
50
+ def test_currencies_and_estimates_are_explicit(self):
51
+ value = task()
52
+ other = copy.deepcopy(value["attempts"][0])
53
+ other["cost"] = {"amount": "0.20", "currency": "EUR", "source": "estimate"}
54
+ value["attempts"].append(other)
55
+ group = metrics.summarize([value])["groups"][0]
56
+ self.assertIsNone(group["cost_per_correct_result"])
57
+ self.assertEqual(group["known_cost_by_currency"], {"EUR": "0.20", "USD": "0.10"})
58
+ self.assertEqual(group["cost_coverage"]["estimated"], 1)
59
+
60
+ def test_no_correct_result_has_no_cost_per_correct_result(self):
61
+ group = metrics.summarize([task(outcome="incomplete", checks="not-run")])["groups"][0]
62
+ self.assertIsNone(group["cost_per_correct_result"])
63
+ self.assertEqual(group["checks_not_run"], 1)
64
+
65
+ def test_experiment_arms_are_separate(self):
66
+ results = metrics.summarize([task(), task(arm="baseline"), task(experiment="other")])["groups"]
67
+ self.assertEqual(len(results), 3)
68
+
69
+ def test_analyze_aggregates_per_provider(self):
70
+ openai = task()
71
+ openai["attempts"][0].update({"provider": "openai", "model": "gpt-6.1-sol"})
72
+ anthropic = task("pair-02", outcome="incorrect", checks="failed")
73
+ anthropic["attempts"][0].update({"provider": "anthropic", "model": "claude-opus-5-5"})
74
+ report = metrics.analyze([openai, anthropic])
75
+ by_name = {item["provider"]: item for item in report["providers"]}
76
+ self.assertEqual(set(by_name), {"openai", "anthropic"})
77
+ self.assertEqual(by_name["openai"]["correct_tasks"], 1)
78
+ self.assertEqual(by_name["openai"]["success_rate"], 1.0)
79
+ self.assertEqual(by_name["openai"]["cost_per_correct"], "0.10")
80
+ self.assertEqual(by_name["anthropic"]["correct_tasks"], 0)
81
+ self.assertEqual(by_name["anthropic"]["success_rate"], 0.0)
82
+ self.assertIsNone(by_name["anthropic"]["cost_per_correct"])
83
+
84
+ def test_analyze_keeps_baseline_and_kit_task_identities_distinct(self):
85
+ baseline = task(schema=2, arm="baseline")
86
+ baseline["attempts"][0]["disposition"] = "succeeded"
87
+ kit = task(schema=2, arm="kit", outcome="incorrect", checks="failed")
88
+ kit["attempts"][0]["disposition"] = "failed"
89
+ report = metrics.analyze([baseline, kit])
90
+ provider = report["providers"][0]
91
+ self.assertEqual((provider["tasks"], provider["correct_tasks"]), (2, 1))
92
+ self.assertEqual(provider["attempt_dispositions"],
93
+ {"succeeded": 1, "failed": 1, "inconclusive": 0})
94
+ self.assertEqual(provider["attempt_success_rate"], 0.5)
95
+
96
+ def test_schema_two_requires_attempt_disposition(self):
97
+ value = task(schema=2)
98
+ with self.assertRaises(ValueError):
99
+ metrics.validate_record(value)
100
+ value["attempts"][0]["disposition"] = "inconclusive"
101
+ self.assertEqual(metrics.validate_record(value)["schema"], 2)
102
+
103
+ def test_record_validation_refuses_bad_data_and_task_text(self):
104
+ invalid = [task(schema=True), task(task_id="please fix this bug"), task(prompt="secret"),
105
+ task(outcome=[]), task(checks=[]), task(rework=True), task(escalations=-1),
106
+ task(outcome="correct", checks="failed"), task(attempts=[]), task(attempts=[3])]
107
+ for field, value in (("duration_seconds", float("nan")), ("duration_seconds", True),
108
+ ("duration_seconds", 10 ** 400),
109
+ ("input_tokens", -1), ("input_tokens", True),
110
+ ("cached_input_tokens", 101), ("reasoning_tokens", 41),
111
+ ("model", "model with task text"), ("prompt", "secret")):
112
+ record = task()
113
+ record["attempts"][0][field] = value
114
+ invalid.append(record)
115
+ for cost in ({"amount": "-1", "currency": "USD", "source": "billing"},
116
+ {"amount": "NaN", "currency": "USD", "source": "billing"},
117
+ {"amount": 0.1, "currency": "USD", "source": "billing"},
118
+ {"amount": "1", "currency": "usd", "source": "billing"},
119
+ {"amount": "1", "currency": "USD", "source": []}):
120
+ record = task()
121
+ record["attempts"][0]["cost"] = cost
122
+ invalid.append(record)
123
+ for record in invalid:
124
+ with self.subTest(record=record), self.assertRaises(ValueError):
125
+ metrics.validate_record(record)
126
+
127
+
128
+ class JournalTests(unittest.TestCase):
129
+ def setUp(self):
130
+ self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-metrics-")
131
+ self.addCleanup(self.temp.cleanup)
132
+ self.project = Path(self.temp.name).resolve()
133
+ self.journal = self.project / metrics.JOURNAL
134
+
135
+ def test_preview_and_empty_summary_write_nothing(self):
136
+ result = metrics.record_task(self.project, task())
137
+ self.assertTrue(result["preview"])
138
+ self.assertEqual(metrics.summarize(metrics.read_records(self.project))["groups"], [])
139
+ self.assertEqual(list(self.project.iterdir()), [])
140
+
141
+ def test_apply_and_duplicate_preserve_data(self):
142
+ metrics.record_task(self.project, task(), apply=True)
143
+ original = self.journal.read_bytes()
144
+ with self.assertRaises(ValueError):
145
+ metrics.record_task(self.project, task(), apply=True)
146
+ self.assertEqual(self.journal.read_bytes(), original)
147
+ metrics.record_task(self.project, task("pair-02"), apply=True)
148
+ self.assertEqual(len(metrics.read_records(self.project)), 2)
149
+ self.assertFalse((self.project / metrics.LOCK).exists())
150
+
151
+ def test_malformed_journal_refuses_without_overwrite(self):
152
+ self.journal.parent.mkdir(parents=True)
153
+ self.journal.write_bytes(b"not-json\n")
154
+ with self.assertRaises(ValueError):
155
+ metrics.record_task(self.project, task(), apply=True)
156
+ self.assertEqual(self.journal.read_bytes(), b"not-json\n")
157
+
158
+ def test_duplicate_existing_journal_is_refused(self):
159
+ self.journal.parent.mkdir(parents=True)
160
+ line = json.dumps(task()) + "\n"
161
+ self.journal.write_text(line * 2, encoding="utf-8")
162
+ with self.assertRaises(ValueError):
163
+ metrics.read_records(self.project)
164
+
165
+ def test_busy_writer_lock_is_preserved(self):
166
+ lock = self.project / metrics.LOCK
167
+ lock.parent.mkdir(parents=True)
168
+ lock.write_bytes(b"other writer\n")
169
+ with self.assertRaises(FileExistsError):
170
+ metrics.record_task(self.project, task(), apply=True)
171
+ self.assertEqual(lock.read_bytes(), b"other writer\n")
172
+ self.assertFalse(self.journal.exists())
173
+
174
+ def test_write_failure_keeps_journal_and_cleans_owned_lock(self):
175
+ metrics.record_task(self.project, task(), apply=True)
176
+ original = self.journal.read_bytes()
177
+ with mock.patch.object(metrics.install, "atomic_write", side_effect=OSError("disk failure")):
178
+ with self.assertRaises(OSError):
179
+ metrics.record_task(self.project, task("pair-02"), apply=True)
180
+ self.assertEqual(self.journal.read_bytes(), original)
181
+ self.assertFalse((self.project / metrics.LOCK).exists())
182
+
183
+ def test_file_parent_refused_before_writes(self):
184
+ (self.project / "ai-kit").write_bytes(b"native data")
185
+ with self.assertRaises(NotADirectoryError):
186
+ metrics.record_task(self.project, task(), apply=True)
187
+ self.assertEqual((self.project / "ai-kit").read_bytes(), b"native data")
188
+
189
+ def test_linked_root_parent_and_destination_refused(self):
190
+ for candidate in (self.project, self.journal.parent, self.journal, self.project / metrics.LOCK):
191
+ with self.subTest(candidate=candidate), mock.patch.object(
192
+ metrics.install, "is_link", side_effect=lambda path: path == candidate):
193
+ with self.assertRaises(ValueError):
194
+ metrics.record_task(self.project, task(), apply=True)
195
+ self.assertEqual(list(self.project.iterdir()), [])
196
+
197
+ def test_cli_preview_apply_summary_and_invalid_input(self):
198
+ source = self.project / "record.json"
199
+ source.write_text(json.dumps(task()), encoding="utf-8")
200
+ output = io.StringIO()
201
+ with contextlib.redirect_stdout(output):
202
+ self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source)]), 0)
203
+ self.assertFalse(self.journal.exists())
204
+ with contextlib.redirect_stdout(io.StringIO()):
205
+ self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source), "--apply"]), 0)
206
+ self.assertEqual(metrics.main(["summary", str(self.project)]), 0)
207
+ source.write_text("{}\n", encoding="utf-8")
208
+ with contextlib.redirect_stderr(io.StringIO()):
209
+ self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source)]), 1)
@@ -0,0 +1,342 @@
1
+ """Exercise generated client settings, scoped module rules, presets, and workflow skills."""
2
+ import contextlib
3
+ import io
4
+ import json
5
+ from pathlib import Path
6
+ import shutil
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
12
+ import doctor
13
+ import install
14
+
15
+ ALL_SCOPED = ["codex", "claude", "cursor", "copilot", "windsurf", "cline"]
16
+
17
+
18
+ class NativeConfigurationTests(unittest.TestCase):
19
+ def setUp(self):
20
+ self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-native-")
21
+ self.addCleanup(self.temp.cleanup)
22
+ self.base = Path(self.temp.name).resolve()
23
+ self.target = self.base / "project with spaces"
24
+ self.source = self.base / "reference"
25
+ shutil.copytree(install.ROOT, self.source, ignore=shutil.ignore_patterns("__pycache__", ".git"))
26
+
27
+ def plan(self, **options):
28
+ return install.build_plan(self.target, root=self.source, **options)
29
+
30
+ def apply(self, **options):
31
+ plan = self.plan(**options)
32
+ self.assertFalse(plan["conflicts"], plan["conflicts"])
33
+ self.assertTrue(install.apply_plan(plan))
34
+ return plan
35
+
36
+ def write(self, path, data):
37
+ destination = self.target / path
38
+ destination.parent.mkdir(parents=True, exist_ok=True)
39
+ destination.write_text(data, encoding="utf-8")
40
+
41
+ def read_json(self, path):
42
+ return json.loads((self.target / path).read_text(encoding="utf-8"))
43
+
44
+ def laravel_and_go(self):
45
+ self.write("web/composer.json", json.dumps({"require": {"php": "^8.3", "laravel/framework": "^12.0"}}))
46
+ self.write("web/artisan", "#!/usr/bin/env php\n")
47
+ self.write("api/go.mod", "module example.test/api\n\ngo 1.23\n")
48
+
49
+ # Native settings (item 6)
50
+
51
+ def test_private_native_settings_use_the_local_claude_file(self):
52
+ self.laravel_and_go()
53
+ self.apply(agents=["claude"], extras=["native-settings"])
54
+ self.assertFalse((self.target / ".claude/settings.json").exists())
55
+ settings = self.read_json(".claude/settings.local.json")
56
+ self.assertIn("Bash(php artisan test *)", settings["permissions"]["allow"])
57
+ self.assertIn("Bash(go test ./... *)", settings["permissions"]["allow"])
58
+ deny = settings["permissions"]["deny"]
59
+ for rule in ("Read(.env)", "Read(.env.*)", "Read(auth.json)", "Read(/web/storage/*.key)", "Read(/secrets/**)"):
60
+ self.assertIn(rule, deny)
61
+ # Gitignore negations stay after the rules they carve out of.
62
+ self.assertGreater(deny.index("Read(!.env.example)"), deny.index("Read(.env.*)"))
63
+ self.assertNotIn("Read(.pypirc)", deny)
64
+ state = self.read_json("ai-kit/.install-state.json")
65
+ self.assertEqual(sorted(state["managed_json"]), [".claude/settings.local.json"])
66
+ self.assertFalse(self.plan()["actions"])
67
+
68
+ def test_moodle_and_python_secrets_are_module_scoped(self):
69
+ self.write("lms/config-dist.php", "<?php\n")
70
+ self.write("lms/lib/moodlelib.php", "<?php\n")
71
+ self.write("tools/pyproject.toml", '[project]\nname = "tools"\n')
72
+ self.apply(agents=["claude"], extras=["native-settings"])
73
+ deny = self.read_json(".claude/settings.local.json")["permissions"]["deny"]
74
+ self.assertIn("Read(/lms/config.php)", deny)
75
+ self.assertIn("Read(.pypirc)", deny)
76
+ self.assertNotIn("Read(config.php)", deny)
77
+
78
+ def test_team_mode_uses_shared_file_and_switching_moves_only_owned_entries(self):
79
+ self.laravel_and_go()
80
+ self.write(".claude/settings.local.json", json.dumps({"permissions": {"allow": ["Bash(make dev)"]}}))
81
+ self.apply(agents=["claude"], extras=["native-settings", "guards"])
82
+ self.assertIn("Bash(git push *)", self.read_json(".claude/settings.local.json")["permissions"]["ask"])
83
+ self.apply(mode="team")
84
+ local = self.read_json(".claude/settings.local.json")
85
+ self.assertEqual(local, {"permissions": {"allow": ["Bash(make dev)"]}})
86
+ shared = self.read_json(".claude/settings.json")
87
+ self.assertIn("Bash(git push *)", shared["permissions"]["ask"])
88
+ self.assertIn("Read(.env)", shared["permissions"]["deny"])
89
+ state = self.read_json("ai-kit/.install-state.json")
90
+ self.assertEqual(sorted(state["managed_json"]), [".claude/settings.json"])
91
+ self.assertFalse(self.plan()["actions"])
92
+
93
+ def test_previous_private_entries_in_shared_file_move_to_local_file(self):
94
+ self.apply(agents=["claude"])
95
+ hook = install.SESSION_START_COMMAND
96
+ user_and_old = {"model": "user", "permissions": {"ask": ["Bash(git push *)"]},
97
+ "hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": hook}]}]}}
98
+ self.write(".claude/settings.json", json.dumps(user_and_old))
99
+ state_path = self.target / "ai-kit/.install-state.json"
100
+ state = json.loads(state_path.read_text(encoding="utf-8"))
101
+ state["managed_json"] = {".claude/settings.json": {"permissions.ask": ["Bash(git push *)"],
102
+ "hooks.SessionStart": [hook]}}
103
+ state_path.write_text(json.dumps(state), encoding="utf-8")
104
+ self.apply(extras=["guards", "session-start"])
105
+ self.assertEqual(self.read_json(".claude/settings.json"), {"model": "user"})
106
+ local = self.read_json(".claude/settings.local.json")
107
+ self.assertIn("Bash(git push *)", local["permissions"]["ask"])
108
+ self.assertEqual(local["hooks"]["SessionStart"][0]["hooks"][0]["command"], hook)
109
+
110
+ def test_unsafe_recorded_commands_are_not_allowed(self):
111
+ self.write("pkg/package.json", json.dumps({"scripts": {"test": "jest"}}))
112
+ self.apply(agents=["claude"])
113
+ project = self.read_json("ai-kit/project.json")
114
+ project["modules"][0]["commands"] = {"test": "npm test && rm -rf /", "lint": "npm run lint",
115
+ "build": "echo $HOME"}
116
+ self.write("ai-kit/project.json", json.dumps(project))
117
+ self.apply(extras=["native-settings"])
118
+ allow = self.read_json(".claude/settings.local.json")["permissions"]["allow"]
119
+ self.assertEqual(allow, ["Bash(npm run lint *)"])
120
+
121
+ def test_project_json_facts_drive_generated_settings(self):
122
+ self.laravel_and_go()
123
+ self.apply(agents=["claude"], extras=["native-settings"])
124
+ project = self.read_json("ai-kit/project.json")
125
+ for module in project["modules"]:
126
+ if module["path"] == "/web/":
127
+ module["commands"]["test"] = "composer test"
128
+ self.write("ai-kit/project.json", json.dumps(project))
129
+ self.apply()
130
+ allow = self.read_json(".claude/settings.local.json")["permissions"]["allow"]
131
+ self.assertIn("Bash(composer test *)", allow)
132
+ self.assertNotIn("Bash(php artisan test *)", allow)
133
+
134
+ def test_client_ignore_files_get_a_marked_secret_block(self):
135
+ self.laravel_and_go()
136
+ self.write(".cursorignore", "docs/drafts/\n")
137
+ self.apply(agents=["codex", "cursor", "aider", "gemini"], extras=["native-settings"])
138
+ cursor = (self.target / ".cursorignore").read_text(encoding="utf-8")
139
+ self.assertTrue(cursor.startswith("docs/drafts/\n"))
140
+ self.assertIn(install.START, cursor)
141
+ self.assertIn("\n.env\n", cursor)
142
+ self.assertIn("\n/web/storage/*.key\n", cursor)
143
+ for name in (".aiderignore", ".geminiignore"):
144
+ self.assertIn("!.env.example", (self.target / name).read_text(encoding="utf-8"))
145
+ self.assertFalse(self.plan()["actions"])
146
+ settings_path = self.target / "ai-kit/settings.json"
147
+ settings = json.loads(settings_path.read_text(encoding="utf-8"))
148
+ settings["native_settings"] = False
149
+ settings_path.write_text(json.dumps(settings), encoding="utf-8")
150
+ self.apply()
151
+ self.assertEqual((self.target / ".cursorignore").read_text(encoding="utf-8"), "docs/drafts/\n")
152
+
153
+ def test_ignore_files_are_skipped_for_unselected_clients(self):
154
+ self.apply(agents=["codex", "claude"], extras=["native-settings"])
155
+ for name in (".cursorignore", ".aiderignore", ".geminiignore"):
156
+ self.assertFalse((self.target / name).exists(), name)
157
+
158
+ def test_data_guards_add_universal_and_detected_stack_asks(self):
159
+ self.laravel_and_go()
160
+ self.apply(agents=["claude"], extras=["data-guards"])
161
+ ask = self.read_json(".claude/settings.local.json")["permissions"]["ask"]
162
+ self.assertIn("Bash(rm -rf *)", ask)
163
+ self.assertIn("Bash(php artisan migrate*)", ask)
164
+ self.assertNotIn("Bash(php admin/cli/upgrade.php*)", ask)
165
+ self.assertNotIn("Bash(git push *)", ask)
166
+
167
+ def test_doctor_accepts_generated_configuration(self):
168
+ self.laravel_and_go()
169
+ self.apply(agents=ALL_SCOPED + ["aider", "gemini"], preset="strict")
170
+ report = doctor.examine(self.target, root=self.source)
171
+ self.assertEqual(report["errors"], [])
172
+ self.assertEqual(report["warnings"], [])
173
+
174
+ def test_generated_claude_settings_match_golden_snapshot(self):
175
+ self.laravel_and_go()
176
+ self.apply(agents=["claude"], preset="strict")
177
+ golden = install.ROOT / "tests/fixtures/golden/claude-settings-local.json"
178
+ actual = (self.target / ".claude/settings.local.json").read_bytes()
179
+ # Git autocrlf can check the fixture out with CRLF on Windows; compare content, not endings.
180
+ self.assertEqual(actual.replace(b"\r\n", b"\n"), golden.read_bytes().replace(b"\r\n", b"\n"),
181
+ "Generated .claude/settings.local.json drifted from the golden snapshot; "
182
+ "update the golden file if the change is intentional.")
183
+
184
+ # Scoped rules (item 7)
185
+
186
+ def test_scoped_rules_use_each_client_native_format(self):
187
+ self.laravel_and_go()
188
+ self.apply(agents=ALL_SCOPED, extras=["scoped-rules"])
189
+ claude = (self.target / ".claude/rules/ai-kit-web.md").read_text(encoding="utf-8")
190
+ self.assertTrue(claude.startswith('---\npaths:\n - "web/**"\n---\n'))
191
+ self.assertIn("[LARAVEL](../../ai-kit/stacks/LARAVEL.md)", claude)
192
+ self.assertIn("test `php artisan test`", claude)
193
+ cursor = (self.target / ".cursor/rules/ai-kit-api.mdc").read_text(encoding="utf-8")
194
+ self.assertTrue(cursor.startswith("---\nglobs: api/**\nalwaysApply: false\n---\n"))
195
+ copilot = (self.target / ".github/instructions/ai-kit-api.instructions.md").read_text(encoding="utf-8")
196
+ self.assertTrue(copilot.startswith('---\napplyTo: "api/**"\n---\n'))
197
+ windsurf = (self.target / ".windsurf/rules/ai-kit-web.md").read_text(encoding="utf-8")
198
+ self.assertTrue(windsurf.startswith("---\ntrigger: glob\nglobs: web/**\n---\n"))
199
+ cline = (self.target / ".clinerules/ai-kit-web.md").read_text(encoding="utf-8")
200
+ self.assertIn("[PHP](../ai-kit/stacks/PHP.md)", cline)
201
+ # Codex reads AGENTS.md from the root down to the working directory only; no nested files.
202
+ self.assertFalse((self.target / "web/AGENTS.md").exists())
203
+ self.assertEqual(doctor.link_warnings(self.target), [])
204
+ self.assertFalse(self.plan()["actions"])
205
+
206
+ def test_root_module_uses_always_on_forms(self):
207
+ self.write("go.mod", "module example.test/app\n")
208
+ self.apply(agents=ALL_SCOPED, extras=["scoped-rules"])
209
+ self.assertTrue((self.target / ".claude/rules/ai-kit-root.md").read_text(encoding="utf-8")
210
+ .startswith("# AI-KIT module rules: /\n"))
211
+ self.assertTrue((self.target / ".cursor/rules/ai-kit-root.mdc").read_text(encoding="utf-8")
212
+ .startswith("---\nalwaysApply: true\n---\n"))
213
+ self.assertTrue((self.target / ".github/instructions/ai-kit-root.instructions.md")
214
+ .read_text(encoding="utf-8").startswith('---\napplyTo: "**"\n---\n'))
215
+ self.assertTrue((self.target / ".windsurf/rules/ai-kit-root.md").read_text(encoding="utf-8")
216
+ .startswith("---\ntrigger: always_on\n---\n"))
217
+
218
+ def test_scoped_rules_follow_project_json_and_edited_rules_conflict(self):
219
+ self.laravel_and_go()
220
+ self.apply(agents=["claude"], extras=["scoped-rules"])
221
+ project = self.read_json("ai-kit/project.json")
222
+ for module in project["modules"]:
223
+ if module["path"] == "/web/":
224
+ module["profiles"] = ["FILAMENT", "LARAVEL", "PHP"]
225
+ self.write("ai-kit/project.json", json.dumps(project))
226
+ self.apply()
227
+ self.assertIn("[FILAMENT]", (self.target / ".claude/rules/ai-kit-web.md").read_text(encoding="utf-8"))
228
+ rule = self.target / ".claude/rules/ai-kit-api.md"
229
+ rule.write_text(rule.read_text(encoding="utf-8") + "Local note.\n", encoding="utf-8")
230
+ for module in project["modules"]:
231
+ module["commands"] = {}
232
+ self.write("ai-kit/project.json", json.dumps(project))
233
+ plan = self.plan()
234
+ self.assertIn(".claude/rules/ai-kit-api.md", plan["conflicts"])
235
+
236
+ def test_unsupported_module_paths_are_skipped_with_a_warning(self):
237
+ self.apply(agents=["claude"])
238
+ self.write("ai-kit/project.json", json.dumps({"schema": 1, "operations": {}, "modules": [
239
+ {"path": "/odd dir/", "profiles": ["GO"], "commands": {}},
240
+ {"path": "/../escape/", "profiles": ["GO"], "commands": {}},
241
+ {"path": "/svc", "profiles": ["GO"], "commands": {}}]}))
242
+ plan = self.apply(extras=["scoped-rules"])
243
+ self.assertEqual(sum("Skipping module path" in w for w in plan["warnings"]), 2)
244
+ self.assertTrue((self.target / ".claude/rules/ai-kit-svc.md").is_file())
245
+ self.assertEqual(len(list((self.target / ".claude/rules").iterdir())), 1)
246
+
247
+ def test_colliding_module_slugs_stay_unique(self):
248
+ self.write("a-b/go.mod", "module example.test/one\n")
249
+ self.write("a/b/go.mod", "module example.test/two\n")
250
+ self.apply(agents=["claude"], extras=["scoped-rules"])
251
+ self.assertEqual(len(list((self.target / ".claude/rules").glob("ai-kit-a-b*.md"))), 2)
252
+
253
+ def test_deselecting_a_client_with_scoped_rules_requires_retirement_review(self):
254
+ self.laravel_and_go()
255
+ self.apply(agents=["codex", "cursor"], extras=["scoped-rules"])
256
+ plan = self.plan(agents=["codex"])
257
+ self.assertIn(".cursor/rules/ai-kit-web.mdc", plan["adapter_conflicts"])
258
+
259
+ # Presets (item 8)
260
+
261
+ def test_presets_set_mode_and_managed_extras(self):
262
+ plan = self.plan(preset="solo")
263
+ self.assertEqual(plan["mode"], "private")
264
+ self.assertEqual(plan["extras"], ["native-settings", "scoped-rules", "session-start"])
265
+ plan = self.plan(preset="team")
266
+ self.assertEqual(plan["mode"], "team")
267
+ self.assertIn("guards", plan["extras"])
268
+ plan = self.plan(preset="team", mode="private", extras=["ci"])
269
+ self.assertEqual(plan["mode"], "private")
270
+ self.assertIn("ci", plan["extras"])
271
+
272
+ def test_minimal_preset_turns_managed_extras_off_and_keeps_mode(self):
273
+ self.laravel_and_go()
274
+ self.apply(agents=["claude"], preset="team")
275
+ self.assertTrue((self.target / ".claude/settings.json").is_file())
276
+ plan = self.apply(preset="minimal")
277
+ self.assertEqual(plan["mode"], "team")
278
+ self.assertEqual(plan["extras"], [])
279
+ self.assertTrue(any("Preset minimal turns off previously enabled extras: session-start, guards, "
280
+ "native-settings, scoped-rules" in w for w in plan["warnings"]))
281
+ settings = self.read_json("ai-kit/settings.json")
282
+ self.assertIs(settings["guards"], False)
283
+ self.assertIs(settings["scoped_rules"], False)
284
+ self.assertNotIn("managed_json", self.read_json("ai-kit/.install-state.json"))
285
+ self.assertNotIn("permissions", self.read_json(".claude/settings.json"))
286
+ # Generated rules are never deleted; the preview names them for review.
287
+ self.assertTrue(any("retained without a current source: .claude/rules/ai-kit-web.md" in w
288
+ for w in plan["warnings"]))
289
+
290
+ def test_explicit_flag_keeps_an_extra_the_preset_would_turn_off(self):
291
+ self.apply(agents=["claude"], extras=["guards"])
292
+ plan = self.apply(preset="solo", extras=["guards"])
293
+ self.assertIn("guards", plan["extras"])
294
+ self.assertFalse(any("turns off" in w for w in plan["warnings"]))
295
+ self.assertIn("Bash(git push *)", self.read_json(".claude/settings.local.json")["permissions"]["ask"])
296
+
297
+ def test_unknown_preset_is_refused(self):
298
+ with self.assertRaises(ValueError):
299
+ self.plan(preset="everything")
300
+
301
+ def test_cli_preset_and_new_flags(self):
302
+ output = io.StringIO()
303
+ with contextlib.redirect_stdout(output):
304
+ code = install.main([str(self.target), "--preset", "strict", "--with-ci"])
305
+ self.assertEqual(code, 0)
306
+ report = json.loads(output.getvalue())
307
+ self.assertEqual(report["preset"], "strict")
308
+ self.assertEqual(report["extras"], sorted(install.PRESET_EXTRAS + ("ci",)))
309
+ output = io.StringIO()
310
+ with contextlib.redirect_stdout(output):
311
+ install.main([str(self.target), "--with-native-settings", "--with-scoped-rules", "--with-data-guards"])
312
+ self.assertEqual(json.loads(output.getvalue())["extras"], ["data-guards", "native-settings", "scoped-rules"])
313
+
314
+ # Workflow skills (item 9)
315
+
316
+ def test_workflow_skills_install_with_native_copies(self):
317
+ self.apply(agents=["codex", "claude"])
318
+ for name in ("ai-kit-bootstrap", "ai-kit-review", "ai-kit-sync-context"):
319
+ source = self.target / ".agents/skills" / name / "SKILL.md"
320
+ self.assertTrue(source.is_file(), name)
321
+ self.assertEqual(source.read_bytes(), (self.target / ".claude/skills" / name / "SKILL.md").read_bytes())
322
+ self.assertIn("Run only when the user explicitly asks", source.read_text(encoding="utf-8"))
323
+ self.assertEqual(doctor.link_warnings(self.target), [])
324
+
325
+ def test_registry_refuses_invalid_scoped_rules_and_ignore_files(self):
326
+ registry_path = self.source / install.AGENT_REGISTRY_PATH
327
+ original = json.loads(registry_path.read_text(encoding="utf-8"))
328
+ for field, value in (("scoped_rules", {"directory": "../rules/", "format": "claude-paths"}),
329
+ ("scoped_rules", {"directory": ".claude/rules/", "format": "unknown"}),
330
+ ("scoped_rules", ".claude/rules/"),
331
+ ("ignore_file", "../.cursorignore"),
332
+ ("ignore_file", "sub/.ignore")):
333
+ with self.subTest(field=field, value=value):
334
+ registry = json.loads(json.dumps(original))
335
+ registry["agents"]["cursor"][field] = value
336
+ registry_path.write_text(json.dumps(registry), encoding="utf-8")
337
+ with self.assertRaises(ValueError):
338
+ install.load_agent_registry(self.source)
339
+
340
+
341
+ if __name__ == "__main__":
342
+ unittest.main()