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,336 @@
1
+ """T6: adapter-root-bins/ build-pipeline primitive class.
2
+
3
+ Source rule: ``packs/<pack>/.apm/adapter-root-bins/*.py``.
4
+ Target rule (self-host, repo scope): project each
5
+ ``adapter-root-bins/*.py`` file byte-identically to
6
+ ``<working_tree>/.agentbundle/bin/<basename>.py`` with POSIX mode
7
+ ``0o755`` (Windows inherits the parent DACL — no explicit chmod).
8
+ At user-scope install time the install command projects the same
9
+ files to ``$HOME/.agentbundle/bin/<basename>.py``; that surface is
10
+ the install command's responsibility, not this module's.
11
+
12
+ This module owns both halves of the build-pipeline contract for the
13
+ new primitive class:
14
+
15
+ - ``apply_projection(working_tree, packs_dir)`` — write the files.
16
+ Called by ``make build-self``.
17
+ - ``check_drift(working_tree, packs_dir)`` — read-only gate. Returns
18
+ a list of drift descriptions (empty list == clean). Three outcomes
19
+ per RFC-0013 § 4d / spec AC22-AC23:
20
+ * **modified** — projected file exists but bytes diverge from source
21
+ * **missing** — source exists but projected file absent
22
+ * **orphaned** — projected file present but source has been removed
23
+
24
+ Inter-pack basename collision is a hard error at ``collect_sources``
25
+ time. v1 ships exactly one source (``sso-broker.py`` in
26
+ ``credential-brokers``); the rail guards against a future collision.
27
+
28
+ Path-jail compliance: the target (``.agentbundle/``) is fenced by the
29
+ v0.7 contract's ``allowed-prefixes.repo`` for the three user-scope
30
+ adapters (``claude-code``, ``kiro``, ``codex``). The projection writes
31
+ under that prefix and never anywhere else; no PATH manipulation, no
32
+ shell-config edits.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import shutil
39
+ import stat
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+
43
+ from . import shared_libs
44
+
45
+ # Pin the source path so a downstream consumer that wants to enumerate
46
+ # sources doesn't hardcode the literal repeatedly.
47
+ SOURCE_SUBDIR = ".apm/adapter-root-bins"
48
+
49
+ # Target subtree under the per-scope artifact root. Mirrors the
50
+ # `~/.agentbundle/bin/` path at user scope (install-time surface).
51
+ TARGET_SUBDIR = Path(".agentbundle") / "bin"
52
+
53
+ # POSIX mode bits applied after copy. AC22 pins 0o755; Windows
54
+ # inherits the DACL from %USERPROFILE% (no explicit chmod call).
55
+ EXECUTABLE_MODE = 0o755
56
+
57
+ # AC22b: shim-companion projection. When a pack ships both
58
+ # adapter-root-bins/ and shared-libs/credentials_shim.py, the shim is
59
+ # projected as a sibling under `bin/` so that per-platform Tier-2
60
+ # backend modules under adapter-root-bins/ (e.g. _sso_keychain_macos.py)
61
+ # can resolve `from .credentials_shim import Tier2HardFailError`.
62
+ SHIM_COMPANION_BASENAME = "credentials_shim.py"
63
+
64
+ # AC22b content-grep trigger. Any *.py under adapter-root-bins/ whose
65
+ # bytes contain this literal substring is considered shim-dependent;
66
+ # the pack must then ship .apm/shared-libs/credentials_shim.py or the
67
+ # build hard-errors. Literal-substring match has a documented
68
+ # false-positive surface (a docstring quoting the line); accepted for
69
+ # v1 because the failure mode is benign (the shim is projected
70
+ # unnecessarily — no functional or security regression). AST-walk is
71
+ # the documented tightening path.
72
+ SHIM_IMPORT_GREP = b"from .credentials_shim import"
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class AdapterRootBinProjection:
77
+ """One concrete projection: copy ``source`` to ``target``."""
78
+
79
+ source: Path
80
+ target: Path
81
+
82
+
83
+ def collect_sources(packs_dir: Path) -> dict[str, Path]:
84
+ """Return ``{basename → source_path}`` for every
85
+ ``.apm/adapter-root-bins/*.py`` file across every pack.
86
+
87
+ Raises ``ValueError`` on inter-pack basename collision — two
88
+ packs shipping the same basename produces non-deterministic
89
+ projection order and silent overwrites; refuse hard at
90
+ enumeration time.
91
+ """
92
+ sources: dict[str, Path] = {}
93
+ for pack in sorted(packs_dir.iterdir()):
94
+ if not pack.is_dir() or not (pack / "pack.toml").exists():
95
+ continue
96
+ bins = pack / SOURCE_SUBDIR
97
+ if not bins.is_dir():
98
+ continue
99
+ for src in sorted(bins.glob("*.py")):
100
+ if src.name in sources:
101
+ raise ValueError(
102
+ f"adapter-root-bins collision: '{src.name}' shipped by both "
103
+ f"{sources[src.name]} and {src}"
104
+ )
105
+ sources[src.name] = src
106
+ return sources
107
+
108
+
109
+ def _packs_with_adapter_root_bins(packs_dir: Path) -> list[Path]:
110
+ """Return every pack directory whose ``.apm/adapter-root-bins/``
111
+ contains at least one ``*.py`` source. Sorted for determinism.
112
+
113
+ Used by the AC22b shim-companion enumeration and by the
114
+ content-grep hard-error rail — both predicate on "the pack ships
115
+ adapter-root-bins/", not on what's inside it.
116
+ """
117
+ out: list[Path] = []
118
+ for pack in sorted(packs_dir.iterdir()):
119
+ if not pack.is_dir() or not (pack / "pack.toml").exists():
120
+ continue
121
+ bins = pack / SOURCE_SUBDIR
122
+ if not bins.is_dir():
123
+ continue
124
+ if any(bins.glob("*.py")):
125
+ out.append(pack)
126
+ return out
127
+
128
+
129
+ def _assert_shim_companion_present(packs_dir: Path) -> None:
130
+ """AC22b hard-error rail (content-based, generalises past _sso_*).
131
+
132
+ For each pack that ships any ``.apm/adapter-root-bins/*.py``,
133
+ content-grep its sources for the literal substring
134
+ ``from .credentials_shim import``; if any match AND the pack does
135
+ not ship ``.apm/shared-libs/credentials_shim.py``, raise
136
+ ``ValueError`` with the broker-agnostic pinned message. Generalises
137
+ so a future ``_oauth_macos.py`` or any other adapter-root-bins
138
+ module with the same dependency is auto-covered.
139
+ """
140
+ for pack in _packs_with_adapter_root_bins(packs_dir):
141
+ shim_source = pack / shared_libs.SOURCE_SUBDIR / SHIM_COMPANION_BASENAME
142
+ if shim_source.is_file():
143
+ continue # pack ships the companion — no need to grep.
144
+ bins_dir = pack / SOURCE_SUBDIR
145
+ offenders: list[str] = []
146
+ for src in sorted(bins_dir.glob("*.py")):
147
+ try:
148
+ body = src.read_bytes()
149
+ except OSError:
150
+ continue
151
+ if SHIM_IMPORT_GREP in body:
152
+ offenders.append(src.name)
153
+ if offenders:
154
+ offender_list = ", ".join(offenders)
155
+ raise ValueError(
156
+ f"adapter-root-bins/{{{offender_list}}} imports "
157
+ f".credentials_shim but .apm/shared-libs/credentials_shim.py "
158
+ f"is missing in pack {pack.name!r} — the importing module's "
159
+ f"Tier-2 dispatch would degrade silently on macOS/Windows"
160
+ )
161
+
162
+
163
+ def collect_companion_shim(packs_dir: Path) -> dict[str, Path]:
164
+ """AC22b companion projection enumeration.
165
+
166
+ Returns ``{basename → source_path}`` for the shim companion when
167
+ at least one pack ships BOTH ``.apm/adapter-root-bins/`` AND
168
+ ``.apm/shared-libs/credentials_shim.py``. Cross-pack basename
169
+ collision on the shim is detected by ``shared_libs.collect_sources``
170
+ (single source of truth — one error shape, one ownership boundary).
171
+ The companion's target is always
172
+ ``<working_tree>/.agentbundle/bin/credentials_shim.py``; callers
173
+ compose ``working_tree`` themselves.
174
+ """
175
+ shim_sources = shared_libs.collect_sources(packs_dir)
176
+ shim_source = shim_sources.get(SHIM_COMPANION_BASENAME)
177
+ if shim_source is None:
178
+ return {}
179
+ for pack in _packs_with_adapter_root_bins(packs_dir):
180
+ pack_shim = pack / shared_libs.SOURCE_SUBDIR / SHIM_COMPANION_BASENAME
181
+ if pack_shim.is_file():
182
+ # At least one pack ships both adapter-root-bins/ and
183
+ # shared-libs/credentials_shim.py. Project the canonical
184
+ # shim source as the companion. Opt-in by ship-both: packs
185
+ # that ship adapter-root-bins/ alone do not get the shim
186
+ # — the AC22b hard-error rail catches the case where they
187
+ # *need* it but don't ship it.
188
+ return {SHIM_COMPANION_BASENAME: shim_source}
189
+ return {}
190
+
191
+
192
+ def compute_projections(
193
+ working_tree: Path, packs_dir: Path
194
+ ) -> list[AdapterRootBinProjection]:
195
+ """Return the full list of ``(source → target)`` pairs.
196
+
197
+ Deterministic order — drift gates depend on it. Includes the AC22b
198
+ shim companion when applicable (opt-in by ship-both).
199
+ """
200
+ sources = collect_sources(packs_dir)
201
+ target_dir = working_tree / TARGET_SUBDIR
202
+ projections: list[AdapterRootBinProjection] = [
203
+ AdapterRootBinProjection(source=sources[name], target=target_dir / name)
204
+ for name in sorted(sources)
205
+ ]
206
+ companion = collect_companion_shim(packs_dir)
207
+ for basename in sorted(companion):
208
+ projections.append(
209
+ AdapterRootBinProjection(
210
+ source=companion[basename],
211
+ target=target_dir / basename,
212
+ )
213
+ )
214
+ return projections
215
+
216
+
217
+ def _is_companion_projection(proj: AdapterRootBinProjection) -> bool:
218
+ """True iff ``proj`` is the AC22b shim-companion (source rooted in
219
+ ``shared-libs/``), not a primary adapter-root-bins target.
220
+
221
+ Drives the ``[adapter-root-bins:shim-companion]`` diagnostic
222
+ prefix in ``check_drift`` so the source-side reference reads
223
+ coherently next to its diagnostic class. Derives the comparison
224
+ leaf-name from ``shared_libs.SOURCE_SUBDIR`` so a future rename of
225
+ that constant propagates here automatically.
226
+ """
227
+ shared_libs_leaf = Path(shared_libs.SOURCE_SUBDIR).name
228
+ return proj.source.parent.name == shared_libs_leaf
229
+
230
+
231
+ def apply_projection(working_tree: Path, packs_dir: Path) -> None:
232
+ """Write every projection target and remove orphans.
233
+
234
+ Called by ``make build-self``. Idempotent — running twice produces
235
+ the same on-disk state. POSIX mode bits set to ``0o755`` after
236
+ copy. Windows inherits the parent DACL (no explicit chmod).
237
+
238
+ Three drift outcomes resolved here:
239
+ * **missing** → file written from source
240
+ * **modified** → file overwritten from source
241
+ * **orphaned** → file removed (source basename no longer
242
+ shipped by any pack)
243
+
244
+ AC22b: also projects the shim companion when a pack ships both
245
+ ``.apm/adapter-root-bins/`` and ``.apm/shared-libs/credentials_shim.py``.
246
+ AC22b hard-error rail fires before any writes if a pack imports
247
+ the shim but doesn't ship the source.
248
+ """
249
+ _assert_shim_companion_present(packs_dir)
250
+ projections = compute_projections(working_tree, packs_dir)
251
+ expected_targets = {p.target for p in projections}
252
+ for proj in projections:
253
+ proj.target.parent.mkdir(parents=True, exist_ok=True)
254
+ shutil.copy2(proj.source, proj.target)
255
+ if os.name == "posix":
256
+ os.chmod(proj.target, EXECUTABLE_MODE)
257
+ # Orphan removal: any *.py file under <working_tree>/.agentbundle/bin/
258
+ # not claimed by an expected target.
259
+ target_dir = working_tree / TARGET_SUBDIR
260
+ if target_dir.is_dir():
261
+ for existing in sorted(target_dir.glob("*.py")):
262
+ if existing not in expected_targets:
263
+ try:
264
+ existing.unlink()
265
+ except FileNotFoundError: # pragma: no cover — race-only
266
+ pass
267
+
268
+
269
+ def check_drift(working_tree: Path, packs_dir: Path) -> list[str]:
270
+ """Return drift descriptions for ``make build-check``.
271
+
272
+ Three outcomes per RFC-0013 § 4d / spec AC22-AC23:
273
+ * **modified** — projected bytes diverge from source
274
+ * **missing** — source exists but projected file absent
275
+ * **orphaned** — projected file present, no source claiming it
276
+
277
+ Each description ends with the regeneration command.
278
+ """
279
+ drifts: list[str] = []
280
+ try:
281
+ sources = collect_sources(packs_dir)
282
+ except ValueError as exc:
283
+ drifts.append(f"[adapter-root-bins] {exc}; run: make build-self")
284
+ return drifts
285
+ try:
286
+ _assert_shim_companion_present(packs_dir)
287
+ except ValueError as exc:
288
+ drifts.append(f"[adapter-root-bins:shim-companion] {exc}; run: make build-self")
289
+ return drifts
290
+
291
+ target_dir = working_tree / TARGET_SUBDIR
292
+ expected_targets: set[Path] = set()
293
+
294
+ for proj in compute_projections(working_tree, packs_dir):
295
+ expected_targets.add(proj.target)
296
+ prefix = (
297
+ "[adapter-root-bins:shim-companion]"
298
+ if _is_companion_projection(proj)
299
+ else "[adapter-root-bins]"
300
+ )
301
+ try:
302
+ source_bytes = proj.source.read_bytes()
303
+ except OSError as exc: # pragma: no cover — defensive
304
+ drifts.append(f"{prefix} source unreadable: {exc}")
305
+ continue
306
+ if not proj.target.exists():
307
+ drifts.append(
308
+ f"{prefix} missing: "
309
+ f"{proj.target.relative_to(working_tree).as_posix()} "
310
+ f"(source: "
311
+ f"{proj.source.relative_to(packs_dir.parent).as_posix()}); "
312
+ f"run: make build-self FORCE=1"
313
+ )
314
+ continue
315
+ if proj.target.read_bytes() != source_bytes:
316
+ drifts.append(
317
+ f"{prefix} modified: "
318
+ f"{proj.target.relative_to(working_tree).as_posix()} "
319
+ f"diverges from "
320
+ f"{proj.source.relative_to(packs_dir.parent).as_posix()}; "
321
+ f"run: make build-self FORCE=1"
322
+ )
323
+
324
+ # Orphan check.
325
+ if target_dir.is_dir():
326
+ for existing in sorted(target_dir.glob("*.py")):
327
+ if existing not in expected_targets:
328
+ drifts.append(
329
+ f"[adapter-root-bins] orphaned: "
330
+ f"{existing.relative_to(working_tree).as_posix()} "
331
+ f"present but no pack ships "
332
+ f"adapter-root-bins/{existing.name}; "
333
+ f"run: make build-self FORCE=1"
334
+ )
335
+
336
+ return drifts
@@ -0,0 +1,46 @@
1
+ """Adapter registry — keyed by contract adapter name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+ from typing import Callable, Dict, Mapping
9
+
10
+ from agentbundle.build.adapters import claude_code, codex, copilot, kiro, kiro_cli, kiro_ide
11
+
12
+
13
+ def _kiro_alias_project(pack_path: Path, contract: dict, output_root: Path) -> None:
14
+ """Deprecated alias: `kiro` → `kiro-ide`. Emits a build-time warning."""
15
+ warnings.warn(
16
+ "kiro: deprecated alias for kiro-ide; update allowed-adapters in pack.toml",
17
+ DeprecationWarning,
18
+ stacklevel=2,
19
+ )
20
+ kiro_ide.project(pack_path, contract, output_root)
21
+
22
+
23
+ # Original callable registry (hyphenated contract names) — preserved for
24
+ # F-build's recipe runner which keys recipes by contract names.
25
+ ADAPTERS: Dict[str, Callable] = {
26
+ "claude-code": claude_code.project,
27
+ "kiro-ide": kiro_ide.project,
28
+ "kiro-cli": kiro_cli.project,
29
+ "kiro": _kiro_alias_project, # deprecated alias → kiro-ide (RFC-0022 D1)
30
+ "copilot": copilot.project,
31
+ "codex": codex.project,
32
+ }
33
+
34
+ # Module-keyed registry — the surface RFC-0003 F-cli AC requires.
35
+ # Keys are the Python module names (`claude_code`, etc.) which is what the
36
+ # CLI's `list-targets` and the AC's test reference. Values are the adapter
37
+ # modules themselves so callers can introspect any future per-adapter
38
+ # attribute the sibling spec pins onto `AdapterModule`.
39
+ registry: Mapping[str, ModuleType] = {
40
+ "claude_code": claude_code,
41
+ "kiro_ide": kiro_ide,
42
+ "kiro_cli": kiro_cli,
43
+ "kiro": kiro, # legacy module; use kiro_ide for new code
44
+ "copilot": copilot,
45
+ "codex": codex,
46
+ }
@@ -0,0 +1,142 @@
1
+ """Claude Code adapter — projects every primitive per the contract.
2
+
3
+ Projection modes used (read from contract["adapter"]["claude-code"]):
4
+ - skill → direct-directory → .claude/skills/<name>/
5
+ - agent → direct-file → .claude/agents/<name>.md
6
+ - hook-body → direct-file → tools/hooks/<name>.{sh,py}
7
+ - hook-wiring → merge-json → .claude/settings.local.json (hooks key)
8
+ - command → direct-file → .claude/commands/<name>.md
9
+
10
+ The merge-json projection is idempotent because we re-serialise with
11
+ `sort_keys=True` and re-read the existing file's `hooks` key before
12
+ deep-merging the incoming TOML payload.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ from pathlib import Path
19
+ from typing import Iterator
20
+
21
+
22
+ # Phase order from RFC-0005 § Build-pipeline ordering invariant.
23
+ # Uniform across all reference adapters even though Claude Code's
24
+ # wiring lands in a settings file (not in agents) — the uniformity
25
+ # keeps the phases predictable, which the spec calls for.
26
+ from agentbundle.build.phase_order import PHASE_ORDER as _PHASE_ORDER
27
+ from agentbundle.build.projections.direct_directory import sweep_orphans
28
+ from agentbundle.build.projections.merge_json import project_merge_json
29
+
30
+
31
+ def _iter_primitives(contract: dict) -> Iterator[str]:
32
+ """Yield Claude Code's projected primitive names in phase order."""
33
+ adapter_block = contract["adapter"]["claude-code"]
34
+ array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
35
+ for primitive_name in _PHASE_ORDER:
36
+ if primitive_name in array_form and array_form[primitive_name].get("mode") != "dropped":
37
+ yield primitive_name
38
+
39
+
40
+ def project(pack_path: Path, contract: dict, output_root: Path) -> None:
41
+ """Single-pack convenience wrapper. Delegates to `project_packs`."""
42
+ project_packs([pack_path], contract, output_root)
43
+
44
+
45
+ def project_packs(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
46
+ """Project every pack in `pack_paths` in order, then run the
47
+ shared orphan-sweep post-pass on the `skill` target directory.
48
+
49
+ Same-name collision rule: pack source order as supplied here; the
50
+ last pack's `<name>` overwrites earlier packs' (`_project_direct_directory`
51
+ `rmtree`s the destination before `copytree`). The orphan sweep
52
+ observes the union of source skill names across the call's pack
53
+ list (not per-pack) so a pack shipping a subset can co-exist with
54
+ another that ships the union complement.
55
+ """
56
+ for pack_path in pack_paths:
57
+ _project_single(pack_path, contract, output_root)
58
+ _sweep_skill_orphans(pack_paths, contract, output_root)
59
+
60
+
61
+ # Mirror of kiro.py:_skill_direct_directory_target — keep in sync.
62
+ # A shared helper is barred by the spec's `Never do` boundary (no
63
+ # expansion of projections/direct_directory.py beyond `sweep_orphans`).
64
+ def _skill_direct_directory_target(contract: dict, output_root: Path) -> Path | None:
65
+ adapter_block = contract["adapter"]["claude-code"]
66
+ for entry in adapter_block.get("projection", []):
67
+ if entry.get("primitive") == "skill" and entry.get("mode") == "direct-directory":
68
+ return output_root / entry["target-path"].rstrip("/")
69
+ return None
70
+
71
+
72
+ def _sweep_skill_orphans(pack_paths: list[Path], contract: dict, output_root: Path) -> None:
73
+ target_dir = _skill_direct_directory_target(contract, output_root)
74
+ if target_dir is None:
75
+ return
76
+ skill_source_path = contract["primitive"]["skill"]["source-path"].rstrip("/")
77
+ expected_names: set[str] = set()
78
+ for pack_path in pack_paths:
79
+ source_dir = pack_path / skill_source_path
80
+ if not source_dir.exists():
81
+ continue
82
+ for entry in source_dir.iterdir():
83
+ if entry.is_dir():
84
+ expected_names.add(entry.name)
85
+ sweep_orphans(target_dir, expected_names)
86
+
87
+
88
+ def _project_single(pack_path: Path, contract: dict, output_root: Path) -> None:
89
+ adapter_block = contract["adapter"]["claude-code"]
90
+ rules_by_primitive = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
91
+
92
+ for primitive_name in _iter_primitives(contract):
93
+ rule = rules_by_primitive[primitive_name]
94
+ mode = rule["mode"]
95
+ primitive = contract["primitive"][primitive_name]
96
+ source_dir = pack_path / primitive["source-path"].rstrip("/")
97
+ if not source_dir.exists():
98
+ continue
99
+
100
+ if mode == "direct-directory":
101
+ _project_direct_directory(source_dir, output_root / rule["target-path"].rstrip("/"))
102
+ elif mode == "direct-file":
103
+ _project_direct_file(source_dir, output_root, rule["target-path"])
104
+ elif mode == "merge-json":
105
+ project_merge_json(source_dir, output_root, rule)
106
+ else:
107
+ raise ValueError(f"claude-code: unhandled mode {mode!r} for {primitive_name}")
108
+
109
+
110
+ def _project_direct_directory(source_dir: Path, target_dir: Path) -> None:
111
+ for entry in sorted(source_dir.iterdir()):
112
+ # Defense-in-depth — `lint-packs` rejects packs that ship
113
+ # symlinks, but a direct `project_packs` caller bypasses
114
+ # that gate. A symlink at the skill-root level would be
115
+ # dereferenced by `copytree`.
116
+ if entry.is_symlink():
117
+ continue
118
+ if entry.is_dir():
119
+ destination = target_dir / entry.name
120
+ # Spec § Never do — `shutil.rmtree` is barred against
121
+ # any entry whose `is_symlink()` is true. If a previous
122
+ # run left a symlink at the destination path, unlink it
123
+ # (removes the link, not the target).
124
+ if destination.is_symlink():
125
+ destination.unlink()
126
+ elif destination.exists():
127
+ shutil.rmtree(destination)
128
+ # symlinks=True preserves symlinks rather than dereferencing
129
+ # them — a malicious pack with a symlink to /etc/passwd
130
+ # cannot exfiltrate the target into the projection.
131
+ shutil.copytree(entry, destination, symlinks=True)
132
+
133
+
134
+ def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
135
+ target_dir = output_root / target_prefix.rstrip("/")
136
+ target_dir.mkdir(parents=True, exist_ok=True)
137
+ for entry in sorted(source_dir.iterdir()):
138
+ if entry.is_file():
139
+ destination = target_dir / entry.name
140
+ shutil.copy2(entry, destination, follow_symlinks=False)
141
+
142
+