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,232 @@
1
+ """Markdown → TOML serialiser for the codex `agent` projection.
2
+
3
+ The codex CLI consumes subagents declared in TOML at
4
+ ``.codex/agents/<name>.toml`` (per https://developers.openai.com/codex/subagents).
5
+ Source format in a pack is the same as every other agent primitive —
6
+ ``.apm/agents/<name>.md`` with YAML-style frontmatter + a markdown body.
7
+ This module emits the equivalent TOML.
8
+
9
+ Field mapping (driven by the contract's
10
+ ``[frontmatter-mapping."codex-agent-frontmatter-v0.8"]`` per-key
11
+ sub-tables):
12
+
13
+ - YAML ``name`` → TOML ``name``
14
+ - YAML ``description`` → TOML ``description``
15
+ - markdown body → TOML ``developer_instructions`` (mode-level
16
+ convention; **not** a frontmatter rename, because the body isn't a
17
+ frontmatter field). Empty body → empty string.
18
+ - Unmapped YAML fields (``tools``, ``model``, …) drop silently —
19
+ codex TOML agents have no equivalent slot.
20
+
21
+ TOML emission shape: each output field is emitted as ``<key> = <value>``.
22
+ ``name`` / ``description`` use TOML basic strings (``"..."``);
23
+ ``developer_instructions`` uses a multi-line basic string
24
+ (``\"\"\"..."\"\"\"``) so newlines render literally and reviewers reading
25
+ the file see the agent's prose, not an escape-laden one-liner. Backslashes
26
+ and double-quotes inside the body are escaped, and the leading-newline
27
+ trim rule (``\"\"\"\\n<body>...`` keeps the first body line on its own row
28
+ so the parsed value starts with the body's first character, byte-for-byte).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Frontmatter split / parse (mirrors kiro.py:_split_frontmatter +
39
+ # _parse_frontmatter without reaching across module boundaries; the cross-
40
+ # module duplication is acknowledged in docs/specs/dropped-primitives-
41
+ # coverage spec § Always do — sibling-projection-mode rules duplicate
42
+ # rather than depend on each other's privates).
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
47
+ lines = text.splitlines(keepends=True)
48
+ if not lines or not lines[0].startswith("---"):
49
+ return {}, text
50
+ end_index = None
51
+ for index in range(1, len(lines)):
52
+ if lines[index].startswith("---"):
53
+ end_index = index
54
+ break
55
+ if end_index is None:
56
+ return {}, text
57
+ frontmatter_lines = lines[1:end_index]
58
+ body = "".join(lines[end_index + 1 :])
59
+ return _parse_frontmatter(frontmatter_lines), body
60
+
61
+
62
+ def _parse_frontmatter(lines: list[str]) -> dict[str, Any]:
63
+ result: dict[str, Any] = {}
64
+ for line in lines:
65
+ stripped = line.rstrip("\n")
66
+ if not stripped.strip() or stripped.lstrip().startswith("#"):
67
+ continue
68
+ if ":" not in stripped:
69
+ continue
70
+ key, _, value = stripped.partition(":")
71
+ value = value.strip()
72
+ if value.startswith("[") and value.endswith("]"):
73
+ items = [item.strip() for item in value[1:-1].split(",") if item.strip()]
74
+ result[key.strip()] = items
75
+ else:
76
+ if (value.startswith('"') and value.endswith('"')) or (
77
+ value.startswith("'") and value.endswith("'")
78
+ ):
79
+ value = value[1:-1]
80
+ result[key.strip()] = value
81
+ return result
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # TOML emission
86
+ # ---------------------------------------------------------------------------
87
+
88
+
89
+ def _emit_basic_string(value: str) -> str:
90
+ """TOML 1.0 basic-string literal (with surrounding quotes).
91
+
92
+ Same shape as ``agentbundle.config._emit_basic_string`` — duplicated
93
+ here to keep ``build.projections`` independent of the CLI-side
94
+ config module.
95
+ """
96
+ chunks: list[str] = ['"']
97
+ for ch in value:
98
+ code = ord(ch)
99
+ if ch == "\\":
100
+ chunks.append("\\\\")
101
+ elif ch == '"':
102
+ chunks.append('\\"')
103
+ elif ch == "\b":
104
+ chunks.append("\\b")
105
+ elif ch == "\t":
106
+ chunks.append("\\t")
107
+ elif ch == "\n":
108
+ chunks.append("\\n")
109
+ elif ch == "\f":
110
+ chunks.append("\\f")
111
+ elif ch == "\r":
112
+ chunks.append("\\r")
113
+ elif code < 0x20 or code == 0x7F:
114
+ chunks.append(f"\\u{code:04X}")
115
+ else:
116
+ chunks.append(ch)
117
+ chunks.append('"')
118
+ return "".join(chunks)
119
+
120
+
121
+ def _emit_multiline_basic_string(value: str) -> str:
122
+ """TOML 1.0 multi-line basic string for the markdown body.
123
+
124
+ Returns the ``\"\"\"...\"\"\"`` form with:
125
+ - backslashes escaped (``\\\\``)
126
+ - double-quotes escaped (``\\\"``) — over-eager but correct, avoids
127
+ the ``\"\"\"``-termination ambiguity
128
+ - control chars except ``\\n`` / ``\\t`` / ``\\r`` rendered as
129
+ ``\\uXXXX``
130
+ - a leading ``\\n`` after the opening ``\"\"\"`` so the parsed value
131
+ starts at the body's first character (TOML's leading-newline
132
+ trim rule).
133
+ """
134
+ chunks: list[str] = ['"""\n']
135
+ for ch in value:
136
+ code = ord(ch)
137
+ if ch == "\\":
138
+ chunks.append("\\\\")
139
+ elif ch == '"':
140
+ chunks.append('\\"')
141
+ elif ch == "\b":
142
+ chunks.append("\\b")
143
+ elif ch == "\f":
144
+ chunks.append("\\f")
145
+ elif ch in ("\n", "\t", "\r"):
146
+ chunks.append(ch)
147
+ elif code < 0x20 or code == 0x7F:
148
+ chunks.append(f"\\u{code:04X}")
149
+ else:
150
+ chunks.append(ch)
151
+ chunks.append('"""')
152
+ return "".join(chunks)
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Mapping application
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ def _apply_mapping(
161
+ frontmatter: dict[str, Any], mapping: dict[str, Any]
162
+ ) -> dict[str, str]:
163
+ """Apply the frontmatter-mapping rename rules; drop unmapped keys.
164
+
165
+ The codex contract maps ``name``/``description`` straight through
166
+ (no rename). Pack authors writing claude-code-style frontmatter
167
+ (``name``, ``description``, optional ``tools``, ``model``, …) get
168
+ their first two fields propagated; the rest drop silently — codex
169
+ TOML agents have no equivalent slot.
170
+
171
+ Returned values are coerced to ``str`` so the TOML emitter doesn't
172
+ have to handle lists or dicts at this surface (the codex agent
173
+ schema is flat: ``name``, ``description``, ``developer_instructions``).
174
+ Lists collapse to a comma-joined string for backward compatibility
175
+ with packs that ship ``description: [foo, bar]``; that's a degenerate
176
+ case rather than a supported shape.
177
+ """
178
+ rewritten: dict[str, str] = {}
179
+ for source_key, rule in mapping.items():
180
+ if source_key not in frontmatter:
181
+ continue
182
+ new_key = rule.get("rename", source_key)
183
+ value = frontmatter[source_key]
184
+ if isinstance(value, list):
185
+ value = ", ".join(str(item) for item in value)
186
+ rewritten[new_key] = str(value)
187
+ return rewritten
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Public entry point
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def project_codex_agent_toml(
196
+ source_dir: Path,
197
+ output_root: Path,
198
+ rule: dict,
199
+ frontmatter_mapping: dict[str, Any],
200
+ ) -> None:
201
+ """Project ``<source_dir>/<name>.md`` → ``<output>/<target>/<name>.toml``.
202
+
203
+ Iterates ``*.md`` files in sorted order. For each:
204
+ 1. Split YAML frontmatter from markdown body.
205
+ 2. Apply ``frontmatter_mapping`` rename rules; drop unmapped keys.
206
+ 3. Emit a TOML file whose keys are the mapped frontmatter (basic
207
+ strings) plus ``developer_instructions`` (multi-line basic
208
+ string) carrying the body verbatim.
209
+
210
+ Empty body → ``developer_instructions = \"\"`` (empty basic string,
211
+ not missing).
212
+ """
213
+ target_dir = output_root / rule["target-path"].rstrip("/")
214
+ target_dir.mkdir(parents=True, exist_ok=True)
215
+ for entry in sorted(source_dir.iterdir()):
216
+ if not (entry.is_file() and entry.suffix == ".md"):
217
+ continue
218
+ frontmatter, body = _split_frontmatter(
219
+ entry.read_text(encoding="utf-8")
220
+ )
221
+ rewritten = _apply_mapping(frontmatter, frontmatter_mapping)
222
+ toml_lines: list[str] = []
223
+ for key in sorted(rewritten.keys()):
224
+ toml_lines.append(f"{key} = {_emit_basic_string(rewritten[key])}")
225
+ if body:
226
+ toml_lines.append(
227
+ f"developer_instructions = {_emit_multiline_basic_string(body)}"
228
+ )
229
+ else:
230
+ toml_lines.append('developer_instructions = ""')
231
+ destination = target_dir / (entry.stem + ".toml")
232
+ destination.write_text("\n".join(toml_lines) + "\n", encoding="utf-8")
@@ -0,0 +1,206 @@
1
+ """Markdown → ``.agent.md`` serialiser for the copilot ``agent`` projection.
2
+
3
+ GitHub Copilot (app + CLI, verified 1.0.59) discovers custom agents from
4
+ ``.github/agents/<name>.agent.md`` (repo scope) and
5
+ ``~/.copilot/agents/<name>.agent.md`` (user scope). The on-disk shape is the
6
+ same Claude-style markdown as every other agent primitive — YAML frontmatter
7
+ plus a markdown body that becomes the agent's instructions — so this module's
8
+ job is narrow: filter and validate the frontmatter, then re-emit.
9
+
10
+ Field handling (driven by the contract's
11
+ ``[frontmatter-mapping."copilot-agent-frontmatter-v0.10"]`` per-key
12
+ sub-tables for ``name`` / ``description``):
13
+
14
+ - YAML ``name`` → ``name`` (identity rename)
15
+ - YAML ``description`` → ``description`` (identity rename)
16
+ - YAML ``tools`` → emitted **verbatim** after allow-list validation. The
17
+ ``.agent.md`` parser accepts the Claude comma-separated format and
18
+ resolves the names itself (``Read``→``view``, ``Grep``→``grep``,
19
+ ``Glob``→``glob``); we keep the source string rather than rewrite it.
20
+ - YAML ``model`` → **dropped**. The CLI ignores ``model`` and our values
21
+ (``opus``/``sonnet``) are not Copilot model ids (copilot-cli#2133/#1195).
22
+ - ``target`` → **never emitted**; Copilot defaults to both ``vscode`` and
23
+ ``github-copilot``.
24
+ - markdown body → the agent's instructions, byte-for-byte.
25
+
26
+ Tool allow-list (fail-closed): ``tools`` tokens are validated against the set
27
+ of names known to be accepted by Copilot custom agents. ``WebFetch`` /
28
+ ``WebSearch`` are *known-and-recorded* — they pass through, but Copilot
29
+ exposes no web tool to custom agents, so they are inert there (the
30
+ ``research`` degradation, documented, not silently dropped). A token in no
31
+ set raises ``ValueError`` rather than passing through to be silently ignored
32
+ by Copilot — which would drop a needed capability invisibly. This is a
33
+ deliberately stricter policy than codex's drop-on-unmapped.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+
42
+ # Tool names Copilot custom agents are known to accept (verified 1.0.59) plus
43
+ # the known-and-explicitly-recorded web tools (inert on Copilot, see module
44
+ # docstring). A `tools` token outside this set fails the build.
45
+ _KNOWN_TOOLS: frozenset[str] = frozenset(
46
+ {
47
+ "Read",
48
+ "Grep",
49
+ "Glob",
50
+ "Edit",
51
+ "Write",
52
+ "MultiEdit",
53
+ "Bash",
54
+ "WebFetch",
55
+ "WebSearch",
56
+ }
57
+ )
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Frontmatter split / parse (mirrors copilot.py / codex_agent_toml.py without
62
+ # reaching across module boundaries; the cross-module duplication is the
63
+ # acknowledged sibling-projection convention — see docs/specs/copilot-full-
64
+ # parity § Always do).
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _split_frontmatter(text: str) -> tuple[dict[str, str], str]:
69
+ lines = text.splitlines(keepends=True)
70
+ if not lines or not lines[0].startswith("---"):
71
+ return {}, text
72
+ end_index = None
73
+ for index in range(1, len(lines)):
74
+ if lines[index].startswith("---"):
75
+ end_index = index
76
+ break
77
+ if end_index is None:
78
+ return {}, text
79
+ frontmatter_lines = lines[1:end_index]
80
+ body = "".join(lines[end_index + 1 :])
81
+ return _parse_frontmatter(frontmatter_lines), body
82
+
83
+
84
+ def _parse_frontmatter(lines: list[str]) -> dict[str, str]:
85
+ """Parse ``key: value`` frontmatter, preserving the raw value.
86
+
87
+ Surrounding quotes are *not* stripped — the source is Claude-format
88
+ (unquoted) and Copilot's parser accepts that format, so the highest
89
+ fidelity is to re-emit the value exactly as authored. Blank lines and
90
+ ``#`` comments are skipped.
91
+ """
92
+ result: dict[str, str] = {}
93
+ for line in lines:
94
+ stripped = line.rstrip("\n")
95
+ if not stripped.strip() or stripped.lstrip().startswith("#"):
96
+ continue
97
+ if ":" not in stripped:
98
+ continue
99
+ key, _, value = stripped.partition(":")
100
+ result[key.strip()] = value.strip()
101
+ return result
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Mapping + tool validation
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ def _apply_mapping(
110
+ frontmatter: dict[str, str], mapping: dict[str, Any]
111
+ ) -> dict[str, str]:
112
+ """Apply the frontmatter-mapping rename rules; drop unmapped keys.
113
+
114
+ The copilot mapping carries ``name`` / ``description`` (identity
115
+ renames). ``model`` is absent from the mapping, so it drops here.
116
+ ``tools`` is handled separately by the caller (allow-list pass-through),
117
+ not via a rename rule.
118
+ """
119
+ rewritten: dict[str, str] = {}
120
+ for source_key, rule in mapping.items():
121
+ if source_key not in frontmatter:
122
+ continue
123
+ new_key = rule.get("rename", source_key)
124
+ rewritten[new_key] = frontmatter[source_key]
125
+ return rewritten
126
+
127
+
128
+ def _validate_tools(tools_value: str) -> None:
129
+ """Raise ``ValueError`` if any token is outside ``_KNOWN_TOOLS``, or if a
130
+ declared ``tools`` field yields **no** tokens.
131
+
132
+ Fail-closed on two arms:
133
+ - an unknown token would be silently ignored by Copilot, dropping a
134
+ capability invisibly; and
135
+ - a declared-but-empty ``tools`` (a bare ``tools:`` line, or the YAML
136
+ list form ``tools:\\n - Read`` which this line-based parser reads as
137
+ empty) would emit a bare ``tools:`` line — and in Copilot an empty /
138
+ omitted ``tools`` grants **all** tools, silently widening a read-only
139
+ agent. Both fail the build rather than ship a silent widening.
140
+ """
141
+ tokens = [t.strip() for t in tools_value.split(",") if t.strip()]
142
+ if not tokens:
143
+ raise ValueError(
144
+ "copilot-agent-md: agent declares a `tools` field that resolves to "
145
+ "no tokens (a bare `tools:` line, or the YAML list form which is "
146
+ "unsupported here — use the Claude comma form `tools: Read, Grep`). "
147
+ "Refusing to emit a `.agent.md` with an empty `tools` line, which "
148
+ "Copilot reads as 'all tools' (silent permission widening)"
149
+ )
150
+ for token in tokens:
151
+ if token not in _KNOWN_TOOLS:
152
+ raise ValueError(
153
+ f"copilot-agent-md: tool token {token!r} is not in the "
154
+ f"Copilot custom-agent allow-list "
155
+ f"({', '.join(sorted(_KNOWN_TOOLS))}); refusing to emit a "
156
+ f"`.agent.md` that silently drops it"
157
+ )
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Emission
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ def _emit(frontmatter: dict[str, str], body: str) -> str:
166
+ lines = ["---"]
167
+ for key, value in frontmatter.items():
168
+ lines.append(f"{key}: {value}")
169
+ lines.append("---\n")
170
+ return "\n".join(lines) + body
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Public entry point
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def project_copilot_agent_md(
179
+ source_dir: Path,
180
+ output_root: Path,
181
+ rule: dict,
182
+ frontmatter_mapping: dict[str, Any],
183
+ ) -> None:
184
+ """Project ``<source_dir>/<name>.md`` → ``<output>/<target>/<name>.agent.md``.
185
+
186
+ Iterates ``*.md`` files in sorted order. For each:
187
+ 1. Split YAML frontmatter from markdown body.
188
+ 2. Apply the rename rules (``name`` / ``description``); ``model`` drops.
189
+ 3. Validate ``tools`` against the allow-list (fail-closed) and emit it
190
+ verbatim; ``target`` is never emitted.
191
+ 4. Emit frontmatter + body to ``<name>.agent.md``.
192
+ """
193
+ target_dir = output_root / rule["target-path"].rstrip("/")
194
+ target_dir.mkdir(parents=True, exist_ok=True)
195
+ for entry in sorted(source_dir.iterdir()):
196
+ if not (entry.is_file() and entry.suffix == ".md"):
197
+ continue
198
+ frontmatter, body = _split_frontmatter(
199
+ entry.read_text(encoding="utf-8")
200
+ )
201
+ emitted = _apply_mapping(frontmatter, frontmatter_mapping)
202
+ if "tools" in frontmatter:
203
+ _validate_tools(frontmatter["tools"])
204
+ emitted["tools"] = frontmatter["tools"]
205
+ destination = target_dir / (entry.stem + ".agent.md")
206
+ destination.write_text(_emit(emitted, body), encoding="utf-8")
@@ -0,0 +1,142 @@
1
+ """Hook-wiring ``.toml`` → ``.json`` serialiser for the copilot
2
+ ``hook-wiring`` projection.
3
+
4
+ GitHub Copilot (app + CLI, verified 1.0.59) reads **every** ``*.json`` file
5
+ in its hooks dir (``.github/hooks/`` repo, ``~/.copilot/hooks/`` user). So,
6
+ unlike codex's single mergeable ``hooks.json``, each source hook-wiring
7
+ ``.toml`` serialises to its own self-contained file:
8
+
9
+ {"version": 1,
10
+ "hooks": {"<copilotEvent>": [{"type": "command",
11
+ "bash": "<cmd>",
12
+ "powershell": "<cmd>"}]}}
13
+
14
+ The one-source-file → one-output-file shape mirrors ``codex-agent-toml``; the
15
+ per-file (vs. merged) distinction is the new part, and the reason this is a
16
+ separate mode rather than a reuse of ``merge-json``.
17
+
18
+ Event-name map (frozen; all six verified to fire — RFC-0024 § Acceptance
19
+ Runs 2–4, CLI + app 1.0.59). A source event with no entry **fails the build**
20
+ (fail-closed; never emit an unrecognised event key).
21
+
22
+ Shell-agnostic-source precondition: the source command is carried into
23
+ **both** the ``bash`` and ``powershell`` handler keys. Our shipped wiring is
24
+ shell-agnostic (``python tools/...``). A wiring whose command is bash-only
25
+ would emit a broken ``powershell`` handler; per-shell source commands are a
26
+ follow-on, out of scope here (no shipped wiring needs them).
27
+
28
+ Hook-body path rewrite: copilot retargets ``hook-body`` from ``tools/hooks/``
29
+ to ``.github/hooks/`` (contract v0.10). A wiring command that references the
30
+ body by its legacy path (``python tools/hooks/<name>.py``) is rewritten to the
31
+ new location so the emitted JSON references the script where it actually lands
32
+ (spec AC9-repo: "the scripts land alongside the ``<name>.json`` wiring that
33
+ references them"). Without this, an adopter's ``sessionStart`` hook fires but
34
+ fails to find its script. **Repo-scope only:** the rewrite targets the
35
+ ``.github/hooks/`` repo-relpath; resolving the command at *user* scope
36
+ (``~/.copilot/hooks/``, where the session CWD is arbitrary) is an unsolved
37
+ follow-on — no shipped pack ships a user-scope copilot hook (core is
38
+ repo-only), so it is not exercised here.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import tomllib
45
+ from pathlib import Path
46
+
47
+
48
+ # copilot's hook-body retarget (contract v0.10): legacy `tools/hooks/` →
49
+ # `.github/hooks/`. A carried command that references a hook body by its legacy
50
+ # path is rewritten so it points where `direct-file` actually lands the script.
51
+ _LEGACY_HOOK_BODY_PREFIX = "tools/hooks/"
52
+ _COPILOT_HOOK_BODY_PREFIX = ".github/hooks/"
53
+
54
+
55
+ def _rewrite_hook_body_path(command: str) -> str:
56
+ return command.replace(_LEGACY_HOOK_BODY_PREFIX, _COPILOT_HOOK_BODY_PREFIX)
57
+
58
+
59
+ # Our hook event vocabulary → Copilot's. Frozen; version-sensitive (Copilot is
60
+ # preview). `errorOccurred` exists upstream but we ship no wiring for it.
61
+ _EVENT_MAP: dict[str, str] = {
62
+ "SessionStart": "sessionStart",
63
+ "SessionEnd": "sessionEnd",
64
+ "UserPromptSubmit": "userPromptSubmitted",
65
+ "PreToolUse": "preToolUse",
66
+ "PostToolUse": "postToolUse",
67
+ "Stop": "agentStop",
68
+ }
69
+
70
+
71
+ def _to_copilot_event(source_event: str) -> str:
72
+ try:
73
+ return _EVENT_MAP[source_event]
74
+ except KeyError:
75
+ raise ValueError(
76
+ f"copilot-hooks-json: hook event {source_event!r} has no entry in "
77
+ f"the frozen Copilot event-name map "
78
+ f"({', '.join(sorted(_EVENT_MAP))}); refusing to emit an "
79
+ f"unrecognised event key"
80
+ ) from None
81
+
82
+
83
+ def project_copilot_hooks_json(
84
+ source_dir: Path,
85
+ output_root: Path,
86
+ rule: dict,
87
+ ) -> None:
88
+ """Project each ``<source_dir>/<name>.toml`` → ``<output>/<target>/<name>.json``.
89
+
90
+ Source shape is the Claude-Code nested-event form::
91
+
92
+ [[hooks.<Event>]]
93
+ hooks = [ { type = "command", command = "<cmd>" } ]
94
+
95
+ Each ``<Event>`` is translated through the frozen event map (unmapped →
96
+ build error), and each inner handler becomes
97
+ ``{"type": ..., "bash": <cmd>, "powershell": <cmd>}``.
98
+ """
99
+ target_dir = output_root / rule["target-path"].rstrip("/")
100
+ target_dir.mkdir(parents=True, exist_ok=True)
101
+ for entry in sorted(source_dir.iterdir()):
102
+ if not (entry.is_file() and entry.suffix == ".toml"):
103
+ continue
104
+ data = tomllib.loads(entry.read_text(encoding="utf-8"))
105
+ events = data.get("hooks", {})
106
+ copilot_hooks: dict[str, list[dict]] = {}
107
+ for source_event in sorted(events):
108
+ copilot_event = _to_copilot_event(source_event)
109
+ handlers: list[dict] = copilot_hooks.setdefault(copilot_event, [])
110
+ for outer in events[source_event]:
111
+ for handler in outer.get("hooks", []):
112
+ # Fail-closed with an actionable message (naming the file)
113
+ # on a malformed handler — a bare `handler["command"]` would
114
+ # raise an uncaught KeyError (not in the install handler's
115
+ # `except (FileNotFoundError, ValueError)`), crashing with an
116
+ # unlocated traceback instead of a clean refusal.
117
+ handler_type = handler.get("type")
118
+ command = handler.get("command")
119
+ if handler_type != "command" or not isinstance(command, str):
120
+ raise ValueError(
121
+ f"copilot-hooks-json: {entry.name}: hook handler for "
122
+ f"event {source_event!r} must declare "
123
+ f'`type = "command"` and a string `command`; got '
124
+ f"type={handler_type!r}, command={command!r}"
125
+ )
126
+ command = _rewrite_hook_body_path(command)
127
+ handlers.append(
128
+ {
129
+ "type": handler_type,
130
+ "bash": command,
131
+ "powershell": command,
132
+ }
133
+ )
134
+ # A wiring file with no events emits `{"version":1,"hooks":{}}` — one
135
+ # output file per source file is the contract (mirrors codex-agent-toml),
136
+ # and Copilot reads an empty-hooks file as a harmless no-op.
137
+ document = {"version": 1, "hooks": copilot_hooks}
138
+ destination = target_dir / (entry.stem + ".json")
139
+ destination.write_text(
140
+ json.dumps(document, indent=2, sort_keys=False) + "\n",
141
+ encoding="utf-8",
142
+ )
@@ -0,0 +1,41 @@
1
+ """Shared post-pass helper for `direct-directory` skill projections.
2
+
3
+ After every multi-pack `project_packs(...)` call, the orphan sweep
4
+ removes child directories of the projected skill target whose names
5
+ are not in the union of source skill names across the call's pack list.
6
+
7
+ Bound to the `skill` primitive only — other `direct-directory`
8
+ projections opt in explicitly via their adapter's `project_packs`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+
18
+ def sweep_orphans(target_dir: Path, expected_names: set[str]) -> None:
19
+ if not target_dir.exists():
20
+ return
21
+ for entry in target_dir.iterdir():
22
+ if entry.is_symlink():
23
+ if entry.name not in expected_names:
24
+ # Destructive operation — leave a breadcrumb so adopters
25
+ # can trace what disappeared without bisecting commits.
26
+ print(
27
+ f"sweep_orphans: removed orphan symlink {entry} "
28
+ f"(not in expected source-skill names)",
29
+ file=sys.stderr,
30
+ )
31
+ entry.unlink()
32
+ continue
33
+ if not entry.is_dir():
34
+ continue
35
+ if entry.name not in expected_names:
36
+ print(
37
+ f"sweep_orphans: removed orphan directory {entry} "
38
+ f"(not in expected source-skill names)",
39
+ file=sys.stderr,
40
+ )
41
+ shutil.rmtree(entry)
@@ -0,0 +1,27 @@
1
+ """Hook-entry id synthesis — shared between ``user_merge_json`` (Claude
2
+ Code user scope) and ``merge_into_agent_json`` (Kiro at both scopes).
3
+
4
+ Per RFC-0005 § Merge semantics step 2, every hook entry the CLI writes
5
+ carries an ``id`` field of the form ``<pack-name>:<hook-source-basename>``.
6
+ The id is the ownership tag the merger uses to detect "this is mine"
7
+ on reinstall / replace / uninstall. Claude Code today treats unknown
8
+ keys on hook entries as opaque (the synthetic ``id`` survives without
9
+ runtime effect); Kiro's hook-entry schema is observed-but-not-publicly-
10
+ documented and treats it the same. RFC-0005 Unresolved Q1 holds the
11
+ ``id`` → ``agentbundle-id`` rename open; this module is the single
12
+ chokepoint to flip if that resolves.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ def synthesize_id(pack_name: str, hook_source_basename: str) -> str:
19
+ """Return the ownership-tag id for a single (pack, wiring-toml) pair.
20
+
21
+ The basename is the wiring TOML filename without ``.toml`` — e.g.
22
+ ``on-prompt`` for ``.apm/hook-wiring/on-prompt.toml``. Both adapters
23
+ share this synthesis: id collision across packs means the same pack
24
+ name + same wiring basename, which is the genuine conflict
25
+ RFC-0005 § Conflict refuses install on.
26
+ """
27
+ return f"{pack_name}:{hook_source_basename}"