Skip to content

feat: auto-load .env alongside agctl.yaml (--env-file / AGCTL_ENV_FILE)#48

Merged
HumanBean17 merged 1 commit into
mainfrom
feat/dotenv-auto-load
Jul 13, 2026
Merged

feat: auto-load .env alongside agctl.yaml (--env-file / AGCTL_ENV_FILE)#48
HumanBean17 merged 1 commit into
mainfrom
feat/dotenv-auto-load

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

Summary

agctl's ${VAR} interpolation previously read only os.environ, so using a .env file required manually sourcing it first (set -a; . ./.env; set +a). This adds first-class .env support — a .env next to the resolved agctl.yaml is loaded automatically as env defaults, with real environment winning, plus explicit --env-file / AGCTL_ENV_FILE sources.

Behavior

  • Auto-load: .env sitting next to the resolved agctl.yaml is loaded automatically — no shell sourcing needed.
  • Real env wins: .env provides defaults only; a value already in the real environment overrides it (docker-compose / python-dotenv / rails / django convention — lets CI/prod inject real secrets over committed defaults).
  • Explicit sources: --env-file <path> (global flag, and per-command wherever --config is declared) and AGCTL_ENV_FILE.
  • Precedence: --env-file > AGCTL_ENV_FILE > sibling .env. Explicit replaces auto-load (like --config replaces walk-up).
  • Missing file: missing sibling .env → silent no-op; missing explicit --env-file/AGCTL_ENV_FILEConfigError (exit 2), mirroring --config.
  • Single interpolation engine: python-dotenv parses with its own ${VAR} expansion disabled, so agctl's one interpolate() owns all ${...} resolution (chained/nested). AGCTL_* lines in .env apply as overrides (real env still wins).

Implementation

  • New module agctl/config/env_file.py: load_env_file(path, *, required) + resolve_dotenv_values(explicit, env, base_path).
  • compose_config merges .env defaults (env = {**dotenv, **env}, never mutates os.environ) after config discovery, before interpolation — so the sibling sits next to the resolved config and AGCTL_CONFIG/AGCTL_ENV_FILE set inside .env can't steer their own resolution (no cycle).
  • CLI: global --env-file in cli.py; per-command --env-file mirroring --config; threaded through _core/_envelope helpers and callbacks (parallel to overlay_paths). AGCTL_ENV_FILE needs no CLI plumbing (resolved in compose_config).
  • Dep: python-dotenv>=1.0 added to core deps.

Review

Reviewed by a 4-reviewer parallel team (core pipeline, CLI threading, tests, docs). All findings resolved:

  • Critical: mock start now forwards --env-file to the spawned daemon (it re-loads config from scratch) — guarded by a regression test.
  • Important: malformed-UTF8 .env now surfaces as ConfigError (not InternalError); the headline invariant (dotenv interpolation off) and the circularity / sibling-only guards are now pinned by tests.

Test plan

  • tests/unit/test_env_file.py — 24 tests (parsing, real-env-wins, precedence, circularity guard, sibling-only, raw-values pin, malformed-UTF8, CLI wiring)
  • tests/unit/test_mock_commands.py — daemon-argv --env-file forwarding regression test
  • Full suite: 1101 passed, 18 skipped (was 1076)
  • ruff check: zero new errors; new files ruff format-clean
  • Docs synced (DESIGN §2.2/§3/§5/§6, ARCHITECTURE §3/§5/§11, README, skills) via docs-watcher

🤖 Generated with Claude Code

agctl's \${VAR} interpolation read only os.environ, so using a .env file
required manually sourcing it first. This adds first-class .env support:

- Auto-load .env next to the resolved agctl.yaml as env defaults, with real
  environment winning (.env fills blanks only — docker-compose/dotenv
  convention; CI/prod inject real env over committed defaults).
- Explicit sources: --env-file <path> (global flag + per-command wherever
  --config is declared) and AGCTL_ENV_FILE. Precedence: --env-file >
  AGCTL_ENV_FILE > sibling .env. Missing sibling = no-op; missing explicit
  source = ConfigError (exit 2).
- python-dotenv parses with its own \${VAR} expansion disabled, so agctl's
  single interpolate() owns all \${...} resolution (chained/nested).
  AGCTL_* lines in .env apply as overrides (real env still wins).

New module agctl/config/env_file.py; merge step in compose_config after
config discovery, before interpolation. Adds python-dotenv>=1.0 dep.

Reviewed via a 4-reviewer parallel team; all Critical/Important findings
fixed and guarded by regression tests (incl. mock-start daemon argv
forwarding and malformed-UTF8 error wrapping).

Tests: 24 in tests/unit/test_env_file.py + a mock-start daemon-argv
regression test; full suite green (1101 passed, 18 skipped).

Co-Authored-By: Claude <noreply@anthropic.com>
@HumanBean17
HumanBean17 merged commit e83ccd3 into main Jul 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant