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,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)
|