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
|
@@ -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())
|
dotagents/cli/_common.py
ADDED
|
@@ -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
|