gdmcode 0.1.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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/config.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""GdmConfig — loads and validates all configuration.
|
|
2
|
+
|
|
3
|
+
Legacy session config priority (highest → lowest):
|
|
4
|
+
1. Environment variables (XAI_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, GDM_*)
|
|
5
|
+
2. System keychain (via CredentialStore / keyring)
|
|
6
|
+
3. ~/.config/gdm/config.toml [api] section
|
|
7
|
+
4. Built-in defaults
|
|
8
|
+
|
|
9
|
+
The config object is immutable once created. Re-create to reload.
|
|
10
|
+
|
|
11
|
+
Application settings (sdk-002) — ConfigLoader with 7-layer precedence:
|
|
12
|
+
1. Hardcoded defaults
|
|
13
|
+
2. User config (~/.config/gdm/config.toml)
|
|
14
|
+
3. Project config (.gdm/config.toml)
|
|
15
|
+
4. Team preferences (.gdm/team.toml [preferences])
|
|
16
|
+
5. Team policy (.gdm/team.toml [policy]) ← overrides env + CLI
|
|
17
|
+
6. Environment variables (GDM_* prefix)
|
|
18
|
+
7. CLI flags
|
|
19
|
+
|
|
20
|
+
Secrets (api_key, token, password, credential) go to OS keychain — never to TOML.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import tomllib
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, Field, field_validator
|
|
33
|
+
|
|
34
|
+
from src._internal.constants import (
|
|
35
|
+
_CONTEXT_MEMORY_DIR,
|
|
36
|
+
_DEFAULT_COST_LIMIT_USD,
|
|
37
|
+
_MAX_AGENT_TURNS,
|
|
38
|
+
)
|
|
39
|
+
from src.exceptions import ConfigError
|
|
40
|
+
from src.models.definitions import Provider
|
|
41
|
+
|
|
42
|
+
__all__ = ["GdmConfig", "GdmSettings", "ConfigValue", "ConfigLoader", "KEYCHAIN_KEYS", "load_config"]
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_GLOBAL_CONFIG_DIR = Path.home() / ".config" / "gdm"
|
|
47
|
+
_GLOBAL_CONFIG_FILE = _GLOBAL_CONFIG_DIR / "config.toml"
|
|
48
|
+
_GLOBAL_INSTRUCTIONS_FILE = _GLOBAL_CONFIG_DIR / "instructions.md"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class GdmConfig:
|
|
53
|
+
"""Immutable configuration for a gdm session."""
|
|
54
|
+
|
|
55
|
+
# Provider + API keys
|
|
56
|
+
provider: str
|
|
57
|
+
api_key: str
|
|
58
|
+
xai_api_key: str | None
|
|
59
|
+
gemini_api_key: str | None
|
|
60
|
+
openai_api_key: str | None
|
|
61
|
+
|
|
62
|
+
# Project paths
|
|
63
|
+
project_root: Path
|
|
64
|
+
context_memory_dir: Path
|
|
65
|
+
gdm_instructions: str # contents of .gdm + global instructions, pre-merged
|
|
66
|
+
|
|
67
|
+
# Agent behaviour
|
|
68
|
+
max_turns: int
|
|
69
|
+
cost_limit_usd: float
|
|
70
|
+
|
|
71
|
+
# Spinner customisation (user-appended verbs from config.toml)
|
|
72
|
+
extra_spinner_verbs: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
# Model ID overrides — resolved from env vars + TOML [models] section
|
|
75
|
+
model_ids: dict[str, str] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
# Tool execution settings
|
|
78
|
+
tools_timeout_secs: int = 30
|
|
79
|
+
|
|
80
|
+
# API fallback settings
|
|
81
|
+
fallback_provider: str | None = None
|
|
82
|
+
model_id_map: dict[str, str] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
# Debug loop settings
|
|
85
|
+
debug_auto_search_iteration: int = 3 # 0 = disabled
|
|
86
|
+
|
|
87
|
+
# Debate model override — "" means use reasoner/debate tier from config
|
|
88
|
+
debate_model: str = ""
|
|
89
|
+
|
|
90
|
+
# Artifact auto-detection (opt-in, off by default to prevent alert fatigue)
|
|
91
|
+
artifacts_auto_detect: bool = False
|
|
92
|
+
|
|
93
|
+
# Whole-codebase mode: load all source files upfront when project fits in context
|
|
94
|
+
whole_codebase: str = field(default="auto") # "auto" | "always" | "never"
|
|
95
|
+
|
|
96
|
+
# Quiet mode: suppress non-essential startup output (e.g. memory hints)
|
|
97
|
+
gdm_quiet: bool = False
|
|
98
|
+
|
|
99
|
+
# Local provider configurations (Ollama, vLLM) — parsed from [providers.*] TOML
|
|
100
|
+
local_providers: list = field(default_factory=list) # list[LocalProviderConfig]
|
|
101
|
+
|
|
102
|
+
# Proxy settings — allows geo-restricted regions to route calls via gdm relay.
|
|
103
|
+
# proxy_token comes from OS keychain / env; never stored in TOML plaintext.
|
|
104
|
+
proxy_url: str = ""
|
|
105
|
+
proxy_token: str | None = None
|
|
106
|
+
proxy_enabled: bool = False
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def has_grok(self) -> bool:
|
|
110
|
+
return bool(self.xai_api_key)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def has_gemini(self) -> bool:
|
|
114
|
+
return bool(self.gemini_api_key)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def has_codex(self) -> bool:
|
|
118
|
+
return bool(self.openai_api_key)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_config(project_root: Path | None = None) -> GdmConfig:
|
|
122
|
+
"""Load and validate configuration from all sources.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
project_root: project directory (defaults to cwd).
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ConfigError: if no API key is found from any source.
|
|
129
|
+
"""
|
|
130
|
+
from src.auth import CredentialStore
|
|
131
|
+
|
|
132
|
+
root = (project_root or Path.cwd()).resolve()
|
|
133
|
+
store = CredentialStore()
|
|
134
|
+
creds = store.load_all()
|
|
135
|
+
|
|
136
|
+
# ── 1. API keys: env → keychain (merged in load_all) ────────────────
|
|
137
|
+
xai_key = creds.xai_api_key
|
|
138
|
+
gemini_key = creds.gemini_api_key
|
|
139
|
+
openai_key = creds.openai_api_key
|
|
140
|
+
|
|
141
|
+
# ── 2. Global config.toml ─────────────────────────────────────────
|
|
142
|
+
toml_cfg: dict = {} # type: ignore[type-arg]
|
|
143
|
+
if _GLOBAL_CONFIG_FILE.exists():
|
|
144
|
+
try:
|
|
145
|
+
toml_cfg = tomllib.loads(_GLOBAL_CONFIG_FILE.read_text(encoding="utf-8"))
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
log.warning("Could not parse %s: %s", _GLOBAL_CONFIG_FILE, exc)
|
|
148
|
+
|
|
149
|
+
# Keys can also live in config.toml under [api] (lowest priority)
|
|
150
|
+
api_section = toml_cfg.get("api", {})
|
|
151
|
+
if not xai_key:
|
|
152
|
+
xai_key = api_section.get("xai_api_key") or None
|
|
153
|
+
if not gemini_key:
|
|
154
|
+
gemini_key = api_section.get("gemini_api_key") or None
|
|
155
|
+
if not openai_key:
|
|
156
|
+
openai_key = api_section.get("openai_api_key") or None
|
|
157
|
+
|
|
158
|
+
# ── 3. Provider selection ─────────────────────────────────────────
|
|
159
|
+
preferred = os.environ.get("GDM_PROVIDER") or toml_cfg.get("provider") or None
|
|
160
|
+
|
|
161
|
+
provider, api_key = _select_provider(preferred, xai_key, gemini_key, openai_key)
|
|
162
|
+
|
|
163
|
+
# ── 4. User instructions (.gdm + global) ─────────────────────────
|
|
164
|
+
instructions_parts: list[str] = []
|
|
165
|
+
if _GLOBAL_INSTRUCTIONS_FILE.exists():
|
|
166
|
+
instructions_parts.append(_GLOBAL_INSTRUCTIONS_FILE.read_text(encoding="utf-8").strip())
|
|
167
|
+
|
|
168
|
+
project_gdm = root / ".gdm"
|
|
169
|
+
_maybe_migrate_gdm_dir(root) # migrate flat .gdm file to .gdm/ directory
|
|
170
|
+
if project_gdm.is_dir():
|
|
171
|
+
instructions_file = project_gdm / "instructions.md"
|
|
172
|
+
if instructions_file.exists():
|
|
173
|
+
instructions_parts.append(instructions_file.read_text(encoding="utf-8").strip())
|
|
174
|
+
elif project_gdm.is_file():
|
|
175
|
+
instructions_parts.append(project_gdm.read_text(encoding="utf-8").strip())
|
|
176
|
+
|
|
177
|
+
gdm_instructions = "\n\n".join(instructions_parts)
|
|
178
|
+
|
|
179
|
+
# ── 5. Agent settings ─────────────────────────────────────────────
|
|
180
|
+
agent_cfg = toml_cfg.get("agent", {})
|
|
181
|
+
max_turns: int = int(
|
|
182
|
+
os.environ.get("GDM_MAX_TURNS") or agent_cfg.get("max_turns", _MAX_AGENT_TURNS)
|
|
183
|
+
)
|
|
184
|
+
cost_limit: float = float(
|
|
185
|
+
os.environ.get("GDM_COST_LIMIT") or agent_cfg.get("cost_limit_usd", _DEFAULT_COST_LIMIT_USD)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# ── 6. Spinner extra verbs ─────────────────────────────────────────
|
|
189
|
+
extra_verbs: list[str] = toml_cfg.get("spinner", {}).get("extra_verbs", [])
|
|
190
|
+
|
|
191
|
+
# ── 7. Model ID overrides (env vars → TOML [models]) ──────────────
|
|
192
|
+
model_ids = _load_model_id_overrides(toml_cfg)
|
|
193
|
+
|
|
194
|
+
# Apply overrides to module-level ModelDef constants before first use
|
|
195
|
+
from src.models.definitions import apply_model_id_overrides
|
|
196
|
+
apply_model_id_overrides(model_ids)
|
|
197
|
+
|
|
198
|
+
# ── 8. Debug settings ─────────────────────────────────────────────
|
|
199
|
+
debug_cfg = toml_cfg.get("debug", {})
|
|
200
|
+
debug_auto_search_iteration: int = int(
|
|
201
|
+
os.environ.get("GDM_DEBUG_AUTO_SEARCH_ITERATION")
|
|
202
|
+
or debug_cfg.get("auto_search_iteration", 3)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# ── 9. Tool settings ──────────────────────────────────────────────
|
|
206
|
+
tools_cfg = toml_cfg.get("tools", {})
|
|
207
|
+
tools_timeout_secs: int = int(
|
|
208
|
+
os.environ.get("GDM_TOOLS_TIMEOUT") or tools_cfg.get("timeout_secs", 30)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# ── 10. API fallback settings ─────────────────────────────────────
|
|
212
|
+
fallback_cfg = toml_cfg.get("fallback", {})
|
|
213
|
+
fallback_provider: str | None = (
|
|
214
|
+
os.environ.get("GDM_FALLBACK_PROVIDER") or fallback_cfg.get("provider") or None
|
|
215
|
+
)
|
|
216
|
+
model_id_map: dict[str, str] = dict(fallback_cfg.get("model_id_map", {}))
|
|
217
|
+
|
|
218
|
+
# ── 11. Proxy settings ────────────────────────────────────────────
|
|
219
|
+
proxy_cfg = toml_cfg.get("proxy", {})
|
|
220
|
+
proxy_url: str = os.environ.get("GDM_PROXY_URL") or proxy_cfg.get("url") or ""
|
|
221
|
+
# Token comes from env → keychain only; never stored in TOML plaintext.
|
|
222
|
+
proxy_token: str | None = creds.proxy_token
|
|
223
|
+
proxy_enabled: bool = bool(
|
|
224
|
+
os.environ.get("GDM_PROXY_ENABLED", "").lower() in ("1", "true", "yes")
|
|
225
|
+
or proxy_cfg.get("enabled", False)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return GdmConfig(
|
|
229
|
+
provider=provider,
|
|
230
|
+
api_key=api_key,
|
|
231
|
+
xai_api_key=xai_key,
|
|
232
|
+
gemini_api_key=gemini_key,
|
|
233
|
+
openai_api_key=openai_key,
|
|
234
|
+
project_root=root,
|
|
235
|
+
context_memory_dir=root / _CONTEXT_MEMORY_DIR,
|
|
236
|
+
gdm_instructions=gdm_instructions,
|
|
237
|
+
max_turns=max_turns,
|
|
238
|
+
cost_limit_usd=cost_limit,
|
|
239
|
+
extra_spinner_verbs=extra_verbs,
|
|
240
|
+
model_ids=model_ids,
|
|
241
|
+
debug_auto_search_iteration=debug_auto_search_iteration,
|
|
242
|
+
tools_timeout_secs=tools_timeout_secs,
|
|
243
|
+
fallback_provider=fallback_provider,
|
|
244
|
+
model_id_map=model_id_map,
|
|
245
|
+
debate_model=toml_cfg.get("debate", {}).get("model", ""),
|
|
246
|
+
artifacts_auto_detect=bool(toml_cfg.get("artifacts", {}).get("auto_detect", False)),
|
|
247
|
+
whole_codebase=toml_cfg.get("context", {}).get("whole_codebase", "auto"),
|
|
248
|
+
gdm_quiet=bool(
|
|
249
|
+
os.environ.get("GDM_QUIET", "").lower() in ("1", "true", "yes")
|
|
250
|
+
or toml_cfg.get("agent", {}).get("quiet", False)
|
|
251
|
+
),
|
|
252
|
+
local_providers=_load_local_providers(toml_cfg),
|
|
253
|
+
proxy_url=proxy_url,
|
|
254
|
+
proxy_token=proxy_token,
|
|
255
|
+
proxy_enabled=proxy_enabled,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _load_local_providers(toml_cfg: dict) -> list: # type: ignore[type-arg]
|
|
260
|
+
"""Parse ``[providers.ollama]`` and ``[providers.vllm]`` TOML sections.
|
|
261
|
+
|
|
262
|
+
Returns a list of ``LocalProviderConfig`` for each enabled local provider.
|
|
263
|
+
Providers with ``enabled = false`` (or missing) are skipped.
|
|
264
|
+
"""
|
|
265
|
+
# Lazy import avoids circular dependency (client.py imports config.py)
|
|
266
|
+
from src.models.client import LocalProviderConfig
|
|
267
|
+
|
|
268
|
+
_DEFAULTS: dict[str, str] = {
|
|
269
|
+
"ollama": "http://localhost:11434",
|
|
270
|
+
"vllm": "http://localhost:8000",
|
|
271
|
+
}
|
|
272
|
+
providers_cfg = toml_cfg.get("providers", {})
|
|
273
|
+
result: list = []
|
|
274
|
+
|
|
275
|
+
for provider_name in ("ollama", "vllm"):
|
|
276
|
+
section = providers_cfg.get(provider_name, {})
|
|
277
|
+
if not section.get("enabled", False):
|
|
278
|
+
continue
|
|
279
|
+
result.append(
|
|
280
|
+
LocalProviderConfig(
|
|
281
|
+
provider=provider_name,
|
|
282
|
+
base_url=section.get("base_url", _DEFAULTS[provider_name]),
|
|
283
|
+
model=section.get("model", ""),
|
|
284
|
+
local_only=bool(section.get("local_only", True)),
|
|
285
|
+
allow_cloud_fallback=bool(section.get("allow_cloud_fallback", False)),
|
|
286
|
+
allow_external=bool(section.get("allow_external", False)),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _load_model_id_overrides(toml_cfg: dict) -> dict[str, str]: # type: ignore[type-arg]
|
|
294
|
+
"""Build a ``{provider.tier: model_id}`` dict from env vars and TOML.
|
|
295
|
+
|
|
296
|
+
Resolution order per key: env var > config.toml > (omitted = use default).
|
|
297
|
+
"""
|
|
298
|
+
_PROVIDERS = ("grok", "gemini", "codex")
|
|
299
|
+
_TIERS = ("scout", "coder", "thinker", "reasoner", "debate", "standard")
|
|
300
|
+
models_section = toml_cfg.get("models", {})
|
|
301
|
+
result: dict[str, str] = {}
|
|
302
|
+
for provider in _PROVIDERS:
|
|
303
|
+
provider_cfg = models_section.get(provider, {})
|
|
304
|
+
for tier in _TIERS:
|
|
305
|
+
env_key = f"GDM_MODEL_{provider.upper()}_{tier.upper()}"
|
|
306
|
+
env_val = os.environ.get(env_key)
|
|
307
|
+
if env_val:
|
|
308
|
+
result[f"{provider}.{tier}"] = env_val
|
|
309
|
+
elif tier in provider_cfg:
|
|
310
|
+
result[f"{provider}.{tier}"] = str(provider_cfg[tier])
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _select_provider( preferred: str | None,
|
|
315
|
+
xai_key: str | None,
|
|
316
|
+
gemini_key: str | None,
|
|
317
|
+
openai_key: str | None,
|
|
318
|
+
) -> tuple[str, str]:
|
|
319
|
+
"""Return (provider_name, api_key) based on preference and available keys.
|
|
320
|
+
|
|
321
|
+
Raises ConfigError if no key is available.
|
|
322
|
+
"""
|
|
323
|
+
# Try the preferred provider first.
|
|
324
|
+
if preferred:
|
|
325
|
+
match preferred.lower():
|
|
326
|
+
case "grok" | "xai" if xai_key:
|
|
327
|
+
return Provider.GROK, xai_key
|
|
328
|
+
case "gemini" | "google" if gemini_key:
|
|
329
|
+
return Provider.GEMINI, gemini_key
|
|
330
|
+
case "codex" | "openai" if openai_key:
|
|
331
|
+
return Provider.CODEX, openai_key
|
|
332
|
+
case _:
|
|
333
|
+
log.warning("Preferred provider %r has no key — falling back", preferred)
|
|
334
|
+
|
|
335
|
+
# Auto-detect: Grok > Gemini > Codex.
|
|
336
|
+
if xai_key:
|
|
337
|
+
return Provider.GROK, xai_key
|
|
338
|
+
if gemini_key:
|
|
339
|
+
return Provider.GEMINI, gemini_key
|
|
340
|
+
if openai_key:
|
|
341
|
+
return Provider.CODEX, openai_key
|
|
342
|
+
|
|
343
|
+
raise ConfigError(
|
|
344
|
+
"No API key found. Set one of:\n"
|
|
345
|
+
" export XAI_API_KEY=xai-... # xAI Grok (recommended)\n"
|
|
346
|
+
" export GEMINI_API_KEY=AIza... # Google Gemini (free tier)\n"
|
|
347
|
+
" export OPENAI_API_KEY=sk-... # OpenAI Codex\n"
|
|
348
|
+
"Or run: gdm login"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
353
|
+
# Application Settings System (sdk-002)
|
|
354
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
355
|
+
|
|
356
|
+
# ── Secret keys that NEVER go to TOML ─────────────────────────────────────────
|
|
357
|
+
KEYCHAIN_KEYS: frozenset[str] = frozenset({
|
|
358
|
+
"xai_api_key", "openai_api_key", "anthropic_api_key",
|
|
359
|
+
"google_api_key", "github_token",
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── Source-annotated config value ──────────────────────────────────────────────
|
|
364
|
+
@dataclass
|
|
365
|
+
class ConfigValue:
|
|
366
|
+
"""A configuration value annotated with its source layer."""
|
|
367
|
+
|
|
368
|
+
value: Any
|
|
369
|
+
source: str # e.g. "default", "user:~/.config/gdm/config.toml",
|
|
370
|
+
# "project:.gdm/config.toml", "env:GDM_AUTONOMY_LEVEL",
|
|
371
|
+
# "cli", "team-policy:.gdm/team.toml[policy]"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ── Pydantic application settings model ───────────────────────────────────────
|
|
375
|
+
class GdmSettings(BaseModel):
|
|
376
|
+
"""Immutable application settings loaded by ConfigLoader."""
|
|
377
|
+
|
|
378
|
+
model_config = {"frozen": True}
|
|
379
|
+
|
|
380
|
+
# [models]
|
|
381
|
+
primary_model: str = "grok-4-0106"
|
|
382
|
+
fallback_model: str = "o3-mini"
|
|
383
|
+
|
|
384
|
+
# [budget]
|
|
385
|
+
session_limit_usd: float = Field(default=10.0, ge=0)
|
|
386
|
+
monthly_limit_usd: float = Field(default=200.0, ge=0)
|
|
387
|
+
|
|
388
|
+
# [autonomy]
|
|
389
|
+
autonomy_level: int = Field(default=2, ge=0, le=5)
|
|
390
|
+
|
|
391
|
+
# [audit]
|
|
392
|
+
retain_days: int = Field(default=90, ge=1)
|
|
393
|
+
|
|
394
|
+
# [analytics]
|
|
395
|
+
consent_required: bool = False
|
|
396
|
+
roi_estimation: bool = False
|
|
397
|
+
anonymise: bool = True
|
|
398
|
+
|
|
399
|
+
# Enterprise policy fields (written from team.toml [policy])
|
|
400
|
+
policy_enforced: bool = False
|
|
401
|
+
allow_git_push: bool = True
|
|
402
|
+
allow_network: bool = True
|
|
403
|
+
network_allowlist: list[str] = Field(default_factory=list)
|
|
404
|
+
|
|
405
|
+
@field_validator("autonomy_level")
|
|
406
|
+
@classmethod
|
|
407
|
+
def autonomy_in_range(cls, v: int) -> int:
|
|
408
|
+
if not 0 <= v <= 5:
|
|
409
|
+
raise ValueError(f"autonomy_level must be 0–5, got {v}")
|
|
410
|
+
return v
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ── TOML section → GdmSettings field mapping ──────────────────────────────────
|
|
414
|
+
# Maps TOML dot-notation keys to GdmSettings field names.
|
|
415
|
+
_TOML_KEY_MAP: dict[str, str] = {
|
|
416
|
+
"models.primary": "primary_model",
|
|
417
|
+
"models.fallback": "fallback_model",
|
|
418
|
+
"budget.session_limit_usd": "session_limit_usd",
|
|
419
|
+
"budget.monthly_limit_usd": "monthly_limit_usd",
|
|
420
|
+
"autonomy.level": "autonomy_level",
|
|
421
|
+
"audit.retain_days": "retain_days",
|
|
422
|
+
"analytics.consent_required": "consent_required",
|
|
423
|
+
"analytics.roi_estimation": "roi_estimation",
|
|
424
|
+
"analytics.anonymise": "anonymise",
|
|
425
|
+
}
|
|
426
|
+
# Reverse: GdmSettings field names → TOML dot-notation keys
|
|
427
|
+
_FIELD_TO_TOML: dict[str, str] = {v: k for k, v in _TOML_KEY_MAP.items()}
|
|
428
|
+
|
|
429
|
+
# Env var names → GdmSettings field names
|
|
430
|
+
_ENV_KEY_MAP: dict[str, str] = {
|
|
431
|
+
"GDM_MODELS_PRIMARY": "primary_model",
|
|
432
|
+
"GDM_MODELS_FALLBACK": "fallback_model",
|
|
433
|
+
"GDM_BUDGET_SESSION_LIMIT_USD": "session_limit_usd",
|
|
434
|
+
"GDM_BUDGET_MONTHLY_LIMIT_USD": "monthly_limit_usd",
|
|
435
|
+
"GDM_AUTONOMY_LEVEL": "autonomy_level",
|
|
436
|
+
"GDM_AUDIT_RETAIN_DAYS": "retain_days",
|
|
437
|
+
"GDM_ANALYTICS_CONSENT_REQUIRED": "consent_required",
|
|
438
|
+
"GDM_ANALYTICS_ROI_ESTIMATION": "roi_estimation",
|
|
439
|
+
"GDM_ANALYTICS_ANONYMISE": "anonymise",
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
# Policy keys in team.toml [policy] that map to GdmSettings fields
|
|
443
|
+
# "max_autonomy_level" is the policy spelling for "autonomy_level"
|
|
444
|
+
_POLICY_KEY_MAP: dict[str, str] = {
|
|
445
|
+
"max_autonomy_level": "autonomy_level",
|
|
446
|
+
"policy_enforced": "policy_enforced",
|
|
447
|
+
"allow_git_push": "allow_git_push",
|
|
448
|
+
"allow_network": "allow_network",
|
|
449
|
+
"network_allowlist": "network_allowlist",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# Integer, float, and bool field sets for env-var coercion
|
|
453
|
+
_INT_FIELDS: frozenset[str] = frozenset({"autonomy_level", "retain_days"})
|
|
454
|
+
_FLOAT_FIELDS: frozenset[str] = frozenset({"session_limit_usd", "monthly_limit_usd"})
|
|
455
|
+
_BOOL_FIELDS: frozenset[str] = frozenset({"consent_required", "roi_estimation", "anonymise"})
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _maybe_migrate_gdm_dir(project_root: Path | None = None) -> None:
|
|
459
|
+
"""Migrate legacy .gdm flat-file to .gdm/ directory layout.
|
|
460
|
+
|
|
461
|
+
The legacy layout stores ``.gdm`` as a plain text file containing
|
|
462
|
+
agent instructions. The new layout uses ``.gdm/`` as a directory
|
|
463
|
+
that contains ``config.toml``, ``team.toml``, ``instructions.md``, etc.
|
|
464
|
+
This shim is idempotent — if ``.gdm`` is already a directory (or absent)
|
|
465
|
+
it does nothing.
|
|
466
|
+
"""
|
|
467
|
+
root = project_root or Path.cwd()
|
|
468
|
+
gdm_path = root / ".gdm"
|
|
469
|
+
if not gdm_path.exists() or gdm_path.is_dir():
|
|
470
|
+
return
|
|
471
|
+
# .gdm exists as a file — migrate to a directory
|
|
472
|
+
legacy_content = gdm_path.read_text(encoding="utf-8", errors="replace")
|
|
473
|
+
gdm_path.unlink()
|
|
474
|
+
gdm_path.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
(gdm_path / "instructions.md").write_text(legacy_content, encoding="utf-8")
|
|
476
|
+
log.info("Migrated .gdm flat file to .gdm/instructions.md")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _is_secret_key(key: str) -> bool:
|
|
480
|
+
"""Return True if *key* matches a known secret pattern."""
|
|
481
|
+
if key in KEYCHAIN_KEYS:
|
|
482
|
+
return True
|
|
483
|
+
return bool(re.search(r"(api_key|token|secret|password|credential)", key, re.I))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _coerce_env_value(field_name: str, value: str) -> Any:
|
|
487
|
+
"""Coerce an env-var string to the correct Python type for *field_name*."""
|
|
488
|
+
if field_name in _INT_FIELDS:
|
|
489
|
+
return int(value)
|
|
490
|
+
if field_name in _FLOAT_FIELDS:
|
|
491
|
+
return float(value)
|
|
492
|
+
if field_name in _BOOL_FIELDS:
|
|
493
|
+
return value.lower() in ("1", "true", "yes", "on")
|
|
494
|
+
return value
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _flatten_toml(data: dict[str, Any]) -> dict[str, Any]:
|
|
498
|
+
"""Flatten a TOML section dict to GdmSettings field names.
|
|
499
|
+
|
|
500
|
+
Handles the standard section layout ([models], [budget], [autonomy], …)
|
|
501
|
+
*and* flat dicts where keys already match GdmSettings field names
|
|
502
|
+
(used for team.toml [preferences]).
|
|
503
|
+
"""
|
|
504
|
+
result: dict[str, Any] = {}
|
|
505
|
+
|
|
506
|
+
# [models]
|
|
507
|
+
models = data.get("models", {})
|
|
508
|
+
if isinstance(models, dict):
|
|
509
|
+
if "primary" in models:
|
|
510
|
+
result["primary_model"] = models["primary"]
|
|
511
|
+
if "fallback" in models:
|
|
512
|
+
result["fallback_model"] = models["fallback"]
|
|
513
|
+
|
|
514
|
+
# [budget]
|
|
515
|
+
budget = data.get("budget", {})
|
|
516
|
+
if isinstance(budget, dict):
|
|
517
|
+
for k in ("session_limit_usd", "monthly_limit_usd"):
|
|
518
|
+
if k in budget:
|
|
519
|
+
result[k] = budget[k]
|
|
520
|
+
|
|
521
|
+
# [autonomy]
|
|
522
|
+
autonomy = data.get("autonomy", {})
|
|
523
|
+
if isinstance(autonomy, dict) and "level" in autonomy:
|
|
524
|
+
result["autonomy_level"] = autonomy["level"]
|
|
525
|
+
|
|
526
|
+
# [audit]
|
|
527
|
+
audit = data.get("audit", {})
|
|
528
|
+
if isinstance(audit, dict) and "retain_days" in audit:
|
|
529
|
+
result["retain_days"] = audit["retain_days"]
|
|
530
|
+
|
|
531
|
+
# [analytics]
|
|
532
|
+
analytics = data.get("analytics", {})
|
|
533
|
+
if isinstance(analytics, dict):
|
|
534
|
+
for k in ("consent_required", "roi_estimation", "anonymise"):
|
|
535
|
+
if k in analytics:
|
|
536
|
+
result[k] = analytics[k]
|
|
537
|
+
|
|
538
|
+
# Flat keys that directly match GdmSettings field names (team-prefs convenience)
|
|
539
|
+
for field_name in GdmSettings.model_fields:
|
|
540
|
+
if field_name in data and field_name not in result:
|
|
541
|
+
result[field_name] = data[field_name]
|
|
542
|
+
|
|
543
|
+
return result
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class ConfigLoader:
|
|
547
|
+
"""Loads :class:`GdmSettings` from the 7-layer precedence stack.
|
|
548
|
+
|
|
549
|
+
Call :meth:`load` to get ``(settings, provenance)`` where *provenance*
|
|
550
|
+
maps every field name to a :class:`ConfigValue` showing value + origin.
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
def __init__(self, project_root: Path | None = None) -> None:
|
|
554
|
+
self._project_root = (project_root or Path.cwd()).resolve()
|
|
555
|
+
|
|
556
|
+
# ── Public API ──────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
def load(
|
|
559
|
+
self, cli_overrides: dict[str, Any] | None = None
|
|
560
|
+
) -> tuple[GdmSettings, dict[str, ConfigValue]]:
|
|
561
|
+
"""Return ``(GdmSettings, provenance)`` after merging all layers.
|
|
562
|
+
|
|
563
|
+
*cli_overrides* is a dict of ``{field_name: value}`` pairs provided
|
|
564
|
+
by CLI flags (highest normal priority, overridden by team policy).
|
|
565
|
+
"""
|
|
566
|
+
user_path = self._user_config_path()
|
|
567
|
+
project_path = self._project_config_path()
|
|
568
|
+
team_path = self._team_config_path()
|
|
569
|
+
|
|
570
|
+
# Load raw values per source
|
|
571
|
+
defaults_vals = self._defaults()
|
|
572
|
+
user_vals = self._load_toml_file(user_path, f"user:{user_path}")
|
|
573
|
+
project_vals = self._load_toml_file(project_path, f"project:{project_path}")
|
|
574
|
+
team_prefs_vals = self._load_team_prefs()
|
|
575
|
+
team_policy_vals = self._load_team_policy()
|
|
576
|
+
env_vals = self._load_env()
|
|
577
|
+
cli_vals: dict[str, Any] = cli_overrides or {}
|
|
578
|
+
|
|
579
|
+
# Apply layers lowest → highest (each overrides previous)
|
|
580
|
+
merged: dict[str, ConfigValue] = {}
|
|
581
|
+
|
|
582
|
+
for field_name, value in defaults_vals.items():
|
|
583
|
+
merged[field_name] = ConfigValue(value=value, source="default")
|
|
584
|
+
|
|
585
|
+
for field_name, cv in user_vals.items():
|
|
586
|
+
merged[field_name] = cv
|
|
587
|
+
|
|
588
|
+
for field_name, cv in project_vals.items():
|
|
589
|
+
merged[field_name] = cv
|
|
590
|
+
|
|
591
|
+
for field_name, cv in team_prefs_vals.items():
|
|
592
|
+
merged[field_name] = cv
|
|
593
|
+
|
|
594
|
+
# env and cli are applied before policy so policy can override them
|
|
595
|
+
for field_name, (value, env_key) in env_vals.items():
|
|
596
|
+
merged[field_name] = ConfigValue(value=value, source=f"env:{env_key}")
|
|
597
|
+
|
|
598
|
+
for field_name, value in cli_vals.items():
|
|
599
|
+
merged[field_name] = ConfigValue(value=value, source="cli")
|
|
600
|
+
|
|
601
|
+
# Team policy overrides env + cli (intentional enterprise enforcement)
|
|
602
|
+
policy_source = f"team-policy:{team_path}[policy]"
|
|
603
|
+
for field_name, value in team_policy_vals.items():
|
|
604
|
+
merged[field_name] = ConfigValue(value=value, source=policy_source)
|
|
605
|
+
|
|
606
|
+
settings_kwargs = {k: cv.value for k, cv in merged.items()}
|
|
607
|
+
settings = GdmSettings(**settings_kwargs)
|
|
608
|
+
return settings, merged
|
|
609
|
+
|
|
610
|
+
def write_user(self, key: str, value: Any) -> None:
|
|
611
|
+
"""Write *key* to the user config TOML (or keychain for secrets).
|
|
612
|
+
|
|
613
|
+
*key* uses TOML dot notation (e.g. ``"models.primary"``).
|
|
614
|
+
"""
|
|
615
|
+
if _is_secret_key(key):
|
|
616
|
+
import keyring # type: ignore[import-untyped]
|
|
617
|
+
keyring.set_password("gdm", key, str(value))
|
|
618
|
+
return
|
|
619
|
+
path = self._user_config_path()
|
|
620
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
621
|
+
self._atomic_write(path, key, value)
|
|
622
|
+
|
|
623
|
+
def write_project(self, key: str, value: Any) -> None:
|
|
624
|
+
"""Write *key* to the project config TOML.
|
|
625
|
+
|
|
626
|
+
Raises :class:`ValueError` if *key* is a secret — secrets must
|
|
627
|
+
go to the OS keychain, never to project TOML.
|
|
628
|
+
"""
|
|
629
|
+
if _is_secret_key(key):
|
|
630
|
+
raise ValueError(
|
|
631
|
+
f"{key!r} is a secret — stored in keychain, never in project TOML. "
|
|
632
|
+
"Use `gdm config set` without --project for secrets."
|
|
633
|
+
)
|
|
634
|
+
_maybe_migrate_gdm_dir(self._project_root)
|
|
635
|
+
path = self._project_config_path()
|
|
636
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
self._atomic_write(path, key, value)
|
|
638
|
+
|
|
639
|
+
def unset_user(self, key: str) -> None:
|
|
640
|
+
"""Remove *key* from the user config TOML atomically."""
|
|
641
|
+
path = self._user_config_path()
|
|
642
|
+
if not path.exists():
|
|
643
|
+
return
|
|
644
|
+
existing = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
645
|
+
keys = key.split(".")
|
|
646
|
+
node: Any = existing
|
|
647
|
+
for k in keys[:-1]:
|
|
648
|
+
if not isinstance(node, dict) or k not in node:
|
|
649
|
+
return
|
|
650
|
+
node = node[k]
|
|
651
|
+
if isinstance(node, dict):
|
|
652
|
+
node.pop(keys[-1], None)
|
|
653
|
+
import tomli_w # type: ignore[import-untyped]
|
|
654
|
+
tmp = path.with_suffix(".tmp")
|
|
655
|
+
try:
|
|
656
|
+
tmp.write_bytes(tomli_w.dumps(existing).encode("utf-8"))
|
|
657
|
+
tmp.replace(path)
|
|
658
|
+
except Exception:
|
|
659
|
+
tmp.unlink(missing_ok=True)
|
|
660
|
+
raise
|
|
661
|
+
|
|
662
|
+
# ── Path helpers ────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
def _user_config_path(self) -> Path:
|
|
665
|
+
return _GLOBAL_CONFIG_FILE
|
|
666
|
+
|
|
667
|
+
def _project_config_path(self) -> Path:
|
|
668
|
+
return self._project_root / ".gdm" / "config.toml"
|
|
669
|
+
|
|
670
|
+
def _team_config_path(self) -> Path:
|
|
671
|
+
return self._project_root / ".gdm" / "team.toml"
|
|
672
|
+
|
|
673
|
+
# ── Layer loaders ────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
def _defaults(self) -> dict[str, Any]:
|
|
676
|
+
return GdmSettings().model_dump()
|
|
677
|
+
|
|
678
|
+
def _load_toml_file(
|
|
679
|
+
self, path: Path, source_label: str
|
|
680
|
+
) -> dict[str, ConfigValue]:
|
|
681
|
+
"""Load a TOML config file and return ``{field: ConfigValue}``."""
|
|
682
|
+
if not path.exists():
|
|
683
|
+
return {}
|
|
684
|
+
try:
|
|
685
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
686
|
+
flat = _flatten_toml(data)
|
|
687
|
+
return {k: ConfigValue(value=v, source=source_label) for k, v in flat.items()}
|
|
688
|
+
except Exception as exc:
|
|
689
|
+
log.warning("Could not parse %s: %s", path, exc)
|
|
690
|
+
return {}
|
|
691
|
+
|
|
692
|
+
def _load_team_prefs(self) -> dict[str, ConfigValue]:
|
|
693
|
+
"""Load team preferences from team.toml ``[preferences]`` section."""
|
|
694
|
+
path = self._team_config_path()
|
|
695
|
+
if not path.exists():
|
|
696
|
+
return {}
|
|
697
|
+
try:
|
|
698
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
699
|
+
prefs = data.get("preferences", {})
|
|
700
|
+
flat = _flatten_toml(prefs)
|
|
701
|
+
source = f"team-prefs:{path}"
|
|
702
|
+
return {k: ConfigValue(value=v, source=source) for k, v in flat.items()}
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
log.warning("Could not parse team.toml [preferences]: %s", exc)
|
|
705
|
+
return {}
|
|
706
|
+
|
|
707
|
+
def _load_team_policy(self) -> dict[str, Any]:
|
|
708
|
+
"""Load team policy from team.toml ``[policy]`` section.
|
|
709
|
+
|
|
710
|
+
Returns ``{field_name: value}`` — stored separately from normal
|
|
711
|
+
ConfigValues so callers can apply policy *after* env + cli.
|
|
712
|
+
"""
|
|
713
|
+
path = self._team_config_path()
|
|
714
|
+
if not path.exists():
|
|
715
|
+
return {}
|
|
716
|
+
try:
|
|
717
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
718
|
+
policy = data.get("policy", {})
|
|
719
|
+
result: dict[str, Any] = {}
|
|
720
|
+
for k, v in policy.items():
|
|
721
|
+
# max_autonomy_level is the policy spelling; maps to autonomy_level
|
|
722
|
+
target = _POLICY_KEY_MAP.get(k) or (k if k in GdmSettings.model_fields else None)
|
|
723
|
+
if target:
|
|
724
|
+
result[target] = v
|
|
725
|
+
return result
|
|
726
|
+
except Exception as exc:
|
|
727
|
+
log.warning("Could not parse team.toml [policy]: %s", exc)
|
|
728
|
+
return {}
|
|
729
|
+
|
|
730
|
+
def _load_env(self) -> dict[str, tuple[Any, str]]:
|
|
731
|
+
"""Load GDM_* env vars, returning ``{field_name: (value, env_key)}``."""
|
|
732
|
+
result: dict[str, tuple[Any, str]] = {}
|
|
733
|
+
for env_key, field_name in _ENV_KEY_MAP.items():
|
|
734
|
+
val = os.environ.get(env_key)
|
|
735
|
+
if val is not None:
|
|
736
|
+
result[field_name] = (_coerce_env_value(field_name, val), env_key)
|
|
737
|
+
return result
|
|
738
|
+
|
|
739
|
+
# ── Atomic TOML write ───────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
def _atomic_write(self, path: Path, key: str, value: Any) -> None:
|
|
742
|
+
"""Write *key=value* to a TOML file atomically via tempfile+rename.
|
|
743
|
+
|
|
744
|
+
*key* uses dot notation (e.g. ``"models.primary"``). Partial writes
|
|
745
|
+
never corrupt the existing file because the rename is atomic on POSIX
|
|
746
|
+
and best-effort on Windows.
|
|
747
|
+
"""
|
|
748
|
+
existing = tomllib.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
749
|
+
keys = key.split(".")
|
|
750
|
+
node: Any = existing
|
|
751
|
+
for k in keys[:-1]:
|
|
752
|
+
node = node.setdefault(k, {})
|
|
753
|
+
node[keys[-1]] = value
|
|
754
|
+
import tomli_w # type: ignore[import-untyped]
|
|
755
|
+
tmp = path.with_suffix(".tmp")
|
|
756
|
+
try:
|
|
757
|
+
tmp.write_bytes(tomli_w.dumps(existing).encode("utf-8"))
|
|
758
|
+
tmp.replace(path) # atomic on POSIX; best-effort on Windows
|
|
759
|
+
except Exception:
|
|
760
|
+
tmp.unlink(missing_ok=True)
|
|
761
|
+
raise
|
|
762
|
+
|