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/_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}
|