dotagents-cli 0.3.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.
- dotagents/AGENTS.md +125 -0
- dotagents/__init__.py +3 -0
- dotagents/__main__.py +6 -0
- dotagents/_agents.py +627 -0
- dotagents/_context.py +311 -0
- dotagents/_env.py +600 -0
- dotagents/_hooks.py +172 -0
- dotagents/_merge.py +122 -0
- dotagents/_overlay/AGENTS.md +31 -0
- dotagents/_overlay/CLAUDE.md +3 -0
- dotagents/_overlay/README.md +41 -0
- dotagents/_overlay/dotagents/DECISIONS.md +20 -0
- dotagents/_overlay/dotagents/cmds/README.md +46 -0
- dotagents/_overlays.py +412 -0
- dotagents/_resolve.py +81 -0
- dotagents/_scope.py +265 -0
- dotagents/_skills.py +236 -0
- dotagents/_sync.py +72 -0
- dotagents/_wrappers.py +112 -0
- dotagents/cli/__init__.py +354 -0
- dotagents/cli/_common.py +331 -0
- dotagents/cli/build_pyz.py +107 -0
- dotagents/cli/context.py +129 -0
- dotagents/cli/env.py +168 -0
- dotagents/cli/init.py +117 -0
- dotagents/cli/overlays.py +366 -0
- dotagents_cli-0.3.0.dist-info/METADATA +291 -0
- dotagents_cli-0.3.0.dist-info/RECORD +31 -0
- dotagents_cli-0.3.0.dist-info/WHEEL +4 -0
- dotagents_cli-0.3.0.dist-info/entry_points.txt +2 -0
- dotagents_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
dotagents/_overlays.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Overlay support: copy an overlay's files in, and collect what it contributes
|
|
2
|
+
to the managed `AGENTS.md` block.
|
|
3
|
+
|
|
4
|
+
An overlay is a directory (optionally carrying an `overlay.toml` manifest) whose
|
|
5
|
+
files install to the same relative path in the destination. Two manifest keys are
|
|
6
|
+
read here:
|
|
7
|
+
|
|
8
|
+
* `routing` — lines appended to the core's "Load on demand" list.
|
|
9
|
+
* `rules` — overlay-relative paths to markdown files whose `- **…` bullet blocks
|
|
10
|
+
are appended to "Always-on rules".
|
|
11
|
+
|
|
12
|
+
Dependency resolution (`requires`) is still deferred to a future
|
|
13
|
+
`dotagents overlays` subcommand.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
#: An overlay's optional idempotent setup script, tried in preference order.
|
|
24
|
+
#: ``setup.py`` is the **recommended, OS-agnostic** form: it runs under the same
|
|
25
|
+
#: interpreter that runs dotagents, so it works on every platform (the ``net``
|
|
26
|
+
#: overlay uses it). The extensionless ``setup`` (a POSIX shell script) is a
|
|
27
|
+
#: **legacy fallback**, kept for existing overlays but discouraged -- a shell
|
|
28
|
+
#: script isn't portable to Windows without a shell. When an overlay ships both,
|
|
29
|
+
#: ``setup.py`` wins (it is first in this tuple). Presence of one file = opt-in;
|
|
30
|
+
#: absence = nothing to run.
|
|
31
|
+
SETUP_SCRIPT_NAMES = ("setup.py", "setup")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_setup_script(overlay_dir: Path) -> "Path | None":
|
|
35
|
+
"""Return the overlay's setup script, or ``None`` if it ships none.
|
|
36
|
+
|
|
37
|
+
Prefers the OS-agnostic ``setup.py`` over the legacy extensionless ``setup``
|
|
38
|
+
when an overlay ships both (``SETUP_SCRIPT_NAMES`` is in preference order).
|
|
39
|
+
|
|
40
|
+
The *installed* overlay dir is what should be handed in: setup runs against
|
|
41
|
+
the copy under ``<scope>/overlays/<name>/`` with that as its cwd, so a script
|
|
42
|
+
can reference its own sibling files by relative path."""
|
|
43
|
+
for name in SETUP_SCRIPT_NAMES:
|
|
44
|
+
candidate = overlay_dir / name
|
|
45
|
+
if candidate.is_file():
|
|
46
|
+
return candidate
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_overlay_setup(
|
|
51
|
+
overlay_dir: Path,
|
|
52
|
+
name: str,
|
|
53
|
+
*,
|
|
54
|
+
agents_dir: Path,
|
|
55
|
+
dry_run: bool,
|
|
56
|
+
logger,
|
|
57
|
+
) -> "int | None":
|
|
58
|
+
"""Run an overlay's idempotent ``setup`` script if it ships one.
|
|
59
|
+
|
|
60
|
+
Returns the script's exit code, or ``None`` when the overlay has no setup
|
|
61
|
+
script (nothing to run -- not an error). Presence of ``setup.py`` (preferred,
|
|
62
|
+
OS-agnostic) or the legacy ``setup`` at the overlay root is the opt-in; the
|
|
63
|
+
runner never second-guesses a script the user chose to install (see the
|
|
64
|
+
overlay-authoring contract).
|
|
65
|
+
|
|
66
|
+
**Idempotency is the overlay author's contract**: the script must be safe to
|
|
67
|
+
run on every ``add``/``sync`` (check-then-act). The runner only invokes it.
|
|
68
|
+
|
|
69
|
+
Invocation (pure stdlib subprocess, the same style the private-sync overlay's
|
|
70
|
+
sync hook uses):
|
|
71
|
+
|
|
72
|
+
* **cwd** = the installed overlay dir, so the script sees its own files.
|
|
73
|
+
* **env** carries ``AGENTS_HOME`` = the resolved store path (D58
|
|
74
|
+
configurable store), so the script never hardcodes ``~/.agents``. It also
|
|
75
|
+
gets ``AGENTS_OVERLAY_DIR`` = its own installed dir for convenience. The
|
|
76
|
+
old ``DOTAGENTS_AGENTS_DIR`` / ``DOTAGENTS_OVERLAY_DIR`` are also set for
|
|
77
|
+
back-compat this release (removable next), so existing setup scripts keep
|
|
78
|
+
working.
|
|
79
|
+
* a ``.py`` script runs under the current interpreter; an extensionless
|
|
80
|
+
``setup`` runs directly, or via ``sh`` on Windows where it isn't executable.
|
|
81
|
+
|
|
82
|
+
A non-zero exit is surfaced (returned) so the caller can raise a clear error
|
|
83
|
+
-- never a silent skip. Never prints ``DOTAGENTS_*`` values (Leakage)."""
|
|
84
|
+
script = find_setup_script(overlay_dir)
|
|
85
|
+
if script is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
logger.info("running setup for %s (%s)", name, script.name)
|
|
89
|
+
if dry_run:
|
|
90
|
+
logger.info("[dry-run] would run %s in %s", script.name, overlay_dir)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
env = dict(os.environ)
|
|
94
|
+
env["AGENTS_HOME"] = str(agents_dir)
|
|
95
|
+
env["AGENTS_OVERLAY_DIR"] = str(overlay_dir)
|
|
96
|
+
# back-compat: DOTAGENTS_* is deprecated, removable next release.
|
|
97
|
+
env["DOTAGENTS_AGENTS_DIR"] = str(agents_dir)
|
|
98
|
+
env["DOTAGENTS_OVERLAY_DIR"] = str(overlay_dir)
|
|
99
|
+
|
|
100
|
+
if script.suffix.lower() == ".py":
|
|
101
|
+
import sys
|
|
102
|
+
cmd = [sys.executable, str(script)]
|
|
103
|
+
else:
|
|
104
|
+
cmd = [str(script)]
|
|
105
|
+
if os.name == "nt":
|
|
106
|
+
# An extensionless script is not directly executable on Windows.
|
|
107
|
+
cmd = ["sh"] + cmd
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
res = subprocess.run(cmd, cwd=str(overlay_dir), env=env)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
logger.error("setup for %s failed to start: %s", name, exc)
|
|
113
|
+
return 1
|
|
114
|
+
if res.returncode != 0:
|
|
115
|
+
logger.error("setup for %s exited %d", name, res.returncode)
|
|
116
|
+
return res.returncode
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _strip_comments(text: str) -> str:
|
|
120
|
+
"""Drop whole-line `#` comments, leaving string contents untouched.
|
|
121
|
+
|
|
122
|
+
Only line comments are removed, and only when `#` is the first non-space
|
|
123
|
+
character -- enough for these manifests, and it cannot corrupt a `#` inside
|
|
124
|
+
a quoted value the way a general strip-to-end-of-line would."""
|
|
125
|
+
return "\n".join(
|
|
126
|
+
ln for ln in text.splitlines() if not ln.lstrip().startswith("#")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_string_array(text: str, key: str) -> "list[str]":
|
|
131
|
+
"""Read a top-level `key = [...]` array of strings from TOML source.
|
|
132
|
+
|
|
133
|
+
A deliberately small reader rather than a TOML dependency: the floor is
|
|
134
|
+
Python 3.9, where `tomllib` does not exist, and pulling in `tomli` to read
|
|
135
|
+
two arrays would be a real dependency for a trivial need (D13). Handles the
|
|
136
|
+
forms these manifests actually use -- `"..."` and `\"\"\"...\"\"\"`
|
|
137
|
+
(multi-line), one or many per array."""
|
|
138
|
+
# Single-line form first (incl. the empty `key = []`). It must be tried
|
|
139
|
+
# before the multi-line form and must not cross a newline: `routing = []`
|
|
140
|
+
# followed later by `rules = [...]` would otherwise let a greedy multi-line
|
|
141
|
+
# match swallow the *next* array and report it as this key's value.
|
|
142
|
+
m = re.search(r"(?m)^%s\s*=\s*\[([^\[\]\n]*)\]\s*$" % re.escape(key), text)
|
|
143
|
+
if m is None:
|
|
144
|
+
m = re.search(r"(?ms)^%s\s*=\s*\[(.*?)^\]" % re.escape(key), text)
|
|
145
|
+
if m is None:
|
|
146
|
+
return []
|
|
147
|
+
body = m.group(1)
|
|
148
|
+
# Triple-quoted first so its content is not re-matched as single-quoted.
|
|
149
|
+
items = re.findall(r'"""(.*?)"""', body, re.DOTALL)
|
|
150
|
+
remainder = re.sub(r'""".*?"""', "", body, flags=re.DOTALL)
|
|
151
|
+
items += re.findall(r'"([^"\n]*)"', remainder)
|
|
152
|
+
return [s.strip("\n") for s in items if s.strip()]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Default merge priority for an overlay that declares none (plan 02). Lower sorts
|
|
156
|
+
# earlier. 500 leaves generous headroom on both sides for overlays that want to
|
|
157
|
+
# sort before (0..499) or after (501..) the unprioritized default.
|
|
158
|
+
DEFAULT_PRIORITY = 500
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_priority(text: str) -> int:
|
|
162
|
+
"""Read a top-level `priority = <int>` from TOML source (plan 02).
|
|
163
|
+
|
|
164
|
+
Same minimal-reader rationale as `_parse_string_array`: no `tomllib` on the
|
|
165
|
+
3.9 floor. Missing/unparseable -> DEFAULT_PRIORITY."""
|
|
166
|
+
m = re.search(r"(?m)^priority\s*=\s*(-?\d+)\s*$", text)
|
|
167
|
+
if m is None:
|
|
168
|
+
return DEFAULT_PRIORITY
|
|
169
|
+
try:
|
|
170
|
+
return int(m.group(1))
|
|
171
|
+
except ValueError:
|
|
172
|
+
return DEFAULT_PRIORITY
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def read_manifest(overlay_dir: Path) -> "dict[str, object]":
|
|
176
|
+
"""Parse `overlay.toml`, returning at least name/routing/rules/priority.
|
|
177
|
+
|
|
178
|
+
A missing or unreadable manifest yields empty contributions -- an overlay is
|
|
179
|
+
allowed to be just a directory of files."""
|
|
180
|
+
path = overlay_dir / "overlay.toml"
|
|
181
|
+
if not path.is_file():
|
|
182
|
+
return {"name": overlay_dir.name, "routing": [], "rules": [], "priority": DEFAULT_PRIORITY}
|
|
183
|
+
try:
|
|
184
|
+
raw = _strip_comments(path.read_text(encoding="utf-8"))
|
|
185
|
+
except OSError:
|
|
186
|
+
return {"name": overlay_dir.name, "routing": [], "rules": [], "priority": DEFAULT_PRIORITY}
|
|
187
|
+
name = re.search(r'(?m)^name\s*=\s*"([^"]*)"', raw)
|
|
188
|
+
return {
|
|
189
|
+
"name": name.group(1) if name else overlay_dir.name,
|
|
190
|
+
"routing": _parse_string_array(raw, "routing"),
|
|
191
|
+
"rules": _parse_string_array(raw, "rules"),
|
|
192
|
+
"priority": _parse_priority(raw),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def overlay_sort_key(overlay_dir: Path) -> "tuple[int, str]":
|
|
197
|
+
"""The `(priority, name)` merge-order key for one overlay (plan 02 / D68).
|
|
198
|
+
|
|
199
|
+
Lower `priority` (default `DEFAULT_PRIORITY`, 500) sorts earlier; a numerically
|
|
200
|
+
higher-priority overlay therefore lands *later* in the merged AGENTS.md block and
|
|
201
|
+
wins on conflict -- the same "lower sorts earlier / higher wins" convention
|
|
202
|
+
`_context.py` uses for its own priority ordering. `name` is the stable tiebreaker
|
|
203
|
+
for equal-priority overlays, keyed off the manifest `name` (falling back to the
|
|
204
|
+
directory name) so output is deterministic regardless of input order."""
|
|
205
|
+
manifest = read_manifest(overlay_dir)
|
|
206
|
+
try:
|
|
207
|
+
priority = int(manifest.get("priority", DEFAULT_PRIORITY)) # type: ignore[arg-type]
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
priority = DEFAULT_PRIORITY
|
|
210
|
+
name = str(manifest.get("name", overlay_dir.name))
|
|
211
|
+
return (priority, name)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def sort_overlays_by_priority(overlay_dirs: "list[Path]") -> "list[Path]":
|
|
215
|
+
"""Return `overlay_dirs` sorted by `(priority, name)` (plan 02 / D68).
|
|
216
|
+
|
|
217
|
+
Deterministic regardless of the caller's order: `install`/`overlays add` pass
|
|
218
|
+
overlays in add-invocation order and `overlays sync` in alphabetical discovery
|
|
219
|
+
order, but the *merged* block must not depend on either -- a high-priority
|
|
220
|
+
overlay's lines must always land last. See `overlay_sort_key` for the convention.
|
|
221
|
+
"""
|
|
222
|
+
return sorted(overlay_dirs, key=overlay_sort_key)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def rule_blocks(overlay_dir: Path, rel_paths: "list[str]") -> "tuple[list[str], list[str]]":
|
|
226
|
+
"""Extract `- **…` bullet blocks from each referenced markdown file.
|
|
227
|
+
|
|
228
|
+
Returns (blocks, warnings). Only the leading run of bullets is taken: a rules
|
|
229
|
+
file may carry explanatory prose under a `## ` heading (engineering/rules.md
|
|
230
|
+
documents *why* its rules are not in the base), and that must not be merged
|
|
231
|
+
into the core. A path that does not exist is reported, not fatal -- a broken
|
|
232
|
+
optional overlay must never block the base config landing."""
|
|
233
|
+
blocks: "list[str]" = []
|
|
234
|
+
warnings: "list[str]" = []
|
|
235
|
+
for rel in rel_paths:
|
|
236
|
+
src = overlay_dir / rel
|
|
237
|
+
if not src.is_file():
|
|
238
|
+
warnings.append("rules file not found: %s" % rel)
|
|
239
|
+
continue
|
|
240
|
+
text = src.read_text(encoding="utf-8")
|
|
241
|
+
# Everything from the first bullet up to the first `## ` heading after it.
|
|
242
|
+
start = re.search(r"(?m)^- \*\*", text)
|
|
243
|
+
if start is None:
|
|
244
|
+
warnings.append("no rules found in: %s" % rel)
|
|
245
|
+
continue
|
|
246
|
+
body = text[start.start():]
|
|
247
|
+
end = re.search(r"(?m)^## ", body)
|
|
248
|
+
if end is not None:
|
|
249
|
+
body = body[: end.start()]
|
|
250
|
+
blocks.append(body.rstrip())
|
|
251
|
+
return blocks, warnings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def overlay_files(overlay_dir: Path) -> "list[Path]":
|
|
255
|
+
"""Files an overlay installs (everything except its manifest / caches)."""
|
|
256
|
+
files = []
|
|
257
|
+
for p in sorted(overlay_dir.rglob("*")):
|
|
258
|
+
if p.is_file() and p.name != "overlay.toml" and "__pycache__" not in p.parts:
|
|
259
|
+
files.append(p)
|
|
260
|
+
return files
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def apply_overlay(overlay_dir: Path, dest: Path, dry_run: bool):
|
|
264
|
+
"""Copy `overlay_dir`'s files into `dest` (create-if-absent; never clobber
|
|
265
|
+
an existing file). Returns (copied, skipped) counts and per-file log lines."""
|
|
266
|
+
copied = skipped = 0
|
|
267
|
+
lines = []
|
|
268
|
+
for src in overlay_files(overlay_dir):
|
|
269
|
+
rel = src.relative_to(overlay_dir)
|
|
270
|
+
target = dest / rel
|
|
271
|
+
if target.exists():
|
|
272
|
+
lines.append("skip (exists): %s" % rel.as_posix())
|
|
273
|
+
skipped += 1
|
|
274
|
+
continue
|
|
275
|
+
lines.append("overlay: %s" % rel.as_posix())
|
|
276
|
+
if not dry_run:
|
|
277
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
shutil.copy2(str(src), str(target))
|
|
279
|
+
copied += 1
|
|
280
|
+
return copied, skipped, lines
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def install_overlay_dir(overlay_dir: Path, dest_overlay_dir: Path, dry_run: bool):
|
|
284
|
+
"""Install an overlay as a *directory* under `<scope>/overlays/<name>/` (the
|
|
285
|
+
"install model = overlay-dirs" decision): the overlay is the discoverable unit.
|
|
286
|
+
|
|
287
|
+
Copies every file the overlay ships (minus its manifest / caches -- see
|
|
288
|
+
`overlay_files`) into `dest_overlay_dir`, create-if-absent so re-adding an
|
|
289
|
+
overlay never clobbers a file the user hand-edited inside the installed copy
|
|
290
|
+
(additive/no-clobber, mirroring `apply_overlay`). The overlay's own
|
|
291
|
+
`overlay.toml` is copied too so a later `sync`/`list` can re-read its manifest
|
|
292
|
+
from the installed copy. Returns (copied, skipped) counts and log lines.
|
|
293
|
+
"""
|
|
294
|
+
copied = skipped = 0
|
|
295
|
+
lines = []
|
|
296
|
+
# Ship the manifest alongside the files so the installed dir is self-describing.
|
|
297
|
+
sources = list(overlay_files(overlay_dir))
|
|
298
|
+
manifest = overlay_dir / "overlay.toml"
|
|
299
|
+
if manifest.is_file():
|
|
300
|
+
sources.append(manifest)
|
|
301
|
+
for src in sources:
|
|
302
|
+
rel = src.relative_to(overlay_dir)
|
|
303
|
+
target = dest_overlay_dir / rel
|
|
304
|
+
if target.exists():
|
|
305
|
+
lines.append("skip (exists): %s" % rel.as_posix())
|
|
306
|
+
skipped += 1
|
|
307
|
+
continue
|
|
308
|
+
lines.append("install: %s" % rel.as_posix())
|
|
309
|
+
if not dry_run:
|
|
310
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
shutil.copy2(str(src), str(target))
|
|
312
|
+
copied += 1
|
|
313
|
+
return copied, skipped, lines
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def merge_overlay_rules(agents_md: Path, overlay_dir: Path, dry_run: bool, logger):
|
|
317
|
+
"""Fold one overlay's D59 routing + rules into an already-installed AGENTS.md's
|
|
318
|
+
managed block, in place (additive).
|
|
319
|
+
|
|
320
|
+
`install` composes these into the base text *before* first write; an incremental
|
|
321
|
+
`overlays add` instead re-composes the *existing* installed file's managed block.
|
|
322
|
+
Extracts the current block (between the dotagents markers), runs the same
|
|
323
|
+
`_compose_block` fold used at install time over just that block text, and writes
|
|
324
|
+
the result back between the markers -- content outside the block is never
|
|
325
|
+
touched. A no-op (returns False) when the overlay contributes nothing, the file
|
|
326
|
+
is absent, or it carries no managed block.
|
|
327
|
+
"""
|
|
328
|
+
from dotagents._merge import BEGIN_MARKER, END_MARKER
|
|
329
|
+
from dotagents.cli import _compose_block
|
|
330
|
+
|
|
331
|
+
manifest = read_manifest(overlay_dir)
|
|
332
|
+
if not manifest["routing"] and not manifest["rules"]:
|
|
333
|
+
return False
|
|
334
|
+
if not agents_md.is_file():
|
|
335
|
+
logger.warning(
|
|
336
|
+
"no installed AGENTS.md at %s; overlay rules/routing not merged", agents_md
|
|
337
|
+
)
|
|
338
|
+
return False
|
|
339
|
+
existing = agents_md.read_text(encoding="utf-8")
|
|
340
|
+
if BEGIN_MARKER not in existing or END_MARKER not in existing:
|
|
341
|
+
logger.warning(
|
|
342
|
+
"AGENTS.md has no dotagents managed block; overlay rules/routing not "
|
|
343
|
+
"merged (run `dotagents init` first)"
|
|
344
|
+
)
|
|
345
|
+
return False
|
|
346
|
+
start = existing.index(BEGIN_MARKER)
|
|
347
|
+
end = existing.index(END_MARKER) + len(END_MARKER)
|
|
348
|
+
block = existing[start:end]
|
|
349
|
+
merged = _compose_block(block, [overlay_dir], logger)
|
|
350
|
+
if merged == block:
|
|
351
|
+
return False
|
|
352
|
+
new_text = existing[:start] + merged + existing[end:]
|
|
353
|
+
if not dry_run:
|
|
354
|
+
agents_md.write_text(new_text, encoding="utf-8")
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def recompose_overlay_block(
|
|
359
|
+
agents_md: Path,
|
|
360
|
+
base_block: str,
|
|
361
|
+
overlay_dirs: "list[Path]",
|
|
362
|
+
dry_run: bool,
|
|
363
|
+
logger,
|
|
364
|
+
) -> bool:
|
|
365
|
+
"""Rebuild AGENTS.md's managed block from the *pristine* base over ALL installed
|
|
366
|
+
overlays in `(priority, name)` order (plan 02 / D68), in place.
|
|
367
|
+
|
|
368
|
+
This is what makes single-`overlays add` positioning correct: a lone `add` merges
|
|
369
|
+
one overlay, but its rules must land in the right slot *relative to overlays
|
|
370
|
+
already present in the block*. Incremental append (`merge_overlay_rules`) can only
|
|
371
|
+
tack the newcomer on at the end, so a high-priority overlay added *after* a
|
|
372
|
+
low-priority one would wrongly sort last. Recomposing from the base each time
|
|
373
|
+
sidesteps that: `base_block` is the freshly-read base overlay's managed block (no
|
|
374
|
+
overlay content), `overlay_dirs` is every installed overlay in the scope, and
|
|
375
|
+
`_compose_block` folds them in sorted order -- so the block is a pure function of
|
|
376
|
+
*which* overlays are installed, never *when* each was added.
|
|
377
|
+
|
|
378
|
+
Only the managed block (between the dotagents markers) is rewritten; content
|
|
379
|
+
outside the markers is untouched. Returns True if the file changed, False on a
|
|
380
|
+
no-op (nothing to merge, file/markers absent, or block already correct).
|
|
381
|
+
"""
|
|
382
|
+
from dotagents._merge import BEGIN_MARKER, END_MARKER
|
|
383
|
+
from dotagents.cli import _compose_block
|
|
384
|
+
|
|
385
|
+
if not agents_md.is_file():
|
|
386
|
+
logger.warning(
|
|
387
|
+
"no installed AGENTS.md at %s; overlay rules/routing not merged", agents_md
|
|
388
|
+
)
|
|
389
|
+
return False
|
|
390
|
+
existing = agents_md.read_text(encoding="utf-8")
|
|
391
|
+
if BEGIN_MARKER not in existing or END_MARKER not in existing:
|
|
392
|
+
logger.warning(
|
|
393
|
+
"AGENTS.md has no dotagents managed block; overlay rules/routing not "
|
|
394
|
+
"merged (run `dotagents init` first)"
|
|
395
|
+
)
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
# Compose over the pristine base block, not the current (already-merged) one, so
|
|
399
|
+
# every install of the same overlay set yields identical output.
|
|
400
|
+
b_start = base_block.index(BEGIN_MARKER)
|
|
401
|
+
b_end = base_block.index(END_MARKER) + len(END_MARKER)
|
|
402
|
+
pristine = base_block[b_start:b_end]
|
|
403
|
+
merged = _compose_block(pristine, overlay_dirs, logger)
|
|
404
|
+
|
|
405
|
+
start = existing.index(BEGIN_MARKER)
|
|
406
|
+
end = existing.index(END_MARKER) + len(END_MARKER)
|
|
407
|
+
if existing[start:end] == merged:
|
|
408
|
+
return False
|
|
409
|
+
new_text = existing[:start] + merged + existing[end:]
|
|
410
|
+
if not dry_run:
|
|
411
|
+
agents_md.write_text(new_text, encoding="utf-8")
|
|
412
|
+
return True
|
dotagents/_resolve.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Contract A resolution for dotagents.
|
|
2
|
+
|
|
3
|
+
Preserves the precedence walk and filename resolution from the precursor `agentic`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_file_paths(
|
|
12
|
+
*names: str | dict[str, str],
|
|
13
|
+
agents_dir: Path,
|
|
14
|
+
project_root: Path,
|
|
15
|
+
global_scope: bool = False,
|
|
16
|
+
include_missing: bool = False,
|
|
17
|
+
) -> list[tuple[str, Path, Path | None]]:
|
|
18
|
+
"""Resolve file paths across the precedence hierarchy (Contract A).
|
|
19
|
+
|
|
20
|
+
Precedence order:
|
|
21
|
+
1. overlays (in ~/.agents/overlays or equivalent)
|
|
22
|
+
2. system (/etc/agents)
|
|
23
|
+
3. user (agents_dir)
|
|
24
|
+
4. project (project_root / .agents) -- skipped if global_scope
|
|
25
|
+
5. project-root (project_root) -- skipped if global_scope
|
|
26
|
+
"""
|
|
27
|
+
files: list[tuple[str, Path, Path | None]] = []
|
|
28
|
+
|
|
29
|
+
def add_name_paths(
|
|
30
|
+
location: Path, level: str, root: Path | None = None, is_overlay: bool = False
|
|
31
|
+
) -> None:
|
|
32
|
+
for name in names:
|
|
33
|
+
if isinstance(name, str):
|
|
34
|
+
name_dict = {level: name}
|
|
35
|
+
else:
|
|
36
|
+
name_dict = name
|
|
37
|
+
|
|
38
|
+
default = name_dict.get("default")
|
|
39
|
+
if is_overlay:
|
|
40
|
+
default = name_dict.get("overlay", default)
|
|
41
|
+
|
|
42
|
+
template = name_dict.get(level, default)
|
|
43
|
+
if not template:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
files.append((level, location / template, root))
|
|
47
|
+
|
|
48
|
+
# 1. Overlays
|
|
49
|
+
#
|
|
50
|
+
# An installed overlay is any directory under ``overlays/`` whose name is a
|
|
51
|
+
# valid overlay name -- the EXACT rule ``_scope.discover_overlays`` uses (the
|
|
52
|
+
# two must agree on what counts). No manifest of any kind is required -- not
|
|
53
|
+
# ``CONTEXT.md``, not ``overlay.toml``. The old ``CONTEXT.md`` gate was a
|
|
54
|
+
# precursor leftover (`agentic` used CONTEXT.md as an overlay manifest;
|
|
55
|
+
# dotagents ships none) that silently excluded EVERY real dotagents overlay
|
|
56
|
+
# from the Contract-A walk -- overlay-level bin/env/cmds resolution never fired
|
|
57
|
+
# (D84). ``is_valid_overlay_name`` skips ``.git``/``__pycache__``/dotfiles.
|
|
58
|
+
from dotagents._scope import is_valid_overlay_name
|
|
59
|
+
|
|
60
|
+
overlay_root = agents_dir / "overlays"
|
|
61
|
+
if overlay_root.is_dir():
|
|
62
|
+
for overlay_dir in sorted(overlay_root.iterdir()):
|
|
63
|
+
if overlay_dir.is_dir() and is_valid_overlay_name(overlay_dir.name):
|
|
64
|
+
add_name_paths(
|
|
65
|
+
overlay_dir, overlay_dir.name, root=overlay_dir, is_overlay=True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 2. System
|
|
69
|
+
add_name_paths(Path("/etc/agents"), "system")
|
|
70
|
+
|
|
71
|
+
# 3. User
|
|
72
|
+
add_name_paths(agents_dir, "user")
|
|
73
|
+
|
|
74
|
+
# 4 & 5. Project (if not global)
|
|
75
|
+
if not global_scope:
|
|
76
|
+
add_name_paths(project_root / ".agents", "project")
|
|
77
|
+
add_name_paths(project_root, "project-root")
|
|
78
|
+
|
|
79
|
+
if include_missing:
|
|
80
|
+
return files
|
|
81
|
+
return [(level, path, root) for level, path, root in files if path.exists()]
|