Skip to content

Agents

dotagents._agents

Agent registry: base Agent type + per-agent adapters.

Agent

Base class for dotagents adapters.

Identity: each adapter declares its harness_id (the stable ecosystem name, e.g. claude-code -- NOT the short registry name), vendor (provider family), and model_source_vars (the vendor-native env vars to read a running model id from, in precedence order). These feed stamp_identity which emits the standardized AGENTS_*/AGENT vars.

base_config_note = '' class-attribute instance-attribute

context_files = [] class-attribute instance-attribute

context_target = '' class-attribute instance-attribute

detect_env_vars = [] class-attribute instance-attribute

harness_id = '' class-attribute instance-attribute

harness_loads = [] class-attribute instance-attribute

include_entry = () class-attribute instance-attribute

launch_command = '' class-attribute instance-attribute

model_source_vars = [] class-attribute instance-attribute

name = '' class-attribute instance-attribute

project_root = None class-attribute instance-attribute

scope_level = None class-attribute instance-attribute

vendor = '' class-attribute instance-attribute

detect(root)

Return True if this agent's config is present in the given root.

Source code in src/dotagents/_agents.py
300
301
302
def detect(self, root: Path) -> bool:
    """Return True if this agent's config is present in the given root."""
    return any((root / f).exists() for f in self.context_files)

detect_env(environ)

Return True if this agent's harness is running based on env vars.

Default: True if any declared marker var is present. Adapters whose markers are prefixes (e.g. Codex's CODEX_SANDBOX_*) override this.

Source code in src/dotagents/_agents.py
164
165
166
167
168
169
170
def detect_env(self, environ: dict[str, str]) -> bool:
    """Return True if this agent's harness is running based on env vars.

    Default: True if any declared marker var is present. Adapters whose
    markers are prefixes (e.g. Codex's ``CODEX_SANDBOX_*``) override this.
    """
    return any(var in environ for var in self.detect_env_vars)

launch_context_args(context_file)

Argv that hands context_file (the assembled context, markdown) to a fresh session as text APPENDED to the harness's own system prompt.

None = the harness has no append mechanism, and launch falls back to :meth:write_context (the managed block in :attr:context_target, which the harness loads by itself). Only a documented append flag earns an override: Codex's model_instructions_file and Gemini's GEMINI_SYSTEM_MD REPLACE the built-in prompt, which is not the same thing and is documented to degrade the harness.

Source code in src/dotagents/_agents.py
309
310
311
312
313
314
315
316
317
318
319
320
def launch_context_args(self, context_file: Path) -> "Optional[list[str]]":
    """Argv that hands ``context_file`` (the assembled context, markdown) to
    a fresh session as text APPENDED to the harness's own system prompt.

    ``None`` = the harness has no append mechanism, and ``launch`` falls
    back to :meth:`write_context` (the managed block in
    :attr:`context_target`, which the harness loads by itself). Only a
    documented append flag earns an override: Codex's
    ``model_instructions_file`` and Gemini's ``GEMINI_SYSTEM_MD`` REPLACE
    the built-in prompt, which is not the same thing and is documented to
    degrade the harness."""
    return None

Link <dest>/skills/<name> into the harness's OWN skills dir, and prune what a removed skill left there. A no-op on the base class: only a harness that reads a skills dir of its own overrides it.

Source code in src/dotagents/_agents.py
292
293
294
295
296
297
298
def link_skills(
    self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """Link ``<dest>/skills/<name>`` into the harness's OWN skills dir, and
    prune what a removed skill left there. A no-op on the base class: only
    a harness that reads a skills dir of its own overrides it."""
    pass

loaded_paths(project_root)

Resolved paths this harness loads by itself, so context never double-sends them. The static :attr:harness_loads entries: ~/ and / forms are absolute, anything else is relative to project_root. A harness with an include mechanism overrides this to report what its entry files actually include on this machine.

Source code in src/dotagents/_agents.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def loaded_paths(self, project_root: Path) -> "list[Path]":
    """Resolved paths this harness loads by itself, so ``context`` never
    double-sends them. The static :attr:`harness_loads` entries: ``~/`` and
    ``/`` forms are absolute, anything else is relative to ``project_root``.
    A harness with an include mechanism overrides this to report what its
    entry files actually include on this machine."""
    out: "list[Path]" = []
    for hl in self.harness_loads:
        try:
            if hl.startswith("~/") or hl.startswith("/"):
                out.append(Path(hl).expanduser().resolve())
            else:
                out.append((Path(project_root) / hl).resolve())
        except OSError:
            pass
    return out

resolve_model(environ)

Return the running model id from the first populated source var, or None.

Source code in src/dotagents/_agents.py
172
173
174
175
176
177
178
def resolve_model(self, environ: dict[str, str]) -> "Optional[str]":
    """Return the running model id from the first populated source var, or None."""
    for var in self.model_source_vars:
        val = environ.get(var)
        if val:
            return val
    return None

skills_wired(dest)

True when this harness's config dir already carries dotagents' wiring for the store dest -- so overlays add/sync/remove keep its skills dir current (:meth:link_skills) without an init. False on the base class: nothing to keep current.

Source code in src/dotagents/_agents.py
285
286
287
288
289
290
def skills_wired(self, dest: Path) -> bool:
    """True when this harness's config dir already carries dotagents'
    wiring for the store ``dest`` -- so ``overlays add``/``sync``/``remove``
    keep its skills dir current (:meth:`link_skills`) without an ``init``.
    False on the base class: nothing to keep current."""
    return False

wire_hooks(dest, *, dry_run, logger, config_root=None)

Wire up hooks in the agent's settings, if this adapter supports it.

A no-op on the base class: the hook schema is harness-specific, so only adapters with a verified schema override this. config_root overrides the agent's own config dir (e.g. ~/.claude) and exists so tests can redirect writes away from the real one.

Source code in src/dotagents/_agents.py
273
274
275
276
277
278
279
280
281
282
283
def wire_hooks(
    self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """Wire up hooks in the agent's settings, if this adapter supports it.

    A no-op on the base class: the hook schema is harness-specific, so only
    adapters with a *verified* schema override this. `config_root` overrides
    the agent's own config dir (e.g. `~/.claude`) and exists so tests can
    redirect writes away from the real one.
    """
    pass

write_base_config(dest, *, dry_run, logger)

This harness's last mile to the store's AGENTS.md, which init writes itself: an @ include in the harness's own entry file (:attr:include_entry), appended as a managed block and skipped when the line is already there by hand; else just :attr:base_config_note.

Source code in src/dotagents/_agents.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def write_base_config(self, dest: Path, *, dry_run: bool, logger) -> None:
    """This harness's last mile to the store's AGENTS.md, which `init`
    writes itself: an ``@`` include in the harness's own entry file
    (:attr:`include_entry`), appended as a managed block and skipped when
    the line is already there by hand; else just
    :attr:`base_config_note`."""
    entry = self._include_file(dest)
    if entry is None:
        if logger and self.base_config_note:
            logger.info("%s: %s", self.name, self.base_config_note)
        return
    from dotagents._merge import merge_include_line

    branch = merge_include_line(entry, self._include_line(dest, entry), dry_run=dry_run)
    if logger:
        logger.info("%s: %s (include)", branch, entry)
    if not self._user_scope(dest) and logger and _git_tracked_or_unignored(entry):
        logger.warning(self._INCLUDE_NOT_IGNORED, entry)

write_context(project_root, effective_context, *, dry_run, logger)

Merge the assembled context into this harness's own instruction file under project_root (:attr:context_target), as a managed block.

project_root is the project, NOT the store: a store's AGENTS.md is a context SOURCE and writing there would re-inline the previous run's output. Prefer the SessionStart hook where the harness has one; this is the static alternative for a harness without hooks.

Source code in src/dotagents/_agents.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def write_context(self, project_root: Path, effective_context: str, *, dry_run: bool, logger) -> None:
    """Merge the assembled context into this harness's own instruction file
    under ``project_root`` (:attr:`context_target`), as a managed block.

    ``project_root`` is the project, NOT the store: a store's ``AGENTS.md``
    is a context SOURCE and writing there would re-inline the previous
    run's output. Prefer the SessionStart hook where the harness has one;
    this is the static alternative for a harness without hooks."""
    if not self.context_target:
        if logger:
            logger.info("%s: no native context file to write", self.name)
        return
    from dotagents._merge import merge_context_block

    target = Path(project_root) / self.context_target
    branch = merge_context_block(target, effective_context, dry_run=dry_run)
    if logger:
        logger.info("%s: %s", "would write" if dry_run else branch, target)

AntigravityAgent

Bases: Agent

PREINVOCATION_HOOK_SCRIPT = 'preinvocation_antigravity_context.py' class-attribute instance-attribute

context_files = ['.agents/rules/dotagents.md'] class-attribute instance-attribute

context_target = '.agents/rules/dotagents.md' class-attribute instance-attribute

detect_env_vars = [] class-attribute instance-attribute

harness_id = 'antigravity' class-attribute instance-attribute

harness_loads = ['~/.gemini/GEMINI.md', '.agents/rules/dotagents.md'] class-attribute instance-attribute

model_source_vars = [] class-attribute instance-attribute

name = 'antigravity' class-attribute instance-attribute

vendor = 'google' class-attribute instance-attribute

wire_hooks(dest, *, dry_run, logger, config_root=None)

Deploys the context-injection script and wires it as a PreInvocation handler in <config_root|~/.gemini/config>/hooks.json.

The global file (~/.gemini/config/hooks.json) is written for the user scope only: a project-scope init must not turn dotagents on in every workspace. Run as the absolute interpreter that ran init -- a bare python is missing on a POSIX box that ships only python3.

Source code in src/dotagents/_agents.py
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
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
def wire_hooks(
    self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """Deploys the context-injection script and wires it as a
    `PreInvocation` handler in `<config_root|~/.gemini/config>/hooks.json`.

    The global file (`~/.gemini/config/hooks.json`) is written for the
    user scope only: a project-scope `init` must not turn dotagents on in
    every workspace. Run as the absolute interpreter that ran `init` -- a
    bare `python` is missing on a POSIX box that ships only `python3`.
    """
    import sys

    from dotagents import _hooks

    if config_root is None and not self._user_scope(dest):
        if logger:
            logger.info(
                "skipped Antigravity hooks: its hooks.json is global; run `init -g "
                "--agents antigravity` to wire it"
            )
        return
    root = Path(config_root) if config_root else (Path.home() / ".gemini" / "config")

    script_changed = _deploy_hook_script(
        root, self.PREINVOCATION_HOOK_SCRIPT, dry_run=dry_run, logger=logger,
        harness="Antigravity",
    )
    if script_changed is None:
        return
    dest_script = root / "hooks" / self.PREINVOCATION_HOOK_SCRIPT

    hooks_path = root / "hooks.json"
    data = _hooks.load_settings(hooks_path)
    # dotagents' own top-level key, matching the docs' shape of named
    # entries (`{"<name>": {"PreInvocation": [...]}}`), not a bare
    # top-level "hooks" object like Claude/Codex's schema.
    entry = data.get("dotagents")
    if not isinstance(entry, dict):
        entry = {}

    script_path = dest_script.resolve()
    command = '"%s" "%s"' % (Path(sys.executable).as_posix(), script_path.as_posix())
    preinvocation, pi_changed = _hooks.merge_hook(
        entry.get("PreInvocation"),
        command,
        status_message="Loading agent context",
    )
    entry["PreInvocation"] = preinvocation
    data["dotagents"] = entry

    if not (script_changed or pi_changed):
        if logger:
            logger.info("hooks already wired: %s", hooks_path)
        return

    if dry_run:
        if logger:
            logger.info("would wire PreInvocation hook: %s", hooks_path)
        return
    _hooks.write_settings(hooks_path, data)
    if logger:
        logger.info("wired PreInvocation hook: %s", hooks_path)

ClaudeAgent

Bases: Agent

CWD_CHANGED_COMMAND = 'if [ -n "$CLAUDE_ENV_FILE" ] && [ -d .agents ]; then kept=(); if [ -f "$CLAUDE_ENV_FILE" ]; then while IFS= read -r line || [ -n "$line" ]; do case "$line" in "export AGENTS_PROJECT_ROOT="*) ;; *) kept+=("$line") ;; esac; done < "$CLAUDE_ENV_FILE"; fi; { for line in ${kept[@]+"${kept[@]}"}; do printf \'%s\\n\' "$line"; done; printf \'export AGENTS_PROJECT_ROOT=%q\\n\' "$(pwd -W 2>/dev/null || pwd)"; } > "$CLAUDE_ENV_FILE"; fi; [ -f AGENTS.md ] && cat AGENTS.md || true' class-attribute instance-attribute

CWD_CHANGED_COMMAND_POWERSHELL = _PS_NO_BASH + 'if (Test-Path AGENTS.md) { Get-Content AGENTS.md -Raw } }' class-attribute instance-attribute

ENTRY_FILES = ('CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.md') class-attribute instance-attribute

PRETOOLUSE_POWERSHELL_COMMAND = '$h = [Console]::In.ReadToEnd() | ConvertFrom-Json; if ($h.tool_name -eq "PowerShell" -and -not $env:AGENTS_RUNTIME_SET -and $h.tool_input.command) { $p = \'if (-not $env:AGENTS_RUNTIME_SET) { $env:AGENTS_RUNTIME_SET = "1"; $s = if ($env:AGENTS_HOME) { $env:AGENTS_HOME } else { "$HOME\\.agents" }; $c = "$s\\bin\\dotagents.cmd"; if (-not (Test-Path $c)) { $c = (Get-Command dotagents -ErrorAction SilentlyContinue).Source }; if ($c) { $oe = [Console]::OutputEncoding; [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); try { & $c env --diff --format powershell --cache 2>$null | Invoke-Expression } finally { [Console]::OutputEncoding = $oe } } }; \'; $u = $h.tool_input.PSObject.Copy(); $u.command = $p + $h.tool_input.command; @{hookSpecificOutput=@{hookEventName="PreToolUse";permissionDecision="allow";updatedInput=$u}} | ConvertTo-Json -Depth 10 -Compress }' class-attribute instance-attribute

PRETOOLUSE_STATUS = 'Checking PowerShell env' class-attribute instance-attribute

SESSION_START_COMMAND = _HOOK_DOTAGENTS + 'if [ -n "$CLAUDE_ENV_FILE" ]; then "$d" env --diff --format export --into "$CLAUDE_ENV_FILE"; fi; "$d" context' class-attribute instance-attribute

SESSION_START_COMMAND_POWERSHELL = _PS_NO_BASH + _PS_DOTAGENTS + '; if ($d) { & $d context } }' class-attribute instance-attribute

context_files = ['CLAUDE.md'] class-attribute instance-attribute

context_target = '.claude/CLAUDE.md' class-attribute instance-attribute

detect_env_vars = ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'] class-attribute instance-attribute

harness_id = 'claude-code' class-attribute instance-attribute

harness_loads = [] class-attribute instance-attribute

launch_command = 'claude' class-attribute instance-attribute

model_source_vars = ['ANTHROPIC_MODEL'] class-attribute instance-attribute

name = 'claude' class-attribute instance-attribute

powershell_env_hook = False class-attribute instance-attribute

vendor = 'anthropic' class-attribute instance-attribute

launch_context_args(context_file)

Source code in src/dotagents/_agents.py
351
352
353
354
355
356
357
def launch_context_args(self, context_file: Path) -> "Optional[list[str]]":
    # The file form, not `--append-system-prompt <text>`: the text is
    # multi-line markdown, and on Windows `claude` is an npm `.cmd` shim
    # that cmd.exe re-parses -- a newline inside an argument ends the
    # command there. Both flags work in interactive and -p mode
    # (code.claude.com/docs/en/cli-reference, system prompt flags).
    return ["--append-system-prompt-file", str(context_file)]

The skills last mile: publishing into <scope>/skills/ only helps if Claude reads that dir, and it never does. So each skill is linked PER SKILL into <config>/skills/<name> (a user's own skills there are never a conflict): a symlink, else (Windows) a junction, else a copy. Ownership is recorded beside them, so a stale copy is refreshed and a skill the store no longer has is removed; a same-named skill the user placed or edited is left alone, with a warning (see :func:dotagents._skills.link_skills_into).

Source code in src/dotagents/_agents.py
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
def link_skills(
    self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """The skills last mile: publishing into `<scope>/skills/` only helps
    if Claude reads that dir, and it never does. So each skill is linked
    PER SKILL into `<config>/skills/<name>` (a user's own skills there are
    never a conflict): a symlink, else (Windows) a junction, else a copy.
    Ownership is recorded beside them, so a stale copy is refreshed and a
    skill the store no longer has is removed; a same-named skill the user
    placed or edited is left alone, with a warning (see
    :func:`dotagents._skills.link_skills_into`)."""
    from dotagents import _skills

    root = Path(config_root) if config_root else self._config_root(dest)
    shared_skills = Path(dest) / "skills"
    target = root / "skills"
    has_skills = shared_skills.is_dir() and any(
        d.is_dir() and not d.name.startswith(".") for d in shared_skills.iterdir()
    )
    if not has_skills and not _skills.linked_names(shared_skills, target) and not target.is_dir():
        if logger:
            logger.info("no skills to link (%s has none)", shared_skills)
        return
    result = _skills.link_skills_into(shared_skills, target, dry_run=dry_run, logger=logger)
    if result.copied and logger:
        logger.warning(
            "skill(s) %s were COPIED, not linked (no symlink or junction support "
            "here): a copy is a snapshot -- `dotagents init` and `overlays sync` "
            "refresh it while it is unedited", ", ".join(result.copied),
        )
    # A project's `.claude/` is often committed: links/copies there point
    # teammates at a `.agents/` that never is (the include gets the same care).
    if config_root is None and not self._user_scope(dest) and logger and result.owned:
        exposed = [n for n in result.owned if _git_tracked_or_unignored(target / n)]
        if exposed:
            logger.warning(
                "%s is not gitignored: skill(s) %s there come from .agents/, which is "
                "never committed -- keep them out of commits (or add %s to .gitignore)",
                target, ", ".join(exposed), target,
            )

loaded_paths(project_root)

Source code in src/dotagents/_agents.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def loaded_paths(self, project_root: Path) -> "list[Path]":
    seen: "set[Path]" = set()
    _claude_includes(self._user_config_dir() / "CLAUDE.md", seen)
    # The project root's entry files and every ancestor's: Claude walks up.
    try:
        chain = [Path(project_root).resolve(), *Path(project_root).resolve().parents]
    except OSError:
        chain = [Path(project_root)]
    if self._walk_stop is not None:
        stop = Path(self._walk_stop).resolve()
        chain = [d for d in chain if d == stop or stop in d.parents]
    user_dir = self._user_config_dir().resolve()
    for d in chain:
        for entry in self.ENTRY_FILES:
            if (d / entry).resolve() == user_dir / "CLAUDE.md":
                continue
            _claude_includes(d / entry, seen)
    seen.update(self._fallback_agents_md(Path(project_root)))
    return super().loaded_paths(project_root) + sorted(seen)

skills_wired(dest)

Claude is wired for dest when its entry file (CLAUDE.md in :meth:_config_root) includes the store's AGENTS.md, or its skills dir already holds skills linked from this store.

Source code in src/dotagents/_agents.py
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def skills_wired(self, dest: Path) -> bool:
    """Claude is wired for ``dest`` when its entry file (``CLAUDE.md`` in
    :meth:`_config_root`) includes the store's AGENTS.md, or its skills
    dir already holds skills linked from this store."""
    from dotagents import _skills

    root = self._config_root(dest)
    if _skills.linked_names(Path(dest) / "skills", root / "skills"):
        return True
    seen: "set[Path]" = set()
    _claude_includes(root / "CLAUDE.md", seen)
    try:
        return (Path(dest).expanduser().resolve() / "AGENTS.md") in seen
    except OSError:
        return False

wire_hooks(dest, *, dry_run, logger, config_root=None)

Link the shared skills dir into ~/.claude and merge our two hooks.

Additive and idempotent: unrelated settings keys and foreign hooks survive, and a second run writes nothing.

Source code in src/dotagents/_agents.py
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
713
714
715
716
717
718
719
720
721
722
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
def wire_hooks(
    self,
    dest: Path,
    *,
    dry_run: bool,
    logger,
    config_root: "Optional[Path]" = None,
) -> None:
    """Link the shared skills dir into `~/.claude` and merge our two hooks.

    Additive and idempotent: unrelated settings keys and foreign hooks survive,
    and a second run writes nothing.
    """
    from dotagents import _hooks

    # Scope-aware, like the rest of dotagents: a project-scope `init` must not
    # silently edit the user's GLOBAL settings. `dest` is `<scope>/.agents`, so
    # its parent is the project root in project scope; the user store is
    # whatever `resolve_user_store` says (`$AGENTS_HOME`), not literally
    # `~/.agents`.
    is_user_scope = self._user_scope(dest)
    if config_root:
        root = Path(config_root)
        is_user_scope = False
    else:
        root = self._config_root(dest)

    # 1. Skills last mile (`link_skills`; `overlays add/sync/remove` call it too).
    self.link_skills(dest, dry_run=dry_run, logger=logger, config_root=config_root)

    # 2. Hooks. In a project, write `settings.local.json` -- the gitignored
    # personal file. `settings.json` there is checked into source control, and
    # these hooks carry machine-specific paths that must never be committed.
    settings_path = root / (
        "settings.json" if is_user_scope else "settings.local.json"
    )
    settings = _hooks.load_settings(settings_path)
    hooks = settings.get("hooks")
    if not isinstance(hooks, dict):
        hooks = {}
    changed = False

    # Per event, a bash-syntax handler -- and on WINDOWS a second,
    # PowerShell-native one (`shell: powershell`) that selects itself only
    # when `bash` is absent (see the constants above). On POSIX Claude Code
    # runs a `shell: powershell` entry through bash regardless, so the gate
    # is the OS, never "is pwsh installed", and the PowerShell entries are
    # RETRACTED there so an existing install converges on the next `init`.
    # Each `merge_hook`/`remove_hook` is keyed by its own `status_message`,
    # so the variants never collide with each other's identity.
    windows = self._is_windows()
    events = (
        ("SessionStart", hooks.get("SessionStart"), self.SESSION_START_COMMAND,
         self.SESSION_START_COMMAND_POWERSHELL, "Loading agent context"),
        ("CwdChanged", hooks.get("CwdChanged"), self.CWD_CHANGED_COMMAND,
         self.CWD_CHANGED_COMMAND_POWERSHELL, "Checking for AGENTS.md"),
    )
    for event, existing, bash_command, ps_command, status in events:
        entries, c = _hooks.merge_hook(existing, bash_command, status_message=status)
        changed |= c
        if windows:
            entries, c = _hooks.merge_hook(
                entries, ps_command, status_message=status + " (PowerShell)", shell="powershell",
            )
        else:
            entries, c = _hooks.remove_hook(entries, status + " (PowerShell)")
        changed |= c
        hooks[event] = entries

    if windows and self.powershell_env_hook:
        changed |= self._wire_powershell_pretooluse(hooks)
    else:
        pretooluse, c = _hooks.remove_hook(hooks.get("PreToolUse"), self.PRETOOLUSE_STATUS)
        if c:
            changed = True
            if pretooluse:
                hooks["PreToolUse"] = pretooluse
            else:
                hooks.pop("PreToolUse", None)

    if not changed:
        if logger:
            logger.info("hooks already wired: %s", settings_path)
        return

    settings["hooks"] = hooks
    wired = "SessionStart + CwdChanged" + (
        " + PreToolUse" if windows and self.powershell_env_hook else ""
    )
    if dry_run:
        if logger:
            logger.info("would wire %s hooks: %s", wired, settings_path)
        return
    _hooks.write_settings(settings_path, settings)
    if logger:
        logger.info("wired %s hooks: %s", wired, settings_path)

CodexAgent

Bases: Agent

ENV_BLOCK_BEGIN = '# dotagents:begin' class-attribute instance-attribute

ENV_BLOCK_END = '# dotagents:end' class-attribute instance-attribute

PRETOOLUSE_HOOK_SCRIPT = 'pretooluse_codex_env.py' class-attribute instance-attribute

SESSION_START_HOOK_SCRIPT = 'sessionstart_codex_context.py' class-attribute instance-attribute

context_files = ['AGENTS.md'] class-attribute instance-attribute

context_target = 'AGENTS.md' class-attribute instance-attribute

detect_env_vars = ['CODEX_SANDBOX'] class-attribute instance-attribute

harness_id = 'codex' class-attribute instance-attribute

harness_loads = ['AGENTS.md'] class-attribute instance-attribute

launch_command = 'codex' class-attribute instance-attribute

model_source_vars = ['OPENAI_MODEL'] class-attribute instance-attribute

name = 'codex' class-attribute instance-attribute

vendor = 'openai' class-attribute instance-attribute

detect_env(environ)

Source code in src/dotagents/_agents.py
937
938
939
940
941
def detect_env(self, environ: "dict[str, str]") -> bool:
    # Exact markers plus any CODEX_SANDBOX_* variant (prefix match).
    if any(var in environ for var in self.detect_env_vars):
        return True
    return any(k.startswith("CODEX_SANDBOX") for k in environ)

hook_commands(root, script_name) staticmethod

(command, commandWindows) running the deployed script_name under the absolute interpreter running init.

Source code in src/dotagents/_agents.py
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
@staticmethod
def hook_commands(root: Path, script_name: str) -> "tuple[str, str]":
    """``(command, commandWindows)`` running the deployed ``script_name``
    under the absolute interpreter running ``init``."""
    import sys

    script = (Path(root) / "hooks" / script_name).resolve()
    python = Path(sys.executable)
    return (
        '"%s" "%s"' % (python.as_posix(), script.as_posix()),
        '"%s" "%s"' % (python, script),
    )

remove_env_block(*, dry_run, logger, config_root=None)

Remove the static env block an earlier init --agents codex wrote into config.toml; the rest of the file is untouched.

Source code in src/dotagents/_agents.py
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
def remove_env_block(
    self, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """Remove the static env block an earlier ``init --agents codex``
    wrote into ``config.toml``; the rest of the file is untouched."""
    from dotagents._merge import remove_block

    config_path = self._config_root(config_root) / "config.toml"
    branch = remove_block(
        config_path, dry_run=dry_run,
        begin_marker=self.ENV_BLOCK_BEGIN, end_marker=self.ENV_BLOCK_END,
    )
    if branch == "removed" and logger:
        logger.info(
            "%s the old static env block from %s (the PreToolUse hook supplies the env)",
            "would remove" if dry_run else "removed", config_path,
        )

wire_hooks(dest, *, dry_run, logger, config_root=None)

Merge SessionStart + PreToolUse into <codex-home>/hooks.json, and deploy the PreToolUse env-loader script alongside it.

Source code in src/dotagents/_agents.py
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
def wire_hooks(
    self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
) -> None:
    """Merge SessionStart + PreToolUse into `<codex-home>/hooks.json`, and
    deploy the PreToolUse env-loader script alongside it."""
    from dotagents import _hooks

    if config_root is None and not self._user_scope(dest):
        if logger:
            logger.info(
                "skipped Codex hooks: its config is global; run `init -g --agents "
                "codex` to wire it"
            )
        return
    root = self._config_root(config_root)

    self.remove_env_block(dry_run=dry_run, logger=logger, config_root=root)
    script_changed = False
    for name in (self.SESSION_START_HOOK_SCRIPT, self.PRETOOLUSE_HOOK_SCRIPT):
        script_changed |= bool(_deploy_hook_script(
            root, name, dry_run=dry_run, logger=logger, harness="Codex",
        ))

    hooks_path = root / "hooks.json"
    data = _hooks.load_settings(hooks_path)
    hooks = data.get("hooks")
    if not isinstance(hooks, dict):
        hooks = {}

    # Absolute interpreter and script, quoted for a possible space: this
    # hooks.json is machine-local config written on the machine it runs on.
    # `commandWindows` is Codex's documented Windows-only override.
    ss_command, ss_command_windows = self.hook_commands(root, self.SESSION_START_HOOK_SCRIPT)
    session_start, ss_changed = _hooks.merge_hook(
        hooks.get("SessionStart"),
        ss_command,
        status_message="Loading agent context",
        command_windows=ss_command_windows,
    )
    hooks["SessionStart"] = session_start

    pt_command, pt_command_windows = self.hook_commands(root, self.PRETOOLUSE_HOOK_SCRIPT)
    pretooluse, pt_changed = _hooks.merge_hook(
        hooks.get("PreToolUse"),
        pt_command,
        matcher="Bash",
        status_message="Loading agent env",
        command_windows=pt_command_windows,
    )
    hooks["PreToolUse"] = pretooluse

    if not (ss_changed or pt_changed or script_changed):
        if logger:
            logger.info("hooks already wired: %s", hooks_path)
        return

    data["hooks"] = hooks
    if dry_run:
        if logger:
            logger.info("would wire SessionStart + PreToolUse hooks: %s", hooks_path)
        return
    _hooks.write_settings(hooks_path, data)
    if logger:
        logger.info("wired SessionStart + PreToolUse hooks: %s", hooks_path)

CopilotAgent

Bases: Agent

base_config_note = 'reads the context via `context --write-agent` or `launch`' class-attribute instance-attribute

context_files = ['.github/copilot-instructions.md'] class-attribute instance-attribute

context_target = '.github/copilot-instructions.md' class-attribute instance-attribute

detect_env_vars = [] class-attribute instance-attribute

harness_id = 'copilot' class-attribute instance-attribute

harness_loads = ['AGENTS.md', '.github/copilot-instructions.md'] class-attribute instance-attribute

launch_command = 'copilot' class-attribute instance-attribute

model_source_vars = ['COPILOT_MODEL'] class-attribute instance-attribute

name = 'copilot' class-attribute instance-attribute

vendor = 'github' class-attribute instance-attribute

CursorAgent

Bases: Agent

base_config_note = 'reads the context via `context --write-agent` or `launch`' class-attribute instance-attribute

context_files = ['.cursorrules', '.cursor/rules'] class-attribute instance-attribute

context_target = '.cursor/rules/dotagents.mdc' class-attribute instance-attribute

detect_env_vars = ['CURSOR_AGENT'] class-attribute instance-attribute

harness_id = 'cursor' class-attribute instance-attribute

harness_loads = ['AGENTS.md', '.cursor/rules/dotagents.mdc'] class-attribute instance-attribute

launch_command = 'cursor-agent' class-attribute instance-attribute

model_source_vars = ['CURSOR_DEFAULT_MODEL'] class-attribute instance-attribute

name = 'cursor' class-attribute instance-attribute

vendor = 'cursor' class-attribute instance-attribute

write_context(project_root, effective_context, *, dry_run, logger)

The managed block in .cursor/rules/dotagents.mdc, created with the alwaysApply: true frontmatter a rule needs to load every session.

Source code in src/dotagents/_agents.py
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
def write_context(self, project_root: Path, effective_context: str, *, dry_run: bool, logger) -> None:
    """The managed block in `.cursor/rules/dotagents.mdc`, created with the
    `alwaysApply: true` frontmatter a rule needs to load every session."""
    target = Path(project_root) / self.context_target
    if not target.exists() and not dry_run:
        write_text_lf(
            target,
            "---\ndescription: dotagents assembled context\nalwaysApply: true\n---\n",
        )
    super().write_context(project_root, effective_context, dry_run=dry_run, logger=logger)

GeminiAgent

Bases: Agent

context_files = ['GEMINI.md'] class-attribute instance-attribute

context_target = 'GEMINI.md' class-attribute instance-attribute

detect_env_vars = ['GEMINI_CLI'] class-attribute instance-attribute

harness_id = 'gemini-cli' class-attribute instance-attribute

harness_loads = ['GEMINI.md'] class-attribute instance-attribute

include_entry = ('.gemini/GEMINI.md', 'GEMINI.md') class-attribute instance-attribute

launch_command = 'gemini' class-attribute instance-attribute

model_source_vars = ['GEMINI_MODEL'] class-attribute instance-attribute

name = 'gemini' class-attribute instance-attribute

vendor = 'google' class-attribute instance-attribute

PiAgent

Bases: Agent

pi (pi.dev; npm @earendil-works/pi-coding-agent; the pi command).

Documented conventions (packages/coding-agent/README.md and docs/environment-variables.md in badlogic/pi-mono):

  • Context files: AGENTS.md / CLAUDE.md (AGENTS.override.md replaces both in its directory), read from the config dir (~/.pi/agent, $PI_CODING_AGENT_DIR overrides) first, then every parent of the cwd, then the cwd. No include syntax.
  • System prompt: --system-prompt <text> replaces, --append-system-prompt <text> appends; the file forms are SYSTEM.md (replaces) and APPEND_SYSTEM.md (appends), in <project>/.pi/ or the config dir.
  • Markers in every process its tools run: PI_CODING_AGENT=true, AI_AGENT=pi; PI_MODEL / PI_PROVIDER name the selected model. pi is multi-provider, so it has no vendor of its own.

POINTER_BEGIN_MARKER = '<!-- dotagents:pointer:begin -->' class-attribute instance-attribute

POINTER_END_MARKER = '<!-- dotagents:pointer:end -->' class-attribute instance-attribute

context_files = ['.pi/APPEND_SYSTEM.md', '.pi/SYSTEM.md'] class-attribute instance-attribute

context_target = '.pi/APPEND_SYSTEM.md' class-attribute instance-attribute

detect_env_vars = ['PI_CODING_AGENT'] class-attribute instance-attribute

harness_id = 'pi' class-attribute instance-attribute

harness_loads = ['AGENTS.md', 'CLAUDE.md', '.pi/APPEND_SYSTEM.md'] class-attribute instance-attribute

launch_command = 'pi' class-attribute instance-attribute

model_source_vars = ['PI_MODEL'] class-attribute instance-attribute

name = 'pi' class-attribute instance-attribute

vendor = '' class-attribute instance-attribute

launch_context_args(context_file)

Source code in src/dotagents/_agents.py
1235
1236
1237
1238
1239
1240
1241
1242
1243
def launch_context_args(self, context_file: Path) -> "Optional[list[str]]":
    # `--append-system-prompt <text>` is the documented append; there is no
    # file form. On Windows `pi` is an npm `.cmd` shim that cmd.exe
    # re-parses, and a newline inside an argument ends the command there,
    # so the multi-line context takes the static route instead
    # (`.pi/APPEND_SYSTEM.md`, which pi appends by itself).
    if self._is_windows():
        return None
    return ["--append-system-prompt", context_file.read_text(encoding="utf-8")]

loaded_paths(project_root)

Source code in src/dotagents/_agents.py
1180
1181
1182
1183
1184
1185
1186
1187
def loaded_paths(self, project_root: Path) -> "list[Path]":
    out = super().loaded_paths(project_root)
    for name in ("AGENTS.md", "CLAUDE.md", "APPEND_SYSTEM.md"):
        try:
            out.append((self._config_dir() / name).resolve())
        except OSError:
            pass
    return out

write_base_config(dest, *, dry_run, logger)

Source code in src/dotagents/_agents.py
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
def write_base_config(self, dest: Path, *, dry_run: bool, logger) -> None:
    from dotagents._merge import BEGIN_MARKER, END_MARKER, merge_block

    # The last mile: pi has no include syntax, so a managed block POINTING
    # at the store's AGENTS.md -- not a copy of it, which would reach a
    # launched session twice (once from pi's own file, once as the context
    # `launch` appends). For the user store that block goes in
    # `<config dir>/AGENTS.md`, which pi reads in every session. pi reads a
    # project's ROOT AGENTS.md, never `<project>/.agents/AGENTS.md`, so a
    # project store's pointer goes in `<project>/.pi/APPEND_SYSTEM.md`,
    # which pi appends to its system prompt in that project.
    store_agents = Path(dest).expanduser().resolve() / "AGENTS.md"
    begin, end = BEGIN_MARKER, END_MARKER
    if self._user_scope(dest):
        entry = self._config_dir() / "AGENTS.md"
        where, what = store_agents.as_posix(), "the global agent configuration"
    else:
        # Markers of its own: the same file carries `write_context`'s
        # context block, whose text (the store's AGENTS.md) holds plain
        # `dotagents:begin`/`end` lines a plain merge would take for ours.
        begin, end = self.POINTER_BEGIN_MARKER, self.POINTER_END_MARKER
        project = self._project_dir(dest)
        entry = project / self.context_target
        try:
            # Relative to the project root (pi's cwd): no machine path in a
            # file that may be committed.
            where = store_agents.relative_to(project).as_posix()
        except ValueError:
            where = store_agents.as_posix()
        what = "this project's agent configuration"
    pointer = (
        "%s\n# dotagents\n\n"
        "Read `%s` before anything else: it is %s "
        "`dotagents init` manages (the always-on rules and the routing to "
        "on-demand files).\n%s\n" % (begin, where, what, end)
    )
    # pi's own file is the user's: block-merged even under `--force`,
    # like Claude's include, so their text around the block survives.
    branch = merge_block(entry, pointer, dry_run=dry_run, begin_marker=begin, end_marker=end)
    if logger: logger.info("%s: %s (pointer to the store)", branch, entry)
    if not self._user_scope(dest) and logger and _git_tracked_or_unignored(entry):
        logger.warning(
            "%s is not gitignored: its pointer names .agents/, which is never "
            "committed -- keep the file out of commits (or add it to .gitignore)", entry,
        )

detect_runtime_agent(environ, explicit=None)

The agent actually driving this process, from runtime signals only: explicit name > $AGENTS_HARNESS > env-var markers. None in a plain shell -- config files in the tree say which tools a repo is set up for, not that one of them is running.

Source code in src/dotagents/_agents.py
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
def detect_runtime_agent(
    environ: dict[str, str], explicit: Optional[str] = None
) -> "Optional[Agent]":
    """The agent actually driving this process, from runtime signals only:
    explicit name > ``$AGENTS_HARNESS`` > env-var markers. ``None`` in a plain
    shell -- config files in the tree say which tools a repo is set up for, not
    that one of them is running."""
    if explicit and explicit in _REGISTRY:
        return _REGISTRY[explicit]()
    stamped = environ.get("AGENTS_HARNESS")
    if stamped:
        alias = _harness_alias(stamped)
        if alias:
            return _REGISTRY[alias]()
    for cls in _REGISTRY.values():
        agent = cls()
        if agent.detect_env(environ):
            return agent
    return None

get_agent(name)

Source code in src/dotagents/_agents.py
1257
1258
1259
def get_agent(name: str) -> Optional[Agent]:
    cls = _REGISTRY.get(name)
    return cls() if cls else None

get_all_agents()

Source code in src/dotagents/_agents.py
1261
1262
def get_all_agents() -> list[Agent]:
    return [cls() for cls in _REGISTRY.values()]

resolve_active_agent(environ, explicit=None, root=None)

Resolve the active agent by precedence.

Precedence: explicit (--agents) > $AGENTS_HARNESS (registry name or harness_id) > env-var detection (detect_env) > config-file detection (detect(root)) > default (claude).

root is where config-file detect() looks (default: cwd). The TARGET picker for context / launch; identity uses :func:detect_runtime_agent alone.

Source code in src/dotagents/_agents.py
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
def resolve_active_agent(
    environ: dict[str, str],
    explicit: Optional[str] = None,
    root: Optional[Path] = None,
) -> Agent:
    """Resolve the active agent by precedence.

    Precedence: explicit (--agents) > $AGENTS_HARNESS (registry name or
    harness_id) > env-var detection (detect_env) > config-file detection
    (detect(root)) > default (claude).

    ``root`` is where config-file detect() looks (default: cwd). The TARGET
    picker for ``context`` / ``launch``; identity uses
    :func:`detect_runtime_agent` alone.
    """
    runtime = detect_runtime_agent(environ, explicit=explicit)
    if runtime is not None:
        return runtime

    # Config-file fallback: which agent's config is present in the tree?
    detect_root = root if root is not None else Path.cwd()
    for name, cls in _REGISTRY.items():
        agent = cls()
        try:
            if agent.detect(detect_root):
                return agent
        except OSError:
            pass

    return ClaudeAgent()

stamp_identity(environ, explicit=None, root=None)

Return the standardized AGENTS_* / AGENT identity vars.

Emits AGENTS_HARNESS (the harness_id, e.g. claude-code -- NOT the short name), AGENTS_VENDOR, AGENT (= AGENTS_HARNESS, the ecosystem marker used by Goose/Amp; agentsmd/agents.md#136), and, when derivable, AGENTS_MODEL (from the adapter's vendor model var).

Never emits a var it cannot source (no empty AGENTS_MODEL=). Does NOT clobber a value already set in environ -- an explicit user/harness value wins -- UNLESS explicit names the agent: then the identity describes THAT agent and overrides whatever environ carries, since a base-env identity is the running harness's (the env-loader hook exports AGENT=claude-code into every command a Claude session runs), not a pin.

AGENTS_AGENT (a named persona) is not emitted; nothing may branch on it.

Runtime signals only (:func:detect_runtime_agent): in a plain shell -- no explicit name, no $AGENTS_HARNESS, no harness marker -- nothing is emitted. Config-file detection stamped AGENT=codex into any shell that sourced env in a repo with an AGENTS.md, telling other tools an agent was driving it. root is accepted for compatibility and unused.

Source code in src/dotagents/_agents.py
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
def stamp_identity(
    environ: dict[str, str],
    explicit: Optional[str] = None,
    root: Optional[Path] = None,
) -> dict[str, str]:
    """Return the standardized ``AGENTS_*`` / ``AGENT`` identity vars.

    Emits ``AGENTS_HARNESS`` (the harness_id, e.g. ``claude-code`` -- NOT the
    short name), ``AGENTS_VENDOR``, ``AGENT`` (= AGENTS_HARNESS, the ecosystem
    marker used by Goose/Amp; agentsmd/agents.md#136), and, when derivable,
    ``AGENTS_MODEL`` (from the adapter's vendor model var).

    Never emits a var it cannot source (no empty ``AGENTS_MODEL=``). Does NOT
    clobber a value already set in ``environ`` -- an explicit user/harness value
    wins -- UNLESS ``explicit`` names the agent: then the identity describes
    THAT agent and overrides whatever ``environ`` carries, since a base-env
    identity is the *running* harness's (the env-loader hook exports
    ``AGENT=claude-code`` into every command a Claude session runs), not a pin.

    ``AGENTS_AGENT`` (a named persona) is not emitted; nothing may branch on it.

    Runtime signals only (:func:`detect_runtime_agent`): in a plain shell --
    no explicit name, no ``$AGENTS_HARNESS``, no harness marker -- nothing is
    emitted. Config-file detection stamped ``AGENT=codex`` into any shell that
    sourced ``env`` in a repo with an AGENTS.md, telling other tools an agent
    was driving it. ``root`` is accepted for compatibility and unused.
    """
    active = detect_runtime_agent(environ, explicit=explicit)
    if active is None:
        return {}
    # Only a KNOWN explicit name overrides: an unknown one falls through to
    # detection above, and the detected identity must not clobber a pin.
    override = explicit is not None and explicit in _REGISTRY

    identity: dict[str, str] = {}

    def _set(key: str, value: "Optional[str]") -> None:
        if value and (override or not environ.get(key)):
            identity[key] = value

    _set("AGENTS_HARNESS", active.harness_id or active.name)
    _set("AGENTS_VENDOR", active.vendor)
    _set("AGENT", active.harness_id or active.name)
    _set("AGENTS_MODEL", active.resolve_model(environ))

    return identity