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.
@@ -0,0 +1,354 @@
1
+ """dotagents CLI: init / context / env / overlays / build-pyz built-in
2
+ subcommands, plus any user or overlay command modules discovered from a `cmds`
3
+ directory (D76/D84).
4
+
5
+ dotagents bundles NO command module of its own (D85). `link`/`sync` used to ship
6
+ in `_overlay/dotagents/cmds/`; they are now `link-project`/`sync-project`,
7
+ shipped -- together with their logic -- by the opt-in **private-sync** overlay,
8
+ so plain dotagents carries no private-sync workflow. The bundled cmds DIR still
9
+ exists and is still laid down by `init`: it is the user's documented drop-in
10
+ point for their own commands. `leak-check` is likewise not in the repo: it
11
+ enforces personal plan-naming conventions, so it lives in the user's private
12
+ `.agents/dotagents/cmds/` as a discovered command module (D84).
13
+
14
+ The per-command classes live in sibling modules (`cli/init.py`, `cli/overlays.py`,
15
+ ...); this package base holds the shared helpers (in `cli/_common.py`, re-exported
16
+ here), the `Dotagents(LoggingArgs, Cli)` umbrella, `main()` (the `install.py` shim +
17
+ `python -m dotagents` entrypoint), `_discover` (command-source resolution), and
18
+ `_repoint_zipapp_sources` (the zipapp source-extraction shim). (`install.py` at the
19
+ repo root is the entrypoint shim, unrelated to the removed `install` subcommand --
20
+ `init` now lays down the base + optional `--bin-dir` wrappers, D82.)
21
+
22
+ Dispatch (D76): `main()` routes through `duho.app`, not `duho.main`, so command
23
+ discovery runs. Command MODULES are discovered from the bundled
24
+ `_overlay/dotagents/cmds/` dir (empty by default since D85, but still a source),
25
+ from each installed overlay's `<overlay-root>/cmds/` -- this is how the
26
+ private-sync overlay supplies `link-project`/`sync-project` -- and from a
27
+ per-scope `cmds` dir (one `get_file_paths` Contract-A walk, `_cmds_dirs`).
28
+ `app`'s DEFAULT dispatch is used (dotagents has no fan-out):
29
+ a plain `(LoggingArgs, Cmd)` class command dispatches through `app` exactly as it
30
+ did through `main` -- `app` calls the class's `__call__`.
31
+
32
+ `_compose_block` and `_package_data_dir` are re-exported at package level because
33
+ other package modules import them as `dotagents.cli._compose_block` /
34
+ `dotagents.cli._package_data_dir` (see `_overlays.py`, `_scope.py`).
35
+ """
36
+
37
+ import logging
38
+ import os
39
+ import sys
40
+ import tempfile
41
+ from pathlib import Path
42
+
43
+ import duho
44
+ from duho import Cli, LoggingArgs
45
+
46
+ from dotagents import __version__
47
+
48
+ # Re-export shared helpers so `dotagents.cli.<name>` keeps resolving for both the
49
+ # command modules and external importers (`dotagents._overlays`, `dotagents._scope`).
50
+ from dotagents.cli._common import ( # noqa: F401
51
+ BASE_PLAIN_FILES,
52
+ BASE_ROOT,
53
+ _apply_base,
54
+ _compose_block,
55
+ _installed_overlay_dirs,
56
+ _package_data_dir,
57
+ _resolve_from,
58
+ _run_overlay_setup,
59
+ )
60
+
61
+ # Import each built-in command class to register it as a compiled subcommand.
62
+ # Importing the command modules here (never the reverse) keeps the dependency
63
+ # edges one-directional: command modules -> cli._common / dotagents._*, and
64
+ # cli/__init__ -> command modules. link/sync are NOT imported here -- they left
65
+ # the package entirely (D85: the private-sync overlay ships link-project/
66
+ # sync-project as discovered command modules; see `_discover`).
67
+ from dotagents.cli.build_pyz import BuildPyz
68
+ from dotagents.cli.context import Context
69
+ from dotagents.cli.env import Env
70
+ from dotagents.cli.init import Init
71
+ from dotagents.cli.overlays import ( # noqa: F401 (re-exported for tests)
72
+ OverlayAdd,
73
+ OverlayList,
74
+ OverlayRemove,
75
+ OverlaySync,
76
+ Overlays,
77
+ )
78
+
79
+ _LOGGER = logging.getLogger("dotagents")
80
+
81
+ # The compiled built-in command classes, in --help order. This is dotagents' WHOLE
82
+ # shipped surface: link/sync left the package with their logic (D85 -- the
83
+ # private-sync overlay supplies link-project/sync-project), leak-check is personal
84
+ # (D84 -- the user's private `.agents/dotagents/cmds/`), and audit is repo CI
85
+ # tooling (`tools/audit.py`), not a command. `_discover` seeds the command set
86
+ # with these, then layers discovered commands over them (later source wins on a
87
+ # name clash).
88
+ _BUILTIN_COMMANDS = [
89
+ Init,
90
+ BuildPyz,
91
+ Context,
92
+ Env,
93
+ Overlays,
94
+ ]
95
+
96
+ # The cli submodules whose sources duho introspects for flag/help definitions.
97
+ # Every module that defines a field-bearing BUILT-IN command class must be
98
+ # repointed inside a zipapp (see `_repoint_zipapp_sources`). DISCOVERED command
99
+ # modules never need repointing: an overlay's cmds live on the real filesystem,
100
+ # and the bundled cmds dir is extracted to real temp files by `_package_data_dir`
101
+ # before import (so its `__file__` already exists -- see `_bundled_cmds_dir`).
102
+ _COMMAND_MODULES = (
103
+ "dotagents.cli.init",
104
+ "dotagents.cli.context",
105
+ "dotagents.cli.env",
106
+ "dotagents.cli.overlays",
107
+ "dotagents.cli.build_pyz",
108
+ )
109
+
110
+ #: Extra env-var command search paths (os.pathsep-split), additive to the scope
111
+ #: walk.
112
+ CMDS_PATH_ENV = "AGENTS_CMDS_PATH"
113
+ #: back-compat: DOTAGENTS_CMDS_PATH is deprecated, removable next release.
114
+ CMDS_PATH_ENV_LEGACY = "DOTAGENTS_CMDS_PATH"
115
+
116
+ #: The configurable user-scope store (D58). Discovery resolves the user scope's
117
+ #: cmds dir through this, not a hardcoded ~/.agents, when set. This is the same
118
+ #: var `dotagents env` emits (D79).
119
+ AGENTS_DIR_ENV = "AGENTS_HOME"
120
+ #: back-compat: DOTAGENTS_AGENTS_DIR is deprecated, removable next release.
121
+ AGENTS_DIR_ENV_LEGACY = "DOTAGENTS_AGENTS_DIR"
122
+
123
+
124
+ class Dotagents(LoggingArgs, Cli):
125
+ """Umbrella CLI for installing and building the dotagents config."""
126
+
127
+ _version_ = __version__
128
+ # Built-ins are handed to `duho.app` via `commands=` (see `main`/`_discover`),
129
+ # NOT resolved from `_subcommands_`: `app` returns the `commands=` list as-is
130
+ # and does not merge `_subcommands_` on top of it, so keeping the built-ins
131
+ # here too would double-register them. `_discover` is the single source.
132
+ _subcommands_ = []
133
+
134
+ #: Extra command search path(s), additive to the scope walk + env var.
135
+ #: `duho.parse_globals(Dotagents, argv)` reads this before the full parser is
136
+ #: built so `_discover` can honor it (an extra `--cmdspath` search path).
137
+ cmdspath: "list[str]" = []
138
+ "Extra directory to discover command modules from (repeatable)."
139
+ ("--cmdspath",)
140
+
141
+ def __call__(self) -> int:
142
+ self._logger_.info(
143
+ "pick a subcommand, e.g. `init`, `overlays`, `context`, `env`, "
144
+ "`build-pyz`"
145
+ )
146
+ return 0
147
+
148
+
149
+ def _bundled_cmds_dir() -> "Path | None":
150
+ """The bundled command-module dir shipped inside the package (D76).
151
+
152
+ `<package>/_overlay/dotagents/cmds` is that dir. dotagents bundles no command
153
+ module of its own since D85 (link/sync moved to the private-sync overlay), so
154
+ it normally holds only its README -- but it stays a discovery source, and
155
+ `init` still lays it down as the user's drop-in point. Resolved `.pyz`-safe
156
+ via `_package_data_dir`, which
157
+ extracts a zip-backed `_overlay` to a real temp dir once -- so the modules
158
+ `discover_commands` imports from here always have a real on-disk `__file__`,
159
+ and the zipapp AST-introspection shim (`_repoint_zipapp_sources`) does NOT
160
+ need to cover them. Returns None if the package bundles no cmds dir."""
161
+ base = _package_data_dir("_overlay")
162
+ if base is None:
163
+ return None
164
+ cmds = base / "dotagents" / "cmds"
165
+ return cmds if cmds.is_dir() else None
166
+
167
+
168
+ def _discover_dir(source, by_name: dict) -> None:
169
+ """Discover command modules from one directory source, resiliently.
170
+
171
+ A missing / unreadable / non-command source is skipped with a warning, never
172
+ fatal -- mirroring `duho.discover_commands`'s own per-command resilience.
173
+ Each discovered command is keyed by its
174
+ resolved subcommand name (`_parsername_` / class name), so a LATER source
175
+ overrides an earlier same-named command (built-in < bundled < scope < env <
176
+ flag)."""
177
+ from duho.discovery import discover_commands
178
+
179
+ path = Path(source)
180
+ if not path.is_dir():
181
+ return
182
+ try:
183
+ commands = discover_commands(path)
184
+ except (ImportError, NotImplementedError, OSError) as exc:
185
+ _LOGGER.warning("skipping command source %r: %s", str(source), exc)
186
+ return
187
+ for command in commands:
188
+ name = getattr(command, "_parsername_", None) or getattr(
189
+ command, "__name__", None
190
+ )
191
+ if name:
192
+ by_name[name] = command # later source wins
193
+
194
+
195
+ def _cmds_dirs() -> "list[Path]":
196
+ """Every `cmds` dir to discover command modules from, in Contract-A order.
197
+
198
+ Resolved with the SAME `get_file_paths` walk that backs `bin`/PATH discovery
199
+ (`_env.get_bin_paths`) -- one resolver call yields the cmds dir at EVERY level
200
+ in precedence order, so command discovery, PATH, and every other Contract-A
201
+ seam agree on which roots exist and in what order. NOT a hand-rolled loop over
202
+ installed overlays.
203
+
204
+ The per-level name-dict maps overlay levels to `<overlay-root>/cmds` and every
205
+ other level (system/user/project) to `<agents_root>/dotagents/cmds` (the
206
+ `Scope.cmds_dir` layout, D76). `get_file_paths` returns them in Contract-A
207
+ precedence: overlays first, then system, user, project. Discovery layers later
208
+ sources over earlier ones (see `_discover_dir`), so a project cmd overrides a
209
+ user cmd overrides an overlay cmd of the same name -- the intended precedence
210
+ (an overlay may SHIP a command; a user/project can still override it).
211
+
212
+ The store location is configurable (D58/D79): the user scope resolves through
213
+ `$AGENTS_HOME` (default `~/.agents`); the project scope is `<cwd>/.agents`.
214
+ `include_missing=True` (precursor semantics): every level's cmds dir is offered
215
+ and the caller's `_discover_dir` skips the ones that don't exist."""
216
+ from dotagents import _resolve, _scope
217
+
218
+ agents_dir = (
219
+ os.environ.get(AGENTS_DIR_ENV)
220
+ or os.environ.get(AGENTS_DIR_ENV_LEGACY)
221
+ or None
222
+ )
223
+ user = _scope.resolve_scope(global_scope=True, agents_dir=agents_dir)
224
+ resolved = _resolve.get_file_paths(
225
+ {"default": "dotagents/cmds", "overlay": "cmds"},
226
+ agents_dir=user.agents_root,
227
+ project_root=_scope.project_root_default(),
228
+ global_scope=False,
229
+ include_missing=True,
230
+ )
231
+ return [path for _level, path, _root in resolved]
232
+
233
+
234
+ def _discover(argv=None) -> "list":
235
+ """Resolve the full command set: built-ins, then discovered modules.
236
+
237
+ Sources, earliest-to-latest (a later source overrides a same-named command,
238
+ dedup by resolved subcommand name):
239
+
240
+ 1. the compiled built-in command classes (`_BUILTIN_COMMANDS`);
241
+ 2. the bundled command-module dir `<package>/_overlay/dotagents/cmds` --
242
+ always available, even before an install (empty of commands since D85);
243
+ 3. the Contract-A `cmds` dirs (`_cmds_dirs`): each installed overlay's
244
+ `<overlay-root>/cmds`, then system/user/project `<scope>/dotagents/cmds`,
245
+ in Contract-A precedence (overlays < system < user < project). This is what
246
+ lets an installed overlay SHIP a command, or a user drop a personal command
247
+ module into their private `<scope>/dotagents/cmds` (e.g. `leak-check`), while
248
+ a user/project cmd still overrides a same-named overlay cmd;
249
+ 4. `$AGENTS_CMDS_PATH` entries (os.pathsep-split);
250
+ 5. `--cmdspath` entries, read via `duho.parse_globals` before the full parser.
251
+
252
+ Every directory source is resilient: a missing / bad one is skipped with a
253
+ warning, never fatal.
254
+ """
255
+ by_name: "dict[str, object]" = {}
256
+
257
+ # 1. built-ins (lowest precedence)
258
+ for command in _BUILTIN_COMMANDS:
259
+ name = getattr(command, "_parsername_", None) or command.__name__
260
+ by_name[name] = command
261
+
262
+ # 2. bundled cmds dir (ships no command of its own since D85)
263
+ bundled = _bundled_cmds_dir()
264
+ if bundled is not None:
265
+ _discover_dir(bundled, by_name)
266
+
267
+ # 3. Contract-A cmds dirs: overlay cmds + scope (user/project) cmds, in
268
+ # precedence order (overlays first, project last -> project wins).
269
+ for cmds_dir in _cmds_dirs():
270
+ _discover_dir(cmds_dir, by_name)
271
+
272
+ # 4. $AGENTS_CMDS_PATH (back-compat: $DOTAGENTS_CMDS_PATH, removable next release)
273
+ raw = os.environ.get(CMDS_PATH_ENV) or os.environ.get(CMDS_PATH_ENV_LEGACY)
274
+ if raw:
275
+ for entry in raw.split(os.pathsep):
276
+ if entry:
277
+ _discover_dir(entry, by_name)
278
+
279
+ # 5. --cmdspath (read the global before the full subcommand parser is built)
280
+ try:
281
+ globals_ = duho.parse_globals(Dotagents, argv)
282
+ for entry in getattr(globals_, "cmdspath", None) or []:
283
+ if entry:
284
+ _discover_dir(entry, by_name)
285
+ except Exception as exc: # pragma: no cover - parse_globals is best-effort
286
+ _LOGGER.warning("could not read --cmdspath globals: %s", exc)
287
+
288
+ return list(by_name.values())
289
+
290
+
291
+ def _repoint_zipapp_sources() -> None:
292
+ """Make duho's AST field-introspection work when running from a zipapp.
293
+
294
+ duho discovers each command's flags + help by parsing its module source
295
+ (`_introspect.getclsdef` -> `Path(module.__file__).read_text()`). Inside a
296
+ `.pyz` the module `__file__` is a zip-internal path `read_text()` can't open,
297
+ and duho catches that `OSError` *before* its `inspect.getsource` fallback --
298
+ so every field silently loses its declared flags and help (the positional
299
+ `path` degrades to `--path`, `--from` to `--from-`, help text vanishes).
300
+ Extract the affected module sources to real temp files and repoint `__file__`
301
+ so the read succeeds. A no-op for a plain install, where `__file__` already
302
+ exists on disk.
303
+
304
+ Each BUILT-IN command class lives in its own `dotagents.cli.<x>` module, so
305
+ EVERY such module is repointed (plus duho's `LoggingArgs` preset,
306
+ `duho.presets`). DISCOVERED command modules do NOT need repointing: an
307
+ overlay's or a scope's cmds live on the real filesystem, and for the bundled
308
+ `_overlay/dotagents/cmds` dir `_package_data_dir` extracts a zip-backed
309
+ `_overlay` to real temp files before `discover_commands` imports from it, so
310
+ their `__file__` already exists on disk.
311
+
312
+ Tracked upstream: jose-pr/duho#1 -- drop this shim (and the build-pyz CI
313
+ guard) once duho's getclsdef falls through to inspect.getsource when
314
+ _module_index raises."""
315
+ import importlib.resources as _ir
316
+
317
+ for modname in _COMMAND_MODULES + ("duho.presets",):
318
+ mod = sys.modules.get(modname)
319
+ if mod is None:
320
+ continue
321
+ current = getattr(mod, "__file__", None)
322
+ if current and Path(current).exists():
323
+ continue # plain install: source already readable
324
+ top, _sep, rest = modname.partition(".")
325
+ rel = (rest.replace(".", "/") or "__init__") + ".py"
326
+ try:
327
+ resource = _ir.files(top).joinpath(rel)
328
+ if not resource.is_file():
329
+ continue
330
+ text = resource.read_text(encoding="utf-8")
331
+ except (FileNotFoundError, ModuleNotFoundError, OSError, TypeError):
332
+ continue
333
+ tmp = Path(tempfile.mkdtemp(prefix="dotagents-src-")) / (
334
+ modname.replace(".", "_") + ".py"
335
+ )
336
+ tmp.write_text(text, encoding="utf-8")
337
+ mod.__file__ = str(tmp)
338
+
339
+
340
+ def main(argv=None) -> int:
341
+ _repoint_zipapp_sources()
342
+ # `duho.app` (not `duho.main`) so command discovery runs. Default dispatch:
343
+ # dotagents has no fan-out, so `app` calls each command's `__call__` exactly
344
+ # as `duho.main` did. `commands=` is the resolved built-ins + discovered set.
345
+ return duho.app(
346
+ Dotagents,
347
+ commands=_discover(argv),
348
+ argv=argv,
349
+ name="dotagents",
350
+ )
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main())
@@ -0,0 +1,331 @@
1
+ """Shared CLI-only helpers used across the per-command modules.
2
+
3
+ Pure helpers with no dependency on the command classes or the umbrella, so
4
+ command modules can import from here without creating an import cycle
5
+ (`cli/__init__.py` imports the command modules, not the reverse). The public
6
+ names `_compose_block` and `_package_data_dir` are re-exported from
7
+ `dotagents.cli` (see `cli/__init__.py`) because other package modules import
8
+ them as `dotagents.cli._compose_block` / `dotagents.cli._package_data_dir`.
9
+ """
10
+
11
+ import importlib.resources
12
+ import os
13
+ import re
14
+ import shutil
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+
19
+ _extracted_dirs_cache: "dict[str, Path]" = {}
20
+
21
+
22
+ # NOTE: `_resolve_required_tool` was removed. It existed so a compiled `audit` /
23
+ # `leak-check` wrapper could locate a standalone script under `tools/`. Neither
24
+ # wrapper exists now: `audit` is CI tooling for THIS repo (`tools/audit.py`, not a
25
+ # dotagents command, not shipped), and `leak-check` is a personal command module in
26
+ # the user's private `.agents/`. Nothing in the package shells out to `tools/`.
27
+
28
+
29
+ def _package_data_dir(name: str) -> "Path | None":
30
+ """Resolve a directory under the installed `dotagents` package (e.g.
31
+ `_overlay` or `_payload`) to a real filesystem Path, working whether the
32
+ package is a plain directory (pip install / editable) or inside a zipapp.
33
+
34
+ Inside a zipapp, `importlib.resources.files()` returns a `zipfile.Path`
35
+ (a `Traversable`, not a real filesystem `Path`): `.is_dir()` correctly
36
+ reports membership in the archive, but `str(traversable)` produces a
37
+ path string that does not exist on disk (`Path(str(...)).exists()` is
38
+ always False -- there is no real file there to stat). So a zip-backed
39
+ hit is extracted once to a process-lifetime temp directory and that
40
+ real path is cached and returned; a plain-directory hit is returned
41
+ as-is. Returns None if the directory isn't present in the package at all.
42
+ """
43
+ if name in _extracted_dirs_cache:
44
+ return _extracted_dirs_cache[name]
45
+
46
+ traversable = importlib.resources.files("dotagents") / name
47
+ if not traversable.is_dir():
48
+ return None
49
+
50
+ as_path = Path(str(traversable))
51
+ if as_path.exists():
52
+ _extracted_dirs_cache[name] = as_path
53
+ return as_path
54
+
55
+ # Zip-backed (or otherwise non-filesystem) Traversable: extract to a
56
+ # temp dir that lives for the process lifetime.
57
+ extract_root = Path(tempfile.mkdtemp(prefix="dotagents-%s-" % name))
58
+
59
+ def _extract(node, dest: Path):
60
+ dest.mkdir(parents=True, exist_ok=True)
61
+ for child in node.iterdir():
62
+ child_dest = dest / child.name
63
+ if child.is_dir():
64
+ _extract(child, child_dest)
65
+ else:
66
+ child_dest.write_bytes(child.read_bytes())
67
+
68
+ _extract(traversable, extract_root)
69
+ _extracted_dirs_cache[name] = extract_root
70
+ return extract_root
71
+
72
+
73
+ # The base overlay (neutral minimum) is bundled package data at
74
+ # `src/dotagents/_overlay`; `init` and `install` both lay it down. Overlays
75
+ # beyond the base are opt-in examples applied from an external path via
76
+ # `install --overlays <dir>` (the installer bundles none of them).
77
+ BASE_ROOT = _package_data_dir("_overlay") or (
78
+ Path(__file__).resolve().parent.parent / "_overlay"
79
+ )
80
+
81
+ # Base files that are create-if-absent only (never overwrite), i.e. everything
82
+ # except the managed-block files (AGENTS.md/CLAUDE.md).
83
+ BASE_PLAIN_FILES = [
84
+ "README.md",
85
+ "dotagents/DECISIONS.md",
86
+ ]
87
+
88
+
89
+ def _compose_block(base_text: str, overlays: "list[Path]", logger) -> str:
90
+ """Fold each overlay's `rules`/`routing` contributions into the base block.
91
+
92
+ Rules append to "Always-on rules" and routing to "Load on demand", after the
93
+ base's own -- the base carries the mechanism (D57) and should read first. The
94
+ overlays fold in **`(priority, name)` order** (plan 02 / D68), NOT the caller's
95
+ list order: lower `priority` (default `DEFAULT_PRIORITY`, 500) sorts earlier, so
96
+ a numerically higher-priority overlay lands *last* and wins on conflict -- the
97
+ same "lower sorts earlier / higher wins" convention `_context.py` uses. `name`
98
+ is the tiebreaker, so equal-priority overlays produce a stable, deterministic
99
+ block regardless of add-invocation or discovery order. Returns `base_text`
100
+ unchanged when nothing contributes, so `init` (which takes no overlays) is
101
+ completely unaffected."""
102
+ from dotagents._overlays import read_manifest, rule_blocks, sort_overlays_by_priority
103
+
104
+ rules: "list[str]" = []
105
+ routing: "list[str]" = []
106
+ for overlay_dir in sort_overlays_by_priority(overlays):
107
+ manifest = read_manifest(overlay_dir)
108
+ blocks, warnings = rule_blocks(overlay_dir, manifest["rules"]) # type: ignore[arg-type]
109
+ for warning in warnings:
110
+ logger.warning("overlay %s: %s", manifest["name"], warning)
111
+ rules.extend(blocks)
112
+ routing.extend(manifest["routing"]) # type: ignore[arg-type]
113
+
114
+ if not rules and not routing:
115
+ return base_text
116
+
117
+ text = base_text
118
+ if rules:
119
+ # Append after the last always-on bullet, i.e. just before the next heading.
120
+ m = re.search(r"(?m)^## Load on demand", text)
121
+ if m is None:
122
+ logger.warning("base AGENTS.md has no 'Load on demand' heading; "
123
+ "appending overlay rules at the end of the block")
124
+ else:
125
+ text = text[: m.start()] + "\n".join(rules) + "\n\n" + text[m.start():]
126
+ if routing:
127
+ # The base's placeholder sentence only makes sense with no routing lines.
128
+ text = re.sub(
129
+ r"(?m)^Nothing ships here by default[^\n]*\n(?:[^\n#<][^\n]*\n)*",
130
+ "",
131
+ text,
132
+ )
133
+ end = re.search(r"(?m)^<!-- dotagents:end -->", text)
134
+ insert_at = end.start() if end else len(text)
135
+ text = text[:insert_at] + "\n".join(routing) + "\n" + text[insert_at:]
136
+ return text
137
+
138
+
139
+ def _installed_overlay_dirs(scope, source, *, adding=None, dry_run=False) -> "list[Path]":
140
+ """The overlay dirs to recompose the managed block over (plan 02 / D68).
141
+
142
+ Every overlay installed in `scope` contributes to the block, so the recompose is
143
+ a pure function of *which* overlays are present -- not of add-invocation order.
144
+ Each installed overlay ships its own `overlay.toml` + rules files (see
145
+ `install_overlay_dir`), so the installed dir is self-describing and used directly.
146
+
147
+ `adding` is the names being added/synced this call; on a `--dry-run` `add` they
148
+ are not yet on disk in the scope, so their **source** dir stands in so the dry-run
149
+ preview reflects what a real run would produce. A real (non-dry-run) run reads them
150
+ from the scope like any other installed overlay. Order here is irrelevant --
151
+ `_compose_block` sorts by `(priority, name)`.
152
+ """
153
+ from dotagents import _scope
154
+
155
+ adding = list(adding or [])
156
+ installed = set(_scope.discover_overlays(scope))
157
+ names = sorted(installed | set(adding))
158
+ dirs: "list[Path]" = []
159
+ for name in names:
160
+ if dry_run and name in adding and name not in installed:
161
+ # Not on disk yet (dry-run add): describe it from the source instead.
162
+ try:
163
+ dirs.append(source.overlay_dir(name))
164
+ except SystemExit:
165
+ pass
166
+ else:
167
+ dirs.append(scope.overlay_dir(name))
168
+ return dirs
169
+
170
+
171
+ def _resolved_env(dest: Path, logger, agent_name: str) -> "dict[str, str]":
172
+ """The env CHANGES dotagents contributes, for agents that need a static
173
+ snapshot rather than a per-session hook.
174
+
175
+ `agent_name` is passed as `explicit` so the identity vars describe the agent the
176
+ file is FOR, not whichever harness happens to be running `init` -- a snapshot
177
+ written from Claude must still say `AGENT=codex` inside Codex's own config.
178
+
179
+ Assembling this executes overlay `env.py` files, so a broken overlay must not
180
+ take `init` down with it -- failure degrades to an empty set with a warning.
181
+ """
182
+ from dotagents import _env, _scope
183
+
184
+ try:
185
+ return _env.get_environment(
186
+ agents_dir=Path(dest),
187
+ project_root=_scope.project_root_default(),
188
+ base_env=dict(os.environ),
189
+ explicit=agent_name,
190
+ logger=logger,
191
+ )
192
+ except Exception as exc: # noqa: BLE001 -- never let one overlay break init
193
+ logger.warning("could not assemble env for the static env block: %s", exc)
194
+ return {}
195
+
196
+
197
+ def _apply_base(
198
+ src: Path, dest: Path, force: bool, dry_run: bool, logger,
199
+ agents: "list[str] | None" = None,
200
+ wire_hooks: bool = False,
201
+ ) -> None:
202
+ """Lay down the base overlay: managed-block merge AGENTS.md/CLAUDE.md,
203
+ create-if-absent the plain files. Shared by `init` and `install`.
204
+
205
+ With `wire_hooks`, each active agent also gets its hooks merged and the shared
206
+ skills dir linked into its config dir (a no-op for adapters that don't
207
+ implement it). Done here because this is where the active-agent list is
208
+ already resolved."""
209
+ from dotagents import _agents
210
+ import os
211
+
212
+ base_agents = (Path(src) / "AGENTS.md").read_text(encoding="utf-8")
213
+
214
+ # True only when the caller named agents with `--agents`. Writes that touch an
215
+ # agent's own main config file are gated on this, so merely *running* under a
216
+ # harness never rewrites its config behind the user's back.
217
+ explicitly_requested = bool(agents)
218
+
219
+ active_agents = []
220
+ if agents:
221
+ for name in agents:
222
+ agent = _agents.get_agent(name)
223
+ if agent:
224
+ active_agents.append(agent)
225
+ else:
226
+ logger.warning(f"Unknown agent: {name}")
227
+ else:
228
+ # Default: all detected + claude
229
+ all_agents = _agents.get_all_agents()
230
+ active_agents = [a for a in all_agents if a.detect_env(os.environ)]
231
+ if not any(a.name == "claude" for a in active_agents):
232
+ active_agents.append(_agents.ClaudeAgent())
233
+
234
+ for agent in active_agents:
235
+ agent.write_base_config(
236
+ dest, src, base_agents, force=force, dry_run=dry_run, logger=logger
237
+ )
238
+ if wire_hooks:
239
+ agent.wire_hooks(dest, dry_run=dry_run, logger=logger)
240
+ # Agents with no per-session env mechanism (Codex) take a static
241
+ # snapshot of the resolved env instead. Only ever on an EXPLICIT
242
+ # `--agents <name>`: this edits the user's main config file with values
243
+ # that go stale, so it must be asked for, never triggered by
244
+ # auto-detection. Resolved lazily -- assembling it runs overlay `env.py`.
245
+ if explicitly_requested and hasattr(agent, "write_env_block"):
246
+ agent.write_env_block(
247
+ _resolved_env(dest, logger, agent.name),
248
+ dry_run=dry_run,
249
+ logger=logger,
250
+ )
251
+
252
+ for rel in BASE_PLAIN_FILES:
253
+ source_path = Path(src) / rel
254
+ if not source_path.exists():
255
+ continue
256
+ target_path = dest / rel
257
+ if target_path.exists():
258
+ logger.info("skipped (present): %s", rel)
259
+ continue
260
+ logger.info("created: %s", rel)
261
+ if not dry_run:
262
+ target_path.parent.mkdir(parents=True, exist_ok=True)
263
+ shutil.copy2(str(source_path), str(target_path))
264
+
265
+ # Create `<dest>/dotagents/cmds/` and lay down whatever the base overlay
266
+ # bundles for it. dotagents itself now bundles NO command module (D85:
267
+ # link/sync moved to the private-sync overlay as link-project/sync-project,
268
+ # with their logic) -- but the DIR is still created unconditionally, because
269
+ # it is the documented user extension point: a `*.py` command module dropped
270
+ # here is discovered with zero config (see the README laid down beside it).
271
+ # Create-if-absent per file, exactly like the plain files -- a user's own
272
+ # edits to an installed command module are never clobbered on a reinstall.
273
+ cmds_src = Path(src) / "dotagents" / "cmds"
274
+ cmds_dest = dest / "dotagents" / "cmds"
275
+ if not dry_run:
276
+ cmds_dest.mkdir(parents=True, exist_ok=True)
277
+ if cmds_src.is_dir():
278
+ sources = sorted(cmds_src.glob("*.py")) + sorted(cmds_src.glob("*.md"))
279
+ for source_path in sources:
280
+ if source_path.name.startswith("_"):
281
+ continue
282
+ rel = "dotagents/cmds/%s" % source_path.name
283
+ target_path = cmds_dest / source_path.name
284
+ if target_path.exists():
285
+ logger.info("skipped (present): %s", rel)
286
+ continue
287
+ logger.info("created: %s", rel)
288
+ if not dry_run:
289
+ target_path.parent.mkdir(parents=True, exist_ok=True)
290
+ shutil.copy2(str(source_path), str(target_path))
291
+
292
+
293
+ def _resolve_from(from_arg: "str | None", default: Path) -> Path:
294
+ """Resolve --from to a local directory Path. A bare local path/dir is used
295
+ directly; a URI string is constructed via pathlib_next's UriPath (lazy
296
+ import so the `uri` extra is only required when actually used)."""
297
+ if from_arg is None:
298
+ return default
299
+ candidate = Path(from_arg)
300
+ if candidate.exists():
301
+ return candidate
302
+ if "://" in from_arg or from_arg.startswith(("http:", "https:", "sftp:", "s3:", "zip:")):
303
+ try:
304
+ from pathlib_next import UriPath
305
+ except ImportError as e:
306
+ raise SystemExit(
307
+ 'error: --from %r needs URI support. Install it with: pip install "dotagents-cli[uri]"'
308
+ % from_arg
309
+ ) from e
310
+ return UriPath(from_arg)
311
+ raise SystemExit("error: --from path does not exist: %s" % from_arg)
312
+
313
+
314
+ def _run_overlay_setup(dest_dir, name, *, scope, no_setup, dry_run, logger):
315
+ """Run an installed overlay's `setup` script, honoring `--no-setup`.
316
+
317
+ Thin wrapper over `_overlays.run_overlay_setup` that resolves the store path
318
+ from the scope (D58 configurable store, passed as `AGENTS_HOME`) and
319
+ short-circuits when `--no-setup` is given or the overlay ships no script.
320
+ Returns the setup exit code (0 when skipped / absent), so a non-zero result
321
+ surfaces as a clear error rather than a silent skip."""
322
+ from dotagents import _overlays
323
+
324
+ if no_setup:
325
+ if _overlays.find_setup_script(dest_dir) is not None:
326
+ logger.info("skipping setup for %s (--no-setup)", name)
327
+ return 0
328
+ rc = _overlays.run_overlay_setup(
329
+ dest_dir, name, agents_dir=scope.agents_root, dry_run=dry_run, logger=logger,
330
+ )
331
+ return rc or 0