Skip to content

Commands

The built-in command classes, one module each. Each is a duho command: its fields are the command's flags and its __call__ runs it. The Commands guide describes what each does from the command line.

dotagents.cli.init

dotagents init -- lay down the neutral base config (+ optional wrappers).

Init

Bases: DotAgentsArgs

Lay down the base config: the store's AGENTS.md and each harness's hooks.

That is the store's AGENTS.md managed block plus each harness's include and hooks -- never the opinionated overlays (those come from overlays add).

Scope: project by default -- <root>/.agents, where the root is a pinned $AGENTS_PROJECT_ROOT / $CLAUDE_PROJECT_DIR while the current directory is inside it; from outside a pinned root, the nearest ancestor of the current directory with a .git or its own .agents (with a warning); with no pin, the current directory (run from ~ it is the user store) -- or the user store with -g/--global (~/.agents, or $AGENTS_HOME). --dest overrides the resolved location. --bin-dir additionally writes dotagents wrapper scripts there so the command is on your PATH: at the .pyz when run from one, else running "<this python>" -m dotagents. <scope>/bin/ always gets them.

agents = [] class-attribute instance-attribute

List of agents to install for (e.g. claude,gemini). Default: auto-detect + claude.

bin_dir = None class-attribute instance-attribute

Also write dotagents/dotagents.cmd wrapper scripts here (puts the command on PATH).

dest = None class-attribute instance-attribute

Explicit destination, overriding the resolved scope (project/user).

dry_run = False class-attribute instance-attribute

Show what would be written without touching anything.

force = False class-attribute instance-attribute

Replace AGENTS.md wholesale (with backup) instead of block-merging.

from_ = None class-attribute instance-attribute

Base overlay to use instead of the bundled one: a directory or dotagents checkout, a file:/http(s):/sftp:/s3:/zip: URI, or a git <repo>[@ref][#path]. Recorded, so later runs reuse it.

no_hooks = False class-attribute instance-attribute

Skip wiring agent hooks and the shared skills link into the agent's config dir.

no_venv = False class-attribute instance-attribute

Do not create or update the scope's Python venv (duho for skills, overlay requirements).

powershell_env_hook = False class-attribute instance-attribute

Windows, Claude Code: also wire the PowerShell-tool env loader. It AUTO-APPROVES every PowerShell tool call (skips the permission prompt).

__call__()

Source code in src/dotagents/cli/init.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def __call__(self) -> int:
    project = False
    scope_level, project_root = None, None
    scope = None
    if self.dest is not None:
        dest = Path(self.dest).expanduser().resolve()
    else:
        scope = self.resolve_scope()
        dest = Path(scope.agents_root).expanduser().resolve()
        project = not scope.global_scope
        scope_level, project_root = scope.level, scope.project_root
        self._logger_.info("scope: %s (%s)", scope.level, dest)

    # An explicit --from is recorded in the store's config, so a later plain
    # `init` and every `overlays add/remove/sync` compose over the same base.
    config = read_store_config(dest)
    base_arg = self.from_ if self.from_ is not None else config.get("base")
    src = _resolve_from(base_arg, BASE_ROOT, logger=self._logger_)

    agent_names = []
    if self.agents:
        for a in self.agents:
            agent_names.extend([x.strip() for x in a.split(",") if x.strip()])

    _apply_base(
        Path(src), dest, self.force, self.dry_run, self._logger_,
        agents=agent_names if agent_names else None,
        wire_hooks=not self.no_hooks,
        powershell_env_hook=self.powershell_env_hook,
        project=project,
        scope_level=scope_level,
        project_root=project_root,
    )

    if self.from_ is not None:
        recorded = recorded_from(self.from_, self._logger_)
        if config.get("base") != recorded:
            if self.dry_run:
                self._logger_.info("would record base: %s", dest / STORE_CONFIG)
            else:
                config["base"] = recorded
                self._logger_.info("recorded base: %s", write_store_config(dest, config))

    if self.dry_run:
        # Say where the wrappers would go; nothing else about them is
        # decided until a real run (the .pyz or `python -m` form).
        bin_dirs = [dest / "bin"] + ([Path(self.bin_dir)] if self.bin_dir is not None else [])
        for bin_dir in bin_dirs:
            self._logger_.info(
                "would write wrappers: %s, %s", bin_dir / "dotagents", bin_dir / "dotagents.cmd"
            )
    else:
        from dotagents._wrappers import (
            check_path_warning,
            wrapper_points_at_pyz,
            write_module_wrappers,
            write_wrappers,
        )

        pyz_path = Path(sys.argv[0]).resolve()
        if pyz_path.suffix != ".pyz":
            # Running from a plain install (not a pyz): the wrappers run
            # `"<this python>" -m dotagents` instead of a pyz path.
            pyz_path = None

        # `<scope>/bin/` is always populated, with a path relative to the scope
        # so the store stays relocatable. Everything downstream of `init` --
        # the SessionStart hook, overlay `bin/` PATH entries, an overlay setup
        # script calling a sibling -- shells out to `dotagents` by name, so a
        # scope without it is a scope where those silently fail.
        targets = [(dest / "bin", True)]
        if self.bin_dir is not None:
            targets.append((Path(self.bin_dir), False))

        for bin_dir, relative in targets:
            if pyz_path is not None:
                written = write_wrappers(bin_dir, pyz_path, relative=relative)
            else:
                if wrapper_points_at_pyz(bin_dir):
                    self._logger_.warning(
                        "replacing the .pyz wrappers in %s with ones running "
                        "this install (%s -m dotagents)", bin_dir, sys.executable,
                    )
                written = write_module_wrappers(bin_dir)
            for w in written:
                self._logger_.info("wrapper: %s", w)

        if self.bin_dir is not None:
            warning = check_path_warning(Path(self.bin_dir))
            if warning:
                self._logger_.warning(warning)

    if scope is not None and not self.no_venv:
        # A store without its venv still works (skills fall back to the
        # session's PYTHONPATH), so a failure here -- offline, no pip -- is
        # a warning, never a failed init.
        from dotagents import _pyvenv

        try:
            _pyvenv.sync_venvs(scope, dry_run=self.dry_run, logger=self._logger_)
        except SystemExit as exc:
            self._logger_.warning("%s; `dotagents overlays sync` retries", exc.code)

    if self.dry_run:
        self._logger_.info("dry-run: no files were written")
    return 0

dotagents.cli.overlays

dotagents overlays -- add / remove / list / sync / show, with skills sync.

The logic lives in _scope.py / _sources.py / _skills.py / _overlays.py; cli/__init__.py lists the Overlays umbrella in _BUILTIN_COMMANDS. Discover-not-track: installed overlays are the dirs under <store>/overlays/. Each carries an install record (Overlay.INSTALL_RECORD): the repo it came from, which sync resolves it from again, and the digest of every file installed, which lets sync prune what the source dropped.

OverlayAdd

Bases: _RepoArgs

Install overlay(s) by name into a scope, and publish their skills.

Resolves each <name> against the source (--repo, the env repos, the stores' registries); a glob ('*', 'py*') stands for every name the source offers that matches it, so add '*' installs what a store's registry names. Installs what each manifest requires first, copies it into <store>/overlays/<name>/ with a record of the repo it came from, and publishes its skills/ from the INSTALLED copy into the shared <store>/skills/, linking them into each agent's own skills dir that init already wired for the scope (Claude's <config>/skills/; otherwise re-run init). --copy mirrors skills as real dirs instead of symlinks (Windows / no-symlink). The AGENTS.md managed block is then rebuilt from the pristine base over every installed overlay's routing and rules, in (priority, name) order. When an overlay's requirements or setup fail, only that overlay is affected: one this command was installing for the first time is removed again, one that was already installed stays (not set up). Every other overlay is still installed, except those that require the failed one (directly or not), and the command exits 1 naming every overlay that did not install. The block is recomposed over whatever is installed either way. Adding an overlay already installed makes it match its source again, as sync does.

copy = False class-attribute instance-attribute

Copy skills into the shared dir instead of symlinking (no-symlink fallback).

dry_run = False class-attribute instance-attribute

Show what would happen without touching anything.

name = [] class-attribute instance-attribute

Overlay name(s) or glob(s) to install, resolved against the source ('*': every overlay it offers).

no_requires = False class-attribute instance-attribute

Do not install the overlays each manifest's requires names.

no_setup = False class-attribute instance-attribute

Skip running an overlay's idempotent setup script after install.

no_venv = False class-attribute instance-attribute

Do not create or update the scope's Python venv (overlay requirements, duho, the .pth layering).

prune = False class-attribute instance-attribute

Re-adding an installed overlay: also clear its .gitignore/.ignore'd files and caches.

setup_timeout = None class-attribute instance-attribute

Seconds a setup script may run (default: its manifest's setup_timeout, else 300; 0 = no limit).

__call__()

Source code in src/dotagents/cli/overlays.py
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
def __call__(self) -> int:
    from dotagents import _overlays, _scope

    if not self.name:
        return _usage_error("overlays add needs at least one overlay name (or '*')")
    _validated_names([n for n in self.name if not _is_glob(n)], "add")

    scope = self.resolve_scope()
    source = _scope.resolve_source(self.repo, scope=scope, logger=self._logger_)
    self._logger_.info("scope: %s (%s)", scope.level, scope.agents_root)
    names = _validated_names(_expand_globs(self.name, source), "add")

    # Resolve every name (and its requires) against the source BEFORE the
    # first install, so `add good bad` fails on `bad` with nothing touched.
    # What the scope's walk already has satisfies a requirement as it is.
    installed = frozenset(o.normalized_name for o in scope.overlays)
    order = _install_order(
        source, names, follow_requires=not self.no_requires, logger=self._logger_,
        installed=installed,
    )
    located = {name: source.locate(name) for name in order}
    for name in order:
        problem = _source_inside_install_root(scope, name, located[name][0])
        if problem:
            raise SystemExit("error: " + problem)

    # One overlay failing (its copy, requirements or setup) does not stop
    # the rest: each is tried in dependency order, and only what requires a
    # failed one -- directly or through another -- is skipped.
    requires = {
        name: {
            _overlays.Overlay.normalize_name(str(dep))
            for dep in _overlays.Overlay(located[name][0]).read_manifest()["requires"]  # type: ignore[union-attr]
        }
        for name in order
    }
    failed: "dict[str, str]" = {}  # name -> why, in the order they failed
    skills: "list[str]" = []
    try:
        for name in order:
            blocked = sorted(requires[name] & set(failed))
            if blocked:
                why = "it requires %s, which failed" % ", ".join(blocked)
                self._logger_.error("overlay %s not installed: %s", name, why)
                failed[name] = why
                continue
            src, origin = located[name]
            try:
                skills += _install_one(
                    scope, name, src, origin, copy=self.copy, no_setup=self.no_setup,
                    dry_run=self.dry_run, logger=self._logger_,
                    setup_timeout=self.setup_timeout, prune=self.prune,
                    no_venv=self.no_venv,
                )
            except (SystemExit, OSError) as exc:
                why = str(exc.code if isinstance(exc, SystemExit) else exc)
                why = why[len("error: "):] if why.startswith("error: ") else why
                self._logger_.error("overlay %s: %s", name, why)
                failed[name] = why
    finally:
        # Recompose the whole managed block from the pristine base over ALL
        # installed overlays in (priority, name) order (D68) -- also when an
        # install failed part-way, so what did land is merged.
        extra = [
            located[n][0] for n in order if self.dry_run and _installed_dir(scope, n) is None
        ]
        _recompose(scope, self._logger_, dry_run=self.dry_run, extra=extra)
    if not self.dry_run:
        _link_agent_skills(scope, self._logger_, skills)
    if self.dry_run:
        self._logger_.info("dry-run: no files were written")
    if failed:
        raise SystemExit("error: add failed for: %s" % "; ".join(
            "%s (%s)" % (name, why) for name, why in failed.items()
        ))
    return 0

OverlayList

Bases: _RepoArgs

List the overlays installed in the scope and those available from source.

In the project scope the user store's are listed too -- both are in play for a project session; a same-named project overlay shadows the store's copy.

installed is discovered by presence under <store>/overlays/; no registry file. An installed overlay whose requires no installed overlay provides is flagged. available is what the source offers (* = installed in either scope; a repo that cannot be loaded is a warning, the others are still listed). --json emits everything as a machine-readable object.

json = False class-attribute instance-attribute

Emit JSON instead of plain text.

__call__()

Source code in src/dotagents/cli/overlays.py
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def __call__(self) -> int:
    import json as _json

    from dotagents import _scope
    from dotagents._overlays import Overlay
    from dotagents._sources import SourceError

    scope = self.resolve_scope()
    # `scope.overlays`: every store in play (system, user, and the project's
    # in a project scope), a same-named overlay in a later store shadowing
    # the earlier copies. With -g there is no project store.
    active = scope.overlays
    by_store = {store: [o.name for o in active if o.store == store] for store in scope.stores}
    # Shadowed copies are not in `active` at all; list them for the eye.
    shadowed = {
        store: [
            o.name for o in Overlay.discover(store / "overlays")
            if o.name not in by_store[store]
        ]
        for store in scope.stores
    }
    unmet = _unmet_requires(active)

    def _broken(spec, exc) -> None:
        self._logger_.warning("repo %s not listed: %s", _redacted(spec), exc)

    try:
        available = _scope.resolve_source(
            self.repo, scope=scope, logger=self._logger_,
        ).available(on_error=_broken)
    except SourceError as exc:  # nothing configured at all
        self._logger_.warning("%s", exc)
        available = []
    names = {o.normalized_name for o in active}

    # Listed most-specific first: the scope's own store, then the stores
    # beneath it (user, then system); a store with nothing is shown only
    # when it is the scope's own.
    listing = []
    for store in reversed(scope.stores):
        level = scope.store_level(store)
        if store != scope.agents_root and not by_store[store] and not shadowed[store]:
            continue
        listing.append((level, store, by_store[store], shadowed[store]))

    if self.json:
        payload = {
            "scope": scope.level,
            "root": str(scope.overlay_root),
            "installed": by_store[scope.agents_root],
            "stores": [
                {"level": level, "root": str(store / "overlays"),
                 "installed": names_, "shadowed": shadowed_}
                for level, store, names_, shadowed_ in listing
            ],
            "unmet_requires": unmet,
            "available": available,
        }
        _write_stdout(_json.dumps(payload, indent=2) + "\n")
        return 0

    self._logger_.info("scope: %s (%s)", scope.level, scope.overlay_root)
    lines = []
    for level, store, names_, shadowed_ in listing:
        lines.append("installed (%s):" % level)
        lines += [
            "  %s%s" % (n, "  (requires missing: %s)" % ", ".join(unmet[n]) if n in unmet else "")
            for n in names_
        ]
        lines += ["  %s  (shadowed by a more specific store's)" % n for n in shadowed_]
        if not names_ and not shadowed_:
            lines.append("  (none)")
    lines.append("available (source):")
    lines += [
        "  %s%s" % (n, " *" if Overlay.normalize_name(n) in names else "") for n in available
    ] or ["  (none)"]
    _write_stdout("\n".join(lines) + "\n")
    return 0

OverlayRemove

Bases: DotAgentsArgs

Remove installed overlays, unpublish their skills, recompose AGENTS.md.

Each overlay dir is deleted, its skills unpublished (and removed from each agent's own skills dir init wired), and AGENTS.md's managed block recomposed over what remains.

Deletes only <store>/overlays/<name>/ (a symlinked or junctioned overlay is unlinked, its target untouched) and unpublishes only the skills that overlay published (matched to its own skills/, by content) -- and only once the delete succeeded. Refuses an overlay another installed overlay requires (and no other store provides) unless --force. The block is recomposed over the overlays still installed, even when a removal fails part-way.

dry_run = False class-attribute instance-attribute

Show what would happen without touching anything.

force = False class-attribute instance-attribute

Remove even when another installed overlay requires it.

name = [] class-attribute instance-attribute

Overlay name(s) to remove.

no_venv = False class-attribute instance-attribute

Do not create or update the scope's Python venv (overlay requirements, duho, the .pth layering).

__call__()

Source code in src/dotagents/cli/overlays.py
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
def __call__(self) -> int:
    from dotagents import _overlays, _skills

    if not self.name:
        return _usage_error("overlays remove needs at least one overlay name")
    names = _validated_names(self.name, "remove")
    scope = self.resolve_scope()
    self._logger_.info("scope: %s (%s)", scope.level, scope.agents_root)

    targets = []
    for name in names:
        overlay_dir = _installed_dir(scope, name)
        if overlay_dir is None:
            self._logger_.warning(
                "overlay %r not installed at %s", name, scope.overlay_dir(name)
            )
            continue
        targets.append((name, overlay_dir))
    removing = frozenset(name for name, _ in targets)

    if removing and not self.force:
        # What the scope's stores still provide once these are gone: a
        # requirement another store satisfies is not broken by the removal.
        after = [
            o for store in scope.stores
            for o in _overlays.Overlay.discover(store / "overlays")
            if not (store == scope.agents_root and o.normalized_name in removing)
        ]
        unmet = _unmet_requires(after)
        dependents = sorted(
            "%s (required by %s)" % (dep, ", ".join(sorted(
                who for who, missing in unmet.items() if dep in missing
            )))
            for dep in removing if any(dep in m for m in unmet.values())
        )
        if dependents:
            raise SystemExit(
                "error: not removing %s; pass --force to remove anyway"
                % "; ".join(dependents)
            )

    removed_names: "set[str]" = set()
    try:
        for name, overlay_dir in targets:
            if self.dry_run:
                removed_names.add(name)
                self._logger_.info("removed overlay %s (%s) [dry-run]", name, overlay_dir)
                continue
            # Which published skills are this overlay's is decided while it
            # still exists; they are unpublished only once it is gone, so a
            # failed delete leaves the overlay and its skills as they were.
            owned = _skills.owned_overlay_skills(
                overlay_dir, scope.shared_skills_dir, logger=self._logger_
            )
            _remove_tree(overlay_dir)
            removed_names.add(name)
            unpublished = _skills.unpublish_skills(
                scope.shared_skills_dir, owned, logger=self._logger_
            )
            if unpublished:
                self._logger_.info("unpublished %d skill(s) from %s", unpublished, name)
            self._logger_.info("removed overlay %s (%s)", name, overlay_dir)
    finally:
        if targets:
            _recompose(
                scope, self._logger_, dry_run=self.dry_run,
                without=frozenset(removed_names) if self.dry_run else frozenset(),
            )
        if removed_names and not self.dry_run:
            # Prune what the removed skills left in agents' own skills dirs.
            _link_agent_skills(scope, self._logger_)

    # The layering drops the removed overlays' libs. Their requirements
    # stay installed: another overlay may need the same packages.
    if removed_names and not _sync_venvs(
        scope, no_venv=self.no_venv, dry_run=self.dry_run, logger=self._logger_
    ):
        raise SystemExit("error: the scope's Python venv could not be updated")
    if self.dry_run:
        self._logger_.info("dry-run: no files were written")
    return 0

OverlayShow

Bases: _RepoArgs

Describe one overlay: where it is, its manifest, skills and file count.

The manifest part is what it declares (description, priority, requires, routing, rules) and its setup script; for an installed overlay, also the repo it was installed from and any requires no installed overlay provides.

Looks at the INSTALLED copy in the scope first (in the project scope, then the user store's -- the one a project session would use), else the source's.

json = False class-attribute instance-attribute

Emit JSON instead of plain text.

name = '' class-attribute instance-attribute

Overlay name.

__call__()

Source code in src/dotagents/cli/overlays.py
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
def __call__(self) -> int:
    import json as _json

    from dotagents import _overlays, _scope
    from dotagents._sources import record_display

    if not self.name:
        return _usage_error("overlays show needs an overlay name")
    (name,) = _validated_names([self.name], "show")
    scope = self.resolve_scope()
    # The copy a session in this scope would use (the most specific store's),
    # else the source's.
    installed = scope.overlays
    active = [o for o in installed if o.normalized_name == name]
    if active:
        path = active[0].path
        where = "installed (%s)" % scope.store_level(active[0].store)
    else:
        where = "source"
        path = _scope.resolve_source(self.repo, scope=scope, logger=self._logger_).overlay_dir(name)
    overlay = _overlays.Overlay(path)
    manifest = overlay.read_manifest()
    setup = overlay.find_setup_script()
    skills = sorted(
        d.name for d in (path / "skills").iterdir() if d.is_dir()
    ) if (path / "skills").is_dir() else []
    record = overlay.read_install_record() if active else {}
    have = {o.normalized_name for o in installed}
    info = {
        "name": overlay.name,
        "where": where,
        "path": str(path),
        "root_var": overlay.root_var,
        "description": manifest["description"],
        "priority": manifest["priority"],
        "requires": manifest["requires"],
        "unmet_requires": [d for d in manifest["requires"] if d not in have]  # type: ignore[union-attr]
        if active else [],
        "installed_from": record_display(record["source"]) if record.get("source") else None,
        "routing": manifest["routing"],
        "rules": manifest["rules"],
        "setup": setup.name if setup else None,
        "skills": skills,
        "files": len(overlay.files()),
    }
    if self.json:
        _write_stdout(_json.dumps(info, indent=2) + "\n")
        return 0
    lines = [
        "%s (%s): %s" % (info["name"], where, info["path"]),
        "  root var:    %s" % info["root_var"],
        "  description: %s" % (info["description"] or "-"),
        "  priority:    %s" % info["priority"],
        "  requires:    %s%s" % (
            ", ".join(info["requires"]) or "-",  # type: ignore[arg-type]
            "  (missing: %s)" % ", ".join(info["unmet_requires"])  # type: ignore[arg-type]
            if info["unmet_requires"] else "",
        ),
        "  rules files: %s" % (", ".join(info["rules"]) or "-"),  # type: ignore[arg-type]
        "  routing:     %d line(s)" % len(info["routing"]),  # type: ignore[arg-type]
        "  setup:       %s" % (info["setup"] or "-"),
        "  skills:      %s" % (", ".join(skills) or "-"),
        "  files:       %d" % info["files"],
    ]
    if info["installed_from"]:
        lines.append("  from:        %s" % info["installed_from"])
    _write_stdout("\n".join(lines) + "\n")
    return 0

OverlaySync

Bases: _RepoArgs

Refresh installed overlays from source, and resync their skills.

Each installed overlay is fetched again from the repo it was INSTALLED from (its install record) -- not from whichever repo now offers the name first -- unless --repo is given, which replaces each recorded source: the overlay resolves through that chain instead. The install is made to match the source exactly: new and changed files land, overlay.toml is refreshed, and files the source does not ship are removed -- a file edited here is backed up first (<store>/install_backup/). What the overlay's .gitignore / .ignore files match (setup output, local state) and tool caches are left alone; --prune clears them too. A requires added upstream is installed. The managed block is recomposed over every installed overlay. An optional <glob> filters which installed overlays to sync (sync 'py*'). A repo that cannot be loaded fails the sync (after the others are synced); a repo that no longer offers an overlay is a warning.

copy = False class-attribute instance-attribute

Copy skills into the shared dir instead of symlinking (no-symlink fallback).

dry_run = False class-attribute instance-attribute

Show what would happen without touching anything.

no_setup = False class-attribute instance-attribute

Skip running each overlay's idempotent setup script after sync.

no_venv = False class-attribute instance-attribute

Do not create or update the scope's Python venv (overlay requirements, duho, the .pth layering).

pattern = None class-attribute instance-attribute

Glob over installed overlay names to sync (default: all).

prune = False class-attribute instance-attribute

Also clear the overlay's .gitignore/.ignore'd files and caches: exactly the upstream files.

setup_timeout = None class-attribute instance-attribute

Seconds a setup script may run (default: its manifest's setup_timeout, else 300; 0 = no limit).

__call__()

Source code in src/dotagents/cli/overlays.py
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
def __call__(self) -> int:
    from dotagents import _overlays, _scope, _skills
    from dotagents._sources import SourceError, source_record

    scope = self.resolve_scope()
    chain = _scope.resolve_source(
        self.repo, scope=scope, logger=self._logger_, allow_empty=True,
    )
    installed = _overlays.Overlay.discover(scope.overlay_root)
    wanted = set(_scope.filter_names([o.name for o in installed], self.pattern))
    targets = [o for o in installed if o.name in wanted]
    if not targets:
        self._logger_.info(
            "no installed overlays%s to sync",
            "" if self.pattern in (None, "*") else " matching %r" % self.pattern,
        )
        return 0
    self._logger_.info("scope: %s (%s)", scope.level, scope.agents_root)

    failures: "list[str]" = []
    skills: "list[str]" = []
    synced_sources: "dict[str, Path]" = {}
    try:
        for overlay in targets:
            name, dest_dir = overlay.name, overlay.path
            try:
                overlay_src, origin = self._resolve(chain, overlay)
            except SourceError as exc:
                self._logger_.error("overlay %s: %s", name, exc)
                failures.append(name)
                continue
            if overlay_src is None:
                continue
            problem = _source_inside_install_root(scope, name, overlay_src)
            if problem:
                self._logger_.error("%s", problem)
                failures.append(name)
                continue
            result = _overlays.Overlay(overlay_src).install_to(
                dest_dir, self.dry_run, prune=self.prune,
                backup_root=_backup_root(scope, name), source=source_record(origin),
            )
            for line in result.lines:
                if not line.startswith("skip"):
                    self._logger_.info("sync: %s", line)
            _log_install(self._logger_, "written", name, result, self.dry_run)
            synced_sources[overlay.normalized_name] = overlay_src
            if not self.dry_run:
                _skills.resync_overlay_skills(
                    dest_dir, scope.shared_skills_dir, copy=self.copy,
                    overwrite=True, logger=self._logger_,  # a published copy matches too
                )
            if not _sync_venvs(scope, no_venv=self.no_venv, dry_run=self.dry_run,
                               logger=self._logger_):
                failures.append(name)
                continue
            rc = _run_setup(scope, dest_dir, name, no_setup=self.no_setup,
                            dry_run=self.dry_run, logger=self._logger_,
                            source_dir=overlay_src, timeout=self.setup_timeout)
            if rc:
                self._logger_.error("setup for overlay %s failed (exit %d)", name, rc)
                failures.append(name)

        # A `requires` added upstream: install what no store provides yet.
        have = frozenset(o.normalized_name for o in scope.overlays)
        wanted_deps: "list[str]" = []
        for norm, src in synced_sources.items():
            manifest_dir = src if self.dry_run else _installed_dir(scope, norm) or src
            for dep in _overlays.Overlay(manifest_dir).read_manifest()["requires"]:  # type: ignore[union-attr]
                if dep not in have and dep not in wanted_deps:
                    wanted_deps.append(dep)
        if wanted_deps:
            self._logger_.info("installing newly required overlay(s): %s", ", ".join(wanted_deps))
            order = _install_order(
                chain, wanted_deps, follow_requires=True, logger=self._logger_, installed=have,
            )
            for dep in order:
                src, origin = chain.locate(dep)
                problem = _source_inside_install_root(scope, dep, src)
                if problem:
                    self._logger_.error("%s", problem)
                    failures.append(dep)
                    continue
                skills += _install_one(
                    scope, dep, src, origin, copy=self.copy, no_setup=self.no_setup,
                    dry_run=self.dry_run, logger=self._logger_, setup_timeout=self.setup_timeout,
                    no_venv=self.no_venv,
                )
    finally:
        # Recompose over ALL installed overlays in (priority, name) order
        # (D68) -- not just the pattern-matched subset synced above.
        _recompose(scope, self._logger_, dry_run=self.dry_run)
    if not self.dry_run:
        _link_agent_skills(scope, self._logger_, skills)
    if self.dry_run:
        self._logger_.info("dry-run: no files were written")
    if failures:
        raise SystemExit("error: sync failed for: %s" % ", ".join(failures))
    return 0

Overlays

Bases: LoggingArgs, Cli

Manage opt-in overlays by name: add / remove / list / sync / show (+ skills sync).

dotagents.cli.context

dotagents context -- assemble the effective context for agents.

Context

Bases: DotAgentsArgs

Assemble the effective context for agents.

Roots (both configurable, never hardcoded): the user store is --agents-dir -> $AGENTS_HOME -> ~/.agents (:func:~dotagents.cli._common.resolve_user_store), and the project root is $AGENTS_PROJECT_ROOT -> $CLAUDE_PROJECT_DIR -> the cwd (:func:~dotagents._scope.project_root_default). This matters most here: the SessionStart hook runs dotagents context from wherever the session happens to start, so a pinned project root is the only thing that keeps the assembled context stable across subdirectories.

-g/--global means skip the project-level context files, NOT "resolve a different store" -- same narrowed meaning as dotagents env's (and unlike DotAgentsArgs.resolve_scope, which is deliberately not used here).

FORMATS = ('markdown', 'system-reminder', 'json') class-attribute instance-attribute

agents = [] class-attribute instance-attribute

Agents to generate context for, by name or harness id (e.g. claude,gemini-cli). Default: active agent.

agents_dir = None class-attribute instance-attribute

User store root override (default: $AGENTS_HOME, else ~/.agents).

format = 'markdown' class-attribute instance-attribute

Output format: markdown, system-reminder, or json.

global_scope = False class-attribute instance-attribute

Skip project-level context files (the store root is unaffected).

inline = False class-attribute instance-attribute

Also inline the on-demand .md files the sources reference. Off by default: the base rules say to read those only when a task needs them.

out = '-' class-attribute instance-attribute

Output path (positional). Default '-' = stdout; a path writes that file (one agent, or several with --format json); use --write-agent to write each agent's native config file instead.

write_agent = False class-attribute instance-attribute

Merge the context into each agent's own instruction file under the project root (Claude .claude/CLAUDE.md, Codex AGENTS.md, Gemini GEMINI.md, Cursor .cursor/rules/dotagents.mdc, Copilot .github/copilot-instructions.md, pi .pi/APPEND_SYSTEM.md, Antigravity .agents/rules/dotagents.md) as a managed block, instead of printing it.

__call__()

Source code in src/dotagents/cli/context.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def __call__(self) -> int:
    from dotagents import _agents
    from dotagents import _context
    from dotagents import _scope
    import json
    import os

    if self.format not in self.FORMATS:
        raise SystemExit(
            "error: --format must be one of %s (got %r)"
            % (", ".join(self.FORMATS), self.format)
        )
    if self.write_agent and self.format == "json":
        raise SystemExit("error: --write-agent writes markdown; it cannot take --format json")
    if self.write_agent and self.out != "-":
        raise SystemExit("error: --write-agent and an output path are mutually exclusive")
    if self.write_agent and self.global_scope:
        # -g is "the user store only": writing that into a project's
        # (usually committed) instruction file is what -g exists to avoid.
        raise SystemExit("error: --write-agent writes into the project; it cannot take -g")

    project_root = _scope.project_root_default()
    scope = _scope.Scope.of(
        agents_dir=resolve_user_store(self.agents_dir),
        project_root=project_root,
        global_scope=self.global_scope,
    )

    agent_names = []
    if self.agents:
        for a in self.agents:
            agent_names.extend([x.strip() for x in a.split(",") if x.strip()])

    if agent_names:
        active_agents = []
        for name in agent_names:
            # A registry name or a harness id (`claude-code`), like $AGENTS_HARNESS.
            a = _agents.get_agent(_agents._harness_alias(name) or name)
            if a:
                active_agents.append(a)
            else:
                self._logger_.warning("Unknown agent: %s", name)
        if not active_agents:
            self._logger_.error(
                "no known agent in --agents (known: %s)",
                ", ".join(a.name for a in _agents.get_all_agents()),
            )
            return 2
    else:
        # Default target = the active agent (env-var detection / $AGENTS_HARNESS
        # stamp / config-file detect), not "all detected".
        active_agents = [
            _agents.resolve_active_agent(os.environ, root=project_root)
        ]

    if self.format != "json" and self.out != "-" and len(active_agents) > 1:
        # One file, one agent: a loop writing it per agent kept the last.
        raise SystemExit(
            "error: an output path takes one agent (got %d); omit it to print "
            "each agent's context, or use --format json" % len(active_agents)
        )

    # --- JSON: emit structured data (object for one agent, array for many);
    #     never writes native config files. ---
    if self.format == "json":
        payloads = [
            _context.assemble_context_data(agent, scope, inline=self.inline)
            for agent in active_agents
        ]
        out_obj = payloads[0] if len(payloads) == 1 else payloads
        blob = json.dumps(out_obj, indent=2, ensure_ascii=False)
        if self.out and self.out != "-":
            write_text_lf(self.out, blob)
            self._logger_.info("Wrote JSON context to %s", self.out)
        else:
            # default '-' -> stdout (json never writes native configs), as
            # UTF-8: a bare print() encodes with the console codepage and
            # dies on any character outside Latin-1.
            _write_stdout(blob + "\n")
        return 0

    # --- markdown / system-reminder text paths ---
    for agent in active_agents:
        # `$<NAME>_OVERLAY_ROOT` expands for a reader with no populated
        # shell, except in --write-agent's file, which may be committed:
        # the expansion is a machine path.
        text = _context.assemble_context(
            agent, scope, inline=self.inline, expand_vars=not self.write_agent
        )

        if self.format == "system-reminder" and text:
            text = (
                "<!-- system-reminder: begin -->\n"
                + text
                + "\n<!-- system-reminder: end -->"
            )

        if self.write_agent:
            # The PROJECT root, never the store: the target is the harness's
            # own instruction file, merged as a managed block.
            agent.write_context(project_root, text, dry_run=False, logger=self._logger_)
        elif self.out == "-":
            # Just the context on stdout. A per-agent delimiter is emitted ONLY when
            # more than one agent is generated, so a single-agent run (the default)
            # is clean, pipeable output with no decoration to strip.
            if len(active_agents) > 1:
                _write_stdout("# --- %s ---\n" % agent.name)
            _write_stdout(text + "\n")
        else:
            write_text_lf(self.out, text)
            self._logger_.info(f"Wrote {agent.name} context to {self.out}")

    return 0

dotagents.cli.env

dotagents env -- chained env-file assembly + env.py execution.

All assembly logic lives in _env.py (contract B) and _agents.stamp_identity; this module formats the result. Never logs env VALUES -- output goes to stdout for the caller to consume; the logger only ever names vars (Leakage rule).

INTO_BEGIN = ': dotagents-env-begin' module-attribute

INTO_END = ': dotagents-env-end' module-attribute

INTO_FORMATS = ('export',) module-attribute

Env

Bases: DotAgentsArgs

Assemble the chained env (env files + env.py execution) under contract B.

Prepends every level's bin dir to PATH first and every existing lib dir to PYTHONPATH (listing them in AGENTS_PYTHONPATH), then evaluates the pre.* tier and the main tier in precedence order (store by store -- system, user, project -- each store's overlays before the store itself), chaining each file over the accumulated env so later files win. .py files are EXECUTED and emit JSON env changes; plain files are sourced by bash. A project root's own files outside .agents/ never run. Standardized AGENTS_*/AGENT identity vars and the AGENTS_PROXY model are wired in.

--diff emits only the vars that differ from the current environment (what a SessionStart hook injects); the default emits the full assembled env merged over the current one. Output is sensitive -- it may carry secret values. A var an env layer unsets (null from an env.py, unset in a plain file) is emitted as an unset where the format has one.

--cache reuses the output of an earlier run whose inputs were the same -- the whole environment, the cwd, the stores and their overlays, every env file's and lib file's mtime and size -- for up to five minutes (an env.py may read something no key can see), stored owner-only under <user store>/.cache/env/. The env-loader hooks, which run before every shell command, pass it; an edited env file is picked up on the next call.

--format selects the emitted syntax and defaults to auto, which detects the CALLING shell (parent-process chain) and picks a matching format so the output is sourceable where it runs: export (aliases posix/sh/bash), dotenv (env), powershell (pwsh/ps), cmd (bat/batch), fish, plus the data forms json/ini/yaml. An explicit --format always wins. cmd output is meant to run as a batch file: redirect it to a .cmd file and call that file, with delayed expansion off. Only then do its doubled percent signs collapse back, and a !name! in a value is not expanded.

Roots (both configurable, never hardcoded): the user store is --agents-dir -> $AGENTS_HOME -> ~/.agents (:func:~dotagents.cli._common.resolve_user_store), and the project root is $AGENTS_PROJECT_ROOT -> $CLAUDE_PROJECT_DIR -> the cwd (:func:~dotagents._scope.project_root_default). A harness that pins those vars gets ONE root per session regardless of the cwd a subprocess runs in -- which is the point of env emitting them in the first place.

-g/--global here means skip the project-level env files, NOT "resolve a different store": the walk always starts from the USER store and -g only drops the project tiers. (DotAgentsArgs.resolve_scope -- which would return <project>/.agents as the root -- is deliberately NOT used; this command inherits the class only for the shared -g/--agents-dir flag pair.)

agents_dir = None class-attribute instance-attribute

User store root override (default: $AGENTS_HOME, else ~/.agents).

cache = False class-attribute instance-attribute

Reuse the output of an earlier identical run (same environment, cwd, store, overlays and env/lib files, at most 5 minutes old) instead of running the env files again; the per-command env loaders use it.

diff = False class-attribute instance-attribute

Emit only vars that differ from the current environment.

format = 'auto' class-attribute instance-attribute

Output format. Default 'auto' detects the calling shell. Shell forms: export (aliases posix/sh/bash), dotenv (env), powershell (pwsh/ps), cmd (bat/batch), fish. Data forms: json, ini, yaml.

global_scope = False class-attribute instance-attribute

Skip project-level env files (the store root is unaffected).

into = None class-attribute instance-attribute

Write into this file instead of stdout (a hook's $CLAUDE_ENV_FILE), replacing the block an earlier run wrote there and keeping the rest: the file holds one current block however often the hook runs.

__call__()

Source code in src/dotagents/cli/env.py
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
def __call__(self) -> int:
    import os

    from dotagents import _env, _scope

    # The walk's scope: the user store always, plus the project's tier
    # unless -g (which here means "skip the project tiers", not "another
    # store" -- see the class docstring).
    scope = _scope.Scope.of(
        agents_dir=resolve_user_store(self.agents_dir),
        project_root=_scope.project_root_default(),
        global_scope=self.global_scope,
    )
    base = dict(os.environ)

    if self.format not in _env.KNOWN_FORMATS:
        raise SystemExit(
            "error: --format must be one of %s (got %r)"
            % (", ".join(_env.KNOWN_FORMATS), self.format)
        )
    # `auto` (the default) resolves to the calling shell's format; an explicit
    # --format always wins. Detection reads process names only (never values).
    output_format = self.format
    if output_format == "auto":
        output_format = _env.detect_shell_format()

    key = None
    if self.cache:
        # A hook runs this before EVERY shell call; the assembly (one
        # child process per env file) is skipped while its inputs hold.
        key = _env.env_cache_key(scope, base, output_format, "diff" if self.diff else "full")
        cached = _env.read_env_cache(scope, key)
        if cached is not None:
            _write_stdout(cached)
            return 0

    if self.diff:
        env = _env.get_diff(scope, base_env=base, logger=self._logger_)
        removed = env.removed
    else:
        changes = _env.get_environment(scope, base_env=base, logger=self._logger_)
        removed = changes.removed
        # Inherited DOTAGENTS_* values are tool-internal secrets that are
        # never printed; only a change the env layers make shows up.
        env = {
            k: v for k, v in base.items()
            if not k.startswith("DOTAGENTS_") and k not in removed
        }
        env.update(changes)

    # UTF-8 straight to the buffer: a bare print() encodes with the console
    # codepage and dies on the first non-Latin-1 character in any value.
    # With --diff, PATH may be written relative to the caller's own PATH
    # (see `_format_env`): the base is what the diff was taken against.
    text = _format_env(env, output_format, removed, base=base if self.diff else None) + "\n"
    if key is not None:
        _env.write_env_cache(scope, key, text)
    if self.into is not None:
        write_into(Path(self.into), text, output_format)
        return 0
    _write_stdout(text)
    return 0

write_into(path, text, output_format)

Put text into path as the ONE block env --into owns there: every earlier block (between :data:INTO_BEGIN / :data:INTO_END) is removed, anything else in the file -- another hook's lines -- is kept in place, and the new block goes at the end, where a sourced file's later lines win (what an append did). Atomic, LF-only. A hook that re-runs (resume, compact, clear) used to append its whole output each time, and the file Claude Code inlines into every Bash command grew past what Git for Windows' bash accepts as a -c argument (about 8 KB).

Source code in src/dotagents/cli/env.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
def write_into(path: Path, text: str, output_format: str) -> None:
    """Put ``text`` into ``path`` as the ONE block ``env --into`` owns there:
    every earlier block (between :data:`INTO_BEGIN` / :data:`INTO_END`) is
    removed, anything else in the file -- another hook's lines -- is kept in
    place, and the new block goes at the end, where a sourced file's later
    lines win (what an append did). Atomic, LF-only. A hook that re-runs
    (resume, compact, clear) used to append its whole output each time, and
    the file Claude Code inlines into every Bash command grew past what Git
    for Windows' bash accepts as a ``-c`` argument (about 8 KB)."""
    from dotagents._env import FORMAT_ALIASES
    from dotagents._fs import write_text_lf

    fmt = FORMAT_ALIASES.get(output_format, output_format)
    if fmt not in INTO_FORMATS:
        raise SystemExit("error: --into writes the %s format only, not %r" % ("/".join(INTO_FORMATS), fmt))
    kept: "list[str]" = []
    inside = False
    try:
        current = path.read_text(encoding="utf-8")
    except FileNotFoundError:
        current = ""
    for line in current.splitlines():
        if line.strip() == INTO_BEGIN:
            inside = True
        elif line.strip() == INTO_END and inside:
            inside = False
        elif not inside:
            kept.append(line)
    block = [INTO_BEGIN] + [ln for ln in text.splitlines() if ln] + [INTO_END]
    write_text_lf(path, "\n".join(kept + block) + "\n", atomic=True)

dotagents.cli.path

dotagents path -- the dirs dotagents puts on PATH (or PYTHONPATH).

PATH_FORMATS = ('list', 'native', 'posix', 'json') module-attribute

PathCmd

Bases: DotAgentsArgs

Print the bin dirs dotagents puts on PATH (--lib: the lib dirs).

Highest precedence first, the order dotagents env puts them at the front of PATH / PYTHONPATH: the project's .agents first, and each store's own dir before its overlays'. The bin list includes dirs that do not exist yet (as env does, so one created later is found); the lib list only existing ones. Nothing is run -- no env file, no env.py -- so it is a cheap way to put the bins on a shell's PATH::

export PATH="$(dotagents path --format posix):$PATH"

Formats: list (one per line, the default), native (joined with os.pathsep), posix (:-joined; on Windows each dir in its MSYS2 form, /c/...), json. The scope flags mean what they mean for env: the user store is --agents-dir -> $AGENTS_HOME -> ~/.agents, and -g leaves the project's dirs out.

agents_dir = None class-attribute instance-attribute

User store root override (default: $AGENTS_HOME, else ~/.agents).

format = 'list' class-attribute instance-attribute

Output format: list (one per line), native (os.pathsep-joined), posix (':'-joined, MSYS2 form on Windows), json.

global_scope = False class-attribute instance-attribute

Leave out the project's dirs (the store root is unaffected).

lib = False class-attribute instance-attribute

Print the lib dirs (PYTHONPATH) instead of the bin dirs (PATH).

__call__()

Source code in src/dotagents/cli/path.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def __call__(self) -> int:
    from dotagents import _env, _scope

    if self.format not in PATH_FORMATS:
        raise SystemExit(
            "error: --format must be one of %s (got %r)" % (", ".join(PATH_FORMATS), self.format)
        )
    scope = _scope.Scope.of(
        agents_dir=resolve_user_store(self.agents_dir),
        project_root=_scope.project_root_default(),
        global_scope=self.global_scope,
    )
    found = _env.get_lib_paths(scope) if self.lib else _env.get_bin_paths(scope)
    dirs: "list[str]" = []
    for p in reversed(found):  # contract A lists lowest precedence first
        if str(p) not in dirs:
            dirs.append(str(p))
    if self.format == "json":
        _write_stdout(json.dumps(dirs) + "\n")
    elif self.format == "native":
        _write_stdout(os.pathsep.join(dirs) + "\n")
    elif self.format == "posix":
        if os.name == "nt":
            from dotagents.cli.env import _to_posix_path

            dirs = [_to_posix_path(d) for d in dirs]
        _write_stdout(":".join(dirs) + "\n")
    else:
        _write_stdout("".join(d + "\n" for d in dirs))
    return 0

dotagents.cli.about

dotagents about -- what this dotagents is: the CLI's version and every package bundled with it.

dotagents-cli <version>
duho <version>
pathapi <version>

One package per line, <distribution> <version>, the CLI first. From a built .pyz the list is what build-pyz vendored (recorded in dotagents/_bundle.json at build time, since the zipapp carries no dist-info); from a plain install it is whichever of :data:RUNTIME_PACKAGES is installed -- a fixed set, so a package outside it (a client the sftp or s3 extra pulls in) is not listed. --json adds where it runs from and the Python.

BUNDLE_FILE = '_bundle.json' module-attribute

DISTRIBUTION = 'dotagents-cli' module-attribute

RUNTIME_PACKAGES = ('duho', 'pathapi', 'pathapi-uri', 'netimps', 'sftppath', 'objpath', 'repopath') module-attribute

About

Bases: LoggingArgs, Cmd

Print the CLI's version and the packages bundled or installed with it.

One <distribution> <version> per line, dotagents-cli first.

json = False class-attribute instance-attribute

Machine-readable: the same packages plus where this runs from and the Python.

__call__()

Source code in src/dotagents/cli/about.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def __call__(self) -> int:
    found, bundle = packages()
    if self.json:
        payload = {
            "distribution": DISTRIBUTION,
            "version": __version__,
            "packages": found,
            "bundle": bundle is not None,
            "location": sys.argv[0] if bundle is not None else _package_location(),
            "python": "%d.%d.%d" % sys.version_info[:3],
            "executable": sys.executable,
        }
        if bundle is not None:
            payload["built"] = {k: v for k, v in bundle.items() if k != "packages"}
        _write_stdout(json.dumps(payload, indent=2) + "\n")
        return 0
    lines = ["%s %s" % (DISTRIBUTION, __version__)]
    lines += ["%s %s" % (name, version) for name, version in found.items()]
    _write_stdout("\n".join(lines) + "\n")
    return 0

bundle_manifest()

The _bundle.json build-pyz wrote, or None (a plain install).

Source code in src/dotagents/cli/about.py
35
36
37
38
39
40
41
42
43
44
45
def bundle_manifest() -> "Optional[dict]":
    """The ``_bundle.json`` `build-pyz` wrote, or ``None`` (a plain install)."""
    try:
        from importlib.resources import files

        resource = files("dotagents") / BUNDLE_FILE
        if not resource.is_file():
            return None
        return json.loads(resource.read_text(encoding="utf-8"))
    except Exception:
        return None

installed_packages()

{distribution: version} for the runtime packages a plain install has.

Source code in src/dotagents/cli/about.py
48
49
50
51
52
53
54
55
56
57
58
def installed_packages() -> "dict[str, str]":
    """``{distribution: version}`` for the runtime packages a plain install has."""
    from importlib import metadata

    found: "dict[str, str]" = {}
    for name in RUNTIME_PACKAGES:
        try:
            found[name] = metadata.version(name)
        except metadata.PackageNotFoundError:
            continue
    return found

packages()

(packages, bundle): the bundle's packages when running from one, else the installed runtime packages; the CLI itself is never in either.

Source code in src/dotagents/cli/about.py
61
62
63
64
65
66
67
def packages() -> "tuple[dict[str, str], Optional[dict]]":
    """``(packages, bundle)``: the bundle's packages when running from one,
    else the installed runtime packages; the CLI itself is never in either."""
    bundle = bundle_manifest()
    if bundle is not None:
        return dict(bundle.get("packages") or {}), bundle
    return installed_packages(), None

dotagents.cli.build_pyz

dotagents build-pyz -- vendor deps and package a self-contained pyz.

EXTRA_REQUIREMENTS = {'uri': ('pathapi-uri>=0.1.0,<0.2',), 'http': ('pathapi-uri>=0.1.0,<0.2',), 'sftp': ('pathapi-uri>=0.1.0,<0.2', 'sftppath[paramiko]>=0.1.0,<0.2'), 's3': ('pathapi-uri>=0.1.0,<0.2', 'objpath[s3]>=0.1.0,<0.2'), 'repo': ('pathapi-uri>=0.1.0,<0.2', 'repopath[uri]>=0.1.0,<0.2')} module-attribute

BuildPyz

Bases: LoggingArgs, Cmd

Vendor the runtime dependencies and package a self-contained dotagents.pyz.

distlib_version = '0.4.0' class-attribute instance-attribute

Pinned distlib version to vendor (overlay script launchers).

duho_version = '0.7.5' class-attribute instance-attribute

Pinned duho version to vendor.

extras = '' class-attribute instance-attribute

dotagents-cli extras to vendor too, comma-separated (uri, http, s3, repo), so the .pyz speaks those schemes for overlay sources; none by default. Only pure-Python dependencies can be vendored, so sftp (cryptography, bcrypt, pynacl) cannot.

out = Path('dist') / 'dotagents.pyz' class-attribute instance-attribute

Output path for the built pyz.

pathapi_version = '0.1.0' class-attribute instance-attribute

Pinned pathapi version to vendor (--pathlib-next-version is its old name).

python = '/usr/bin/env python3' class-attribute instance-attribute

Shebang line to embed in the pyz.

tomli_version = '1.1.0' class-attribute instance-attribute

Pinned tomli version to vendor (the TOML reader below Python 3.11).

with_pip = False class-attribute instance-attribute

Also vendor the pinned pip wheel, so the .pyz can set up a scope venv where the interpreter has no ensurepip and no index is reachable.

__call__()

Source code in src/dotagents/cli/build_pyz.py
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
def __call__(self) -> int:
    import zipapp

    # This module lives at src/dotagents/cli/build_pyz.py, so the repo root
    # is parents[3] (cli -> dotagents -> src -> repo) and the dotagents
    # package dir is parents[1]. Only the package is bundled; the repo's
    # `tools/` is CI tooling and never ships in the .pyz.
    repo_root = Path(__file__).resolve().parents[3]
    pyproject = repo_root / "pyproject.toml"
    dotagents_pkg_src = Path(__file__).resolve().parents[1]
    if not pyproject.is_file() or not dotagents_pkg_src.is_dir():
        # A wheel install (site-packages) or a run from inside a .pyz has
        # no checkout around it -- say so instead of a FileNotFoundError.
        raise SystemExit(
            "error: build-pyz needs a source checkout (no pyproject.toml above "
            "%s); run it from the dotagents repository" % dotagents_pkg_src
        )

    with tempfile.TemporaryDirectory(prefix="dotagents-pyz-") as tmp:
        stage = Path(tmp) / "stage"
        stage.mkdir()

        names = [e.strip() for e in self.extras.split(",") if e.strip()]
        unknown = [name for name in names if name not in EXTRA_REQUIREMENTS]
        if unknown:
            raise SystemExit(
                "error: build-pyz --extras: unknown extra %s (known: %s)"
                % (", ".join(unknown), ", ".join(EXTRA_REQUIREMENTS))
            )
        extras = ",".join(names)
        requirements = [
            "duho==%s" % self.duho_version,
            "pathapi==%s" % self.pathapi_version,
            "distlib==%s" % self.distlib_version,
            "tomli==%s" % self.tomli_version,
        ]
        for name in names:
            for pin in map(_floor_pin, EXTRA_REQUIREMENTS[name]):
                if pin not in requirements:
                    requirements.append(pin)
        self._logger_.info("vendoring %s via pip --target", " ".join(requirements))
        rc = subprocess.call(
            [
                sys.executable,
                "-m",
                "pip",
                "install",
                "--target",
                str(stage),
                # Pure-Python wheels resolved for the oldest supported
                # interpreter, whatever this build runs on (_VENDOR_PYTHON).
                "--only-binary=:all:",
                "--platform",
                "any",
                "--implementation",
                "py",
                "--python-version",
                _VENDOR_PYTHON,
                *requirements,
            ]
        )
        if rc != 0:
            if extras:
                self._logger_.error(
                    "pip could not vendor the extras %s; an extra whose "
                    "dependencies ship only native wheels (sftp) cannot go "
                    "into a zipapp",
                    extras,
                )
            return rc
        # pip's console-script launchers: native executables for the build
        # machine that embed its interpreter path. A zipapp never runs them.
        shutil.rmtree(stage / "bin", ignore_errors=True)

        # The package is copied as-is: `__version__` in `__init__.py` is
        # the single source of the version (pyproject.toml reads it too).
        dotagents_pkg_dest = stage / "dotagents"
        shutil.copytree(dotagents_pkg_src, dotagents_pkg_dest, ignore=_ignore_unshipped)

        # What went into the bundle, for `dotagents about`: the zipapp
        # carries no dist-info (stripped below), so record the vendored
        # distributions' names and versions beside the package first.
        bundled = {}
        for path in sorted(stage.rglob("*.dist-info")):
            bundled.update(_dist_info_name_version(path))
        if self.with_pip:
            # Kept a wheel (its license inside): pip runs from it as is.
            from dotagents import _pipwheel

            wheel = _pipwheel.pip_wheel(Path(tmp) / "pip", logger=self._logger_)
            vendor = dotagents_pkg_dest.joinpath(*_pipwheel.VENDORED[:-1])
            vendor.mkdir(parents=True, exist_ok=True)
            shutil.copyfile(str(wheel), str(vendor / _pipwheel.PIP_WHEEL))
            bundled["pip"] = _pipwheel.PIP_VERSION
        # Generated files are written LF, like every copied source, so the
        # artifact's bytes do not depend on the OS it was built on.
        write_text_lf(
            dotagents_pkg_dest / "_bundle.json",
            json.dumps({
                "packages": bundled,
                "extras": extras,
                "python": "%d.%d.%d" % sys.version_info[:3],
            }, indent=2) + "\n",
        )
        for path in stage.rglob("*.dist-info"):
            shutil.rmtree(path, ignore_errors=True)
        for path in stage.rglob("__pycache__"):
            shutil.rmtree(path, ignore_errors=True)
        for path in stage.rglob("tests"):
            if path.is_dir():
                shutil.rmtree(path, ignore_errors=True)

        write_text_lf(
            stage / "__main__.py",
            "from dotagents.cli import main\n\nraise SystemExit(main())\n",
        )

        out_path = Path(self.out)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        zipapp.create_archive(
            str(stage), target=str(out_path), interpreter=self.python, compressed=True
        )
        self._logger_.info("built %s", out_path)

    return 0