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,429 @@
1
+ """T6 (credential-broker-contract): adapter-root-bins/ build-pipeline
2
+ primitive class — AC22 / AC23.
3
+
4
+ Verifies:
5
+ - AC22: source-to-target projection at `<working_tree>/.agentbundle/bin/<basename>.py`
6
+ - AC22: POSIX mode 0o755
7
+ - AC22: path-jail compliance — the target falls under the v0.7
8
+ contract's `allowed-prefixes.repo` for `.agentbundle/`
9
+ - AC22: no PATH manipulation — `os.environ["PATH"]` unchanged
10
+ - AC23: drift gate distinguishes modified / missing / orphaned;
11
+ build-self resolves all three; inter-pack basename collision is hard-error
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import stat
18
+ import tempfile
19
+ import tomllib
20
+ import unittest
21
+ from pathlib import Path
22
+
23
+ from agentbundle.build import adapter_root_bins as arb
24
+
25
+
26
+ def _make_fixture_pack(
27
+ packs_dir: Path,
28
+ name: str,
29
+ bins: dict[str, bytes],
30
+ shared_libs: dict[str, bytes] | None = None,
31
+ ) -> Path:
32
+ pack = packs_dir / name
33
+ pack.mkdir(parents=True)
34
+ (pack / "pack.toml").write_text(
35
+ f'[pack]\nname = "{name}"\nversion = "0.1.0"\ndescription = "fixture"\n'
36
+ f'[pack.adapter-contract]\nversion = "0.7"\n'
37
+ f'[pack.install]\ndefault-scope = "user"\n'
38
+ f'allowed-scopes = ["user", "repo"]\n',
39
+ encoding="utf-8",
40
+ )
41
+ if bins:
42
+ bins_dir = pack / ".apm" / "adapter-root-bins"
43
+ bins_dir.mkdir(parents=True)
44
+ for basename, content in bins.items():
45
+ (bins_dir / basename).write_bytes(content)
46
+ if shared_libs:
47
+ sl_dir = pack / ".apm" / "shared-libs"
48
+ sl_dir.mkdir(parents=True)
49
+ for basename, content in shared_libs.items():
50
+ (sl_dir / basename).write_bytes(content)
51
+ return pack
52
+
53
+
54
+ class AdapterRootBinsTests(unittest.TestCase):
55
+ def setUp(self) -> None:
56
+ self._tmp = tempfile.TemporaryDirectory()
57
+ self.tmp_path = Path(self._tmp.name)
58
+
59
+ def tearDown(self) -> None:
60
+ self._tmp.cleanup()
61
+
62
+ def _packs(self) -> Path:
63
+ packs = self.tmp_path / "packs"
64
+ packs.mkdir(exist_ok=True)
65
+ return packs
66
+
67
+ def _wt(self) -> Path:
68
+ wt = self.tmp_path / "wt"
69
+ wt.mkdir(exist_ok=True)
70
+ return wt
71
+
72
+ def test_collect_sources_returns_basename_map(self) -> None:
73
+ packs = self._packs()
74
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# stub\n"})
75
+ sources = arb.collect_sources(packs)
76
+ self.assertEqual(set(sources.keys()), {"sso-broker.py"})
77
+ self.assertEqual(sources["sso-broker.py"].read_bytes(), b"# stub\n")
78
+
79
+ def test_collect_sources_collision_hard_errors(self) -> None:
80
+ packs = self._packs()
81
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# p1\n"})
82
+ _make_fixture_pack(packs, "p2", {"sso-broker.py": b"# p2\n"})
83
+ with self.assertRaisesRegex(ValueError, "adapter-root-bins collision"):
84
+ arb.collect_sources(packs)
85
+
86
+ def test_apply_projection_writes_target_with_0755(self) -> None:
87
+ packs = self._packs()
88
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# real broker\n"})
89
+ wt = self._wt()
90
+
91
+ arb.apply_projection(wt, packs)
92
+
93
+ target = wt / ".agentbundle" / "bin" / "sso-broker.py"
94
+ self.assertTrue(target.is_file())
95
+ self.assertEqual(target.read_bytes(), b"# real broker\n")
96
+ if os.name == "posix":
97
+ mode = stat.S_IMODE(target.stat().st_mode)
98
+ self.assertEqual(
99
+ mode & 0o777, 0o755, f"expected mode 0755, got {oct(mode)}"
100
+ )
101
+
102
+ def test_apply_projection_creates_target_dir(self) -> None:
103
+ packs = self._packs()
104
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# stub\n"})
105
+ wt = self._wt()
106
+ arb.apply_projection(wt, packs)
107
+ self.assertTrue((wt / ".agentbundle" / "bin").is_dir())
108
+
109
+ def test_apply_projection_overwrites_modified_target(self) -> None:
110
+ packs = self._packs()
111
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# source\n"})
112
+ wt = self._wt()
113
+ bin_dir = wt / ".agentbundle" / "bin"
114
+ bin_dir.mkdir(parents=True)
115
+ (bin_dir / "sso-broker.py").write_bytes(b"# stale\n")
116
+
117
+ arb.apply_projection(wt, packs)
118
+
119
+ self.assertEqual(
120
+ (bin_dir / "sso-broker.py").read_bytes(), b"# source\n"
121
+ )
122
+
123
+ def test_apply_projection_removes_orphan(self) -> None:
124
+ packs = self._packs()
125
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# real\n"})
126
+ wt = self._wt()
127
+ bin_dir = wt / ".agentbundle" / "bin"
128
+ bin_dir.mkdir(parents=True)
129
+ (bin_dir / "stale.py").write_bytes(b"# orphan\n")
130
+
131
+ arb.apply_projection(wt, packs)
132
+
133
+ self.assertFalse((bin_dir / "stale.py").exists())
134
+ self.assertTrue((bin_dir / "sso-broker.py").is_file())
135
+
136
+ def test_check_drift_clean_after_apply(self) -> None:
137
+ packs = self._packs()
138
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# real\n"})
139
+ wt = self._wt()
140
+ arb.apply_projection(wt, packs)
141
+ self.assertEqual(arb.check_drift(wt, packs), [])
142
+
143
+ def test_check_drift_modified_outcome(self) -> None:
144
+ packs = self._packs()
145
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# source\n"})
146
+ wt = self._wt()
147
+ arb.apply_projection(wt, packs)
148
+ target = wt / ".agentbundle" / "bin" / "sso-broker.py"
149
+ target.write_bytes(b"# tampered\n")
150
+
151
+ drifts = arb.check_drift(wt, packs)
152
+ self.assertEqual(len(drifts), 1)
153
+ self.assertIn("modified", drifts[0])
154
+ self.assertIn("make build-self", drifts[0])
155
+
156
+ def test_check_drift_missing_outcome(self) -> None:
157
+ packs = self._packs()
158
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# source\n"})
159
+ wt = self._wt()
160
+ # No apply_projection — target missing.
161
+
162
+ drifts = arb.check_drift(wt, packs)
163
+ self.assertEqual(len(drifts), 1)
164
+ self.assertIn("missing", drifts[0])
165
+ self.assertIn("make build-self", drifts[0])
166
+
167
+ def test_check_drift_orphaned_outcome(self) -> None:
168
+ packs = self._packs()
169
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# real\n"})
170
+ wt = self._wt()
171
+ arb.apply_projection(wt, packs)
172
+ orphan = wt / ".agentbundle" / "bin" / "phantom.py"
173
+ orphan.write_bytes(b"# orphan\n")
174
+
175
+ drifts = arb.check_drift(wt, packs)
176
+ self.assertTrue(any("orphaned" in d for d in drifts))
177
+
178
+ def test_check_drift_collision_short_circuits(self) -> None:
179
+ packs = self._packs()
180
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# p1\n"})
181
+ _make_fixture_pack(packs, "p2", {"sso-broker.py": b"# p2\n"})
182
+ wt = self._wt()
183
+ drifts = arb.check_drift(wt, packs)
184
+ self.assertEqual(len(drifts), 1)
185
+ self.assertIn("collision", drifts[0])
186
+
187
+ def test_no_path_manipulation(self) -> None:
188
+ """AC22: os.environ['PATH'] is unchanged before/after apply_projection."""
189
+ packs = self._packs()
190
+ _make_fixture_pack(packs, "p1", {"sso-broker.py": b"# real\n"})
191
+ wt = self._wt()
192
+ path_before = os.environ.get("PATH", "")
193
+ arb.apply_projection(wt, packs)
194
+ path_after = os.environ.get("PATH", "")
195
+ self.assertEqual(path_before, path_after)
196
+
197
+ def test_path_jail_compliance_against_contract(self) -> None:
198
+ """AC22 path-jail: `.agentbundle/` is in `allowed-prefixes.repo`
199
+ for the named user-scope adapters in the v0.7 contract."""
200
+ contract_path = (
201
+ Path(__file__).resolve().parents[2] / "_data" / "adapter.toml"
202
+ )
203
+ with contract_path.open("rb") as fh:
204
+ contract = tomllib.load(fh)
205
+ for adapter_name in ("claude-code", "kiro"):
206
+ prefixes = contract["adapter"][adapter_name]["scope"][
207
+ "allowed-prefixes"
208
+ ]["repo"]
209
+ target_prefix = str(arb.TARGET_SUBDIR.parts[0]) + "/"
210
+ self.assertIn(
211
+ target_prefix, prefixes,
212
+ f"adapter {adapter_name!r}: {target_prefix!r} not in {prefixes!r}",
213
+ )
214
+
215
+ def test_real_pack_projection_against_credential_brokers(self) -> None:
216
+ """Smoke test: the real credential-brokers pack source projects
217
+ into <tmp>/.agentbundle/bin/sso-broker.py with the real bytes."""
218
+ real_packs = Path(__file__).resolve().parents[5] / "packs"
219
+ if not (real_packs / "credential-brokers").is_dir():
220
+ self.skipTest("credential-brokers pack not present")
221
+ wt = self.tmp_path / "real-wt"
222
+ wt.mkdir()
223
+ arb.apply_projection(wt, real_packs)
224
+ target = wt / ".agentbundle" / "bin" / "sso-broker.py"
225
+ source = (
226
+ real_packs / "credential-brokers" / ".apm"
227
+ / "adapter-root-bins" / "sso-broker.py"
228
+ )
229
+ self.assertEqual(target.read_bytes(), source.read_bytes())
230
+
231
+
232
+ class AdapterRootBinsShimCompanionTests(unittest.TestCase):
233
+ """AC22b: shim-companion projection alongside adapter-root-bins/.
234
+
235
+ Closes the deferred-projection gap from the credential
236
+ user-install fix — under bare user-scope
237
+ install, `_sso_*` modules' `from .credentials_shim import
238
+ Tier2HardFailError` previously failed and `sso-broker.py`'s
239
+ try/except cascade silently degraded `_tier2_backend` to `None`
240
+ on macOS / Windows.
241
+ """
242
+
243
+ def setUp(self) -> None:
244
+ self._tmp = tempfile.TemporaryDirectory()
245
+ self.tmp_path = Path(self._tmp.name)
246
+
247
+ def tearDown(self) -> None:
248
+ self._tmp.cleanup()
249
+
250
+ def _packs(self) -> Path:
251
+ packs = self.tmp_path / "packs"
252
+ packs.mkdir(exist_ok=True)
253
+ return packs
254
+
255
+ def _wt(self) -> Path:
256
+ wt = self.tmp_path / "wt"
257
+ wt.mkdir(exist_ok=True)
258
+ return wt
259
+
260
+ def test_apply_projection_writes_shim_companion(self) -> None:
261
+ """AC22b: pack ships both adapter-root-bins/ and
262
+ shared-libs/credentials_shim.py — companion projected as a
263
+ sibling under bin/."""
264
+ packs = self._packs()
265
+ _make_fixture_pack(
266
+ packs,
267
+ "p1",
268
+ bins={"sso-broker.py": b"# broker\n"},
269
+ shared_libs={"credentials_shim.py": b"# shim\n"},
270
+ )
271
+ wt = self._wt()
272
+ arb.apply_projection(wt, packs)
273
+ bin_dir = wt / ".agentbundle" / "bin"
274
+ self.assertEqual((bin_dir / "sso-broker.py").read_bytes(), b"# broker\n")
275
+ self.assertEqual(
276
+ (bin_dir / "credentials_shim.py").read_bytes(), b"# shim\n"
277
+ )
278
+
279
+ def test_apply_projection_omits_companion_when_adapter_root_bins_absent(
280
+ self,
281
+ ) -> None:
282
+ """Opt-in by ship-both. A pack that ships only shared-libs/ —
283
+ no adapter-root-bins/ — does NOT trigger the bin/ companion."""
284
+ packs = self._packs()
285
+ # Use ``bins={}`` then strip the empty dir so the fixture
286
+ # really only has shared-libs/.
287
+ _make_fixture_pack(
288
+ packs,
289
+ "p1",
290
+ bins={},
291
+ shared_libs={"credentials_shim.py": b"# shim\n"},
292
+ )
293
+ wt = self._wt()
294
+ arb.apply_projection(wt, packs)
295
+ bin_dir = wt / ".agentbundle" / "bin"
296
+ # No adapter-root-bins source → no bin/ at all.
297
+ self.assertFalse(
298
+ (bin_dir / "credentials_shim.py").exists(),
299
+ "companion projected without an adapter-root-bins/ trigger",
300
+ )
301
+
302
+ def test_apply_projection_hard_errors_on_shim_import_without_companion(
303
+ self,
304
+ ) -> None:
305
+ """AC22b content-grep rail. A pack ships an adapter-root-bins
306
+ module that imports the shim, but does NOT ship the shim
307
+ source — refuse the build with the broker-agnostic message.
308
+ Uses a non-`_sso_*` basename to exercise the generalised
309
+ trigger (the rail must not be coupled to `_sso_*`)."""
310
+ packs = self._packs()
311
+ _make_fixture_pack(
312
+ packs,
313
+ "p1",
314
+ bins={
315
+ "oauth-broker.py": b"# stub\n",
316
+ "_oauth_macos.py": (
317
+ b"from .credentials_shim import Tier2HardFailError\n"
318
+ ),
319
+ },
320
+ shared_libs=None, # NB: no credentials_shim.py in pack.
321
+ )
322
+ wt = self._wt()
323
+ with self.assertRaises(ValueError) as cm:
324
+ arb.apply_projection(wt, packs)
325
+ msg = str(cm.exception)
326
+ self.assertIn("_oauth_macos.py", msg)
327
+ self.assertIn("credentials_shim.py is missing", msg)
328
+ self.assertIn(
329
+ "Tier-2 dispatch would degrade silently on macOS/Windows", msg,
330
+ f"hard-error message must be broker-agnostic; got: {msg!r}",
331
+ )
332
+
333
+ def test_check_drift_modified_shim_companion_carries_prefix(self) -> None:
334
+ """AC22b: companion drift descriptions use the
335
+ `[adapter-root-bins:shim-companion]` prefix so the source-side
336
+ reference (under `shared-libs/`) reads coherently."""
337
+ packs = self._packs()
338
+ _make_fixture_pack(
339
+ packs,
340
+ "p1",
341
+ bins={"sso-broker.py": b"# broker\n"},
342
+ shared_libs={"credentials_shim.py": b"# shim\n"},
343
+ )
344
+ wt = self._wt()
345
+ arb.apply_projection(wt, packs)
346
+ # Tamper the companion target.
347
+ (wt / ".agentbundle" / "bin" / "credentials_shim.py").write_bytes(
348
+ b"# tampered\n"
349
+ )
350
+
351
+ drifts = arb.check_drift(wt, packs)
352
+ companion_drifts = [
353
+ d for d in drifts if "[adapter-root-bins:shim-companion]" in d
354
+ ]
355
+ self.assertEqual(len(companion_drifts), 1, drifts)
356
+ self.assertIn("modified", companion_drifts[0])
357
+ # The companion source is rooted in shared-libs/ — the
358
+ # diagnostic reference must name that.
359
+ self.assertIn("shared-libs/credentials_shim.py", companion_drifts[0])
360
+
361
+ def test_check_drift_missing_shim_companion_carries_prefix(self) -> None:
362
+ """Companion target absent → missing drift with the
363
+ shim-companion prefix."""
364
+ packs = self._packs()
365
+ _make_fixture_pack(
366
+ packs,
367
+ "p1",
368
+ bins={"sso-broker.py": b"# broker\n"},
369
+ shared_libs={"credentials_shim.py": b"# shim\n"},
370
+ )
371
+ wt = self._wt()
372
+ # No apply_projection — every target is missing. We isolate
373
+ # the companion's diagnostic shape.
374
+ drifts = arb.check_drift(wt, packs)
375
+ companion_missing = [
376
+ d for d in drifts
377
+ if "[adapter-root-bins:shim-companion]" in d and "missing" in d
378
+ ]
379
+ self.assertEqual(len(companion_missing), 1, drifts)
380
+
381
+ def test_check_drift_orphaned_companion_not_misfiring(self) -> None:
382
+ """The companion target must land in `expected_targets` so
383
+ the orphan rail does not flag it. After `apply_projection`,
384
+ `check_drift` returns no entries — and in particular no
385
+ `orphaned` entry referencing `credentials_shim.py`."""
386
+ packs = self._packs()
387
+ _make_fixture_pack(
388
+ packs,
389
+ "p1",
390
+ bins={"sso-broker.py": b"# broker\n"},
391
+ shared_libs={"credentials_shim.py": b"# shim\n"},
392
+ )
393
+ wt = self._wt()
394
+ arb.apply_projection(wt, packs)
395
+ drifts = arb.check_drift(wt, packs)
396
+ self.assertEqual(drifts, [])
397
+ # Explicit invariant: even if a future contributor relaxes the
398
+ # equality check above, the companion must never be reported
399
+ # as orphaned.
400
+ self.assertFalse(
401
+ any("orphaned" in d and "credentials_shim.py" in d for d in drifts),
402
+ f"orphan rail misfired on the shim companion: {drifts}",
403
+ )
404
+
405
+ def test_real_pack_projection_includes_shim_companion(self) -> None:
406
+ """Smoke test: the real `credential-brokers` pack projects
407
+ `credentials_shim.py` into `<wt>/.agentbundle/bin/` with the
408
+ real shared-libs source bytes."""
409
+ real_packs = Path(__file__).resolve().parents[5] / "packs"
410
+ if not (real_packs / "credential-brokers").is_dir():
411
+ self.skipTest("credential-brokers pack not present")
412
+ wt = self.tmp_path / "real-wt"
413
+ wt.mkdir()
414
+ arb.apply_projection(wt, real_packs)
415
+ target = wt / ".agentbundle" / "bin" / "credentials_shim.py"
416
+ source = (
417
+ real_packs / "credential-brokers" / ".apm"
418
+ / "shared-libs" / "credentials_shim.py"
419
+ )
420
+ self.assertTrue(
421
+ target.is_file(),
422
+ "AC22b companion projection did not write "
423
+ "credentials_shim.py into bin/ from the real pack",
424
+ )
425
+ self.assertEqual(target.read_bytes(), source.read_bytes())
426
+
427
+
428
+ if __name__ == "__main__": # pragma: no cover
429
+ unittest.main()
@@ -0,0 +1,78 @@
1
+ """T5 (issue #190 Finding 1, dist half): the per-pack APM and Claude-plugin
2
+ build recipes ship the pack's seeds/ inside the artifact.
3
+
4
+ RFC-0001 §595 (APM) + §281-284 (both routes) require a pack's governance seeds
5
+ to travel inside the published artifact so the content is available on every
6
+ install route. The build never copied seeds/; this test drives the real build
7
+ pipeline against a seed-bearing temp pack and asserts seeds land in both
8
+ per-pack outputs. (`dist/` is gitignored, so this is verified by test, not by a
9
+ committed snapshot.)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ REPO_ROOT = Path(__file__).resolve().parents[5]
22
+
23
+
24
+ def _make_seed_pack(packs_dir: Path) -> None:
25
+ pack = packs_dir / "seedpack"
26
+ skill = pack / ".apm" / "skills" / "demo"
27
+ skill.mkdir(parents=True)
28
+ (skill / "SKILL.md").write_text(
29
+ "---\nname: demo\ndescription: A demo skill for the seed-ship build test.\n---\n\nBody.\n",
30
+ encoding="utf-8",
31
+ )
32
+ (pack / "pack.toml").write_text(
33
+ '[pack]\nname = "seedpack"\nversion = "0.1.0"\ndescription = "seed-ship test pack."\n',
34
+ encoding="utf-8",
35
+ )
36
+ (pack / ".claude-plugin").mkdir()
37
+ (pack / ".claude-plugin" / "plugin.json").write_text(
38
+ json.dumps({"name": "seedpack", "version": "0.1.0", "description": "seed-ship test pack."}) + "\n",
39
+ encoding="utf-8",
40
+ )
41
+ docs = pack / "seeds" / "docs"
42
+ docs.mkdir(parents=True)
43
+ (pack / "seeds" / "AGENTS.md").write_text("# seedpack agents\n", encoding="utf-8")
44
+ (docs / "CHARTER.md").write_text("# seedpack charter\n", encoding="utf-8")
45
+
46
+
47
+ class BuildShipsSeedsTests(unittest.TestCase):
48
+ def test_seeds_shipped_in_both_artifacts(self) -> None:
49
+ with tempfile.TemporaryDirectory() as tmp:
50
+ tmp_path = Path(tmp)
51
+ packs_dir = tmp_path / "packs"
52
+ packs_dir.mkdir()
53
+ _make_seed_pack(packs_dir)
54
+ out = tmp_path / "dist"
55
+
56
+ result = subprocess.run(
57
+ [
58
+ sys.executable, "-m", "agentbundle.build", "build",
59
+ "--packs-dir", str(packs_dir),
60
+ "--output-dir", str(out),
61
+ ],
62
+ capture_output=True, text=True, cwd=REPO_ROOT,
63
+ )
64
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
65
+
66
+ # Claude-plugin artifact carries seeds/.
67
+ plugin = out / "claude-plugins" / "seedpack" / "seeds"
68
+ self.assertTrue((plugin / "AGENTS.md").exists(), msg=result.stderr)
69
+ self.assertTrue((plugin / "docs" / "CHARTER.md").exists())
70
+
71
+ # APM artifact carries seeds/.
72
+ apm = out / "apm" / "seedpack" / "seeds"
73
+ self.assertTrue((apm / "AGENTS.md").exists())
74
+ self.assertTrue((apm / "docs" / "CHARTER.md").exists())
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()