dotagents-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotagents/AGENTS.md +125 -0
- dotagents/__init__.py +3 -0
- dotagents/__main__.py +6 -0
- dotagents/_agents.py +627 -0
- dotagents/_context.py +311 -0
- dotagents/_env.py +600 -0
- dotagents/_hooks.py +172 -0
- dotagents/_merge.py +122 -0
- dotagents/_overlay/AGENTS.md +31 -0
- dotagents/_overlay/CLAUDE.md +3 -0
- dotagents/_overlay/README.md +41 -0
- dotagents/_overlay/dotagents/DECISIONS.md +20 -0
- dotagents/_overlay/dotagents/cmds/README.md +46 -0
- dotagents/_overlays.py +412 -0
- dotagents/_resolve.py +81 -0
- dotagents/_scope.py +265 -0
- dotagents/_skills.py +236 -0
- dotagents/_sync.py +72 -0
- dotagents/_wrappers.py +112 -0
- dotagents/cli/__init__.py +354 -0
- dotagents/cli/_common.py +331 -0
- dotagents/cli/build_pyz.py +107 -0
- dotagents/cli/context.py +129 -0
- dotagents/cli/env.py +168 -0
- dotagents/cli/init.py +117 -0
- dotagents/cli/overlays.py +366 -0
- dotagents_cli-0.3.0.dist-info/METADATA +291 -0
- dotagents_cli-0.3.0.dist-info/RECORD +31 -0
- dotagents_cli-0.3.0.dist-info/WHEEL +4 -0
- dotagents_cli-0.3.0.dist-info/entry_points.txt +2 -0
- dotagents_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
dotagents/_agents.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Agent registry: base Agent type + per-agent adapters (Plan 00)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Agent:
|
|
10
|
+
"""Base class for dotagents adapters.
|
|
11
|
+
|
|
12
|
+
Identity (plan 08): each adapter declares its ``harness_id`` (the stable
|
|
13
|
+
ecosystem name, e.g. ``claude-code`` -- NOT the short registry ``name``),
|
|
14
|
+
``vendor`` (provider family), and ``model_source_vars`` (the vendor-native
|
|
15
|
+
env vars to read a running model id from, in precedence order). These feed
|
|
16
|
+
``stamp_identity`` which emits the standardized ``AGENTS_*``/``AGENT`` vars.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str = ""
|
|
20
|
+
context_files: list[str] = []
|
|
21
|
+
harness_loads: list[str] = []
|
|
22
|
+
detect_env_vars: list[str] = []
|
|
23
|
+
|
|
24
|
+
# --- identity mapping (plan 08) ------------------------------------
|
|
25
|
+
harness_id: str = ""
|
|
26
|
+
vendor: str = ""
|
|
27
|
+
model_source_vars: list[str] = []
|
|
28
|
+
|
|
29
|
+
def detect_env(self, environ: dict[str, str]) -> bool:
|
|
30
|
+
"""Return True if this agent's harness is running based on env vars.
|
|
31
|
+
|
|
32
|
+
Default: True if any declared marker var is present. Adapters whose
|
|
33
|
+
markers are prefixes (e.g. Codex's ``CODEX_SANDBOX_*``) override this.
|
|
34
|
+
"""
|
|
35
|
+
return any(var in environ for var in self.detect_env_vars)
|
|
36
|
+
|
|
37
|
+
def resolve_model(self, environ: dict[str, str]) -> "Optional[str]":
|
|
38
|
+
"""Return the running model id from the first populated source var, or None."""
|
|
39
|
+
for var in self.model_source_vars:
|
|
40
|
+
val = environ.get(var)
|
|
41
|
+
if val:
|
|
42
|
+
return val
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def write_base_config(
|
|
46
|
+
self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Write the base configuration files for this agent (used by init/install)."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
52
|
+
"""Write the assembled context file for this agent (used by context generator)."""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def wire_hooks(
|
|
56
|
+
self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Wire up hooks in the agent's settings, if this adapter supports it.
|
|
59
|
+
|
|
60
|
+
A no-op on the base class: the hook schema is harness-specific, so only
|
|
61
|
+
adapters with a *verified* schema override this. `config_root` overrides
|
|
62
|
+
the agent's own config dir (e.g. `~/.claude`) and exists so tests can
|
|
63
|
+
redirect writes away from the real one.
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def detect(self, root: Path) -> bool:
|
|
68
|
+
"""Return True if this agent's config is present in the given root."""
|
|
69
|
+
return any((root / f).exists() for f in self.context_files)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClaudeAgent(Agent):
|
|
73
|
+
name = "claude"
|
|
74
|
+
context_files = ["CLAUDE.md"]
|
|
75
|
+
# Claude's harness loads ~/.agents/AGENTS.md (via @-include) and any per-dir AGENTS.md
|
|
76
|
+
harness_loads = ["~/.agents/AGENTS.md", "AGENTS.md"]
|
|
77
|
+
# Confirmed markers (code.claude.com/docs/en/env-vars): CLAUDECODE=1 plus the
|
|
78
|
+
# CLAUDE_CODE_* family (CLAUDE_CODE_ENTRYPOINT, ...).
|
|
79
|
+
detect_env_vars = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"]
|
|
80
|
+
harness_id = "claude-code"
|
|
81
|
+
vendor = "anthropic"
|
|
82
|
+
model_source_vars = ["ANTHROPIC_MODEL"]
|
|
83
|
+
|
|
84
|
+
def write_base_config(self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
85
|
+
from dotagents._merge import merge_block, merge_claude_md, timestamped_backup_root
|
|
86
|
+
|
|
87
|
+
backup_root = timestamped_backup_root(dest) if force else None
|
|
88
|
+
|
|
89
|
+
branch = merge_block(
|
|
90
|
+
dest / "AGENTS.md",
|
|
91
|
+
base_agents_text,
|
|
92
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
93
|
+
)
|
|
94
|
+
if logger: logger.info("%s: AGENTS.md", branch)
|
|
95
|
+
|
|
96
|
+
claude_md_src = src / "CLAUDE.md"
|
|
97
|
+
if claude_md_src.exists():
|
|
98
|
+
branch = merge_claude_md(
|
|
99
|
+
dest / "CLAUDE.md",
|
|
100
|
+
claude_md_src.read_text(encoding="utf-8"),
|
|
101
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
102
|
+
)
|
|
103
|
+
if logger: logger.info("%s: CLAUDE.md", branch)
|
|
104
|
+
|
|
105
|
+
# --- hooks ---------------------------------------------------------
|
|
106
|
+
#
|
|
107
|
+
# SessionStart does two things:
|
|
108
|
+
#
|
|
109
|
+
# 1. `dotagents env` output is appended to $CLAUDE_ENV_FILE -- a real, documented
|
|
110
|
+
# variable (code.claude.com/docs/en/env-vars + hooks.md "Persist environment
|
|
111
|
+
# variables"). Claude sources that file before each Bash command, so exports
|
|
112
|
+
# land in every subsequent command of the session. It is provided to
|
|
113
|
+
# SessionStart/Setup/CwdChanged/FileChanged hooks ONLY, and only inside the
|
|
114
|
+
# hook process -- it is absent from the session's own environment, so
|
|
115
|
+
# `env | grep CLAUDE_ENV_FILE` in a normal shell proves nothing.
|
|
116
|
+
#
|
|
117
|
+
# Two details the docs are explicit about, both load-bearing:
|
|
118
|
+
# * APPEND (`>>`), never truncate -- other hooks write to the same file and
|
|
119
|
+
# `>` would silently discard their variables.
|
|
120
|
+
# * Guard on non-empty (`[ -n "$CLAUDE_ENV_FILE" ]`) -- when unset (a hook
|
|
121
|
+
# type without access), `>> ""` would create a file literally named "".
|
|
122
|
+
# `--diff` keeps it to the change set rather than re-exporting the world.
|
|
123
|
+
#
|
|
124
|
+
# 2. `dotagents context` runs after it; Claude injects a SessionStart hook's
|
|
125
|
+
# **stdout into the session context**, which is how the assembled context
|
|
126
|
+
# reaches the model without the user remembering to run anything.
|
|
127
|
+
#
|
|
128
|
+
# Both halves are one `type: command` hook: SessionStart supports only
|
|
129
|
+
# command/mcp_tool hooks, and ordering matters (env first, so context sees it).
|
|
130
|
+
#
|
|
131
|
+
# The PATH prefix makes the hook SELF-SUFFICIENT: `init` populates
|
|
132
|
+
# `<scope>/bin/`, and prefixing it here means the hook finds `dotagents` with no
|
|
133
|
+
# global install and no PATH edit by the user. Without it the hook is one
|
|
134
|
+
# `command not found` away from silently delivering nothing -- which is exactly
|
|
135
|
+
# what happened on this project's own dev box. Project scope comes first so a
|
|
136
|
+
# project's own wrapper wins over the user store's.
|
|
137
|
+
_HOOK_PATH = 'PATH=".agents/bin:$HOME/.agents/bin:$PATH"'
|
|
138
|
+
SESSION_START_COMMAND = (
|
|
139
|
+
'if [ -n "$CLAUDE_ENV_FILE" ]; then '
|
|
140
|
+
'%(path)s dotagents env --diff --format export >> "$CLAUDE_ENV_FILE"; '
|
|
141
|
+
"fi; "
|
|
142
|
+
"%(path)s dotagents context"
|
|
143
|
+
) % {"path": _HOOK_PATH}
|
|
144
|
+
CWD_CHANGED_COMMAND = "[ -f AGENTS.md ] && cat AGENTS.md || true"
|
|
145
|
+
|
|
146
|
+
def wire_hooks(
|
|
147
|
+
self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Link the shared skills dir into `~/.claude` and merge our two hooks.
|
|
150
|
+
|
|
151
|
+
Additive and idempotent: unrelated settings keys and foreign hooks survive,
|
|
152
|
+
and a second run writes nothing.
|
|
153
|
+
"""
|
|
154
|
+
from dotagents import _hooks, _skills
|
|
155
|
+
|
|
156
|
+
# Scope-aware, like the rest of dotagents: a project-scope `init` must not
|
|
157
|
+
# silently edit the user's GLOBAL settings. `dest` is `<scope>/.agents`, so
|
|
158
|
+
# its parent is the project root in project scope and $HOME in user scope.
|
|
159
|
+
if config_root:
|
|
160
|
+
root = Path(config_root)
|
|
161
|
+
elif Path(dest).expanduser().resolve() == (Path.home() / ".agents").resolve():
|
|
162
|
+
root = Path.home() / ".claude"
|
|
163
|
+
else:
|
|
164
|
+
root = Path(dest).parent / ".claude"
|
|
165
|
+
|
|
166
|
+
# 1. Skills last mile. Publishing into `<scope>/skills/` only helps if the
|
|
167
|
+
# agent reads that dir; without this link it never does.
|
|
168
|
+
shared_skills = Path(dest) / "skills"
|
|
169
|
+
if not shared_skills.is_dir():
|
|
170
|
+
if logger:
|
|
171
|
+
logger.info("no skills to link (%s absent)", shared_skills)
|
|
172
|
+
elif dry_run:
|
|
173
|
+
if logger:
|
|
174
|
+
logger.info("would link skills: %s -> %s", shared_skills, root / "skills")
|
|
175
|
+
else:
|
|
176
|
+
result = _skills.sync_path(shared_skills, root / "skills", prefer_symlink=True)
|
|
177
|
+
if not result.success:
|
|
178
|
+
if logger:
|
|
179
|
+
logger.warning("skills not linked: %s", result.message)
|
|
180
|
+
else:
|
|
181
|
+
if logger:
|
|
182
|
+
logger.info("skills (%s): %s", result.mode, result.message)
|
|
183
|
+
if result.mode == "copy" and logger:
|
|
184
|
+
logger.warning(
|
|
185
|
+
"skills were COPIED, not symlinked (no symlink support here): "
|
|
186
|
+
"the copy is a point-in-time snapshot and goes stale when overlay "
|
|
187
|
+
"skills change -- re-run `dotagents init` to refresh it"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# 2. Hooks. In a project, write `settings.local.json` -- the gitignored
|
|
191
|
+
# personal file. `settings.json` there is checked into source control, and
|
|
192
|
+
# these hooks carry machine-specific paths that must never be committed.
|
|
193
|
+
is_user_scope = root == (Path.home() / ".claude")
|
|
194
|
+
settings_path = root / (
|
|
195
|
+
"settings.json" if is_user_scope else "settings.local.json"
|
|
196
|
+
)
|
|
197
|
+
settings = _hooks.load_settings(settings_path)
|
|
198
|
+
hooks = settings.get("hooks")
|
|
199
|
+
if not isinstance(hooks, dict):
|
|
200
|
+
hooks = {}
|
|
201
|
+
changed = False
|
|
202
|
+
|
|
203
|
+
# Legacy lowercase key from the precursor -- fold it in, then drop it.
|
|
204
|
+
legacy = hooks.pop("session_start", None)
|
|
205
|
+
if legacy is not None:
|
|
206
|
+
changed = True
|
|
207
|
+
|
|
208
|
+
session_start, ss_changed = _hooks.merge_hook(
|
|
209
|
+
hooks.get("SessionStart", legacy),
|
|
210
|
+
self.SESSION_START_COMMAND,
|
|
211
|
+
status_message="Loading agent context",
|
|
212
|
+
)
|
|
213
|
+
hooks["SessionStart"] = session_start
|
|
214
|
+
|
|
215
|
+
cwd_changed, cc_changed = _hooks.merge_hook(
|
|
216
|
+
hooks.get("CwdChanged"),
|
|
217
|
+
self.CWD_CHANGED_COMMAND,
|
|
218
|
+
status_message="Checking for AGENTS.md",
|
|
219
|
+
)
|
|
220
|
+
hooks["CwdChanged"] = cwd_changed
|
|
221
|
+
|
|
222
|
+
if not (changed or ss_changed or cc_changed):
|
|
223
|
+
if logger:
|
|
224
|
+
logger.info("hooks already wired: %s", settings_path)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
settings["hooks"] = hooks
|
|
228
|
+
if dry_run:
|
|
229
|
+
if logger:
|
|
230
|
+
logger.info("would wire SessionStart + CwdChanged hooks: %s", settings_path)
|
|
231
|
+
return
|
|
232
|
+
_hooks.write_settings(settings_path, settings)
|
|
233
|
+
if logger:
|
|
234
|
+
logger.info("wired SessionStart + CwdChanged hooks: %s", settings_path)
|
|
235
|
+
|
|
236
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
237
|
+
target = dest / "CONTEXT.md"
|
|
238
|
+
if not dry_run:
|
|
239
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
target.write_text(effective_context, encoding="utf-8")
|
|
241
|
+
if logger: logger.info("wrote context to %s", target)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class GeminiAgent(Agent):
|
|
245
|
+
name = "gemini"
|
|
246
|
+
context_files = ["GEMINI.md"]
|
|
247
|
+
harness_loads = ["GEMINI.md"]
|
|
248
|
+
# Confirmed marker: GEMINI_CLI=1, set by Gemini CLI in every child process it
|
|
249
|
+
# spawns (google-gemini.github.io/gemini-cli, run_shell_command docs). The old
|
|
250
|
+
# GEMINI_SESSION was invented; GEMINI_API_KEY is a credential, not a marker.
|
|
251
|
+
detect_env_vars = ["GEMINI_CLI"]
|
|
252
|
+
harness_id = "gemini-cli"
|
|
253
|
+
vendor = "google"
|
|
254
|
+
model_source_vars = ["GEMINI_MODEL"]
|
|
255
|
+
|
|
256
|
+
def write_base_config(self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
257
|
+
from dotagents._merge import merge_block, timestamped_backup_root
|
|
258
|
+
backup_root = timestamped_backup_root(dest) if force else None
|
|
259
|
+
branch = merge_block(
|
|
260
|
+
dest / "GEMINI.md",
|
|
261
|
+
base_agents_text,
|
|
262
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
263
|
+
)
|
|
264
|
+
if logger: logger.info("%s: GEMINI.md", branch)
|
|
265
|
+
|
|
266
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
267
|
+
target = dest / "GEMINI.md"
|
|
268
|
+
if not dry_run:
|
|
269
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
target.write_text(effective_context, encoding="utf-8")
|
|
271
|
+
if logger: logger.info("wrote context to %s", target)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class CodexAgent(Agent):
|
|
275
|
+
name = "codex"
|
|
276
|
+
context_files = ["AGENTS.md"]
|
|
277
|
+
harness_loads = ["AGENTS.md"]
|
|
278
|
+
# Codex ships NO dedicated runtime marker (openai/codex). The old CODEX_SESSION
|
|
279
|
+
# was invented. Detect via the state dir CODEX_HOME or the sandbox signal vars
|
|
280
|
+
# Codex sets for child processes (CODEX_SANDBOX, CODEX_SANDBOX_NETWORK_DISABLED,
|
|
281
|
+
# any CODEX_SANDBOX_* -- matched by prefix in detect_env below).
|
|
282
|
+
detect_env_vars = ["CODEX_HOME", "CODEX_SANDBOX"]
|
|
283
|
+
harness_id = "codex"
|
|
284
|
+
vendor = "openai"
|
|
285
|
+
# OpenAI base/model live under OPENAI_* (support the OPENAI_API_BASE alias
|
|
286
|
+
# elsewhere); Codex has no dedicated model var, so read the OpenAI one.
|
|
287
|
+
model_source_vars = ["OPENAI_MODEL"]
|
|
288
|
+
|
|
289
|
+
def detect_env(self, environ: "dict[str, str]") -> bool:
|
|
290
|
+
# Exact markers plus any CODEX_SANDBOX_* variant (prefix match).
|
|
291
|
+
if any(var in environ for var in self.detect_env_vars):
|
|
292
|
+
return True
|
|
293
|
+
return any(k.startswith("CODEX_SANDBOX") for k in environ)
|
|
294
|
+
|
|
295
|
+
def write_base_config(self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
296
|
+
from dotagents._merge import merge_block, timestamped_backup_root
|
|
297
|
+
backup_root = timestamped_backup_root(dest) if force else None
|
|
298
|
+
branch = merge_block(
|
|
299
|
+
dest / "AGENTS.md",
|
|
300
|
+
base_agents_text,
|
|
301
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
302
|
+
)
|
|
303
|
+
if logger: logger.info("%s: AGENTS.md (Codex)", branch)
|
|
304
|
+
|
|
305
|
+
# --- hooks ---------------------------------------------------------
|
|
306
|
+
#
|
|
307
|
+
# Codex ships a hooks framework (learn.chatgpt.com/docs/hooks) whose JSON is
|
|
308
|
+
# **structurally identical** to Claude's -- `hooks.<Event>` is a list of
|
|
309
|
+
# matcher-objects each holding its own `hooks` list of
|
|
310
|
+
# {type, command, statusMessage} -- so `_hooks` merges it unchanged.
|
|
311
|
+
#
|
|
312
|
+
# Context half only. Codex's SessionStart adds "plain text on stdout ... as
|
|
313
|
+
# extra developer context", which is what we need. There is deliberately NO env
|
|
314
|
+
# half: Codex has no CLAUDE_ENV_FILE equivalent -- its hooks *receive* plugin
|
|
315
|
+
# vars (PLUGIN_ROOT/PLUGIN_DATA) but nothing persists exports back into the
|
|
316
|
+
# session. Writing one would be inventing a mechanism.
|
|
317
|
+
#
|
|
318
|
+
# Target `hooks.json`, not `config.toml`: Codex reads either, but a dedicated
|
|
319
|
+
# file means we never rewrite (and risk mangling) the user's main TOML config.
|
|
320
|
+
# Codex warns at startup if one layer has both, so a user with inline [hooks]
|
|
321
|
+
# should pass --no-hooks.
|
|
322
|
+
# Same PATH prefix as Claude's hook, for the same reason: `<scope>/bin/` holds
|
|
323
|
+
# the wrapper `init` wrote, so the hook resolves `dotagents` with no global
|
|
324
|
+
# install and no PATH edit by the user.
|
|
325
|
+
SESSION_START_COMMAND = (
|
|
326
|
+
'PATH=".agents/bin:$HOME/.agents/bin:$PATH" dotagents context'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Codex has NO per-session env mechanism: no CLAUDE_ENV_FILE equivalent, no
|
|
330
|
+
# `.env` loading anywhere (confirmed across its full docs set), and no hook that
|
|
331
|
+
# can run before config load -- hooks are *defined in* the config layers, and
|
|
332
|
+
# Codex's earliest event is SessionStart (it has no `Setup` event). So the only
|
|
333
|
+
# way to give Codex our env is `shell_environment_policy.set` in config.toml:
|
|
334
|
+
# "explicit environment overrides injected into every subprocess".
|
|
335
|
+
#
|
|
336
|
+
# That is STATIC -- read once at startup, not recomputed per session. The values
|
|
337
|
+
# therefore go stale when an overlay's env changes, and `dotagents init` is the
|
|
338
|
+
# refresh. `set` MERGES on top of whatever `inherit` admits rather than replacing
|
|
339
|
+
# it, so writing only our AGENTS_* keys leaves the user's environment alone.
|
|
340
|
+
#
|
|
341
|
+
# This is the user's main config (provider/auth settings live here), so the write
|
|
342
|
+
# is a marker-delimited managed block appended to the end -- never a rewrite. It
|
|
343
|
+
# must APPEND: a TOML `[table]` header captures every following key line, so
|
|
344
|
+
# prepending our table would silently swallow the user's top-level keys into it.
|
|
345
|
+
ENV_BLOCK_BEGIN = "# dotagents:begin"
|
|
346
|
+
ENV_BLOCK_END = "# dotagents:end"
|
|
347
|
+
|
|
348
|
+
# PATH is excluded: a ~2KB machine-specific absolute list that leaks local
|
|
349
|
+
# layout into a config file, is wrong on any other machine, and -- because
|
|
350
|
+
# `set` overrides per subprocess -- would REPLACE the inherited PATH of
|
|
351
|
+
# everything Codex spawns.
|
|
352
|
+
#
|
|
353
|
+
# Identity vars (AGENT/AGENTS_HARNESS/AGENTS_VENDOR) are deliberately KEPT.
|
|
354
|
+
# They are computed for *this adapter* (`get_environment(explicit=<name>)`),
|
|
355
|
+
# not for whichever harness ran `init`, so Codex's config correctly says
|
|
356
|
+
# AGENT=codex even when initialized from Claude. That is the whole point of a
|
|
357
|
+
# per-agent file.
|
|
358
|
+
ENV_BLOCK_SKIP = frozenset({"PATH"})
|
|
359
|
+
|
|
360
|
+
def _config_root(self, config_root: "Optional[Path]" = None) -> Path:
|
|
361
|
+
import os
|
|
362
|
+
|
|
363
|
+
if config_root:
|
|
364
|
+
return Path(config_root)
|
|
365
|
+
# CODEX_HOME is Codex's own documented state-dir override.
|
|
366
|
+
return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _toml_escape(value: str) -> str:
|
|
370
|
+
"""Escape a TOML basic-string value (backslash first, then quote)."""
|
|
371
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
372
|
+
|
|
373
|
+
def write_env_block(
|
|
374
|
+
self,
|
|
375
|
+
env: "dict[str, str]",
|
|
376
|
+
*,
|
|
377
|
+
dry_run: bool,
|
|
378
|
+
logger,
|
|
379
|
+
config_root: "Optional[Path]" = None,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Write `env` into a managed `[shell_environment_policy]` block in
|
|
382
|
+
`config.toml`, so Codex injects those vars into every subprocess."""
|
|
383
|
+
from dotagents._merge import merge_block
|
|
384
|
+
|
|
385
|
+
env = {k: v for k, v in env.items() if k not in self.ENV_BLOCK_SKIP}
|
|
386
|
+
if not env:
|
|
387
|
+
if logger:
|
|
388
|
+
logger.info("no env vars to write for codex")
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
root = self._config_root(config_root)
|
|
392
|
+
lines = [
|
|
393
|
+
self.ENV_BLOCK_BEGIN,
|
|
394
|
+
"# Managed by dotagents -- edits inside this block are overwritten by",
|
|
395
|
+
"# `dotagents init`. Values are a snapshot: re-run init after changing",
|
|
396
|
+
"# your env layers. Add your own settings OUTSIDE the markers.",
|
|
397
|
+
"[shell_environment_policy]",
|
|
398
|
+
"set = {%s}"
|
|
399
|
+
% ", ".join(
|
|
400
|
+
'%s = "%s"' % (key, self._toml_escape(env[key])) for key in sorted(env)
|
|
401
|
+
),
|
|
402
|
+
self.ENV_BLOCK_END,
|
|
403
|
+
]
|
|
404
|
+
block = "\n".join(lines) + "\n"
|
|
405
|
+
|
|
406
|
+
config_path = root / "config.toml"
|
|
407
|
+
if not dry_run:
|
|
408
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
branch = merge_block(
|
|
410
|
+
config_path,
|
|
411
|
+
block,
|
|
412
|
+
dry_run=dry_run,
|
|
413
|
+
begin_marker=self.ENV_BLOCK_BEGIN,
|
|
414
|
+
end_marker=self.ENV_BLOCK_END,
|
|
415
|
+
append=True,
|
|
416
|
+
)
|
|
417
|
+
if logger:
|
|
418
|
+
verb = "would write" if dry_run else branch
|
|
419
|
+
logger.info("%s: %s (%d vars)", verb, config_path, len(env))
|
|
420
|
+
|
|
421
|
+
def wire_hooks(
|
|
422
|
+
self, dest: Path, *, dry_run: bool, logger, config_root: "Optional[Path]" = None
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Merge our SessionStart hook into `<codex-home>/hooks.json`."""
|
|
425
|
+
from dotagents import _hooks
|
|
426
|
+
|
|
427
|
+
root = self._config_root(config_root)
|
|
428
|
+
|
|
429
|
+
hooks_path = root / "hooks.json"
|
|
430
|
+
data = _hooks.load_settings(hooks_path)
|
|
431
|
+
hooks = data.get("hooks")
|
|
432
|
+
if not isinstance(hooks, dict):
|
|
433
|
+
hooks = {}
|
|
434
|
+
|
|
435
|
+
session_start, changed = _hooks.merge_hook(
|
|
436
|
+
hooks.get("SessionStart"),
|
|
437
|
+
self.SESSION_START_COMMAND,
|
|
438
|
+
status_message="Loading agent context",
|
|
439
|
+
)
|
|
440
|
+
hooks["SessionStart"] = session_start
|
|
441
|
+
|
|
442
|
+
if not changed:
|
|
443
|
+
if logger:
|
|
444
|
+
logger.info("hooks already wired: %s", hooks_path)
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
data["hooks"] = hooks
|
|
448
|
+
if dry_run:
|
|
449
|
+
if logger:
|
|
450
|
+
logger.info("would wire SessionStart hook: %s", hooks_path)
|
|
451
|
+
return
|
|
452
|
+
_hooks.write_settings(hooks_path, data)
|
|
453
|
+
if logger:
|
|
454
|
+
logger.info("wired SessionStart hook: %s", hooks_path)
|
|
455
|
+
|
|
456
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
457
|
+
target = dest / "AGENTS.md"
|
|
458
|
+
if not dry_run:
|
|
459
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
target.write_text(effective_context, encoding="utf-8")
|
|
461
|
+
if logger: logger.info("wrote context to %s", target)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
class CursorAgent(Agent):
|
|
465
|
+
name = "cursor"
|
|
466
|
+
context_files = [".cursorrules", ".cursor/rules/"]
|
|
467
|
+
harness_loads = [".cursorrules", ".cursor/rules/"]
|
|
468
|
+
# Intended marker: CURSOR_AGENT=1 (cursor.com/docs/cli). Note a known bug where
|
|
469
|
+
# it is not always propagated to spawned bash (forum.cursor.com/t/.../132427),
|
|
470
|
+
# so config-file detect() remains the fallback. The old CURSOR_SESSION_ID was
|
|
471
|
+
# invented.
|
|
472
|
+
detect_env_vars = ["CURSOR_AGENT"]
|
|
473
|
+
harness_id = "cursor"
|
|
474
|
+
vendor = "cursor"
|
|
475
|
+
model_source_vars = ["CURSOR_DEFAULT_MODEL"]
|
|
476
|
+
|
|
477
|
+
def write_base_config(self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
478
|
+
from dotagents._merge import merge_block, timestamped_backup_root
|
|
479
|
+
backup_root = timestamped_backup_root(dest) if force else None
|
|
480
|
+
branch = merge_block(
|
|
481
|
+
dest / ".cursorrules",
|
|
482
|
+
base_agents_text,
|
|
483
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
484
|
+
)
|
|
485
|
+
if logger: logger.info("%s: .cursorrules", branch)
|
|
486
|
+
|
|
487
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
488
|
+
target = dest / ".cursorrules"
|
|
489
|
+
if not dry_run:
|
|
490
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
target.write_text(effective_context, encoding="utf-8")
|
|
492
|
+
if logger: logger.info("wrote context to %s", target)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
class CopilotAgent(Agent):
|
|
496
|
+
name = "copilot"
|
|
497
|
+
context_files = [".github/copilot-instructions.md"]
|
|
498
|
+
harness_loads = [".github/copilot-instructions.md"]
|
|
499
|
+
# Copilot ships NO runtime marker var yet -- the request for one (e.g.
|
|
500
|
+
# COPILOT_AGENT) is still open (microsoft/vscode#311734). COPILOT_MODEL /
|
|
501
|
+
# COPILOT_HOME exist but are config, not reliable "am I running" markers, so
|
|
502
|
+
# they are NOT used for detection; detection falls back to config-file
|
|
503
|
+
# detect(). The old COPILOT_SESSION_ID / GITHUB_COPILOT were invented.
|
|
504
|
+
detect_env_vars = []
|
|
505
|
+
harness_id = "copilot"
|
|
506
|
+
vendor = "github"
|
|
507
|
+
model_source_vars = ["COPILOT_MODEL"]
|
|
508
|
+
|
|
509
|
+
def write_base_config(self, dest: Path, src: Path, base_agents_text: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
510
|
+
from dotagents._merge import merge_block, timestamped_backup_root
|
|
511
|
+
backup_root = timestamped_backup_root(dest) if force else None
|
|
512
|
+
target = dest / ".github" / "copilot-instructions.md"
|
|
513
|
+
branch = merge_block(
|
|
514
|
+
target,
|
|
515
|
+
base_agents_text,
|
|
516
|
+
force=force, dry_run=dry_run, backup_root=backup_root,
|
|
517
|
+
)
|
|
518
|
+
if logger: logger.info("%s: %s", branch, target.relative_to(dest))
|
|
519
|
+
|
|
520
|
+
def write_context(self, dest: Path, effective_context: str, *, force: bool, dry_run: bool, logger) -> None:
|
|
521
|
+
target = dest / ".github" / "copilot-instructions.md"
|
|
522
|
+
if not dry_run:
|
|
523
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
524
|
+
target.write_text(effective_context, encoding="utf-8")
|
|
525
|
+
if logger: logger.info("wrote context to %s", target)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# Registry of agents
|
|
529
|
+
_REGISTRY: dict[str, type[Agent]] = {
|
|
530
|
+
"claude": ClaudeAgent,
|
|
531
|
+
"gemini": GeminiAgent,
|
|
532
|
+
"codex": CodexAgent,
|
|
533
|
+
"cursor": CursorAgent,
|
|
534
|
+
"copilot": CopilotAgent,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
def get_agent(name: str) -> Optional[Agent]:
|
|
538
|
+
cls = _REGISTRY.get(name)
|
|
539
|
+
return cls() if cls else None
|
|
540
|
+
|
|
541
|
+
def get_all_agents() -> list[Agent]:
|
|
542
|
+
return [cls() for cls in _REGISTRY.values()]
|
|
543
|
+
|
|
544
|
+
def _harness_alias(value: str) -> "Optional[str]":
|
|
545
|
+
"""Map an $AGENTS_HARNESS value (registry name OR harness_id) to a name."""
|
|
546
|
+
if value in _REGISTRY:
|
|
547
|
+
return value
|
|
548
|
+
for name, cls in _REGISTRY.items():
|
|
549
|
+
if cls.harness_id == value:
|
|
550
|
+
return name
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def resolve_active_agent(
|
|
555
|
+
environ: dict[str, str],
|
|
556
|
+
explicit: Optional[str] = None,
|
|
557
|
+
root: Optional[Path] = None,
|
|
558
|
+
) -> Agent:
|
|
559
|
+
"""Resolve the active agent by precedence (plan 00).
|
|
560
|
+
|
|
561
|
+
Precedence: explicit (--agents) > $AGENTS_HARNESS (registry name or
|
|
562
|
+
harness_id) > env-var detection (detect_env) > config-file detection
|
|
563
|
+
(detect(root)) > default (claude).
|
|
564
|
+
|
|
565
|
+
``root`` is where config-file detect() looks (default: cwd).
|
|
566
|
+
"""
|
|
567
|
+
if explicit and explicit in _REGISTRY:
|
|
568
|
+
return _REGISTRY[explicit]()
|
|
569
|
+
|
|
570
|
+
stamped = environ.get("AGENTS_HARNESS")
|
|
571
|
+
if stamped:
|
|
572
|
+
alias = _harness_alias(stamped)
|
|
573
|
+
if alias:
|
|
574
|
+
return _REGISTRY[alias]()
|
|
575
|
+
|
|
576
|
+
for name, cls in _REGISTRY.items():
|
|
577
|
+
agent = cls()
|
|
578
|
+
if agent.detect_env(environ):
|
|
579
|
+
return agent
|
|
580
|
+
|
|
581
|
+
# Config-file fallback: which agent's config is present in the tree?
|
|
582
|
+
detect_root = root if root is not None else Path.cwd()
|
|
583
|
+
for name, cls in _REGISTRY.items():
|
|
584
|
+
agent = cls()
|
|
585
|
+
try:
|
|
586
|
+
if agent.detect(detect_root):
|
|
587
|
+
return agent
|
|
588
|
+
except OSError:
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
return ClaudeAgent()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def stamp_identity(
|
|
595
|
+
environ: dict[str, str],
|
|
596
|
+
explicit: Optional[str] = None,
|
|
597
|
+
root: Optional[Path] = None,
|
|
598
|
+
) -> dict[str, str]:
|
|
599
|
+
"""Return the standardized ``AGENTS_*`` / ``AGENT`` identity vars (plan 08).
|
|
600
|
+
|
|
601
|
+
Emits ``AGENTS_HARNESS`` (the harness_id, e.g. ``claude-code`` -- NOT the
|
|
602
|
+
short name), ``AGENTS_VENDOR``, ``AGENT`` (= AGENTS_HARNESS, aligning with
|
|
603
|
+
the emerging ecosystem marker used by Goose/Amp; agentsmd/agents.md#136),
|
|
604
|
+
and, when derivable, ``AGENTS_MODEL`` (from the adapter's vendor model var)
|
|
605
|
+
and ``AGENTS_AGENT`` (a named persona, from $AGENTS_AGENT if already set).
|
|
606
|
+
|
|
607
|
+
Never emits a var it cannot source (no empty ``AGENTS_MODEL=``). Does NOT
|
|
608
|
+
clobber a value already set in ``environ`` -- an explicit user/harness value
|
|
609
|
+
wins. The deliberate curated mapping replaces the precursor's blanket
|
|
610
|
+
``CLAUDE_*``->``AGENTS_*`` rewrite (no ``AGENTS_CODE_SESSION_ID`` junk).
|
|
611
|
+
"""
|
|
612
|
+
active = resolve_active_agent(environ, explicit=explicit, root=root)
|
|
613
|
+
|
|
614
|
+
identity: dict[str, str] = {}
|
|
615
|
+
|
|
616
|
+
def _set(key: str, value: "Optional[str]") -> None:
|
|
617
|
+
if value and not environ.get(key):
|
|
618
|
+
identity[key] = value
|
|
619
|
+
|
|
620
|
+
_set("AGENTS_HARNESS", active.harness_id or active.name)
|
|
621
|
+
_set("AGENTS_VENDOR", active.vendor)
|
|
622
|
+
_set("AGENT", active.harness_id or active.name)
|
|
623
|
+
_set("AGENTS_MODEL", active.resolve_model(environ))
|
|
624
|
+
# A named persona (~/.agents/<agent>.md); only surface one already selected.
|
|
625
|
+
_set("AGENTS_AGENT", environ.get("AGENTS_AGENT"))
|
|
626
|
+
|
|
627
|
+
return identity
|