agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,327 @@
1
+ """Tests for plugin-manifest.schema.json and plugin-manifest.derived.schema.json.
2
+
3
+ Verifies:
4
+ - plugin-manifest.schema.json (source shape) accepts a minimal hand-authored
5
+ .claude-plugin/plugin.json (AC 4).
6
+ - The source schema loads with the expected top-level shape.
7
+ - T2: source schema forbids the hooks property (AC10 gate 1).
8
+ - T2: derived schema accepts the synthesised hooks.SessionStart block (AC10 gate 1).
9
+ - T5: every source-tree packs/*/.claude-plugin/plugin.json carries no hooks
10
+ block (AC10).
11
+ - T5: every source-tree packs/*/.claude-plugin/plugin.json validates against
12
+ the source-shape schema (AC10).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[5]
22
+ PLUGIN_MANIFEST_SCHEMA_PATH = (
23
+ REPO_ROOT / "docs" / "contracts" / "plugin-manifest.schema.json"
24
+ )
25
+ PLUGIN_MANIFEST_DERIVED_SCHEMA_PATH = (
26
+ REPO_ROOT / "docs" / "contracts" / "plugin-manifest.derived.schema.json"
27
+ )
28
+
29
+
30
+ def _load_schema() -> dict:
31
+ return json.loads(PLUGIN_MANIFEST_SCHEMA_PATH.read_text(encoding="utf-8"))
32
+
33
+
34
+ def _load_derived_schema() -> dict:
35
+ return json.loads(PLUGIN_MANIFEST_DERIVED_SCHEMA_PATH.read_text(encoding="utf-8"))
36
+
37
+
38
+ class PluginManifestSchemaAcceptsValidExamplesTests(unittest.TestCase):
39
+ """plugin-manifest.schema.json accepts well-formed plugin.json structures."""
40
+
41
+ def test_accepts_minimal_plugin_manifest(self) -> None:
42
+ """A minimal hand-authored .claude-plugin/plugin.json is accepted.
43
+
44
+ Verifies AC 4: the schema validates the hand-authored per-pack manifest.
45
+ """
46
+ from agentbundle.build.validate import validate
47
+
48
+ schema = _load_schema()
49
+ minimal = {
50
+ "name": "agent-ready-core",
51
+ "version": "0.1.0",
52
+ "description": "Core agent skills for the agent-ready-repo template.",
53
+ }
54
+ errors = validate(minimal, schema)
55
+ self.assertEqual(
56
+ errors,
57
+ [],
58
+ f"schema rejected minimal plugin.json:\n" + "\n".join(errors),
59
+ )
60
+
61
+ def test_accepts_plugin_manifest_with_skills_and_agents(self) -> None:
62
+ """A plugin.json with optional skills and agents arrays is accepted."""
63
+ from agentbundle.build.validate import validate
64
+
65
+ schema = _load_schema()
66
+ full = {
67
+ "name": "agent-ready-governance-extras",
68
+ "version": "0.1.0",
69
+ "description": "RFC/ADR ceremony skills.",
70
+ "skills": ["new-rfc", "new-adr", "update-conventions"],
71
+ "agents": ["adversarial-reviewer"],
72
+ }
73
+ errors = validate(full, schema)
74
+ self.assertEqual(
75
+ errors,
76
+ [],
77
+ f"schema rejected plugin.json with skills and agents:\n"
78
+ + "\n".join(errors),
79
+ )
80
+
81
+ def test_accepts_plugin_manifest_without_optional_fields(self) -> None:
82
+ """A plugin.json with only required fields (no skills, no agents) is accepted."""
83
+ from agentbundle.build.validate import validate
84
+
85
+ schema = _load_schema()
86
+ minimal = {
87
+ "name": "agent-ready-user-guide-diataxis",
88
+ "version": "0.2.0",
89
+ "description": "Diátaxis user-guide scaffolding.",
90
+ }
91
+ errors = validate(minimal, schema)
92
+ self.assertEqual(
93
+ errors,
94
+ [],
95
+ f"schema rejected plugin.json with only required fields:\n"
96
+ + "\n".join(errors),
97
+ )
98
+
99
+
100
+ class PluginManifestSchemaRejectsInvalidExamplesTests(unittest.TestCase):
101
+ """plugin-manifest.schema.json rejects malformed plugin.json structures."""
102
+
103
+ def test_rejects_missing_name(self) -> None:
104
+ """A plugin.json without a name field is rejected."""
105
+ from agentbundle.build.validate import validate
106
+
107
+ schema = _load_schema()
108
+ instance = {
109
+ "version": "0.1.0",
110
+ "description": "Missing name.",
111
+ }
112
+ errors = validate(instance, schema)
113
+ self.assertTrue(errors, "schema accepted plugin.json missing 'name'")
114
+ self.assertTrue(
115
+ any("name" in e for e in errors),
116
+ f"error should mention 'name'; got: {errors}",
117
+ )
118
+
119
+ def test_rejects_missing_version(self) -> None:
120
+ """A plugin.json without a version field is rejected."""
121
+ from agentbundle.build.validate import validate
122
+
123
+ schema = _load_schema()
124
+ instance = {
125
+ "name": "agent-ready-core",
126
+ "description": "Missing version.",
127
+ }
128
+ errors = validate(instance, schema)
129
+ self.assertTrue(errors, "schema accepted plugin.json missing 'version'")
130
+
131
+ def test_rejects_missing_description(self) -> None:
132
+ """A plugin.json without a description field is rejected."""
133
+ from agentbundle.build.validate import validate
134
+
135
+ schema = _load_schema()
136
+ instance = {
137
+ "name": "agent-ready-core",
138
+ "version": "0.1.0",
139
+ }
140
+ errors = validate(instance, schema)
141
+ self.assertTrue(errors, "schema accepted plugin.json missing 'description'")
142
+
143
+
144
+ class PluginManifestSchemaLoadsTests(unittest.TestCase):
145
+ """Smoke test: the schema file loads and has the expected top-level shape."""
146
+
147
+ def test_schema_loads(self) -> None:
148
+ schema = _load_schema()
149
+ self.assertEqual(schema.get("type"), "object")
150
+
151
+ def test_schema_requires_name_version_description(self) -> None:
152
+ schema = _load_schema()
153
+ required = schema.get("required", [])
154
+ self.assertIn("name", required)
155
+ self.assertIn("version", required)
156
+ self.assertIn("description", required)
157
+
158
+
159
+ class PluginManifestSchemaSplitTests(unittest.TestCase):
160
+ """T2: Source schema forbids hooks; derived schema accepts synthesised hooks (AC10 gate 1).
161
+
162
+ test_source_plugin_manifest_schema_forbids_hooks
163
+ test_derived_plugin_manifest_schema_accepts_synthesised_hooks
164
+ """
165
+
166
+ def test_source_plugin_manifest_schema_forbids_hooks(self) -> None:
167
+ """Source-shape schema rejects any manifest carrying a hooks property.
168
+
169
+ AC10 gate 1 (Blocker-5 rail): a stray hooks block in a source-tree
170
+ plugin.json must fail schema validation. The additionalProperties: false
171
+ + explicit property list is the mechanism — hooks is not in the list.
172
+ """
173
+ from agentbundle.build.validate import validate
174
+
175
+ schema = _load_schema()
176
+
177
+ # Minimal manifest (no hooks) must still validate.
178
+ minimal = {
179
+ "name": "agent-ready-core",
180
+ "version": "0.1.0",
181
+ "description": "Core agent skills.",
182
+ }
183
+ errors = validate(minimal, schema)
184
+ self.assertEqual(
185
+ errors,
186
+ [],
187
+ f"source schema rejected a valid manifest with no hooks:\n"
188
+ + "\n".join(errors),
189
+ )
190
+
191
+ # Manifest with hooks must be rejected — hooks is not in the source
192
+ # schema's properties enumeration and additionalProperties is false.
193
+ with_hooks = {
194
+ "name": "agent-ready-core",
195
+ "version": "0.1.0",
196
+ "description": "Core agent skills.",
197
+ "hooks": {
198
+ "SessionStart": [
199
+ {
200
+ "command": 'python3 "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/scripts/install-marker.py"'
201
+ }
202
+ ]
203
+ },
204
+ }
205
+ errors = validate(with_hooks, schema)
206
+ self.assertTrue(
207
+ errors,
208
+ "source schema must reject a manifest carrying a hooks property "
209
+ "(hooks is not in the source schema's properties list; "
210
+ "additionalProperties: false should block it)",
211
+ )
212
+
213
+ def test_derived_plugin_manifest_schema_accepts_synthesised_hooks(self) -> None:
214
+ """Derived-shape schema accepts a manifest with the synthesised hooks.SessionStart block.
215
+
216
+ AC10 gate 1: the build pipeline validates derived-tree manifests against
217
+ the derived schema. The derived schema adds hooks to the properties
218
+ enumeration so additionalProperties: false still holds.
219
+ """
220
+ from agentbundle.build.validate import validate
221
+
222
+ derived_schema = _load_derived_schema()
223
+
224
+ # Minimal manifest (no hooks) must also be valid under the derived schema.
225
+ minimal = {
226
+ "name": "agent-ready-core",
227
+ "version": "0.1.0",
228
+ "description": "Core agent skills.",
229
+ }
230
+ errors = validate(minimal, derived_schema)
231
+ self.assertEqual(
232
+ errors,
233
+ [],
234
+ f"derived schema rejected a valid manifest with no hooks:\n"
235
+ + "\n".join(errors),
236
+ )
237
+
238
+ # Manifest with synthesised hooks.SessionStart block must be accepted.
239
+ derived = {
240
+ "name": "agent-ready-core",
241
+ "version": "0.1.0",
242
+ "description": "Core agent skills.",
243
+ "hooks": {
244
+ "SessionStart": [
245
+ {
246
+ "command": 'python3 "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/scripts/install-marker.py"'
247
+ }
248
+ ]
249
+ },
250
+ }
251
+ errors = validate(derived, derived_schema)
252
+ self.assertEqual(
253
+ errors,
254
+ [],
255
+ f"derived schema rejected a manifest with the synthesised hooks block:\n"
256
+ + "\n".join(errors),
257
+ )
258
+
259
+
260
+ class SourcePluginJsonAuditTests(unittest.TestCase):
261
+ """T5: Audit every source-tree packs/*/.claude-plugin/plugin.json (AC10).
262
+
263
+ test_no_source_plugin_json_carries_hooks
264
+ test_source_plugin_json_validates_against_schema
265
+ """
266
+
267
+ def _source_manifests(self) -> list[Path]:
268
+ """Return paths for all source-tree per-pack plugin.json files."""
269
+ return sorted((REPO_ROOT / "packs").glob("*/.claude-plugin/plugin.json"))
270
+
271
+ def test_no_source_plugin_json_carries_hooks(self) -> None:
272
+ """Every source-tree plugin.json must not declare a hooks block.
273
+
274
+ AC10: the hooks block is synthesised by the build pipeline; hand-authored
275
+ source manifests must never pre-declare it. This test pins that invariant
276
+ permanently so a future accidental hooks block is caught immediately.
277
+ """
278
+ manifests = self._source_manifests()
279
+ self.assertTrue(
280
+ manifests,
281
+ "No packs/*/.claude-plugin/plugin.json found — "
282
+ "check that REPO_ROOT resolves correctly.",
283
+ )
284
+ for manifest_path in manifests:
285
+ with self.subTest(pack=manifest_path.parent.parent.name):
286
+ content = json.loads(manifest_path.read_text(encoding="utf-8"))
287
+ self.assertNotIn(
288
+ "hooks",
289
+ content,
290
+ f"{manifest_path.relative_to(REPO_ROOT)}: "
291
+ f"source-tree plugin.json must not carry a 'hooks' block "
292
+ f"(the build pipeline synthesises it). "
293
+ f"Remove the stray hooks block from the source file.",
294
+ )
295
+
296
+ def test_source_plugin_json_validates_against_schema(self) -> None:
297
+ """Every source-tree plugin.json must validate against the source-shape schema.
298
+
299
+ AC10: the source schema (plugin-manifest.schema.json) explicitly forbids
300
+ hooks via additionalProperties: false. Validating every source-tree
301
+ manifest against it here provides a second gate that catches both missing
302
+ required fields and any stray additional properties (including hooks).
303
+ """
304
+ from agentbundle.build.validate import validate
305
+
306
+ schema = _load_schema()
307
+ manifests = self._source_manifests()
308
+ self.assertTrue(
309
+ manifests,
310
+ "No packs/*/.claude-plugin/plugin.json found — "
311
+ "check that REPO_ROOT resolves correctly.",
312
+ )
313
+ for manifest_path in manifests:
314
+ with self.subTest(pack=manifest_path.parent.parent.name):
315
+ content = json.loads(manifest_path.read_text(encoding="utf-8"))
316
+ errors = validate(content, schema)
317
+ self.assertEqual(
318
+ errors,
319
+ [],
320
+ f"{manifest_path.relative_to(REPO_ROOT)} failed schema "
321
+ f"validation against plugin-manifest.schema.json:\n"
322
+ + "\n".join(errors),
323
+ )
324
+
325
+
326
+ if __name__ == "__main__":
327
+ unittest.main()
@@ -0,0 +1,148 @@
1
+ """Tests for the lifted `merge-json` projection helper.
2
+
3
+ Originally private to ``adapters/claude_code.py`` as ``_project_merge_json``;
4
+ lifted to ``build/projections/merge_json.py`` by
5
+ docs/specs/dropped-primitives-coverage (T2). The existing claude-code
6
+ merge-json tests at ``test_adapter_claude_code.py`` remain green and
7
+ form the regression safety net for the lift; this module pins the
8
+ helper's contract directly so codex.py (T4) can rely on it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import tempfile
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+ from agentbundle.build.projections.merge_json import project_merge_json
19
+
20
+
21
+ class TestProjectMergeJson(unittest.TestCase):
22
+ def setUp(self) -> None:
23
+ self._tmpdir = tempfile.TemporaryDirectory()
24
+ self.source = Path(self._tmpdir.name) / "source"
25
+ self.output = Path(self._tmpdir.name) / "output"
26
+ self.source.mkdir()
27
+ self.output.mkdir()
28
+
29
+ def tearDown(self) -> None:
30
+ self._tmpdir.cleanup()
31
+
32
+ def _rule(
33
+ self,
34
+ target_path: str = ".target/hooks.json",
35
+ managed_key: str = "hooks",
36
+ ) -> dict:
37
+ return {"target-path": target_path, "managed-key": managed_key}
38
+
39
+ def test_empty_source_dir_writes_nothing(self) -> None:
40
+ """No TOML files → no JSON output at the target path."""
41
+ project_merge_json(self.source, self.output, self._rule())
42
+ self.assertFalse((self.output / ".target" / "hooks.json").exists())
43
+
44
+ def test_empty_managed_key_writes_nothing(self) -> None:
45
+ """TOML file present but managed-key payload is empty → no output."""
46
+ (self.source / "one.toml").write_text("[hooks]\n", encoding="utf-8")
47
+ project_merge_json(self.source, self.output, self._rule())
48
+ self.assertFalse((self.output / ".target" / "hooks.json").exists())
49
+
50
+ def test_single_toml_writes_managed_key(self) -> None:
51
+ """One TOML's managed-key payload lands at the target JSON."""
52
+ (self.source / "one.toml").write_text(
53
+ '[hooks]\n'
54
+ '"SessionStart" = [{ matcher = "*", hooks = [{ type = "command", command = "echo hi" }] }]\n',
55
+ encoding="utf-8",
56
+ )
57
+ project_merge_json(self.source, self.output, self._rule())
58
+ target = self.output / ".target" / "hooks.json"
59
+ self.assertTrue(target.exists())
60
+ data = json.loads(target.read_text(encoding="utf-8"))
61
+ self.assertIn("hooks", data)
62
+ self.assertIn("SessionStart", data["hooks"])
63
+
64
+ def test_merges_into_existing_json(self) -> None:
65
+ """Existing non-managed keys in the JSON target are preserved."""
66
+ target = self.output / ".target" / "hooks.json"
67
+ target.parent.mkdir(parents=True)
68
+ target.write_text(
69
+ json.dumps({"other-key": {"keep": "this"}, "hooks": {"X": ["old"]}}),
70
+ encoding="utf-8",
71
+ )
72
+ (self.source / "one.toml").write_text(
73
+ '[hooks]\n"Y" = ["new"]\n', encoding="utf-8"
74
+ )
75
+ project_merge_json(self.source, self.output, self._rule())
76
+ data = json.loads(target.read_text(encoding="utf-8"))
77
+ self.assertEqual(data["other-key"], {"keep": "this"})
78
+ # Both old and new managed-key entries merged.
79
+ self.assertEqual(data["hooks"]["X"], ["old"])
80
+ self.assertEqual(data["hooks"]["Y"], ["new"])
81
+
82
+ def test_multiple_toml_files_merge_in_sorted_order(self) -> None:
83
+ """Source files are iterated sorted; later overrides earlier."""
84
+ (self.source / "a.toml").write_text(
85
+ '[hooks]\n"X" = ["from-a"]\n', encoding="utf-8"
86
+ )
87
+ (self.source / "b.toml").write_text(
88
+ '[hooks]\n"X" = ["from-b"]\n', encoding="utf-8"
89
+ )
90
+ project_merge_json(self.source, self.output, self._rule())
91
+ target = self.output / ".target" / "hooks.json"
92
+ data = json.loads(target.read_text(encoding="utf-8"))
93
+ self.assertEqual(data["hooks"]["X"], ["from-b"])
94
+
95
+ def test_output_serialisation_shape(self) -> None:
96
+ """Output uses indent=2, sort_keys=True, trailing newline (idempotency)."""
97
+ (self.source / "one.toml").write_text(
98
+ '[hooks]\n"Y" = ["y"]\n"X" = ["x"]\n', encoding="utf-8"
99
+ )
100
+ project_merge_json(self.source, self.output, self._rule())
101
+ target = self.output / ".target" / "hooks.json"
102
+ text = target.read_text(encoding="utf-8")
103
+ self.assertTrue(text.endswith("\n"), "expected trailing newline")
104
+ # sort_keys: X before Y in the serialised hooks dict.
105
+ x_pos = text.index('"X"')
106
+ y_pos = text.index('"Y"')
107
+ self.assertLess(x_pos, y_pos, "expected sort_keys=True ordering")
108
+
109
+ def test_non_toml_files_ignored(self) -> None:
110
+ """Files without .toml suffix are skipped."""
111
+ (self.source / "skip.md").write_text("ignored", encoding="utf-8")
112
+ (self.source / "one.toml").write_text(
113
+ '[hooks]\n"X" = ["x"]\n', encoding="utf-8"
114
+ )
115
+ project_merge_json(self.source, self.output, self._rule())
116
+ target = self.output / ".target" / "hooks.json"
117
+ data = json.loads(target.read_text(encoding="utf-8"))
118
+ self.assertEqual(data["hooks"], {"X": ["x"]})
119
+
120
+ def test_target_path_at_target_root(self) -> None:
121
+ """Target paths land where the rule says — verified for codex's
122
+ `.codex/hooks.json` shape that T4 will dispatch to."""
123
+ (self.source / "one.toml").write_text(
124
+ '[hooks]\n"SessionStart" = [{matcher="*", hooks=[{type="command", command="x"}]}]\n',
125
+ encoding="utf-8",
126
+ )
127
+ project_merge_json(
128
+ self.source, self.output, self._rule(target_path=".codex/hooks.json")
129
+ )
130
+ self.assertTrue((self.output / ".codex" / "hooks.json").exists())
131
+
132
+
133
+ class TestClaudeCodeIntegrationStillGreen(unittest.TestCase):
134
+ """Belt-and-braces: re-import claude-code's project_packs and confirm
135
+ the merge-json branch still dispatches through the lifted helper."""
136
+
137
+ def test_claude_code_imports_lifted_helper(self) -> None:
138
+ from agentbundle.build.adapters import claude_code
139
+ from agentbundle.build.projections import merge_json as projections_merge_json
140
+
141
+ # The lifted symbol is the same one claude_code consumes.
142
+ self.assertIs(
143
+ claude_code.project_merge_json, projections_merge_json.project_merge_json
144
+ )
145
+
146
+
147
+ if __name__ == "__main__":
148
+ unittest.main()