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,309 @@
1
+ """T4: shared-libs/ build-pipeline primitive class.
2
+
3
+ Source rule: ``packs/<pack>/.apm/shared-libs/*.py``.
4
+ Target rule: for every skill in any pack whose ``SKILL.md`` declares
5
+ ``metadata.auth: creds``, project each ``shared-libs/*.py`` byte-
6
+ identical into that skill's ``scripts/`` directory; create
7
+ ``scripts/`` if absent.
8
+
9
+ This module owns both halves of the projection contract:
10
+
11
+ - ``apply_projection(packs_dir)`` — write the files. Called by
12
+ ``make build-self``.
13
+ - ``check_drift(packs_dir)`` — read-only gate. Returns a list of
14
+ drift descriptions (empty list == clean). Each description
15
+ classifies one of the three outcomes RFC-0013 § 4c pins:
16
+ * **modified** — projected file exists but bytes diverge from source
17
+ * **missing** — consumer declares ``auth: creds`` but projected file absent
18
+ * **orphaned** — projected file present but consumer no longer
19
+ declares ``auth: creds`` (or the source has been removed)
20
+
21
+ Inter-pack collision: two packs both shipping ``shared-libs/<file>``
22
+ under the same basename is a hard error (``ValueError``) at
23
+ projection time. The v1 catalogue ships one source pack
24
+ (``credential-brokers``); the rail exists to refuse a future
25
+ second-pack collision before it can silently overwrite.
26
+
27
+ The detection of ``metadata.auth: creds`` is a regex scan against
28
+ ``SKILL.md`` text, not a full YAML parse. The strict lint
29
+ (``tools/lint-agent-artifacts.py``) does the YAML round-trip;
30
+ this module only needs to know *which* skills are consumers, and
31
+ the regex is stdlib-only — the build pipeline carries no PyYAML
32
+ dependency.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import shutil
39
+ from dataclasses import dataclass
40
+ from pathlib import Path
41
+
42
+ # Pin the source path so a downstream consumer that wants to
43
+ # enumerate shim sources doesn't hardcode the literal repeatedly.
44
+ SOURCE_SUBDIR = ".apm/shared-libs"
45
+
46
+ # Static allow-list of basenames the build pipeline recognises as
47
+ # shim files. Used by the orphan-rail when ``collect_sources`` returns
48
+ # empty (a future PR removes the shared-libs source) — without this,
49
+ # the orphan check would key on an empty source set and silently miss
50
+ # stale projected copies under consumer skills.
51
+ KNOWN_SHIM_BASENAMES: frozenset[str] = frozenset({
52
+ "credentials_shim.py",
53
+ "_keychain_macos.py",
54
+ "_credman_windows.py",
55
+ })
56
+
57
+ # Regex match against `auth: creds` as an indented mapping value under
58
+ # `metadata:`. The shape we look for, anchored to start-of-line:
59
+ # metadata:
60
+ # ...
61
+ # auth: creds
62
+ # We don't try to honour YAML's full grammar — quoted form
63
+ # (`auth: "creds"`) and inline form (`metadata: { auth: creds }`) are
64
+ # refused by the lint, so they cannot reach the build pipeline.
65
+ # An unquoted scalar token is what every in-tree credentialed skill
66
+ # carries today.
67
+ _AUTH_CREDS_RE = re.compile(
68
+ r"^[ \t]+auth:[ \t]+creds[ \t]*(?:#.*)?$",
69
+ re.MULTILINE,
70
+ )
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class SharedLibProjection:
75
+ """One concrete projection: copy `source` to `target`."""
76
+
77
+ source: Path
78
+ target: Path
79
+
80
+
81
+ def collect_sources(packs_dir: Path) -> dict[str, Path]:
82
+ """Return ``{basename → source_path}`` for every ``.apm/shared-libs/*.py``.
83
+
84
+ Raises ``ValueError`` on inter-pack basename collision — two packs
85
+ shipping the same basename produces non-deterministic projection
86
+ order and silent overwrites; refuse hard at enumeration time.
87
+ """
88
+ sources: dict[str, Path] = {}
89
+ for pack in sorted(packs_dir.iterdir()):
90
+ if not pack.is_dir() or not (pack / "pack.toml").exists():
91
+ continue
92
+ shared = pack / SOURCE_SUBDIR
93
+ if not shared.is_dir():
94
+ continue
95
+ for src in sorted(shared.glob("*.py")):
96
+ if src.name in sources:
97
+ raise ValueError(
98
+ f"shared-libs collision: '{src.name}' shipped by both "
99
+ f"{sources[src.name]} and {src}"
100
+ )
101
+ sources[src.name] = src
102
+ return sources
103
+
104
+
105
+ def _skill_declares_auth_creds(skill_md: Path) -> bool:
106
+ """Return True if ``SKILL.md``'s frontmatter declares ``auth: creds``.
107
+
108
+ Scoped to the frontmatter — text between the first pair of ``---``
109
+ delimiters. A body-only mention (e.g. inside a code fence) is not
110
+ a declaration.
111
+ """
112
+ try:
113
+ text = skill_md.read_text(encoding="utf-8")
114
+ except (OSError, UnicodeDecodeError):
115
+ return False
116
+ lines = text.splitlines()
117
+ if not lines or lines[0].strip() != "---":
118
+ return False
119
+ end = None
120
+ for i in range(1, len(lines)):
121
+ if lines[i].strip() == "---":
122
+ end = i
123
+ break
124
+ if end is None:
125
+ return False
126
+ fm = "\n".join(lines[1:end])
127
+ return _AUTH_CREDS_RE.search(fm) is not None
128
+
129
+
130
+ def find_creds_consumers(packs_dir: Path) -> list[Path]:
131
+ """Return every skill-source directory whose ``SKILL.md`` declares
132
+ ``auth: creds``. Sorted for deterministic projection order.
133
+ """
134
+ consumers: list[Path] = []
135
+ for pack in sorted(packs_dir.iterdir()):
136
+ if not pack.is_dir() or not (pack / "pack.toml").exists():
137
+ continue
138
+ skills_dir = pack / ".apm" / "skills"
139
+ if not skills_dir.is_dir():
140
+ continue
141
+ for skill_dir in sorted(skills_dir.iterdir()):
142
+ if not skill_dir.is_dir():
143
+ continue
144
+ skill_md = skill_dir / "SKILL.md"
145
+ if not skill_md.is_file():
146
+ continue
147
+ if _skill_declares_auth_creds(skill_md):
148
+ consumers.append(skill_dir)
149
+ return consumers
150
+
151
+
152
+ def compute_projections(packs_dir: Path) -> list[SharedLibProjection]:
153
+ """Return the full list of ``(source → target)`` pairs.
154
+
155
+ Order: outer loop over consumers (sorted), inner loop over source
156
+ basenames (sorted). Deterministic — drift gates depend on it.
157
+ """
158
+ sources = collect_sources(packs_dir)
159
+ if not sources:
160
+ return []
161
+ consumers = find_creds_consumers(packs_dir)
162
+ out: list[SharedLibProjection] = []
163
+ for skill_dir in consumers:
164
+ scripts = skill_dir / "scripts"
165
+ for basename in sorted(sources):
166
+ out.append(
167
+ SharedLibProjection(
168
+ source=sources[basename],
169
+ target=scripts / basename,
170
+ )
171
+ )
172
+ return out
173
+
174
+
175
+ def apply_projection(packs_dir: Path) -> None:
176
+ """Write every projection target and remove orphans. Creates
177
+ ``scripts/`` if absent.
178
+
179
+ Called by ``make build-self``. Idempotent — running twice produces
180
+ the same on-disk state.
181
+
182
+ Three drift outcomes RFC-0013 § 4c pins are all resolved here:
183
+ * **missing** → file written from source
184
+ * **modified** → file overwritten from source
185
+ * **orphaned** → file removed (consumer no longer declares
186
+ ``auth: creds``, OR the source basename is no longer shipped)
187
+ """
188
+ projections = compute_projections(packs_dir)
189
+ expected_targets = {p.target for p in projections}
190
+ # Write current set first so an orphan removed below cannot be
191
+ # mistaken for a missing write that needs re-running.
192
+ for proj in projections:
193
+ proj.target.parent.mkdir(parents=True, exist_ok=True)
194
+ shutil.copy2(proj.source, proj.target)
195
+ # Orphan removal uses the static basename allow-list so the rail
196
+ # still fires when ``collect_sources`` returns empty (the source
197
+ # pack has been dropped). Without the static list, the orphan
198
+ # set would silently be empty and stale projected copies would
199
+ # survive.
200
+ for existing in _enumerate_existing_projections(packs_dir, set(KNOWN_SHIM_BASENAMES)):
201
+ if existing not in expected_targets:
202
+ try:
203
+ existing.unlink()
204
+ except FileNotFoundError: # pragma: no cover — race-only
205
+ pass
206
+
207
+
208
+ def _enumerate_existing_projections(
209
+ packs_dir: Path, source_basenames: set[str]
210
+ ) -> list[Path]:
211
+ """Return every existing projected file in any skill's ``scripts/``
212
+ whose basename matches a shared-libs source.
213
+
214
+ Used to detect orphans: a projected file that exists on disk but
215
+ is no longer claimed by any (source × creds-consumer) pairing.
216
+ """
217
+ found: list[Path] = []
218
+ for pack in sorted(packs_dir.iterdir()):
219
+ if not pack.is_dir() or not (pack / "pack.toml").exists():
220
+ continue
221
+ skills_dir = pack / ".apm" / "skills"
222
+ if not skills_dir.is_dir():
223
+ continue
224
+ for skill_dir in sorted(skills_dir.iterdir()):
225
+ if not skill_dir.is_dir():
226
+ continue
227
+ scripts = skill_dir / "scripts"
228
+ if not scripts.is_dir():
229
+ continue
230
+ for entry in sorted(scripts.iterdir()):
231
+ if entry.is_file() and entry.name in source_basenames:
232
+ found.append(entry)
233
+ return found
234
+
235
+
236
+ def check_drift(packs_dir: Path) -> list[str]:
237
+ """Return drift descriptions for ``make build-check``.
238
+
239
+ Three outcomes per RFC-0013 § 4c:
240
+ * **modified** — projected bytes diverge from source
241
+ * **missing** — consumer declares ``auth: creds`` but file absent
242
+ * **orphaned** — projected file present, no claiming pairing
243
+
244
+ Each description ends with the regeneration command so the
245
+ operator can resolve drift in one invocation.
246
+ """
247
+ drifts: list[str] = []
248
+ try:
249
+ sources = collect_sources(packs_dir)
250
+ except ValueError as exc:
251
+ # Collision blocks the projection entirely — report and stop.
252
+ drifts.append(f"[shared-libs] {exc}; run: make build-self")
253
+ return drifts
254
+ if not sources:
255
+ # No source pack carries shared-libs/. The orphan rail still
256
+ # fires: stale projected copies under any consumer skill must
257
+ # surface, otherwise dropping the source pack silently leaves
258
+ # vendored copies behind. Use the static basename allow-list
259
+ # so the rail keys on a known set rather than the (empty)
260
+ # source set.
261
+ for existing in _enumerate_existing_projections(
262
+ packs_dir, set(KNOWN_SHIM_BASENAMES)
263
+ ):
264
+ drifts.append(
265
+ f"[shared-libs] orphaned: "
266
+ f"{existing.relative_to(packs_dir.parent).as_posix()} "
267
+ f"present but no source pack ships shared-libs/ "
268
+ f"(remove the file); "
269
+ f"run: make build-self FORCE=1"
270
+ )
271
+ return drifts
272
+
273
+ expected_targets: set[Path] = set()
274
+ for proj in compute_projections(packs_dir):
275
+ expected_targets.add(proj.target)
276
+ source_bytes = proj.source.read_bytes()
277
+ if not proj.target.exists():
278
+ drifts.append(
279
+ f"[shared-libs] missing: "
280
+ f"{proj.target.relative_to(packs_dir.parent).as_posix()} "
281
+ f"(consumer declares 'auth: creds'; source: "
282
+ f"{proj.source.relative_to(packs_dir.parent).as_posix()}); "
283
+ f"run: make build-self FORCE=1"
284
+ )
285
+ continue
286
+ actual_bytes = proj.target.read_bytes()
287
+ if actual_bytes != source_bytes:
288
+ drifts.append(
289
+ f"[shared-libs] modified: "
290
+ f"{proj.target.relative_to(packs_dir.parent).as_posix()} "
291
+ f"diverges from "
292
+ f"{proj.source.relative_to(packs_dir.parent).as_posix()}; "
293
+ f"run: make build-self FORCE=1"
294
+ )
295
+
296
+ # Orphan check: any projected file whose basename matches a known
297
+ # source but is NOT in expected_targets is orphaned.
298
+ for existing in _enumerate_existing_projections(packs_dir, set(sources)):
299
+ if existing not in expected_targets:
300
+ drifts.append(
301
+ f"[shared-libs] orphaned: "
302
+ f"{existing.relative_to(packs_dir.parent).as_posix()} "
303
+ f"present but no consumer skill claims it "
304
+ f"(remove the file or restore 'metadata.auth: creds' "
305
+ f"in the surrounding SKILL.md); "
306
+ f"run: make build-self FORCE=1"
307
+ )
308
+
309
+ return drifts
@@ -0,0 +1,85 @@
1
+ """Scope-conditional `target` resolver for v0.3 projection declarations.
2
+
3
+ The v0.3 adapter contract (RFC-0005) introduced a string-or-scope-map shape
4
+ for `target` on `[adapter.<x>.projections.<primitive>]` entries:
5
+
6
+ # bare-string (v0.1 legacy shorthand)
7
+ target = "tools/hooks/<name>.{sh,py}"
8
+
9
+ # scope-map (v0.3 fork)
10
+ target.repo = "tools/hooks/<name>.{sh,py}"
11
+ target.user = ".claude/hooks/<name>.{sh,py}"
12
+
13
+ Some templates also carry the `<attach-to-agent>` placeholder for
14
+ `merge-into-agent-json` consumers (Kiro hook-wiring), resolved per wiring
15
+ entry from the pack-side TOML's `attach-to-agent` field.
16
+
17
+ This module is a pure-function utility — no I/O, no filesystem access.
18
+ Scope-root resolution (`.` vs `~`) and `<name>` / `<pack>` placeholder
19
+ substitution are the pipeline consumers' (T5/T6) concern; the resolver
20
+ returns a target-template string they can further process.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+
26
+ _ATTACH_TO_AGENT_PLACEHOLDER = "<attach-to-agent>"
27
+
28
+
29
+ def resolve_target(
30
+ projection: dict,
31
+ scope: str,
32
+ attach_to_agent: str | None = None,
33
+ ) -> str:
34
+ """Resolve a projection entry's `target` for a given scope.
35
+
36
+ Arguments:
37
+ projection: a single entry from `adapter.<x>.projections.<primitive>`.
38
+ Must contain a `target` key (bare string or `{repo, user}` table).
39
+ scope: `"repo"` or `"user"`.
40
+ attach_to_agent: optional pack-side agent name; substituted for any
41
+ `<attach-to-agent>` placeholder in the resolved template. If the
42
+ template contains the placeholder but no name is given, the call
43
+ refuses — passing an unsubstituted template downstream is a bug.
44
+
45
+ Returns:
46
+ The resolved target template as a string. `<name>` and `<pack>`
47
+ placeholders survive verbatim — they're the pipeline's responsibility,
48
+ not this resolver's.
49
+
50
+ Raises:
51
+ ValueError: when `target` is absent, when the requested `scope` is
52
+ absent from a scope-map declaration, or when the resolved template
53
+ contains the `<attach-to-agent>` placeholder but `attach_to_agent`
54
+ is None.
55
+ """
56
+ if "target" not in projection:
57
+ raise ValueError("projection missing 'target' field")
58
+
59
+ target = projection["target"]
60
+ if isinstance(target, str):
61
+ resolved = target
62
+ elif isinstance(target, dict):
63
+ if scope not in target:
64
+ raise ValueError(
65
+ f"projection target has no entry for scope {scope!r}; "
66
+ f"declared scopes: {sorted(target.keys())}"
67
+ )
68
+ resolved = target[scope]
69
+ if not isinstance(resolved, str):
70
+ raise ValueError(
71
+ f"projection target.{scope} is not a string: {type(resolved).__name__}"
72
+ )
73
+ else:
74
+ raise ValueError(
75
+ f"projection target must be string or scope-map; got {type(target).__name__}"
76
+ )
77
+
78
+ if _ATTACH_TO_AGENT_PLACEHOLDER in resolved:
79
+ if attach_to_agent is None:
80
+ raise ValueError(
81
+ f"target template requires attach-to-agent; got None. Template: {resolved!r}"
82
+ )
83
+ resolved = resolved.replace(_ATTACH_TO_AGENT_PLACEHOLDER, attach_to_agent)
84
+
85
+ return resolved
File without changes
@@ -0,0 +1,275 @@
1
+ """Tests for the Claude Code adapter (T2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from agentbundle.build.adapters.claude_code import project, project_packs
11
+ from agentbundle.build.contract import load as load_contract
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parents[5]
14
+ CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
15
+
16
+
17
+ def _seed_pack(root: Path) -> Path:
18
+ pack = root / "pack"
19
+ (pack / ".apm" / "skills" / "foo").mkdir(parents=True)
20
+ (pack / ".apm" / "skills" / "foo" / "SKILL.md").write_text(
21
+ "---\ndescription: foo skill\n---\n# foo\n",
22
+ encoding="utf-8",
23
+ )
24
+ (pack / ".apm" / "skills" / "foo" / "extra.txt").write_text("nested\n", encoding="utf-8")
25
+
26
+ (pack / ".apm" / "agents").mkdir(parents=True)
27
+ (pack / ".apm" / "agents" / "bar.md").write_text(
28
+ "---\nname: bar\n---\nagent body\n",
29
+ encoding="utf-8",
30
+ )
31
+
32
+ (pack / ".apm" / "hooks").mkdir(parents=True)
33
+ (pack / ".apm" / "hooks" / "baz.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
34
+ (pack / ".apm" / "hooks" / "baz.py").write_text("print('hi')\n", encoding="utf-8")
35
+
36
+ (pack / ".apm" / "hook-wiring").mkdir(parents=True)
37
+ (pack / ".apm" / "hook-wiring" / "baz.toml").write_text(
38
+ '[hooks]\nbaz = "tools/hooks/baz.sh"\n',
39
+ encoding="utf-8",
40
+ )
41
+
42
+ (pack / ".apm" / "commands").mkdir(parents=True)
43
+ (pack / ".apm" / "commands" / "qux.md").write_text("# qux\n", encoding="utf-8")
44
+ return pack
45
+
46
+
47
+ class ClaudeCodeAdapterTests(unittest.TestCase):
48
+ @classmethod
49
+ def setUpClass(cls) -> None:
50
+ cls.contract = load_contract(CONTRACT_PATH)
51
+
52
+ def test_skill_projects_to_claude_skills_directory(self) -> None:
53
+ with tempfile.TemporaryDirectory() as tmp:
54
+ tmp_path = Path(tmp)
55
+ pack = _seed_pack(tmp_path)
56
+ out = tmp_path / "out"
57
+ project(pack, self.contract, out)
58
+ self.assertTrue((out / ".claude" / "skills" / "foo" / "SKILL.md").exists())
59
+ self.assertTrue((out / ".claude" / "skills" / "foo" / "extra.txt").exists())
60
+
61
+ def test_agent_projects_to_claude_agents_file(self) -> None:
62
+ with tempfile.TemporaryDirectory() as tmp:
63
+ tmp_path = Path(tmp)
64
+ pack = _seed_pack(tmp_path)
65
+ out = tmp_path / "out"
66
+ project(pack, self.contract, out)
67
+ agent_path = out / ".claude" / "agents" / "bar.md"
68
+ self.assertTrue(agent_path.exists())
69
+ self.assertIn("name: bar", agent_path.read_text(encoding="utf-8"))
70
+
71
+ def test_agent_model_alias_preserved_verbatim(self) -> None:
72
+ """Claude Code agents stay markdown-with-frontmatter; `model:
73
+ opus` is Claude Code's native alias and the projection must
74
+ not translate it (unlike the kiro JSON projection, which
75
+ rewrites aliases via the contract's values map). Comma-string
76
+ `tools` is also preserved verbatim because Claude Code parses
77
+ the markdown frontmatter itself."""
78
+ with tempfile.TemporaryDirectory() as tmp:
79
+ tmp_path = Path(tmp)
80
+ pack = tmp_path / "pack"
81
+ (pack / ".apm" / "agents").mkdir(parents=True)
82
+ (pack / ".apm" / "agents" / "reviewer.md").write_text(
83
+ "---\nname: reviewer\nmodel: opus\ntools: Read, Grep, Glob, Bash\n---\nbody\n",
84
+ encoding="utf-8",
85
+ )
86
+ out = tmp_path / "out"
87
+ project(pack, self.contract, out)
88
+ text = (out / ".claude" / "agents" / "reviewer.md").read_text(encoding="utf-8")
89
+ self.assertIn("model: opus", text)
90
+ self.assertIn("tools: Read, Grep, Glob, Bash", text)
91
+
92
+ def test_hook_body_sh_preserved(self) -> None:
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ tmp_path = Path(tmp)
95
+ pack = _seed_pack(tmp_path)
96
+ out = tmp_path / "out"
97
+ project(pack, self.contract, out)
98
+ self.assertTrue((out / "tools" / "hooks" / "baz.sh").exists())
99
+
100
+ def test_hook_body_py_preserved(self) -> None:
101
+ with tempfile.TemporaryDirectory() as tmp:
102
+ tmp_path = Path(tmp)
103
+ pack = _seed_pack(tmp_path)
104
+ out = tmp_path / "out"
105
+ project(pack, self.contract, out)
106
+ self.assertTrue((out / "tools" / "hooks" / "baz.py").exists())
107
+
108
+ def test_hook_wiring_merges_under_hooks_key(self) -> None:
109
+ with tempfile.TemporaryDirectory() as tmp:
110
+ tmp_path = Path(tmp)
111
+ pack = _seed_pack(tmp_path)
112
+ out = tmp_path / "out"
113
+ settings_path = out / ".claude" / "settings.local.json"
114
+ settings_path.parent.mkdir(parents=True)
115
+ settings_path.write_text(
116
+ json.dumps({"otherKey": {"preserved": True}}),
117
+ encoding="utf-8",
118
+ )
119
+ project(pack, self.contract, out)
120
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
121
+ self.assertEqual(data["hooks"], {"baz": "tools/hooks/baz.sh"})
122
+ self.assertEqual(data["otherKey"], {"preserved": True})
123
+
124
+ def test_command_projects_to_claude_commands(self) -> None:
125
+ with tempfile.TemporaryDirectory() as tmp:
126
+ tmp_path = Path(tmp)
127
+ pack = _seed_pack(tmp_path)
128
+ out = tmp_path / "out"
129
+ project(pack, self.contract, out)
130
+ self.assertTrue((out / ".claude" / "commands" / "qux.md").exists())
131
+
132
+ def test_idempotent_direct_file_and_merge_json(self) -> None:
133
+ with tempfile.TemporaryDirectory() as tmp:
134
+ tmp_path = Path(tmp)
135
+ pack = _seed_pack(tmp_path)
136
+ out = tmp_path / "out"
137
+ project(pack, self.contract, out)
138
+ first_agent = (out / ".claude" / "agents" / "bar.md").read_bytes()
139
+ first_settings = (out / ".claude" / "settings.local.json").read_bytes()
140
+ project(pack, self.contract, out)
141
+ second_agent = (out / ".claude" / "agents" / "bar.md").read_bytes()
142
+ second_settings = (out / ".claude" / "settings.local.json").read_bytes()
143
+ self.assertEqual(first_agent, second_agent)
144
+ self.assertEqual(first_settings, second_settings)
145
+
146
+
147
+ def _seed_minimal_pack(root: Path, name: str, skill_name: str, body: str) -> Path:
148
+ """Pack with a single skill at .apm/skills/<skill_name>/SKILL.md."""
149
+ pack = root / name
150
+ skill_dir = pack / ".apm" / "skills" / skill_name
151
+ skill_dir.mkdir(parents=True)
152
+ (skill_dir / "SKILL.md").write_text(body, encoding="utf-8")
153
+ return pack
154
+
155
+
156
+ class ProjectPacksTests(unittest.TestCase):
157
+ @classmethod
158
+ def setUpClass(cls) -> None:
159
+ cls.contract = load_contract(CONTRACT_PATH)
160
+
161
+ def test_project_packs_iterates_in_order(self) -> None:
162
+ with tempfile.TemporaryDirectory() as tmp:
163
+ tmp_path = Path(tmp)
164
+ pack_a = _seed_minimal_pack(tmp_path, "pack-a", "skill-a", "# a\n")
165
+ pack_b = _seed_minimal_pack(tmp_path, "pack-b", "skill-b", "# b\n")
166
+ out = tmp_path / "out"
167
+
168
+ project_packs([pack_a, pack_b], self.contract, out)
169
+
170
+ self.assertTrue((out / ".claude" / "skills" / "skill-a" / "SKILL.md").is_file())
171
+ self.assertTrue((out / ".claude" / "skills" / "skill-b" / "SKILL.md").is_file())
172
+
173
+ def test_single_pack_project_delegates_to_project_packs(self) -> None:
174
+ with tempfile.TemporaryDirectory() as tmp:
175
+ tmp_path = Path(tmp)
176
+ pack = _seed_minimal_pack(tmp_path, "pack", "skill-x", "# x\n")
177
+ out_a = tmp_path / "out-a"
178
+ out_b = tmp_path / "out-b"
179
+
180
+ project(pack, self.contract, out_a)
181
+ project_packs([pack], self.contract, out_b)
182
+
183
+ self.assertEqual(
184
+ (out_a / ".claude" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
185
+ (out_b / ".claude" / "skills" / "skill-x" / "SKILL.md").read_bytes(),
186
+ )
187
+
188
+ def test_same_name_last_wins(self) -> None:
189
+ with tempfile.TemporaryDirectory() as tmp:
190
+ tmp_path = Path(tmp)
191
+ pack_a = _seed_minimal_pack(
192
+ tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
193
+ )
194
+ pack_b = _seed_minimal_pack(
195
+ tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
196
+ )
197
+ out = tmp_path / "out"
198
+
199
+ project_packs([pack_a, pack_b], self.contract, out)
200
+ body = (out / ".claude" / "skills" / "same-name" / "SKILL.md").read_text(
201
+ encoding="utf-8",
202
+ )
203
+ self.assertIn("PACK_B_SENTINEL", body)
204
+ self.assertNotIn("PACK_A_SENTINEL", body)
205
+
206
+ def test_same_name_last_wins_reversed(self) -> None:
207
+ with tempfile.TemporaryDirectory() as tmp:
208
+ tmp_path = Path(tmp)
209
+ pack_a = _seed_minimal_pack(
210
+ tmp_path, "pack-a", "same-name", "# pack-a\nPACK_A_SENTINEL\n",
211
+ )
212
+ pack_b = _seed_minimal_pack(
213
+ tmp_path, "pack-b", "same-name", "# pack-b\nPACK_B_SENTINEL\n",
214
+ )
215
+ out = tmp_path / "out"
216
+
217
+ project_packs([pack_b, pack_a], self.contract, out)
218
+ body = (out / ".claude" / "skills" / "same-name" / "SKILL.md").read_text(
219
+ encoding="utf-8",
220
+ )
221
+ self.assertIn("PACK_A_SENTINEL", body)
222
+ self.assertNotIn("PACK_B_SENTINEL", body)
223
+
224
+
225
+ def _seed_named_skills_pack(root: Path, pack_name: str, skill_names: list[str]) -> Path:
226
+ pack = root / pack_name
227
+ for skill_name in skill_names:
228
+ skill_dir = pack / ".apm" / "skills" / skill_name
229
+ skill_dir.mkdir(parents=True)
230
+ (skill_dir / "SKILL.md").write_text(
231
+ f"# {skill_name}\nfrom {pack_name}\n",
232
+ encoding="utf-8",
233
+ )
234
+ return pack
235
+
236
+
237
+ class TestClaudeCodeOrphanSweep(unittest.TestCase):
238
+ @classmethod
239
+ def setUpClass(cls) -> None:
240
+ cls.contract = load_contract(CONTRACT_PATH)
241
+
242
+ def test_two_stage_shrink(self) -> None:
243
+ # AC18: project {a, b, c} then {a, c} into the same output.
244
+ with tempfile.TemporaryDirectory() as tmp:
245
+ tmp_path = Path(tmp)
246
+ three = _seed_named_skills_pack(tmp_path, "three-skill", ["a", "b", "c"])
247
+ shrink = _seed_named_skills_pack(tmp_path, "two-skill-shrink", ["a", "c"])
248
+ out = tmp_path / "out"
249
+
250
+ project_packs([three], self.contract, out)
251
+ self.assertTrue((out / ".claude" / "skills" / "b").is_dir())
252
+
253
+ project_packs([shrink], self.contract, out)
254
+ children = {p.name for p in (out / ".claude" / "skills").iterdir()}
255
+ self.assertEqual(children, {"a", "c"})
256
+
257
+ def test_two_pack_union(self) -> None:
258
+ # AC20 — claude-code case.
259
+ with tempfile.TemporaryDirectory() as tmp:
260
+ tmp_path = Path(tmp)
261
+ pack_a = _seed_named_skills_pack(tmp_path, "pack-a", ["a", "b"])
262
+ pack_b = _seed_named_skills_pack(tmp_path, "pack-b", ["b", "c"])
263
+ out = tmp_path / "out"
264
+
265
+ project_packs([pack_a, pack_b], self.contract, out)
266
+ children = {p.name for p in (out / ".claude" / "skills").iterdir()}
267
+ self.assertEqual(children, {"a", "b", "c"})
268
+
269
+ project_packs([pack_a], self.contract, out)
270
+ children = {p.name for p in (out / ".claude" / "skills").iterdir()}
271
+ self.assertEqual(children, {"a", "b"})
272
+
273
+
274
+ if __name__ == "__main__":
275
+ unittest.main()