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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- 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}"
|