Skip to content

Scope

dotagents._scope

Where a session's config lives, and where overlays come from.

  • Scope -- the stores a command writes to and a session reads. :class:Scope is the user scope (<agents_dir>/, the configurable store: $AGENTS_HOME, default ~/.agents) or a project scope (<project>/.agents/, which also walks the user store). An overlay installs into <store>/overlays/<name>/ and skills publish into the shared <store>/skills/; installed overlays are discovered by their presence under overlays/, with no registry file. :meth:Scope.paths is the contract-A walk every reader (env, context, command discovery) goes through. :func:resolve_scope picks a writing command's scope, :func:resolve_user_store, :func:project_root_default and :func:install_project_root resolve its parts.

  • Source -- where an overlay to install comes from. :func:resolve_source returns the repos in precedence order (--repo, the env repos, the project and user stores' dotagents.* registries); a repo is a directory of overlays, a registry file, or a git spec, and the first offering a name wins. See :mod:dotagents._sources for the returned object.

The system store (:func:system_root_default: $AGENTS_SYSTEM_ROOT, or /etc/agents on POSIX; only when administrators alone can write it) is walked like any store, first in precedence, but nothing installs into it -- there is no --system scope for init / overlays.

AGENTS_DIR_ENV = 'AGENTS_HOME' module-attribute

LEVEL_NAMES = frozenset({'default', 'overlay', 'system', 'user', 'project', 'project-root'}) module-attribute

SYSTEM_ROOT_ENV = 'AGENTS_SYSTEM_ROOT' module-attribute

Scope(level, agents_root, *, user_root=None, project_root=None, system_root=None)

Where a session's config lives -- the one object every walk takes.

level is "user" or "project" and agents_root the scope's own store (the install target of overlays add / init): <agents_dir>/ (the configurable store, default ~/.agents) for the user scope, <project_root>/.agents/ for a project. A project scope also knows the user_root (the user store -- every walk starts from it) and the project_root; stores is the pair in precedence order, and :attr:overlays is :meth:Overlay.installed over them (a same-named project overlay shadows the store's copy). The user scope has one store. :meth:paths is the contract-A walk for this scope; env, context and cli._cmds_dirs all go through it.

The overlays/ and skills/ subdirs beneath agents_root are the discover-and-publish surfaces of THIS scope (Overlay.discover(scope.overlay_root)).

A project whose .agents is the user store (the project root is ~) is constructed as the user scope, keeping project_root for the project-root level, so the store is walked once; :attr:stores also drops any directory that appears twice.

Source code in src/dotagents/_scope.py
 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
def __init__(
    self,
    level: str,
    agents_root: "str | os.PathLike[str]",
    *,
    user_root: "str | os.PathLike[str] | None" = None,
    project_root: "str | os.PathLike[str] | None" = None,
    system_root: "str | os.PathLike[str] | None" = None,
):
    self.level = level
    self.agents_root = Path(agents_root)
    #: The machine-wide store (``/etc/agents`` on POSIX, or
    #: ``$AGENTS_SYSTEM_ROOT``; ``None`` when there is none, see
    #: :func:`system_root_default`): walked for overlays/bin/lib/env/cmds/
    #: AGENTS.md like any store, first in precedence; nothing installs into
    #: it (no ``--system`` scope).
    self.system_root: "Optional[Path]" = (
        Path(system_root) if system_root else system_root_default()
    )
    if level == "user":
        self.user_root = self.agents_root
        #: Set on a user scope only when a project's store turned out to BE
        #: the user store (below): the project-root level is still walked.
        self.project_root: "Optional[Path]" = (
            Path(project_root) if project_root else None
        )
    else:
        self.user_root = Path(user_root) if user_root else resolve_user_store()
        self.project_root = (
            Path(project_root) if project_root else self.agents_root.parent
        )
        if _same_path(self.agents_root, self.user_root):
            # A project rooted at the user store's parent (a session started
            # in ~): its `.agents` IS the user store. Walking it as a second,
            # "project" store ran every env.py twice and emitted every
            # AGENTS.md / CONTEXT.md twice; it is the user scope, keeping
            # the project root for the project-root level.
            self.level = "user"
            self.agents_root = self.user_root

agents_root = Path(agents_root) instance-attribute

global_scope property

level = level instance-attribute

overlay_root property

overlays property

Every overlay a session in this scope uses (:meth:Overlay.installed over :attr:stores: the project's copy shadows a same-named store copy).

Memoized on the stores and their overlays/ listings: one env run walks :meth:paths several times, and each rescan checked every entry; an overlay added or removed since (overlays add reads this again after installing) changes a listing and is seen.

project_root = Path(project_root) if project_root else None instance-attribute

project_store property

The project's store (agents_root) -- None in the user scope.

shared_skills_dir property

stores property

The stores in play, in precedence order: the system store (when there is one), the user store, then (in a project scope) the project's.

system_root = Path(system_root) if system_root else system_root_default() instance-attribute

user_root = self.agents_root instance-attribute

__repr__()

Source code in src/dotagents/_scope.py
278
279
280
281
def __repr__(self) -> str:
    if self.global_scope:
        return "Scope(user, root=%s)" % self.agents_root
    return "Scope(project, root=%s, user_root=%s)" % (self.agents_root, self.user_root)

of(*, agents_dir, project_root=None, global_scope=False) classmethod

The walk's scope from its parts: agents_dir (the user store), project_root and global_scope -- what env / context build after resolving each part (resolve_user_store, project_root_default, -g).

Source code in src/dotagents/_scope.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
@classmethod
def of(
    cls,
    *,
    agents_dir: "str | os.PathLike[str]",
    project_root: "str | os.PathLike[str] | None" = None,
    global_scope: bool = False,
) -> "Scope":
    """The walk's scope from its parts: ``agents_dir`` (the user store),
    ``project_root`` and ``global_scope`` -- what ``env`` / ``context`` build
    after resolving each part (``resolve_user_store``,
    ``project_root_default``, ``-g``)."""
    if global_scope or project_root is None:
        return cls("user", agents_dir)
    return cls(
        "project", Path(project_root) / ".agents",
        user_root=agents_dir, project_root=project_root,
    )

overlay_dir(name)

Source code in src/dotagents/_scope.py
275
276
def overlay_dir(self, name: str) -> Path:
    return self.overlay_root / name

paths(*names, include_missing=False)

Resolve file paths across this scope's precedence hierarchy (Contract A).

Each name is a filename (resolved at every level) or a per-level dict -- {"default": ..., "overlay": ..., "<level>": ...} -- where an empty / missing entry skips that level.

Precedence order -- each of :attr:stores in turn, its overlays first, then the store itself; finally the project root: 1. system overlays, then system (:attr:system_root) 2. user-store overlays, then user (:attr:user_root) 3. project overlays (<project_root>/.agents/overlays/<name>/), then project (<project_root>/.agents) -- a project scope only 4. project-root (:attr:project_root) -- whenever there is one: a project scope, or the user scope a project rooted at ~ becomes

An overlay installed in more than one store under the same name is one overlay, the later store's: it SHADOWS the earlier copies entirely (:meth:Overlay.installed), so its bin/lib/env/cmds/CONTEXT.md are the only ones that resolve -- not stacked.

Each returned tuple is (level, path, root): for an overlay, level is the overlay's directory name and root its directory; for every other level root is None -- that is how callers tell overlays apart. include_missing returns every candidate, else only the ones that exist.

Source code in src/dotagents/_scope.py
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
253
254
255
256
257
258
259
260
261
262
263
264
265
def paths(
    self, *names: "str | dict[str, str]", include_missing: bool = False
) -> "list[tuple[str, Path, Optional[Path]]]":
    """Resolve file paths across this scope's precedence hierarchy (Contract A).

    Each ``name`` is a filename (resolved at every level) or a per-level
    dict -- ``{"default": ..., "overlay": ..., "<level>": ...}`` -- where an
    empty / missing entry skips that level.

    Precedence order -- each of :attr:`stores` in turn, its overlays first,
    then the store itself; finally the project root:
    1. system overlays, then system (:attr:`system_root`)
    2. user-store overlays, then user (:attr:`user_root`)
    3. project overlays (``<project_root>/.agents/overlays/<name>/``), then
       project (``<project_root>/.agents``) -- a project scope only
    4. project-root (:attr:`project_root`) -- whenever there is one: a
       project scope, or the user scope a project rooted at ``~`` becomes

    An overlay installed in more than one store under the same name is one
    overlay, the later store's: it SHADOWS the earlier copies entirely
    (:meth:`Overlay.installed`), so its bin/lib/env/cmds/CONTEXT.md are the
    only ones that resolve -- not stacked.

    Each returned tuple is ``(level, path, root)``: for an overlay,
    ``level`` is the overlay's directory name and ``root`` its directory;
    for every other level ``root`` is ``None`` -- that is how callers tell
    overlays apart. ``include_missing`` returns every candidate, else only
    the ones that exist.
    """
    found: "list[tuple[str, Path, Optional[Path]]]" = []

    def add(location: Path, level: str, root: "Optional[Path]" = None,
            is_overlay: bool = False) -> None:
        for name in names:
            name_dict = {level: name} if isinstance(name, str) else name
            default = name_dict.get("default")
            if is_overlay:
                default = name_dict.get("overlay", default)
            template = name_dict.get(level, default)
            if template:
                found.append((level, location / template, root))

    # No manifest is required for an overlay to count -- neither
    # ``CONTEXT.md`` nor ``overlay.toml`` (D84).
    overlays = self.overlays  # shadowing already applied, store-stamped
    for store in self.stores:
        for overlay in overlays:
            if overlay.store == store:
                add(overlay.path, overlay.name, root=overlay.path, is_overlay=True)
        add(store, self.store_level(store))
    if self.project_root is not None:
        add(self.project_root, "project-root")

    if include_missing:
        return found
    return [(level, path, root) for level, path, root in found if path.exists()]

store_level(store)

The contract-A level name of one of :attr:stores: system, user or project.

Source code in src/dotagents/_scope.py
181
182
183
184
185
186
187
188
189
190
191
def store_level(self, store: "str | os.PathLike[str]") -> str:
    """The contract-A level name of one of :attr:`stores`: ``system``,
    ``user`` or ``project``."""
    store = Path(store)
    if self.system_root is not None and store == self.system_root:
        return "system"
    if store == self.user_root:
        return "user"
    if store == self.project_store:
        return "project"
    raise ValueError("%s is not a store of %r" % (store, self))

filter_names(names, pattern)

Glob-filter overlay names (sync 'py*'). None/'*' keep all.

Source code in src/dotagents/_scope.py
542
543
544
545
546
def filter_names(names: "list[str]", pattern: "Optional[str]") -> "list[str]":
    """Glob-filter overlay names (``sync 'py*'``). ``None``/``'*'`` keep all."""
    if not pattern or pattern == "*":
        return list(names)
    return [n for n in names if fnmatch.fnmatch(n, pattern)]

install_project_root()

The project root the WRITING commands (init, overlays, findings) target when none is passed explicitly.

A pinned root ($AGENTS_PROJECT_ROOT, then the harness vars of :data:_HARNESS_PROJECT_ROOT_VARS) is honoured only while the current directory is inside it. A hooked session exports the pin once for its whole life, so after cd ../other the pin still names the FIRST project; a command that writes then targets where it runs -- the nearest ancestor of the cwd with a .git or its own .agents, else the cwd -- and says so with a warning. With no pin, the cwd.

env / context keep :func:project_root_default, where the pin wins regardless of the cwd: what they read must stay stable across the subdirectories a session's commands run in.

Source code in src/dotagents/_scope.py
508
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
def install_project_root() -> Path:
    """The project root the WRITING commands (``init``, ``overlays``,
    ``findings``) target when none is passed explicitly.

    A pinned root (``$AGENTS_PROJECT_ROOT``, then the harness vars of
    :data:`_HARNESS_PROJECT_ROOT_VARS`) is honoured only while the current
    directory is inside it. A hooked session exports the pin once for its whole
    life, so after ``cd ../other`` the pin still names the FIRST project; a
    command that writes then targets where it runs -- the nearest ancestor of
    the cwd with a ``.git`` or its own ``.agents``, else the cwd -- and says so
    with a warning. With no pin, the cwd.

    ``env`` / ``context`` keep :func:`project_root_default`, where the pin wins
    regardless of the cwd: what they read must stay stable across the
    subdirectories a session's commands run in."""
    cwd = Path.cwd()
    for var in ("AGENTS_PROJECT_ROOT", *_HARNESS_PROJECT_ROOT_VARS):
        value = os.environ.get(var)
        if not value:
            continue
        pinned = Path(value).expanduser()
        if _is_within(cwd, pinned):
            return pinned
        found = _project_containing(cwd)
        import logging

        logging.getLogger("dotagents").warning(
            "the current directory is outside the pinned project root %s ($%s); "
            "using %s", pinned, var, found,
        )
        return found
    return cwd

project_root_default()

The project root when none is passed explicitly, in precedence order: $AGENTS_PROJECT_ROOT (dotagents' canonical var) -> a known agent-native var (:data:_HARNESS_PROJECT_ROOT_VARS, e.g. Claude Code's CLAUDE_PROJECT_DIR) -> the current working directory.

Emitting AGENTS_PROJECT_ROOT (see dotagents env) lets every command and subprocess agree on one root regardless of the cwd it happens to run in.

Source code in src/dotagents/_scope.py
460
461
462
463
464
465
466
467
468
469
470
471
472
def project_root_default() -> Path:
    """The project root when none is passed explicitly, in precedence order:
    ``$AGENTS_PROJECT_ROOT`` (dotagents' canonical var) -> a known agent-native var
    (:data:`_HARNESS_PROJECT_ROOT_VARS`, e.g. Claude Code's ``CLAUDE_PROJECT_DIR``)
    -> the current working directory.

    Emitting ``AGENTS_PROJECT_ROOT`` (see ``dotagents env``) lets every command and
    subprocess agree on one root regardless of the cwd it happens to run in."""
    for var in ("AGENTS_PROJECT_ROOT", *_HARNESS_PROJECT_ROOT_VARS):
        value = os.environ.get(var)
        if value:
            return Path(value).expanduser()
    return Path.cwd()

resolve_scope(global_scope=False, *, agents_dir=None, project_root=None)

Pick the install scope.

-g/--global forces the user scope: agents_dir (--agents-dir) if given, else the configurable store -- $AGENTS_HOME, then ~/.agents (:func:resolve_user_store, the same chain env / context use, so a session that pinned the store installs into it rather than into the literal home dir). Otherwise the scope is project: agents_dir if given (the store root override applies to either scope), else <project_root>/.agents where the root is an explicit project_root argument, else :func:install_project_root -- the pinned root ($AGENTS_PROJECT_ROOT, then $CLAUDE_PROJECT_DIR) while the current directory is inside it, and the project the current directory is in once it has left it. A project whose .agents is the user store (the root is ~) is the user scope.

Source code in src/dotagents/_scope.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def resolve_scope(
    global_scope: bool = False,
    *,
    agents_dir: "str | os.PathLike | None" = None,
    project_root: "str | os.PathLike | None" = None,
) -> Scope:
    """Pick the install scope.

    ``-g/--global`` forces the **user** scope: ``agents_dir`` (``--agents-dir``)
    if given, else the configurable store -- ``$AGENTS_HOME``, then
    ``~/.agents`` (:func:`resolve_user_store`,
    the same chain ``env`` / ``context`` use, so a session that pinned the store
    installs into it rather than into the literal home dir).
    Otherwise the scope is **project**: ``agents_dir`` if given (the store root
    override applies to either scope), else ``<project_root>/.agents`` where the
    root is an explicit ``project_root`` argument, else
    :func:`install_project_root` -- the pinned root (``$AGENTS_PROJECT_ROOT``,
    then ``$CLAUDE_PROJECT_DIR``) while the current directory is inside it, and
    the project the current directory is in once it has left it. A project whose
    ``.agents`` is the user store (the root is ``~``) is the user scope.
    """
    if global_scope:
        return Scope("user", resolve_user_store(agents_dir))
    if agents_dir:
        # An explicit store is the write target; the pin only names the root.
        proj = Path(project_root).expanduser() if project_root else project_root_default()
        return Scope("project", Path(agents_dir).expanduser(), project_root=proj)
    proj = Path(project_root).expanduser() if project_root else install_project_root()
    return Scope("project", proj / ".agents", project_root=proj)

resolve_source(repos=None, *, scope=None, logger=None, allow_empty=False)

Resolve where overlays come from: the repos in precedence order -- repos (--repo values), then $AGENTS_OVERLAYS_REPO_<KEY> by KEY, then $AGENTS_OVERLAYS_REPO, then the project and user stores' dotagents.{json,toml,yaml,yml} registries -- the first repo offering a name wins. A repo is a directory of overlays, a registry file, or a git spec (<repo>[@ref][#path]) that materializes to one; see :mod:dotagents._sources. No build bundles overlays: they live on the repo branch and are named as a repo like any other. Callers use only available() / overlay_dir() / root. Raises when nothing at all is configured, unless allow_empty.

Source code in src/dotagents/_scope.py
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
def resolve_source(
    repos: "Optional[list[str]]" = None,
    *,
    scope: "Optional[Scope]" = None,
    logger=None,
    allow_empty: bool = False,
):
    """Resolve where overlays come from: the repos in precedence order --
    ``repos`` (``--repo`` values), then ``$AGENTS_OVERLAYS_REPO_<KEY>`` by KEY,
    then ``$AGENTS_OVERLAYS_REPO``, then the project and user stores'
    ``dotagents.{json,toml,yaml,yml}`` registries -- the first repo offering a
    name wins. A repo is a directory of overlays, a registry file, or a git spec
    (``<repo>[@ref][#path]``) that materializes to one; see
    :mod:`dotagents._sources`. No build bundles overlays: they live on the
    ``repo`` branch and are named as a repo like any other. Callers use only
    ``available()`` / ``overlay_dir()`` / ``root``. Raises when nothing at all
    is configured, unless ``allow_empty``."""
    from dotagents import _sources

    user_root = Path(scope.user_root) if scope is not None else resolve_user_store(None)
    stores = [scope.project_store if scope is not None and not scope.global_scope else None, user_root]
    return _sources.resolve(
        list(repos or []),
        cache_root=user_root / ".cache" / "overlays",
        stores=stores,
        logger=logger,
        allow_empty=allow_empty,
    )

resolve_user_store(agents_dir=None)

The USER store root, in precedence order: an explicit agents_dir (--agents-dir) -> $AGENTS_HOME -> ~/.agents.

:func:resolve_scope defaults its -g store through this; env and context -- whose Contract-A walk takes the user store as agents_dir and the project root separately, and whose -g only skips the project tiers -- call it directly, so the store never becomes the project dir.

Never logs or prints the raw env value (Leakage rule); only the resolved path is ever reported.

Source code in src/dotagents/_scope.py
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def resolve_user_store(agents_dir: "str | os.PathLike | None" = None) -> Path:
    """The USER store root, in precedence order: an explicit ``agents_dir``
    (``--agents-dir``) -> ``$AGENTS_HOME`` -> ``~/.agents``.

    :func:`resolve_scope` defaults its ``-g`` store through this; ``env`` and
    ``context`` -- whose Contract-A walk takes the user store as ``agents_dir``
    and the project root separately, and whose ``-g`` only *skips* the project
    tiers -- call it directly, so the store never becomes the project dir.

    Never logs or prints the raw env value (Leakage rule); only the resolved path
    is ever reported.
    """
    if agents_dir:
        return Path(agents_dir).expanduser()
    value = os.environ.get(AGENTS_DIR_ENV)
    if value:
        return Path(value).expanduser()
    return Path.home() / ".agents"

system_root_default()

The machine-wide store, or None when there is none to walk.

$AGENTS_SYSTEM_ROOT when set (it must be an absolute path), else /etc/agents on POSIX and nothing on Windows -- there /etc/agents is drive-relative (\etc\agents), and any local account can create it. A root that does not exist is None too, so nothing of it (a bin/ on PATH included) is walked. A root that a non-administrator can write is skipped with a warning: its env.py and cmds/ would run in every user's session. Resolved once per process and value.

Source code in src/dotagents/_scope.py
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def system_root_default() -> "Optional[Path]":
    """The machine-wide store, or ``None`` when there is none to walk.

    ``$AGENTS_SYSTEM_ROOT`` when set (it must be an absolute path), else
    ``/etc/agents`` on POSIX and nothing on Windows -- there ``/etc/agents`` is
    drive-relative (``\\etc\\agents``), and any local account can create it.
    A root that does not exist is ``None`` too, so nothing of it (a
    ``bin/`` on PATH included) is walked. A root that a non-administrator can
    write is skipped with a warning: its ``env.py`` and ``cmds/`` would run in
    every user's session. Resolved once per process and value."""
    value = os.environ.get(SYSTEM_ROOT_ENV) or ""
    if value in _system_root_cache:
        return _system_root_cache[value]
    import logging

    log = logging.getLogger("dotagents")
    root: "Optional[Path]" = None
    if value:
        candidate = Path(value).expanduser()
        if not candidate.is_absolute():
            log.warning("ignoring $%s=%r: not an absolute path", SYSTEM_ROOT_ENV, value)
            candidate = None
    else:
        candidate = None if _is_windows() else Path("/etc/agents")
    if candidate is not None and candidate.is_dir():
        ok, reason = _system_root_is_safe(candidate)
        if ok:
            root = candidate
        else:
            log.warning("ignoring the system store %s: %s", candidate, reason)
    _system_root_cache[value] = root
    return root