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