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.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