The CLI loads .env itself — init's promise finally holds

First finding of Eric's clean-room run, and the exact kind the
rehearsal exists for: init writes credentials to .env, but nothing
ever loaded it — the dev repo's committed .envrc + direnv did it
invisibly, and a fresh directory has neither. The web banner then
advised "run bggpipe init or load .env", circular counsel for someone
who just ran init.

A typer callback now loads ./.env before every command, using the
same parsing rules as the wizard that writes it (export prefixes,
quoted values, quoted-empty = unset). Real environment variables
always outrank the file, so direnv setups and explicit overrides keep
working unchanged. Verified in a scrubbed-environment clean room: the
credentials banner is gone with nothing but .env present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-06 09:50:49 -04:00
co-authored by Claude Fable 5
parent 2542560d57
commit 1df784e253
4 changed files with 65 additions and 1 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ Your photos and every pipeline artifact live in the directory where you run it,
The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? Copy [.env.example](.env.example) beside your data, fill it in, and run `playwright install chromium` yourself. The `init` wizard is idempotent — re-run it anytime to check status or add keys you skipped. It prompts for the credentials below (hidden input, saved to a `.env` it creates with owner-only permissions) and offers the one-time Playwright Chromium download. Prefer doing it by hand? Copy [.env.example](.env.example) beside your data, fill it in, and run `playwright install chromium` yourself.
Secrets live in environment variables only, never in config files, code, or logs. `.env` is gitignored. If you use [direnv](https://direnv.net/), the committed `.envrc` loads `.env` automatically after a one-time `direnv allow`; otherwise export the variables yourself (e.g. `set -a; source .env; set +a`). Secrets live in environment variables only, never in config files, code, or logs, and `.env` is gitignored. Every `bggpipe` command loads `.env` from the working directory by itself — real environment variables always win over the file, so [direnv](https://direnv.net/) users and CI overrides keep working unchanged.
| Variable | Used by | What it is | | Variable | Used by | What it is |
|---|---|---| |---|---|---|
+11
View File
@@ -14,6 +14,17 @@ app = typer.Typer(
no_args_is_help=True, no_args_is_help=True,
) )
@app.callback()
def _load_dotenv() -> None:
# credentials live in ./.env (written by init); load them so a fresh
# directory works without direnv or manual sourcing. Real environment
# variables always take precedence over the file.
from bggpipe.init_wizard import load_env_file
load_env_file(Path(".env"))
ConfigOpt = Annotated[ ConfigOpt = Annotated[
Path | None, Path | None,
typer.Option("--config", help="Path to config.toml (default: ./config.toml)"), typer.Option("--config", help="Path to config.toml (default: ./config.toml)"),
+25
View File
@@ -91,6 +91,31 @@ class InitReport:
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
def load_env_file(env_path: Path) -> list[str]:
"""Export .env values into this process's environment — only keys the
environment doesn't already set (a real env var always wins). Returns
the keys loaded. Same parsing rules as _env_file_keys; values go
straight into os.environ and are never printed or logged."""
if not env_path.exists():
return []
loaded = []
for line in env_path.read_text().splitlines():
line = line.strip()
if "=" not in line or line.startswith("#"):
continue
key, _, value = line.partition("=")
key = key.strip()
if key.startswith("export "):
key = key.removeprefix("export ").strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
if key and value and not os.environ.get(key):
os.environ[key] = value
loaded.append(key)
return loaded
def _env_file_keys(env_path: Path) -> set[str]: def _env_file_keys(env_path: Path) -> set[str]:
"""Key names with non-empty values in .env. Handles `export KEY=v` and """Key names with non-empty values in .env. Handles `export KEY=v` and
quoted values; a quoted-empty value ("" / '') counts as NOT set. Values quoted values; a quoted-empty value ("" / '') counts as NOT set. Values
+28
View File
@@ -190,3 +190,31 @@ def test_fresh_config_template_carries_both_vision_blocks(tmp_path, monkeypatch)
cfg = load_config(tmp_path / "config.toml") cfg = load_config(tmp_path / "config.toml")
assert cfg.vision_provider == "anthropic" assert cfg.vision_provider == "anthropic"
assert cfg.model == "claude-sonnet-5" # active block applied cleanly assert cfg.model == "claude-sonnet-5" # active block applied cleanly
def test_load_env_file_exports_without_overriding(tmp_path, monkeypatch):
"""A fresh directory must work straight after init: the CLI loads .env
itself. A real environment variable always outranks the file."""
from bggpipe.init_wizard import load_env_file
env = tmp_path / ".env"
env.write_text(
"# comment\n"
'export BGG_USERNAME="someone"\n'
"BGG_API_TOKEN=tok-123\n"
"EMPTY=''\n"
"BGG_PASSWORD=from-file\n"
)
monkeypatch.delenv("BGG_USERNAME", raising=False)
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
monkeypatch.delenv("EMPTY", raising=False)
monkeypatch.setenv("BGG_PASSWORD", "from-environment")
loaded = load_env_file(env)
assert set(loaded) == {"BGG_USERNAME", "BGG_API_TOKEN"}
assert os.environ["BGG_USERNAME"] == "someone"
assert os.environ["BGG_API_TOKEN"] == "tok-123"
assert "EMPTY" not in os.environ # quoted-empty means NOT set
assert os.environ["BGG_PASSWORD"] == "from-environment" # env wins
assert load_env_file(tmp_path / "absent.env") == []