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/_env.py ADDED
@@ -0,0 +1,600 @@
1
+ """Chained env-file assembly + ``env.py`` execution (plan 07).
2
+
3
+ Ported from the precursor ``agentic`` under **frozen contract B** -- the
4
+ observable behavior (what files are found, in what order, and how they are
5
+ evaluated) is preserved verbatim; only the code shape is duho/dotagents-native.
6
+
7
+ Contract B, the exact sequence :func:`get_environment` performs:
8
+
9
+ 1. **Bins onto PATH FIRST**, before any env eval. Each level's ``bin`` dir
10
+ (contract-A precedence order, *except* project-root) is prepended to
11
+ ``PATH`` so env scripts can call overlay helpers by name.
12
+ 2. **Two tiers, in order**: ALL ``pre.env.py`` / ``pre.env`` / ``pre.local.env``
13
+ first, THEN ALL ``env.py`` / ``env`` / ``local.env`` -- the concatenation of
14
+ two contract-A resolutions (:func:`resolve_env_files`).
15
+ 3. **Within each tier**, files are in the contract-A precedence order
16
+ (overlays -> system -> user -> project -> project-root).
17
+ 4. **Chained, later-overrides-earlier**: each file is evaluated against the
18
+ ACCUMULATED environment of every file before it; the result also
19
+ accumulates. Later files win on conflicting keys.
20
+ 5. **``.py`` files are EXECUTED** (:func:`get_env_from_py` runs the script and
21
+ reads back a JSON object of env changes); plain files are **sourced**
22
+ (:func:`get_env_from_file`, via ``bash ... env -0``).
23
+ 6. :func:`get_diff` returns only the vars that differ from the caller's base
24
+ environment; :func:`get_environment` returns the full change set.
25
+
26
+ Plan-08 identity/proxy model is wired into the output around the file chain:
27
+
28
+ * **Identity** (:func:`dotagents._agents.stamp_identity`) is seeded BEFORE the
29
+ file chain, so env files can branch on ``AGENTS_HARNESS`` and override the
30
+ stamped ``AGENTS_MODEL`` etc. (chained: a later file wins). Never clobbers a
31
+ value already present in the base env.
32
+ * **Proxy** is normalized AFTER the file chain: ``AGENTS_PROXY`` is seeded if
33
+ unset (from ``AGENTS_WEBFETCH_PROXY_URL`` else the global
34
+ HTTPS/HTTP/ALL_PROXY, either case); any proxy var that *already exists* is
35
+ mirrored into BOTH cases (never creating one that was not set;
36
+ ``http_proxy`` stays lowercase-populated per httpoxy). ``AGENTS_PROXY`` is
37
+ NOT fanned out into the global ``HTTP_PROXY``.
38
+
39
+ Security (Leakage rule): ``env.py`` runs arbitrary code, but only from files
40
+ resolved under the store/overlay/project locations by contract A. Never log the
41
+ resulting ``DOTAGENTS_*``/``AGENTS_*`` secret VALUES -- callers that print the
42
+ diff must treat it as sensitive; this module logs var NAMES only.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import os
49
+ import subprocess
50
+ import sys
51
+ from pathlib import Path
52
+ from typing import Optional
53
+
54
+ from dotagents._resolve import get_file_paths
55
+
56
+
57
+ # OS-bootstrap vars a spawned interpreter needs to even start (Windows
58
+ # CreateProcess wants SystemRoot; POSIX loaders want the temp dir). These are
59
+ # backfilled from the real environment ONLY when the accumulated env lacks them,
60
+ # so a child env.py always launches -- without leaking user config the chain did
61
+ # not itself set. Not part of contract B (which is about what the chain
62
+ # resolves/evaluates); purely making subprocess spawn portable.
63
+ _SPAWN_BOOTSTRAP_VARS = (
64
+ "SYSTEMROOT",
65
+ "SystemRoot",
66
+ "COMSPEC",
67
+ "PATHEXT",
68
+ "WINDIR",
69
+ "TEMP",
70
+ "TMP",
71
+ "TMPDIR",
72
+ "LD_LIBRARY_PATH",
73
+ )
74
+
75
+
76
+ def _spawn_env(child_env: "dict[str, str]") -> "dict[str, str]":
77
+ """Backfill OS-bootstrap vars so a spawned process can start (see above)."""
78
+ out = dict(child_env)
79
+ for var in _SPAWN_BOOTSTRAP_VARS:
80
+ if var not in out and var in os.environ:
81
+ out[var] = os.environ[var]
82
+ return out
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # Output-format aliases + calling-shell detection (plan 07, D83).
87
+ # --------------------------------------------------------------------------- #
88
+
89
+ #: Alias -> canonical output format. `_format_env` normalizes through this so the
90
+ #: renderer only ever sees a canonical name. `auto` is resolved earlier (by the
91
+ #: caller, via :func:`detect_shell_format`) and is NOT a renderer format.
92
+ FORMAT_ALIASES = {
93
+ "export": "export",
94
+ "posix": "export",
95
+ "sh": "export",
96
+ "bash": "export",
97
+ "dotenv": "dotenv",
98
+ "env": "dotenv",
99
+ "powershell": "powershell",
100
+ "pwsh": "powershell",
101
+ "ps": "powershell",
102
+ "cmd": "cmd",
103
+ "bat": "cmd",
104
+ "batch": "cmd",
105
+ "fish": "fish",
106
+ "json": "json",
107
+ "ini": "ini",
108
+ "yaml": "yaml",
109
+ }
110
+
111
+ #: Every value `--format` accepts on the CLI (aliases + `auto`).
112
+ KNOWN_FORMATS = tuple(sorted(set(FORMAT_ALIASES) | {"auto"}))
113
+
114
+ #: Shell-exe basename (lowercased, no extension) -> canonical output format.
115
+ _SHELL_EXE_FORMAT = {
116
+ "powershell": "powershell",
117
+ "pwsh": "powershell",
118
+ "cmd": "cmd",
119
+ "bash": "export",
120
+ "sh": "export",
121
+ "zsh": "export",
122
+ "dash": "export",
123
+ "ksh": "export",
124
+ "fish": "fish",
125
+ }
126
+
127
+
128
+ def _win_ppid_exe_map(): # pragma: no cover - exercised only on win32
129
+ """pid -> (parent_pid, exe_basename_lower_noext) via a Toolhelp snapshot.
130
+
131
+ Pure stdlib ctypes against kernel32. Any failure returns ``{}`` so the caller
132
+ degrades to the OS default rather than raising. Reads only process names.
133
+ """
134
+ import ctypes
135
+ from ctypes import wintypes
136
+
137
+ TH32CS_SNAPPROCESS = 0x00000002
138
+ MAX_PATH = 260
139
+
140
+ class PROCESSENTRY32(ctypes.Structure):
141
+ _fields_ = [
142
+ ("dwSize", wintypes.DWORD),
143
+ ("cntUsage", wintypes.DWORD),
144
+ ("th32ProcessID", wintypes.DWORD),
145
+ ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),
146
+ ("th32ModuleID", wintypes.DWORD),
147
+ ("cntThreads", wintypes.DWORD),
148
+ ("th32ParentProcessID", wintypes.DWORD),
149
+ ("pcPriClassBase", ctypes.c_long),
150
+ ("dwFlags", wintypes.DWORD),
151
+ ("szExeFile", ctypes.c_char * MAX_PATH),
152
+ ]
153
+
154
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
155
+ INVALID = wintypes.HANDLE(-1).value
156
+ snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
157
+ if not snap or snap == INVALID:
158
+ return {}
159
+ try:
160
+ entry = PROCESSENTRY32()
161
+ entry.dwSize = ctypes.sizeof(PROCESSENTRY32)
162
+ result = {}
163
+ ok = kernel32.Process32First(snap, ctypes.byref(entry))
164
+ while ok:
165
+ name = entry.szExeFile.decode("ascii", "replace").lower()
166
+ if name.endswith(".exe"):
167
+ name = name[:-4]
168
+ result[int(entry.th32ProcessID)] = (
169
+ int(entry.th32ParentProcessID),
170
+ name,
171
+ )
172
+ ok = kernel32.Process32Next(snap, ctypes.byref(entry))
173
+ return result
174
+ finally:
175
+ kernel32.CloseHandle(snap)
176
+
177
+
178
+ def _detect_shell_format_win(): # pragma: no cover - exercised only on win32
179
+ """Walk the parent-process chain on Windows; first shell exe wins.
180
+
181
+ Returns a canonical format, defaulting to ``"powershell"`` when no shell is
182
+ found in the chain (the common Windows case). Never raises.
183
+ """
184
+ try:
185
+ pmap = _win_ppid_exe_map()
186
+ if not pmap:
187
+ return "powershell"
188
+ pid = os.getpid()
189
+ for _ in range(10):
190
+ entry = pmap.get(pid)
191
+ if entry is None:
192
+ break
193
+ parent_pid, name = entry
194
+ fmt = _SHELL_EXE_FORMAT.get(name)
195
+ if fmt is not None:
196
+ return fmt
197
+ if parent_pid == pid or parent_pid == 0:
198
+ break
199
+ pid = parent_pid
200
+ except Exception: # noqa: BLE001 - detection must never crash
201
+ return "powershell"
202
+ return "powershell"
203
+
204
+
205
+ def _posix_parent_comm(ppid: int) -> str:
206
+ """Parent-process command name on POSIX, via layered fallbacks.
207
+
208
+ ``/proc`` is Linux-only (absent on macOS/OpenBSD), so this tries in order and
209
+ each layer is guarded so a failure falls through to the next, never raising:
210
+
211
+ 1. ``/proc/<ppid>/comm`` -- Linux/WSL, fast.
212
+ 2. ``ps -o comm= -p <ppid>`` -- POSIX-standard; works on macOS, OpenBSD,
213
+ and Linux. Basename of the output is taken.
214
+ 3. ``$SHELL`` basename -- last resort.
215
+
216
+ Returns a lowercased basename with any ``.exe`` suffix stripped, or ``""``.
217
+ """
218
+ # 1. Linux /proc
219
+ try:
220
+ with open("/proc/%d/comm" % ppid, "r", encoding="ascii", errors="replace") as fh:
221
+ comm = fh.read().strip()
222
+ if comm:
223
+ return _norm_comm(comm)
224
+ except Exception: # noqa: BLE001 - fall through to the next layer
225
+ pass
226
+ # 2. POSIX `ps -o comm=` (macOS / OpenBSD / any POSIX)
227
+ try:
228
+ import subprocess as _sp
229
+
230
+ out = _sp.run(
231
+ ["ps", "-o", "comm=", "-p", str(ppid)],
232
+ capture_output=True, text=True, check=False,
233
+ ).stdout.strip()
234
+ if out:
235
+ return _norm_comm(out)
236
+ except Exception: # noqa: BLE001 - fall through to $SHELL
237
+ pass
238
+ # 3. $SHELL basename
239
+ try:
240
+ shell = os.environ.get("SHELL", "")
241
+ if shell:
242
+ return _norm_comm(shell)
243
+ except Exception: # noqa: BLE001
244
+ pass
245
+ return ""
246
+
247
+
248
+ def _norm_comm(name: str) -> str:
249
+ """Lowercase basename of a command/path, ``.exe`` suffix stripped."""
250
+ base = os.path.basename(name.strip()).lower()
251
+ if base.endswith(".exe"):
252
+ base = base[:-4]
253
+ return base
254
+
255
+
256
+ def _detect_shell_format_posix():
257
+ """Detect the calling shell on POSIX; default ``"export"``. Never raises.
258
+
259
+ Uses :func:`_posix_parent_comm` (layered ``/proc`` -> ``ps -o comm=`` ->
260
+ ``$SHELL``) so it works on Linux/WSL, macOS, and OpenBSD alike.
261
+ """
262
+ try:
263
+ comm = _posix_parent_comm(os.getppid())
264
+ return _SHELL_EXE_FORMAT.get(comm, "export")
265
+ except Exception: # noqa: BLE001 - detection must never crash
266
+ return "export"
267
+
268
+
269
+ def detect_shell_format() -> str:
270
+ """Best-effort detection of the calling shell's output format.
271
+
272
+ Walks the parent-process chain (Windows: a stdlib-ctypes Toolhelp snapshot;
273
+ POSIX: ``/proc/<ppid>/comm`` else ``$SHELL``) and returns the canonical
274
+ format for the first shell found. Any failure degrades to the OS default
275
+ (``"powershell"`` on win32, ``"export"`` elsewhere) and NEVER raises. Reads
276
+ process names only -- no environment values.
277
+ """
278
+ if sys.platform == "win32":
279
+ return _detect_shell_format_win()
280
+ return _detect_shell_format_posix()
281
+
282
+
283
+ # --------------------------------------------------------------------------- #
284
+ # File evaluators (ported from the precursor helpers.py -- keep the protocols).
285
+ # --------------------------------------------------------------------------- #
286
+
287
+
288
+ def _changed_env(env_dump: bytes, base_env: "dict[str, str]") -> "dict[str, str]":
289
+ """Parse a NUL-delimited ``env -0`` dump into the vars that changed vs base."""
290
+ sourced: "dict[str, str]" = {}
291
+ for entry in env_dump.split(b"\0"):
292
+ if not entry or b"=" not in entry:
293
+ continue
294
+ key, value = entry.split(b"=", 1)
295
+ if key == b"_":
296
+ continue
297
+ sourced[key.decode()] = value.decode()
298
+ return {
299
+ k: v for k, v in sourced.items() if k not in base_env or base_env[k] != v
300
+ }
301
+
302
+
303
+ def get_env_from_py(
304
+ env_py: Path,
305
+ base_env: "dict[str, str]",
306
+ project_root: Path,
307
+ level: str,
308
+ global_scope: bool,
309
+ logger=None,
310
+ ) -> "dict[str, str]":
311
+ """Execute an ``env.py`` and read back its JSON object of env changes.
312
+
313
+ The script runs as a child with ``base_env`` (the accumulated environment)
314
+ and must print a JSON dict to stdout. A non-zero exit or unparseable output
315
+ contributes nothing and is logged by NAME only -- never abort assembly, and
316
+ never echo the child's stdout (it may carry secret values).
317
+ """
318
+ args = [sys.executable, str(env_py), "--agent", level]
319
+ if global_scope:
320
+ args.append("--global")
321
+ try:
322
+ proc = subprocess.run(
323
+ args, capture_output=True, text=True, check=False, env=_spawn_env(base_env)
324
+ )
325
+ except OSError as e: # pragma: no cover - interpreter missing
326
+ if logger:
327
+ logger.warning("env.py could not run: %s (%s)", env_py, e)
328
+ return {}
329
+ if proc.returncode != 0:
330
+ if logger:
331
+ logger.warning("env.py failed (exit %s): %s", proc.returncode, env_py)
332
+ return {}
333
+ try:
334
+ parsed = json.loads(proc.stdout)
335
+ if not isinstance(parsed, dict):
336
+ raise ValueError("env.py must output a JSON object")
337
+ except (json.JSONDecodeError, ValueError) as e:
338
+ if logger:
339
+ logger.warning("env.py output not JSON: %s (%s)", env_py, e)
340
+ return {}
341
+ return {k: str(v) for k, v in parsed.items() if isinstance(k, str)}
342
+
343
+
344
+ def get_env_from_file(
345
+ env_file: Path, base_env: "dict[str, str]", logger=None
346
+ ) -> "dict[str, str]":
347
+ """Source a plain env file in bash and return the vars it changed.
348
+
349
+ Runs ``set -a; source <file>; env -0`` so exported assignments are captured.
350
+ If ``bash`` is unavailable (or the source errors) the file contributes
351
+ nothing -- logged by name, never fatal.
352
+ """
353
+ quoted = json.dumps(str(env_file))
354
+ spawn = _spawn_env(base_env)
355
+
356
+ # Resolve `bash` against the REAL environment's PATH, not `spawn`'s (which is
357
+ # `base_env`, the chain's ACCUMULATED PATH so far -- contract B step 1 prepends
358
+ # overlay bin dirs onto it, so by design it need not contain bash's actual
359
+ # install location, e.g. `/usr/local/bin` on macOS or Git's `bin` on Windows,
360
+ # where it is never `/usr/bin`). Passing a bare "bash" left resolution to the
361
+ # child process's own PATH (`spawn`'s), which only found it by coincidence
362
+ # where the OS happens to install bash under a directory contract B's PATH
363
+ # already contains -- true on Ubuntu's runner image, false on macOS/Windows.
364
+ import shutil
365
+
366
+ bash = shutil.which("bash", path=os.environ.get("PATH")) or "bash"
367
+ try:
368
+ proc = subprocess.run(
369
+ [bash, "-c", "set -a; source %s >/dev/null 2>&1; env -0" % quoted],
370
+ capture_output=True,
371
+ text=False,
372
+ check=False,
373
+ env=spawn,
374
+ )
375
+ except OSError as e:
376
+ if logger:
377
+ logger.warning("cannot source env file (no bash?): %s (%s)", env_file, e)
378
+ return {}
379
+ if proc.returncode != 0:
380
+ if logger:
381
+ logger.warning("env file source failed: %s", env_file)
382
+ return {}
383
+ # Compare against the spawn env (bootstrap-backfilled) so the bootstrap vars
384
+ # are not misreported as "changes"; only what the sourced file actually set
385
+ # relative to what the child inherited counts.
386
+ return _changed_env(proc.stdout, spawn)
387
+
388
+
389
+ # --------------------------------------------------------------------------- #
390
+ # PATH bins (contract B step 1).
391
+ # --------------------------------------------------------------------------- #
392
+
393
+
394
+ def get_bin_paths(
395
+ *, agents_dir: Path, project_root: Path, global_scope: bool = False
396
+ ) -> "list[Path]":
397
+ """Each level's ``bin`` dir in contract-A precedence order, EXCEPT project-root.
398
+
399
+ Uses ``include_missing=True`` (precursor semantics) so a bin dir is offered
400
+ for every level even if absent -- the caller prepends only real dirs where it
401
+ matters. project-root's ``bin`` is explicitly excluded (``{"project-root":
402
+ None}``): a project's own top-level ``bin`` is not an agent bin.
403
+ """
404
+ resolved = get_file_paths(
405
+ {"default": "bin", "project-root": ""},
406
+ agents_dir=agents_dir,
407
+ project_root=project_root,
408
+ global_scope=global_scope,
409
+ include_missing=True,
410
+ )
411
+ return [path for _level, path, _root in resolved]
412
+
413
+
414
+ def _prepend_missing(path_entries: "list[str]", new_entries: "list[str]") -> "list[str]":
415
+ """Prepend each new entry not already present, preserving precursor order.
416
+
417
+ The precursor inserts each new entry at position 0 in iteration order, so a
418
+ later new entry ends up EARLIER. Reproduced exactly.
419
+ """
420
+ for entry in new_entries:
421
+ if entry not in path_entries:
422
+ path_entries.insert(0, entry)
423
+ return path_entries
424
+
425
+
426
+ # --------------------------------------------------------------------------- #
427
+ # Env-file resolution (contract B steps 2 + 3).
428
+ # --------------------------------------------------------------------------- #
429
+
430
+
431
+ def resolve_env_files(
432
+ *, agents_dir: Path, project_root: Path, global_scope: bool = False
433
+ ) -> "list[tuple[str, Path, Optional[Path]]]":
434
+ """The ordered, existing env files: ALL pre-tier then ALL main-tier.
435
+
436
+ Each tier is one contract-A resolution (:func:`get_file_paths`). Per-level
437
+ filename resolution (contract A point 2): ``pre.env.py``/``pre.env`` and
438
+ ``env.py``/``env`` resolve everywhere; the project + project-root levels use
439
+ ``pre.local.env`` / ``local.env`` instead. Only existing files are returned.
440
+ """
441
+ common = dict(
442
+ agents_dir=agents_dir, project_root=project_root, global_scope=global_scope
443
+ )
444
+ pre_tier = get_file_paths(
445
+ "pre.env.py",
446
+ "pre.env",
447
+ {"project": "pre.local.env", "project-root": "pre.local.env"},
448
+ **common,
449
+ )
450
+ main_tier = get_file_paths(
451
+ "env.py",
452
+ "env",
453
+ {"project": "local.env", "project-root": "local.env"},
454
+ **common,
455
+ )
456
+ return pre_tier + main_tier
457
+
458
+
459
+ # --------------------------------------------------------------------------- #
460
+ # Proxy normalization (plan 08).
461
+ # --------------------------------------------------------------------------- #
462
+
463
+ _PROXY_BASES = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
464
+ #: Seed order for AGENTS_PROXY when it is unset (webfetch var wins, then global).
465
+ _PROXY_SEED_ORDER = (
466
+ "AGENTS_WEBFETCH_PROXY_URL",
467
+ "HTTPS_PROXY",
468
+ "https_proxy",
469
+ "HTTP_PROXY",
470
+ "http_proxy",
471
+ "ALL_PROXY",
472
+ "all_proxy",
473
+ )
474
+
475
+
476
+ def apply_proxy_model(osenv: "dict[str, str]") -> "dict[str, str]":
477
+ """Return the proxy changes for ``osenv`` per the plan-08 proxy model.
478
+
479
+ * Seed ``AGENTS_PROXY`` if unset, from the first populated
480
+ :data:`_PROXY_SEED_ORDER` var (webfetch var wins, then the global proxy in
481
+ either case). An existing ``AGENTS_PROXY`` is respected.
482
+ * Mirror every proxy var that ALREADY EXISTS into both cases -- fills only
483
+ the missing case, never introduces a proxy that was not set. ``http_proxy``
484
+ thus stays lowercase-populated when ``HTTP_PROXY`` is set (httpoxy).
485
+ * ``AGENTS_PROXY`` is NOT fanned into the global ``HTTP_PROXY``.
486
+ """
487
+ changes: "dict[str, str]" = {}
488
+
489
+ if not osenv.get("AGENTS_PROXY"):
490
+ for src in _PROXY_SEED_ORDER:
491
+ val = osenv.get(src)
492
+ if val:
493
+ changes["AGENTS_PROXY"] = val
494
+ break
495
+
496
+ for base in _PROXY_BASES:
497
+ upper, lower = base, base.lower()
498
+ up_val = osenv.get(upper)
499
+ lo_val = osenv.get(lower)
500
+ if up_val and not lo_val:
501
+ changes[lower] = up_val
502
+ elif lo_val and not up_val:
503
+ changes[upper] = lo_val
504
+
505
+ return changes
506
+
507
+
508
+ # --------------------------------------------------------------------------- #
509
+ # Assembly (contract B).
510
+ # --------------------------------------------------------------------------- #
511
+
512
+
513
+ def get_environment(
514
+ *,
515
+ agents_dir: Path,
516
+ project_root: Path,
517
+ base_env: "Optional[dict[str, str]]" = None,
518
+ global_scope: bool = False,
519
+ explicit: "Optional[str]" = None,
520
+ logger=None,
521
+ ) -> "dict[str, str]":
522
+ """Assemble the env CHANGES (vars this adds/overrides vs ``base_env``).
523
+
524
+ Follows frozen contract B: identity seeded, PATH bins first, the two tiers
525
+ chained (later overrides earlier), then proxy normalization. Returns only
526
+ what changed -- mirrors the precursor's ``env={}`` accumulator.
527
+ """
528
+ from dotagents._agents import stamp_identity
529
+
530
+ osenv = dict(base_env if base_env is not None else os.environ)
531
+ env: "dict[str, str]" = {}
532
+
533
+ def _apply(changes: "dict[str, str]") -> None:
534
+ env.update(changes)
535
+ osenv.update(changes)
536
+
537
+ # --- Identity seed (plan 08) --- before the file chain so files can override.
538
+ _apply(stamp_identity(osenv, explicit=explicit, root=project_root))
539
+
540
+ # --- Scope roots --- pin the two scope roots so every command/subprocess agrees:
541
+ # AGENTS_HOME = the user store (agents_dir, ~/.agents by default); AGENTS_PROJECT_ROOT
542
+ # = this project's root (resolve_scope reads it). Respect any already set upstream.
543
+ if not osenv.get("AGENTS_HOME"):
544
+ _apply({"AGENTS_HOME": str(agents_dir)})
545
+ if not osenv.get("AGENTS_PROJECT_ROOT"):
546
+ _apply({"AGENTS_PROJECT_ROOT": str(project_root)})
547
+
548
+ # --- Contract B step 1: bins onto PATH FIRST. ---
549
+ bin_paths = [str(p) for p in get_bin_paths(
550
+ agents_dir=agents_dir, project_root=project_root, global_scope=global_scope
551
+ )]
552
+ current = osenv.get("PATH", "").split(os.pathsep) if osenv.get("PATH") else []
553
+ updated = os.pathsep.join(_prepend_missing(current, bin_paths))
554
+ if updated != osenv.get("PATH", ""):
555
+ _apply({"PATH": updated})
556
+
557
+ # --- Contract B steps 2-5: the two tiers, chained, later-overrides-earlier. ---
558
+ for level, path, _root in resolve_env_files(
559
+ agents_dir=agents_dir, project_root=project_root, global_scope=global_scope
560
+ ):
561
+ if path.suffix == ".py":
562
+ changes = get_env_from_py(
563
+ path, osenv, project_root, level, global_scope, logger=logger
564
+ )
565
+ else:
566
+ changes = get_env_from_file(path, osenv, logger=logger)
567
+ _apply(changes)
568
+
569
+ # --- Proxy normalization (plan 08) --- after the chain so file-set proxies
570
+ # are normalized too.
571
+ _apply(apply_proxy_model(osenv))
572
+
573
+ return env
574
+
575
+
576
+ def get_diff(
577
+ *,
578
+ agents_dir: Path,
579
+ project_root: Path,
580
+ base_env: "Optional[dict[str, str]]" = None,
581
+ global_scope: bool = False,
582
+ explicit: "Optional[str]" = None,
583
+ logger=None,
584
+ ) -> "dict[str, str]":
585
+ """Only the assembled vars that differ from ``base_env`` (current env).
586
+
587
+ ``get_environment`` already returns changes vs ``base_env``, so the diff is
588
+ the subset whose value actually differs from the base -- identical to the
589
+ precursor's ``get_diff`` over ``os.environ``.
590
+ """
591
+ base = dict(base_env if base_env is not None else os.environ)
592
+ full = get_environment(
593
+ agents_dir=agents_dir,
594
+ project_root=project_root,
595
+ base_env=base,
596
+ global_scope=global_scope,
597
+ explicit=explicit,
598
+ logger=logger,
599
+ )
600
+ return {k: v for k, v in full.items() if k not in base or base[k] != v}