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,227 @@
1
+ """Codex adapter — projects skills as full `<name>/SKILL.md` trees under
2
+ `.agents/skills/`, projects hook bodies straight through, drops the rest.
3
+
4
+ Post-RFC-0009 (codex-native-skills): the `skill` primitive lands as
5
+ `direct-directory` mode (full body, byte-equal). Adopters upgrading
6
+ from the legacy `managed-block-inline` shape get a one-shot in-place
7
+ strip of the `<!-- agent-skills:start -->` / `<!-- agent-skills:end -->`
8
+ delimiter region from their existing `<output_root>/AGENTS.md`; the
9
+ strip is destructive by design and bound to the migration window
10
+ (removed together with `_splice_managed_block` in a follow-on release).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Iterator
21
+
22
+
23
+ # RFC-0005 § Build-pipeline ordering invariant — uniform across adapters.
24
+ from agentbundle.build.phase_order import PHASE_ORDER as _PHASE_ORDER
25
+ from agentbundle.build.projections.direct_directory import sweep_orphans
26
+ from agentbundle.build.projections.merge_json import project_merge_json
27
+ from agentbundle.build.projections.codex_agent_toml import (
28
+ project_codex_agent_toml,
29
+ )
30
+
31
+
32
+ def _iter_primitives(contract: dict) -> Iterator[str]:
33
+ """Yield Codex's projected primitive names in phase order."""
34
+ adapter_block = contract["adapter"]["codex"]
35
+ array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
36
+ for primitive_name in _PHASE_ORDER:
37
+ if primitive_name in array_form and array_form[primitive_name].get("mode") != "dropped":
38
+ yield primitive_name
39
+
40
+
41
+ def project(pack_path: Path, contract: dict, output_root: Path) -> None:
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
+ # One-shot migration strip on the project-root AGENTS.md. AC10:
47
+ # in self-host, `_compose_agents_md` rewrites AGENTS.md from the
48
+ # seed (which AC15 stripped of delimiters) just before this call,
49
+ # so the strip is a documented no-op there. In adopter installs
50
+ # against a pre-existing AGENTS.md carrying the legacy block, the
51
+ # strip does real work. Both cases are correct.
52
+ agents_md = output_root / "AGENTS.md"
53
+ if agents_md.exists():
54
+ original = agents_md.read_text(encoding="utf-8")
55
+ stripped = _strip_legacy_skill_block(original)
56
+ if stripped != original:
57
+ # Destructive: any hand-edited prose between the legacy
58
+ # delimiters is gone (RFC-0009 § Failure modes). Leave a
59
+ # breadcrumb so an adopter who discovers missing notes
60
+ # has a way to reconstruct what happened.
61
+ print(
62
+ f"codex: stripped legacy <!-- agent-skills:start --> region "
63
+ f"from {agents_md} — see RFC-0009 § Migration path",
64
+ file=sys.stderr,
65
+ )
66
+ # Atomic rewrite: a crash between truncate and write would
67
+ # otherwise leave a zero-length AGENTS.md. write to a
68
+ # sibling temp file, then `os.replace` for the swap.
69
+ fd, tmp_path = tempfile.mkstemp(
70
+ prefix=".AGENTS.md.strip.",
71
+ suffix=".tmp",
72
+ dir=str(agents_md.parent),
73
+ )
74
+ try:
75
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
76
+ handle.write(stripped)
77
+ os.replace(tmp_path, agents_md)
78
+ except BaseException:
79
+ Path(tmp_path).unlink(missing_ok=True)
80
+ raise
81
+
82
+ adapter_block = contract["adapter"]["codex"]
83
+ rules_by_primitive = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
84
+
85
+ for primitive_name in _iter_primitives(contract):
86
+ rule = rules_by_primitive[primitive_name]
87
+ mode = rule["mode"]
88
+ primitive = contract["primitive"][primitive_name]
89
+ source_dirs = [
90
+ pack_path / primitive["source-path"].rstrip("/")
91
+ for pack_path in pack_paths
92
+ ]
93
+ source_dirs = [source_dir for source_dir in source_dirs if source_dir.exists()]
94
+
95
+ # The `skill` primitive's `direct-directory` projection runs
96
+ # the orphan sweep uniformly across all three adapters, even
97
+ # when no pack ships skills — matching claude_code / kiro
98
+ # which sweep with an empty union (wiping leftover orphans).
99
+ # See spec Objective invariant 4: "after every `project_packs`
100
+ # call". Other primitives keep the early-skip.
101
+ is_skill_direct_directory = (
102
+ mode == "direct-directory" and primitive_name == "skill"
103
+ )
104
+ if not source_dirs and not is_skill_direct_directory:
105
+ continue
106
+
107
+ if mode == "direct-directory":
108
+ target_dir = output_root / rule["target-path"].rstrip("/")
109
+ target_dir.mkdir(parents=True, exist_ok=True)
110
+ expected_names: set[str] = set()
111
+ for source_dir in source_dirs:
112
+ for entry in sorted(source_dir.iterdir()):
113
+ # Defense-in-depth — `lint-packs` already refuses
114
+ # packs that ship symlinks, but a caller invoking
115
+ # `project_packs` directly bypasses that gate. A
116
+ # symlink at the skill-root level would be
117
+ # dereferenced by `copytree` (the `symlinks=True`
118
+ # flag only governs symlinks *inside* the tree),
119
+ # exfiltrating the link target's contents.
120
+ if entry.is_symlink():
121
+ continue
122
+ if entry.is_dir():
123
+ expected_names.add(entry.name)
124
+ destination = target_dir / entry.name
125
+ # Spec § Never do — `shutil.rmtree` is barred
126
+ # against any entry whose `is_symlink()` is
127
+ # true. If a previous projection left a
128
+ # symlink at the destination path, unlink it
129
+ # (removes the link, not the target).
130
+ if destination.is_symlink():
131
+ destination.unlink()
132
+ elif destination.exists():
133
+ shutil.rmtree(destination)
134
+ # symlinks=True keeps source symlinks as
135
+ # symlinks — never dereferences. A malicious
136
+ # pack with a symlink to /etc/passwd cannot
137
+ # exfiltrate.
138
+ shutil.copytree(entry, destination, symlinks=True)
139
+ # Bound to `skill` only per spec § Never do. Other
140
+ # direct-directory primitives opt in explicitly.
141
+ if primitive_name == "skill":
142
+ sweep_orphans(target_dir, expected_names)
143
+ elif mode == "direct-file":
144
+ for source_dir in source_dirs:
145
+ _project_direct_file(source_dir, output_root, rule["target-path"])
146
+ elif mode == "merge-json":
147
+ for source_dir in source_dirs:
148
+ project_merge_json(source_dir, output_root, rule)
149
+ elif mode == "codex-agent-toml":
150
+ mapping_name = rule["frontmatter-mapping"]
151
+ mapping = contract.get("frontmatter-mapping", {}).get(mapping_name, {})
152
+ for source_dir in source_dirs:
153
+ project_codex_agent_toml(source_dir, output_root, rule, mapping)
154
+ else:
155
+ raise ValueError(f"codex: unhandled mode {mode!r} for {primitive_name}")
156
+
157
+
158
+ def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
159
+ target_dir = output_root / target_prefix.rstrip("/")
160
+ target_dir.mkdir(parents=True, exist_ok=True)
161
+ for entry in sorted(source_dir.iterdir()):
162
+ if entry.is_file():
163
+ shutil.copy2(entry, target_dir / entry.name, follow_symlinks=False)
164
+
165
+
166
+ def _splice_managed_block(
167
+ existing: str,
168
+ start_marker: str,
169
+ end_marker: str,
170
+ managed_block: str,
171
+ ) -> str:
172
+ if start_marker in existing and end_marker in existing:
173
+ start_index = existing.index(start_marker)
174
+ end_index = existing.index(end_marker) + len(end_marker)
175
+ if end_index < len(existing) and existing[end_index] == "\n":
176
+ end_index += 1
177
+ return existing[:start_index] + managed_block + existing[end_index:]
178
+ if existing and not existing.endswith("\n"):
179
+ existing += "\n"
180
+ return existing + managed_block
181
+
182
+
183
+ # Legacy delimiter literals, hardcoded for the migration window per
184
+ # RFC-0009 § Adapter implementation change. Removed in the post-strip
185
+ # release together with `_splice_managed_block`.
186
+ _LEGACY_SKILL_BLOCK_START = "<!-- agent-skills:start -->"
187
+ _LEGACY_SKILL_BLOCK_END = "<!-- agent-skills:end -->"
188
+
189
+
190
+ def _strip_legacy_skill_block(text: str) -> str:
191
+ """Strip the legacy `agent-skills` managed block from an `AGENTS.md`.
192
+
193
+ One-shot migration helper: if both delimiters are present, the
194
+ splice removes everything from the start marker through the end
195
+ marker (plus a trailing newline if present). If neither
196
+ delimiter is present, the input is returned byte-equal — the
197
+ strip is a no-op on a clean file. Idempotent.
198
+
199
+ `_splice_managed_block` with an empty `managed_block` argument
200
+ is sufficient on its own; its post-condition is that the
201
+ delimiters and the region between them are gone from the result.
202
+ The call goes through that helper so the AC23 retention test
203
+ still observes the splice symbol being used (any future inlining
204
+ that removes the symbol breaks the retention contract).
205
+ """
206
+ if _LEGACY_SKILL_BLOCK_START not in text or _LEGACY_SKILL_BLOCK_END not in text:
207
+ return text
208
+ # Splice helper indexes the first occurrence of each marker.
209
+ # If `end` precedes `start` (an adopter who reordered the
210
+ # delimiters) the splice would produce a result that still
211
+ # contains both markers and is not idempotent. Refuse the
212
+ # confused-deputy input rather than silently corrupt it.
213
+ start_position = text.index(_LEGACY_SKILL_BLOCK_START)
214
+ end_position = text.index(_LEGACY_SKILL_BLOCK_END)
215
+ if end_position < start_position:
216
+ raise ValueError(
217
+ "codex: <!-- agent-skills:end --> appears before "
218
+ "<!-- agent-skills:start --> in AGENTS.md — the migration "
219
+ "strip refuses confused-deputy input. Restore the "
220
+ "delimiter order or remove the block manually."
221
+ )
222
+ return _splice_managed_block(
223
+ text,
224
+ _LEGACY_SKILL_BLOCK_START,
225
+ _LEGACY_SKILL_BLOCK_END,
226
+ "",
227
+ )
@@ -0,0 +1,149 @@
1
+ """Copilot adapter — projects skills as per-file instructions, agents as
2
+ `.agent.md`, hook-wiring as per-file JSON, hook bodies straight through;
3
+ drops only `command` (copilot-cli#618/#1113).
4
+
5
+ Skill instruction frontmatter (`applyTo: "**"` etc.) comes from the
6
+ contract's `frontmatter-default["copilot-instruction"]` table — never
7
+ hardcoded. Agent + hook-wiring serialisation live in the sibling
8
+ `copilot_agent_md` / `copilot_hooks_json` projection modules (RFC-0024 /
9
+ docs/specs/copilot-full-parity); this adapter only dispatches to them.
10
+
11
+ The adapter is scope-agnostic: it emits repo-relpaths (`.github/…`) at every
12
+ scope. The divergent user-scope home (`~/.copilot/…`) is produced by the
13
+ install handler's post-render prefix rewrite, not here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ from pathlib import Path
20
+ from typing import Any, Iterator
21
+
22
+
23
+ # RFC-0005 § Build-pipeline ordering invariant — uniform across adapters.
24
+ from agentbundle.build.phase_order import PHASE_ORDER as _PHASE_ORDER
25
+ from agentbundle.build.projections.copilot_agent_md import (
26
+ project_copilot_agent_md,
27
+ )
28
+ from agentbundle.build.projections.copilot_hooks_json import (
29
+ project_copilot_hooks_json,
30
+ )
31
+
32
+
33
+ def _iter_primitives(contract: dict) -> Iterator[str]:
34
+ """Yield Copilot's projected primitive names in phase order."""
35
+ adapter_block = contract["adapter"]["copilot"]
36
+ array_form = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
37
+ for primitive_name in _PHASE_ORDER:
38
+ if primitive_name in array_form and array_form[primitive_name].get("mode") != "dropped":
39
+ yield primitive_name
40
+
41
+
42
+ def project(pack_path: Path, contract: dict, output_root: Path) -> None:
43
+ adapter_block = contract["adapter"]["copilot"]
44
+ rules_by_primitive = {entry["primitive"]: entry for entry in adapter_block.get("projection", [])}
45
+
46
+ for primitive_name in _iter_primitives(contract):
47
+ rule = rules_by_primitive[primitive_name]
48
+ mode = rule["mode"]
49
+ primitive = contract["primitive"][primitive_name]
50
+ source_dir = pack_path / primitive["source-path"].rstrip("/")
51
+ if not source_dir.exists():
52
+ continue
53
+
54
+ if mode == "instruction-file":
55
+ _project_instruction_file(source_dir, output_root, rule, contract)
56
+ elif mode == "direct-file":
57
+ _project_direct_file(source_dir, output_root, rule["target-path"])
58
+ elif mode == "copilot-agent-md":
59
+ mapping_name = rule["frontmatter-mapping"]
60
+ mapping = contract.get("frontmatter-mapping", {}).get(mapping_name, {})
61
+ project_copilot_agent_md(source_dir, output_root, rule, mapping)
62
+ elif mode == "copilot-hooks-json":
63
+ project_copilot_hooks_json(source_dir, output_root, rule)
64
+ else:
65
+ raise ValueError(f"copilot: unhandled mode {mode!r} for {primitive_name}")
66
+
67
+
68
+ def _project_direct_file(source_dir: Path, output_root: Path, target_prefix: str) -> None:
69
+ target_dir = output_root / target_prefix.rstrip("/")
70
+ target_dir.mkdir(parents=True, exist_ok=True)
71
+ for entry in sorted(source_dir.iterdir()):
72
+ if entry.is_file():
73
+ shutil.copy2(entry, target_dir / entry.name, follow_symlinks=False)
74
+
75
+
76
+ def _project_instruction_file(
77
+ source_dir: Path,
78
+ output_root: Path,
79
+ rule: dict,
80
+ contract: dict,
81
+ ) -> None:
82
+ target_dir = output_root / rule["target-path"].rstrip("/")
83
+ target_dir.mkdir(parents=True, exist_ok=True)
84
+ default_name = rule.get("frontmatter-default")
85
+ defaults = (
86
+ contract.get("frontmatter-default", {}).get(default_name, {})
87
+ if default_name
88
+ else {}
89
+ )
90
+
91
+ for skill_dir in sorted(source_dir.iterdir()):
92
+ if not skill_dir.is_dir():
93
+ continue
94
+ skill_md = skill_dir / "SKILL.md"
95
+ if not skill_md.exists():
96
+ md_candidates = sorted(skill_dir.glob("*.md"))
97
+ if not md_candidates:
98
+ continue
99
+ skill_md = md_candidates[0]
100
+ frontmatter, body = _split_frontmatter(skill_md.read_text(encoding="utf-8"))
101
+ for key, value in defaults.items():
102
+ frontmatter.setdefault(key, value)
103
+ destination = target_dir / f"{skill_dir.name}.instructions.md"
104
+ destination.write_text(_emit_frontmatter(frontmatter) + body, encoding="utf-8")
105
+
106
+
107
+ def _split_frontmatter(text: str) -> tuple[dict, str]:
108
+ lines = text.splitlines(keepends=True)
109
+ if not lines or not lines[0].startswith("---"):
110
+ return {}, text
111
+ end_index = None
112
+ for index in range(1, len(lines)):
113
+ if lines[index].startswith("---"):
114
+ end_index = index
115
+ break
116
+ if end_index is None:
117
+ return {}, text
118
+ frontmatter_lines = lines[1:end_index]
119
+ body = "".join(lines[end_index + 1 :])
120
+ return _parse_frontmatter(frontmatter_lines), body
121
+
122
+
123
+ def _parse_frontmatter(lines: list[str]) -> dict[str, Any]:
124
+ result: dict[str, Any] = {}
125
+ for line in lines:
126
+ stripped = line.rstrip("\n")
127
+ if not stripped.strip() or stripped.lstrip().startswith("#"):
128
+ continue
129
+ if ":" not in stripped:
130
+ continue
131
+ key, _, value = stripped.partition(":")
132
+ value = value.strip()
133
+ if (value.startswith('"') and value.endswith('"')) or (
134
+ value.startswith("'") and value.endswith("'")
135
+ ):
136
+ value = value[1:-1]
137
+ result[key.strip()] = value
138
+ return result
139
+
140
+
141
+ def _emit_frontmatter(frontmatter: dict[str, Any]) -> str:
142
+ if not frontmatter:
143
+ return ""
144
+ lines = ["---"]
145
+ for key in sorted(frontmatter.keys()):
146
+ value = frontmatter[key]
147
+ lines.append(f'{key}: "{value}"')
148
+ lines.append("---\n")
149
+ return "\n".join(lines)