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/_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()]