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/_scope.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Scope and overlay-source resolution for ``dotagents overlays``.
|
|
2
|
+
|
|
3
|
+
Two orthogonal axes the ``overlays`` command needs, kept out of ``cli.py`` (which
|
|
4
|
+
only wires args) to match ``_overlays.py`` / ``_skills.py`` / ``_sync.py``:
|
|
5
|
+
|
|
6
|
+
* **Scope** -- *where installed overlays live*. ``user`` is ``<agents_dir>/`` (the
|
|
7
|
+
configurable store, default ``~/.agents``); ``project`` is ``<project>/.agents/``.
|
|
8
|
+
An overlay installs into ``<scope>/overlays/<name>/`` and skills publish into the
|
|
9
|
+
shared ``<scope>/skills/``. There is no registry file: installed overlays are
|
|
10
|
+
**discovered** by their presence under ``overlays/`` (the locked "discover, don't
|
|
11
|
+
track" decision). A ``system`` tier (``/etc/agents``) is designed-for but not built.
|
|
12
|
+
|
|
13
|
+
* **Source** -- *where an overlay to install comes from*. ``resolve_source`` returns
|
|
14
|
+
an ``OverlaySource`` whose ``.root`` is a local directory of ``<name>/`` overlay
|
|
15
|
+
dirs. The default is the bundled ``overlays/`` (resolved ``.pyz``-safe via
|
|
16
|
+
``importlib.resources``, mirroring ``cli._package_data_dir``); an explicit
|
|
17
|
+
``--source`` / ``$AGENTS_OVERLAYS_SRC`` overrides it. A git/URI source is a
|
|
18
|
+
*later* swap of the resolver's default -- ``resolve_source`` is the single
|
|
19
|
+
extension point (clone/pull into a cache, then hand back a local ``.root``), so a
|
|
20
|
+
repo source drops in with **no** change to the command classes.
|
|
21
|
+
|
|
22
|
+
Never print ``DOTAGENTS_*`` values (Leakage): this module reads the env var but
|
|
23
|
+
only ever reports the resolved path, never the raw value.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import fnmatch
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
#: A directory under ``overlays/`` is an overlay IFF its name matches this: a
|
|
35
|
+
#: leading ASCII letter, then ASCII letters/digits/``_``/``.``/``-``. A dot is
|
|
36
|
+
#: allowed MID-name (``foo.bar``, ``v1.2``) but not as the first char, so
|
|
37
|
+
#: ``.git``/``.hidden`` are excluded; a leading underscore (``__pycache__``) and
|
|
38
|
+
#: a leading digit (``2fast``) are excluded too. One shared rule for
|
|
39
|
+
#: ``discover_overlays``, the ``get_file_paths`` overlay gate (`_resolve.py`), and
|
|
40
|
+
#: ``overlays add`` (D84). (Whether a path IS a directory is checked separately
|
|
41
|
+
#: with ``is_dir()``, which follows symlinks -- a symlink-to-dir is a valid overlay.)
|
|
42
|
+
_OVERLAY_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_valid_overlay_name(name: str) -> bool:
|
|
46
|
+
"""A dir under ``overlays/`` is an overlay iff its name matches: a leading ASCII
|
|
47
|
+
letter, then ASCII letters/digits/``_``/``.``/``-``. Dots are allowed mid-name
|
|
48
|
+
(``foo.bar``, ``v1.2``) but NOT as the first char, so ``.git``/``.hidden`` and
|
|
49
|
+
``__pycache__`` (leading ``_``) and ``2fast`` (leading digit) are excluded."""
|
|
50
|
+
return bool(_OVERLAY_NAME_RE.match(name))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def normalize_overlay_name(name: str) -> str:
|
|
54
|
+
"""Overlay names normalize to lowercase-dash for matching (precursor rule):
|
|
55
|
+
``name.lower().replace('_', '-')``. So ``My_Overlay`` and ``my-overlay`` are
|
|
56
|
+
the same overlay."""
|
|
57
|
+
return name.lower().replace("_", "-")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Scope:
|
|
61
|
+
"""A resolved install scope: the roots overlays and skills live under.
|
|
62
|
+
|
|
63
|
+
``user`` -> ``<agents_dir>/`` (the configurable store, default ``~/.agents``).
|
|
64
|
+
``project`` -> ``<project_root>/.agents/``. The ``overlays/`` and ``skills/``
|
|
65
|
+
subdirs beneath ``agents_root`` are the discover-and-publish surfaces.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, level: str, agents_root: Path):
|
|
69
|
+
self.level = level
|
|
70
|
+
self.agents_root = Path(agents_root)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def overlay_root(self) -> Path:
|
|
74
|
+
return self.agents_root / "overlays"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def shared_skills_dir(self) -> Path:
|
|
78
|
+
return self.agents_root / "skills"
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def cmds_dir(self) -> Path:
|
|
82
|
+
"""Directory of discovered command modules for this scope (D76).
|
|
83
|
+
|
|
84
|
+
``<agents_root>/dotagents/cmds`` -- a seam alongside ``overlays``/``skills``.
|
|
85
|
+
``init``/``install`` lay the bundled command modules here, and
|
|
86
|
+
``dotagents.cli._discover`` runs ``duho.discover_commands`` over it (per
|
|
87
|
+
scope, user + project) so a user's own ``*.py`` command modules dropped
|
|
88
|
+
beside them are picked up with zero config."""
|
|
89
|
+
return self.agents_root / "dotagents" / "cmds"
|
|
90
|
+
|
|
91
|
+
def overlay_dir(self, name: str) -> Path:
|
|
92
|
+
return self.overlay_root / name
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
return "Scope(%s, root=%s)" % (self.level, self.agents_root)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_scope(
|
|
99
|
+
global_scope: bool = False,
|
|
100
|
+
*,
|
|
101
|
+
agents_dir: "str | os.PathLike | None" = None,
|
|
102
|
+
project_root: "str | os.PathLike | None" = None,
|
|
103
|
+
) -> Scope:
|
|
104
|
+
"""Pick the install scope.
|
|
105
|
+
|
|
106
|
+
``-g/--global`` forces the **user** scope (``agents_dir``, default ``~/.agents``).
|
|
107
|
+
Otherwise the scope is **project**, rooted at (in precedence order): an explicit
|
|
108
|
+
``project_root`` argument, else ``$AGENTS_PROJECT_ROOT`` if set, else the current
|
|
109
|
+
directory. ``$AGENTS_PROJECT_ROOT`` lets a harness (or ``dotagents env``) pin the
|
|
110
|
+
project root once so every command agrees on it regardless of the cwd a subprocess
|
|
111
|
+
happens to run in; ``<root>/.agents/`` is where this project's overlays live. The
|
|
112
|
+
store location is configurable (D58): ``agents_dir`` comes from the caller
|
|
113
|
+
(``--agents-dir``) or defaults to ``~/.agents``; never hardcoded past that default.
|
|
114
|
+
"""
|
|
115
|
+
root = Path(agents_dir).expanduser() if agents_dir else (Path.home() / ".agents")
|
|
116
|
+
if global_scope:
|
|
117
|
+
return Scope("user", root)
|
|
118
|
+
proj = Path(project_root).expanduser() if project_root else project_root_default()
|
|
119
|
+
return Scope("project", proj / ".agents")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
#: Agent-native project-root vars, consulted (in order) as a fallback for
|
|
123
|
+
#: ``AGENTS_PROJECT_ROOT``. A harness that already exposes its workspace root lets
|
|
124
|
+
#: dotagents pick it up without the user setting anything. As of 2026-07, Claude
|
|
125
|
+
#: Code's ``CLAUDE_PROJECT_DIR`` is the ONLY one any major harness sets (and only in
|
|
126
|
+
#: hook / stdio-MCP / plugin-LSP contexts, not its Bash tool) -- Gemini/Codex/Cursor/
|
|
127
|
+
#: Copilot/aider all rely on cwd + internal git-root detection and export nothing.
|
|
128
|
+
#: Extend this tuple as other harnesses adopt one.
|
|
129
|
+
_HARNESS_PROJECT_ROOT_VARS = ("CLAUDE_PROJECT_DIR",)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def project_root_default() -> Path:
|
|
133
|
+
"""The project root when none is passed explicitly, in precedence order:
|
|
134
|
+
``$AGENTS_PROJECT_ROOT`` (dotagents' canonical var) -> a known agent-native var
|
|
135
|
+
(:data:`_HARNESS_PROJECT_ROOT_VARS`, e.g. Claude Code's ``CLAUDE_PROJECT_DIR``)
|
|
136
|
+
-> the current working directory.
|
|
137
|
+
|
|
138
|
+
Emitting ``AGENTS_PROJECT_ROOT`` (see ``dotagents env``) lets every command and
|
|
139
|
+
subprocess agree on one root regardless of the cwd it happens to run in."""
|
|
140
|
+
for var in ("AGENTS_PROJECT_ROOT", *_HARNESS_PROJECT_ROOT_VARS):
|
|
141
|
+
value = os.environ.get(var)
|
|
142
|
+
if value:
|
|
143
|
+
return Path(value).expanduser()
|
|
144
|
+
return Path.cwd()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def discover_overlays(scope: Scope) -> "list[str]":
|
|
148
|
+
"""Installed overlay names in this scope -- the presence of ``overlays/<name>/``.
|
|
149
|
+
|
|
150
|
+
No registry: a directory under ``<scope>/overlays/`` *is* an installed overlay
|
|
151
|
+
(manifest or not) as long as its name is a valid overlay name
|
|
152
|
+
(:func:`is_valid_overlay_name` -- so ``.git``/``__pycache__``/dotfiles are
|
|
153
|
+
skipped). Returns sorted names; empty if the root is absent.
|
|
154
|
+
"""
|
|
155
|
+
root = scope.overlay_root
|
|
156
|
+
if not root.is_dir():
|
|
157
|
+
return []
|
|
158
|
+
return sorted(
|
|
159
|
+
p.name for p in root.iterdir() if p.is_dir() and is_valid_overlay_name(p.name)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def filter_names(names: "list[str]", pattern: "Optional[str]") -> "list[str]":
|
|
164
|
+
"""Glob-filter overlay names (``sync 'py*'``). ``None``/``'*'`` keep all."""
|
|
165
|
+
if not pattern or pattern == "*":
|
|
166
|
+
return list(names)
|
|
167
|
+
return [n for n in names if fnmatch.fnmatch(n, pattern)]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class OverlaySource:
|
|
171
|
+
"""A resolved place overlays are fetched *from* for ``add``/``sync``.
|
|
172
|
+
|
|
173
|
+
``root`` is a local directory holding ``<name>/`` overlay dirs. ``available()``
|
|
174
|
+
lists them; ``overlay_dir(name)`` resolves one (raising if absent). This is the
|
|
175
|
+
seam a future git/URI source slots into: a ``GitOverlaySource`` would clone/pull
|
|
176
|
+
into a cache in ``__init__`` and set ``root`` to that checkout -- the command
|
|
177
|
+
classes only ever touch this interface, so they need no change.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(self, root: Path):
|
|
181
|
+
self.root = Path(root)
|
|
182
|
+
|
|
183
|
+
def available(self) -> "list[str]":
|
|
184
|
+
if not self.root.is_dir():
|
|
185
|
+
return []
|
|
186
|
+
return sorted(
|
|
187
|
+
p.name
|
|
188
|
+
for p in self.root.iterdir()
|
|
189
|
+
if p.is_dir() and not p.name.startswith(".")
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def overlay_dir(self, name: str) -> Path:
|
|
193
|
+
candidate = self.root / name
|
|
194
|
+
if not candidate.is_dir():
|
|
195
|
+
raise SystemExit(
|
|
196
|
+
"error: overlay %r not found in source %s (available: %s)"
|
|
197
|
+
% (name, self.root, ", ".join(self.available()) or "none")
|
|
198
|
+
)
|
|
199
|
+
return candidate
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
return "OverlaySource(%s)" % self.root
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
#: Environment override for the default overlay source (a local directory today;
|
|
206
|
+
#: a git/URI string once ``resolve_source`` grows that branch). Read, never printed.
|
|
207
|
+
SOURCE_ENV = "AGENTS_OVERLAYS_SRC"
|
|
208
|
+
#: back-compat: DOTAGENTS_OVERLAYS_SRC is deprecated, removable next release.
|
|
209
|
+
SOURCE_ENV_LEGACY = "DOTAGENTS_OVERLAYS_SRC"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def bundled_overlays_root() -> "Path | None":
|
|
213
|
+
"""Locate the bundled example ``overlays/`` directory, ``.pyz``-safe.
|
|
214
|
+
|
|
215
|
+
Two homes, tried in order: the packaged copy (``importlib.resources`` under the
|
|
216
|
+
installed ``dotagents`` package -- extracted from a zipapp when needed, exactly
|
|
217
|
+
like ``cli._package_data_dir``), then a repo checkout's top-level ``overlays/``
|
|
218
|
+
(dev use, mirroring ``BuildPyz``'s ``parents[2]`` reach). Returns ``None`` if
|
|
219
|
+
neither exists -- a plain ``pip install`` that bundled no overlays.
|
|
220
|
+
"""
|
|
221
|
+
# Prefer the shared resolver in cli so zipapp extraction is cached once.
|
|
222
|
+
try:
|
|
223
|
+
from dotagents.cli import _package_data_dir
|
|
224
|
+
|
|
225
|
+
packaged = _package_data_dir("_overlays_src")
|
|
226
|
+
if packaged is not None and packaged.is_dir():
|
|
227
|
+
return packaged
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
repo_overlays = Path(__file__).resolve().parents[2] / "overlays"
|
|
232
|
+
if repo_overlays.is_dir():
|
|
233
|
+
return repo_overlays
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_source(source: "Optional[str]" = None) -> OverlaySource:
|
|
238
|
+
"""Resolve the overlay source: explicit ``--source`` / ``$AGENTS_OVERLAYS_SRC``,
|
|
239
|
+
else the bundled ``overlays/``.
|
|
240
|
+
|
|
241
|
+
Today every branch yields a **local directory** ``OverlaySource``. The single
|
|
242
|
+
extension point for a git/URI source is here: when ``raw`` looks like a URI (a
|
|
243
|
+
later change), construct a ``GitOverlaySource`` instead -- callers are unaffected
|
|
244
|
+
because they only use the returned object's ``available``/``overlay_dir``.
|
|
245
|
+
"""
|
|
246
|
+
raw = (
|
|
247
|
+
source
|
|
248
|
+
or os.environ.get(SOURCE_ENV)
|
|
249
|
+
or os.environ.get(SOURCE_ENV_LEGACY)
|
|
250
|
+
or None
|
|
251
|
+
)
|
|
252
|
+
if raw:
|
|
253
|
+
# (extension point) a URI/git ``raw`` would branch to a cached clone here.
|
|
254
|
+
root = Path(raw).expanduser()
|
|
255
|
+
if not root.is_dir():
|
|
256
|
+
raise SystemExit("error: --source path is not a directory: %s" % raw)
|
|
257
|
+
return OverlaySource(root)
|
|
258
|
+
|
|
259
|
+
bundled = bundled_overlays_root()
|
|
260
|
+
if bundled is None:
|
|
261
|
+
raise SystemExit(
|
|
262
|
+
"error: no overlay source. This build bundles no overlays; pass "
|
|
263
|
+
"--source <dir> or set %s to a directory of overlays." % SOURCE_ENV
|
|
264
|
+
)
|
|
265
|
+
return OverlaySource(bundled)
|
dotagents/_skills.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Publish an overlay's skills into a scope's shared ``skills/`` dir, so every
|
|
2
|
+
agent that reads that dir sees the same skills (the "skills synced between agents"
|
|
3
|
+
behavior).
|
|
4
|
+
|
|
5
|
+
An overlay may ship ``skills/<skill-name>/`` directories. Publishing symlinks (or,
|
|
6
|
+
where symlinks aren't available, copies) each into ``<scope>/skills/<skill-name>/``.
|
|
7
|
+
Removing an overlay unpublishes only the skills **it** published (matched against
|
|
8
|
+
its own ``skills/`` source), never a skill another overlay or the user placed there,
|
|
9
|
+
then sweeps any now-broken symlinks.
|
|
10
|
+
|
|
11
|
+
Pure stdlib (``os``/``shutil``) -- no ``pathlib_next`` -- so it works in a plain
|
|
12
|
+
``pip install`` and inside the ``.pyz``. Symlink-preferred with a copy fallback is
|
|
13
|
+
the contract; ``--copy`` forces the copy path up front (Windows / no-symlink).
|
|
14
|
+
|
|
15
|
+
Ported from the precursor's ``managers/skills.py`` + ``helpers.py`` sync
|
|
16
|
+
primitives; the external ``skills``-CLI registry (``register``/``skills.txt``) is
|
|
17
|
+
deliberately **dropped** -- publish-to-shared-dir is the stdlib, useful part.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncResult:
|
|
28
|
+
__slots__ = ("success", "mode", "message")
|
|
29
|
+
|
|
30
|
+
def __init__(self, success: bool, mode: str, message: str = ""):
|
|
31
|
+
self.success = success
|
|
32
|
+
self.mode = mode
|
|
33
|
+
self.message = message
|
|
34
|
+
|
|
35
|
+
def __bool__(self) -> bool:
|
|
36
|
+
return self.success
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _paths_match(source: Path, target: Path) -> bool:
|
|
40
|
+
"""True if ``target`` already mirrors ``source`` (same file set, by name).
|
|
41
|
+
|
|
42
|
+
A cheap structural check -- enough to decide "already published, leave it" from
|
|
43
|
+
"conflicting content, overwrite". Not a content hash: republish on ``sync`` is a
|
|
44
|
+
separate, explicit path (``resync_path``)."""
|
|
45
|
+
if not source.exists() or not target.exists():
|
|
46
|
+
return False
|
|
47
|
+
if source.is_file() and target.is_file():
|
|
48
|
+
return source.stat().st_size == target.stat().st_size
|
|
49
|
+
if source.is_dir() and target.is_dir():
|
|
50
|
+
src_files = {str(p.relative_to(source)) for p in source.rglob("*") if p.is_file()}
|
|
51
|
+
tgt_files = {str(p.relative_to(target)) for p in target.rglob("*") if p.is_file()}
|
|
52
|
+
return src_files == tgt_files
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolves_to(target: Path, source: Path) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
return Path(os.path.realpath(str(target))) == source.resolve()
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sync_path(
|
|
64
|
+
source: Path, target: Path, *, prefer_symlink: bool = True, force: bool = False
|
|
65
|
+
) -> SyncResult:
|
|
66
|
+
"""Publish ``source`` at ``target`` -- symlink if possible, else copy.
|
|
67
|
+
|
|
68
|
+
An existing correct symlink / matching copy is a no-op success. A conflicting
|
|
69
|
+
target is only replaced with ``force=True``; otherwise it is reported as a
|
|
70
|
+
conflict so the caller can decide (the publish path retries once with force)."""
|
|
71
|
+
if not source.exists():
|
|
72
|
+
return SyncResult(False, "error", "source does not exist: %s" % source)
|
|
73
|
+
|
|
74
|
+
if os.path.lexists(str(target)):
|
|
75
|
+
if not force:
|
|
76
|
+
if os.path.islink(str(target)):
|
|
77
|
+
if _resolves_to(target, source):
|
|
78
|
+
return SyncResult(True, "symlink", "already linked")
|
|
79
|
+
return SyncResult(False, "symlink", "target symlink points elsewhere")
|
|
80
|
+
if _paths_match(source, target):
|
|
81
|
+
return SyncResult(True, "copy", "already synced")
|
|
82
|
+
return SyncResult(False, "conflict", "target exists with different content")
|
|
83
|
+
_remove(target)
|
|
84
|
+
|
|
85
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
if prefer_symlink:
|
|
87
|
+
try:
|
|
88
|
+
os.symlink(str(source), str(target), target_is_directory=source.is_dir())
|
|
89
|
+
return SyncResult(True, "symlink", "linked %s" % target.name)
|
|
90
|
+
except (OSError, NotImplementedError):
|
|
91
|
+
pass # fall through to copy
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
if source.is_dir():
|
|
95
|
+
shutil.copytree(str(source), str(target))
|
|
96
|
+
return SyncResult(True, "copy", "copied %s" % target.name)
|
|
97
|
+
shutil.copy2(str(source), str(target))
|
|
98
|
+
return SyncResult(True, "copy", "copied %s" % target.name)
|
|
99
|
+
except (OSError, shutil.Error) as exc:
|
|
100
|
+
return SyncResult(False, "copy", "copy failed: %s" % exc)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def unsync_path(target: Path, source: Path) -> SyncResult:
|
|
104
|
+
"""Unpublish ``target`` -- but only if it is this overlay's (a symlink to
|
|
105
|
+
``source``, or a copy matching it). A symlink pointing elsewhere, or a copy whose
|
|
106
|
+
content differs, is left untouched (it is someone else's)."""
|
|
107
|
+
if not os.path.lexists(str(target)):
|
|
108
|
+
return SyncResult(True, "none", "does not exist")
|
|
109
|
+
if os.path.islink(str(target)):
|
|
110
|
+
if not _resolves_to(target, source):
|
|
111
|
+
return SyncResult(False, "symlink", "symlink points elsewhere")
|
|
112
|
+
os.unlink(str(target))
|
|
113
|
+
return SyncResult(True, "symlink", "removed symlink")
|
|
114
|
+
if not _paths_match(source, target):
|
|
115
|
+
return SyncResult(False, "copy", "content differs from source")
|
|
116
|
+
try:
|
|
117
|
+
_remove(target)
|
|
118
|
+
return SyncResult(True, "copy", "removed copy")
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
return SyncResult(False, "copy", "removal failed: %s" % exc)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resync_path(source: Path, target: Path) -> SyncResult:
|
|
124
|
+
"""Refresh a published skill from its overlay source. A symlink is inherently
|
|
125
|
+
current; a copy is re-copied when its file set drifted."""
|
|
126
|
+
if not source.exists():
|
|
127
|
+
return SyncResult(False, "error", "source does not exist")
|
|
128
|
+
if not os.path.lexists(str(target)):
|
|
129
|
+
return sync_path(source, target, prefer_symlink=True)
|
|
130
|
+
if os.path.islink(str(target)):
|
|
131
|
+
return SyncResult(True, "symlink", "symlink is current")
|
|
132
|
+
if _paths_match(source, target):
|
|
133
|
+
return SyncResult(True, "copy", "current")
|
|
134
|
+
return sync_path(source, target, prefer_symlink=False, force=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _remove(path: Path) -> None:
|
|
138
|
+
if os.path.islink(str(path)) or path.is_file():
|
|
139
|
+
os.unlink(str(path))
|
|
140
|
+
elif path.is_dir():
|
|
141
|
+
shutil.rmtree(str(path))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _prune_empty(path: Path) -> None:
|
|
145
|
+
try:
|
|
146
|
+
path.rmdir()
|
|
147
|
+
except OSError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def clean_broken_syncs(shared_skills: Path, logger=None) -> None:
|
|
152
|
+
"""Drop symlinks under ``shared_skills`` whose target no longer exists (an
|
|
153
|
+
overlay's ``skills/`` went away), then remove the dir if it emptied."""
|
|
154
|
+
if not shared_skills.is_dir():
|
|
155
|
+
return
|
|
156
|
+
for entry in shared_skills.iterdir():
|
|
157
|
+
if entry.name.startswith("."):
|
|
158
|
+
continue
|
|
159
|
+
if os.path.islink(str(entry)) and not entry.exists():
|
|
160
|
+
os.unlink(str(entry))
|
|
161
|
+
if logger is not None:
|
|
162
|
+
logger.info("removed broken skill sync: %s", entry.name)
|
|
163
|
+
_prune_empty(shared_skills)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _overlay_skill_dirs(overlay_dir: Path) -> "list[Path]":
|
|
167
|
+
skills_dir = overlay_dir / "skills"
|
|
168
|
+
if not skills_dir.is_dir():
|
|
169
|
+
return []
|
|
170
|
+
return sorted(d for d in skills_dir.iterdir() if d.is_dir())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def publish_overlay_skills(
|
|
174
|
+
overlay_dir: Path, shared_skills: Path, *, copy: bool = False, logger=None
|
|
175
|
+
) -> int:
|
|
176
|
+
"""Publish each ``overlay_dir/skills/<name>/`` into ``shared_skills``.
|
|
177
|
+
|
|
178
|
+
Symlink-preferred unless ``copy`` forces copies. A conflicting target is
|
|
179
|
+
overwritten (the overlay owns its skill names). Returns the count published.
|
|
180
|
+
Sweeps broken syncs first so a stale symlink can't block a re-publish."""
|
|
181
|
+
clean_broken_syncs(shared_skills, logger=logger)
|
|
182
|
+
skill_dirs = _overlay_skill_dirs(overlay_dir)
|
|
183
|
+
if not skill_dirs:
|
|
184
|
+
return 0
|
|
185
|
+
shared_skills.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
published = 0
|
|
187
|
+
for skill_dir in skill_dirs:
|
|
188
|
+
target = shared_skills / skill_dir.name
|
|
189
|
+
result = sync_path(skill_dir, target, prefer_symlink=not copy, force=False)
|
|
190
|
+
if not result and result.mode == "conflict":
|
|
191
|
+
result = sync_path(skill_dir, target, prefer_symlink=not copy, force=True)
|
|
192
|
+
if result:
|
|
193
|
+
published += 1
|
|
194
|
+
if logger is not None and "already" not in result.message:
|
|
195
|
+
logger.info("skill %s: %s", skill_dir.name, result.message)
|
|
196
|
+
elif logger is not None:
|
|
197
|
+
logger.warning("failed to publish skill %s: %s", skill_dir.name, result.message)
|
|
198
|
+
return published
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def resync_overlay_skills(overlay_dir: Path, shared_skills: Path, *, logger=None) -> int:
|
|
202
|
+
"""Refresh already-published skills of this overlay (copy-mode drift). New
|
|
203
|
+
skills are published; symlinks are inherently current."""
|
|
204
|
+
skill_dirs = _overlay_skill_dirs(overlay_dir)
|
|
205
|
+
if not skill_dirs or not shared_skills.is_dir():
|
|
206
|
+
# Nothing published yet -> fall back to a fresh publish.
|
|
207
|
+
return publish_overlay_skills(overlay_dir, shared_skills, logger=logger)
|
|
208
|
+
updated = 0
|
|
209
|
+
for skill_dir in skill_dirs:
|
|
210
|
+
target = shared_skills / skill_dir.name
|
|
211
|
+
result = resync_path(skill_dir, target)
|
|
212
|
+
if result and result.mode == "copy" and "current" not in result.message:
|
|
213
|
+
updated += 1
|
|
214
|
+
if logger is not None:
|
|
215
|
+
logger.info("skill %s: %s", skill_dir.name, result.message)
|
|
216
|
+
return updated
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def remove_overlay_skills(overlay_dir: Path, shared_skills: Path, *, logger=None) -> int:
|
|
220
|
+
"""Unpublish only the skills *this* overlay published (matched to its source),
|
|
221
|
+
then sweep broken syncs. Returns the count removed."""
|
|
222
|
+
if not shared_skills.is_dir():
|
|
223
|
+
return 0
|
|
224
|
+
removed = 0
|
|
225
|
+
for skill_dir in _overlay_skill_dirs(overlay_dir):
|
|
226
|
+
target = shared_skills / skill_dir.name
|
|
227
|
+
result = unsync_path(target, skill_dir)
|
|
228
|
+
if result and result.mode != "none":
|
|
229
|
+
removed += 1
|
|
230
|
+
if logger is not None:
|
|
231
|
+
logger.info("removed skill: %s (%s)", skill_dir.name, result.mode)
|
|
232
|
+
elif not result and logger is not None:
|
|
233
|
+
logger.warning("kept skill %s: %s", skill_dir.name, result.message)
|
|
234
|
+
clean_broken_syncs(shared_skills, logger=logger)
|
|
235
|
+
_prune_empty(shared_skills)
|
|
236
|
+
return removed
|
dotagents/_sync.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""PathSyncer wrapper reproducing the legacy install.py backup/copy report.
|
|
2
|
+
|
|
3
|
+
Uses pathlib_next's PathSyncer (checksum-driven one-way tree sync) instead of
|
|
4
|
+
a hand-rolled read_bytes()==read_bytes() loop. On a would-overwrite of changed
|
|
5
|
+
content, the hook backs the old target file up to
|
|
6
|
+
<dest>/install_backup/<timestamp>/ before the sync writes, then counts
|
|
7
|
+
installed/backed-up/unchanged exactly as the legacy installer's final line.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pathlib_next import LocalPath
|
|
14
|
+
from pathlib_next.utils.sync import PathSyncer, SyncEvent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Counts:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.installed = 0
|
|
20
|
+
self.backed_up = 0
|
|
21
|
+
self.unchanged = 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sync_payload(
|
|
25
|
+
src: Path,
|
|
26
|
+
dest: Path,
|
|
27
|
+
entries: "list[str]",
|
|
28
|
+
*,
|
|
29
|
+
dry_run: bool = False,
|
|
30
|
+
) -> "tuple[_Counts, list[str]]":
|
|
31
|
+
"""Sync a fixed list of top-level payload entries (files or dirs) from
|
|
32
|
+
`src` into `dest`, matching the legacy installer's PAYLOAD-list behavior.
|
|
33
|
+
"""
|
|
34
|
+
from dotagents._merge import timestamped_backup_root
|
|
35
|
+
|
|
36
|
+
dest = Path(dest)
|
|
37
|
+
backup_root = timestamped_backup_root(dest)
|
|
38
|
+
counts = _Counts()
|
|
39
|
+
lines: "list[str]" = []
|
|
40
|
+
dest_local = LocalPath(dest)
|
|
41
|
+
copied_ids = set()
|
|
42
|
+
|
|
43
|
+
def hook(source, target, event: SyncEvent, is_dry_run: bool):
|
|
44
|
+
if event is SyncEvent.Copy:
|
|
45
|
+
rel = target.path.relative_to(dest_local)
|
|
46
|
+
existed = target.exists()
|
|
47
|
+
if existed:
|
|
48
|
+
lines.append("backup: %s" % rel.as_posix())
|
|
49
|
+
if not is_dry_run:
|
|
50
|
+
bak = backup_root / str(rel)
|
|
51
|
+
bak.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
shutil.copy2(str(target.path), str(bak))
|
|
53
|
+
counts.backed_up += 1
|
|
54
|
+
lines.append("install: %s" % rel.as_posix())
|
|
55
|
+
counts.installed += 1
|
|
56
|
+
copied_ids.add(id(target))
|
|
57
|
+
elif event is SyncEvent.Synced and source.is_file():
|
|
58
|
+
if target.exists() and id(target) not in copied_ids:
|
|
59
|
+
counts.unchanged += 1
|
|
60
|
+
|
|
61
|
+
syncer = PathSyncer(hook=hook)
|
|
62
|
+
|
|
63
|
+
for entry in entries:
|
|
64
|
+
source_path = Path(src) / entry
|
|
65
|
+
target_path = dest / entry
|
|
66
|
+
if not source_path.exists():
|
|
67
|
+
continue
|
|
68
|
+
if not dry_run:
|
|
69
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
syncer.sync(LocalPath(source_path), LocalPath(target_path), dry_run=dry_run)
|
|
71
|
+
|
|
72
|
+
return counts, lines, backup_root
|
dotagents/_wrappers.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Write `dotagents` / `dotagents.cmd` wrappers pointing at a built pyz."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
POSIX_TEMPLATE = '#!/bin/sh\nexec "{python}" "{pyz}" "$@"\n'
|
|
9
|
+
CMD_TEMPLATE = '@echo off\r\n"{python}" "{pyz}" %*\r\n'
|
|
10
|
+
|
|
11
|
+
# Relative forms: resolve the pyz from the wrapper's own directory, so moving or
|
|
12
|
+
# copying the scope dir does not break the command.
|
|
13
|
+
POSIX_TEMPLATE_REL = (
|
|
14
|
+
'#!/bin/sh\nexec "{python}" "$(dirname "$0")/{pyz}" "$@"\n'
|
|
15
|
+
)
|
|
16
|
+
CMD_TEMPLATE_REL = '@echo off\r\n"{python}" "%~dp0{pyz}" %*\r\n'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_wrappers(
|
|
20
|
+
bin_dir: Path,
|
|
21
|
+
pyz_path: Path,
|
|
22
|
+
python: "str | None" = None,
|
|
23
|
+
*,
|
|
24
|
+
relative: bool = False,
|
|
25
|
+
) -> "list[Path]":
|
|
26
|
+
"""Write both wrapper scripts into `bin_dir`, returning the paths written.
|
|
27
|
+
|
|
28
|
+
BOTH files are always written, on every platform: `dotagents` (sh) and
|
|
29
|
+
`dotagents.cmd`. A Windows box with Git Bash / WSL runs the sh one and cmd
|
|
30
|
+
runs the `.cmd`, and the two never collide -- cmd.exe resolves `.cmd` via
|
|
31
|
+
PATHEXT while sh picks the extensionless file. Writing only the platform's
|
|
32
|
+
"native" form (what the precursor did) breaks the other shell on the same box,
|
|
33
|
+
which on this project's own dev machine is the common case.
|
|
34
|
+
|
|
35
|
+
`python` defaults to the interpreter running this code (`sys.executable`),
|
|
36
|
+
embedded as an absolute path. A bare `python`/`python3` is NOT usable: on
|
|
37
|
+
Windows it resolves to the Microsoft Store alias stub ("Python was not
|
|
38
|
+
found...") for anyone who has not installed the Store package, so a wrapper
|
|
39
|
+
calling it exits 0 having done nothing -- which then silently breaks any hook
|
|
40
|
+
that shells out to `dotagents`.
|
|
41
|
+
|
|
42
|
+
`relative=True` points the wrapper at `pyz_path` relative to `bin_dir`
|
|
43
|
+
(`$(dirname $0)` / `%~dp0`), so a scope directory stays relocatable. Falls
|
|
44
|
+
back to an absolute path when the two live on different drives.
|
|
45
|
+
"""
|
|
46
|
+
bin_dir = Path(bin_dir)
|
|
47
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
pyz_path = Path(pyz_path).resolve()
|
|
49
|
+
python = python or sys.executable
|
|
50
|
+
|
|
51
|
+
rel_pyz = None
|
|
52
|
+
if relative:
|
|
53
|
+
try:
|
|
54
|
+
candidate = Path(os.path.relpath(pyz_path, bin_dir))
|
|
55
|
+
except ValueError:
|
|
56
|
+
# Different drives on Windows -- no relative path exists.
|
|
57
|
+
candidate = None
|
|
58
|
+
# Relative is for a pyz living in or beside the scope (keeps the store
|
|
59
|
+
# relocatable): `dotagents.pyz` or `../dotagents.pyz`. A pyz elsewhere on
|
|
60
|
+
# disk yields a long `../../../..` chain that is unreadable and MORE
|
|
61
|
+
# fragile than absolute -- it breaks when either side moves, not just the
|
|
62
|
+
# scope. One `..` is the limit.
|
|
63
|
+
if candidate is not None and candidate.parts.count("..") <= 1:
|
|
64
|
+
rel_pyz = candidate
|
|
65
|
+
|
|
66
|
+
written = []
|
|
67
|
+
|
|
68
|
+
# `Path.write_text(..., newline=...)` needs Python 3.10+; open() directly
|
|
69
|
+
# (with newline="") so the exact \n / \r\n bytes above are preserved
|
|
70
|
+
# unchanged on every Python 3.9+ platform.
|
|
71
|
+
sh_path = bin_dir / "dotagents"
|
|
72
|
+
with open(sh_path, "w", encoding="utf-8", newline="") as f:
|
|
73
|
+
if rel_pyz is not None:
|
|
74
|
+
f.write(
|
|
75
|
+
POSIX_TEMPLATE_REL.format(
|
|
76
|
+
python=Path(python).as_posix(), pyz=rel_pyz.as_posix()
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
f.write(
|
|
81
|
+
POSIX_TEMPLATE.format(
|
|
82
|
+
python=Path(python).as_posix(), pyz=pyz_path.as_posix()
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
if os.name != "nt":
|
|
86
|
+
mode = sh_path.stat().st_mode
|
|
87
|
+
sh_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
88
|
+
written.append(sh_path)
|
|
89
|
+
|
|
90
|
+
cmd_path = bin_dir / "dotagents.cmd"
|
|
91
|
+
with open(cmd_path, "w", encoding="utf-8", newline="") as f:
|
|
92
|
+
if rel_pyz is not None:
|
|
93
|
+
f.write(CMD_TEMPLATE_REL.format(python=str(python), pyz=str(rel_pyz)))
|
|
94
|
+
else:
|
|
95
|
+
f.write(CMD_TEMPLATE.format(python=str(python), pyz=str(pyz_path)))
|
|
96
|
+
written.append(cmd_path)
|
|
97
|
+
|
|
98
|
+
return written
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_path_warning(bin_dir: Path) -> "str | None":
|
|
102
|
+
"""Return a warning string (with the literal export hint) if `bin_dir` is
|
|
103
|
+
not on PATH, else None."""
|
|
104
|
+
bin_dir = str(Path(bin_dir).resolve())
|
|
105
|
+
path_entries = [str(Path(p).resolve()) for p in os.environ.get("PATH", "").split(os.pathsep) if p]
|
|
106
|
+
if bin_dir in path_entries:
|
|
107
|
+
return None
|
|
108
|
+
if os.name == "nt":
|
|
109
|
+
hint = '$env:PATH += ";%s"' % bin_dir
|
|
110
|
+
else:
|
|
111
|
+
hint = 'export PATH="%s:$PATH"' % bin_dir
|
|
112
|
+
return "warning: %s is not on PATH. Add it with:\n %s" % (bin_dir, hint)
|