From c075d07fa1c2e82ae3c3c0b8c55a2816a4d3ba62 Mon Sep 17 00:00:00 2001 From: Tiago Barbosa Date: Tue, 2 Jun 2026 15:25:52 +0100 Subject: [PATCH] Add dotenv loader and wire CLI + MCP entry points to it Introduces `yieldagent.env.load_dotenv`, a small `.env` reader so the campaign-setup CLI and the Meta MCP server pick up local secrets without `set -a; source .env`. No behavior change beyond env loading at startup. Co-Authored-By: Claude Opus 4.7 --- src/yieldagent/agents/campaign_setup/cli.py | 6 ++- src/yieldagent/env.py | 57 +++++++++++++++++++++ src/yieldagent/integrations/meta/server.py | 4 +- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/yieldagent/env.py diff --git a/src/yieldagent/agents/campaign_setup/cli.py b/src/yieldagent/agents/campaign_setup/cli.py index 25ed4d0..514e693 100644 --- a/src/yieldagent/agents/campaign_setup/cli.py +++ b/src/yieldagent/agents/campaign_setup/cli.py @@ -21,6 +21,8 @@ from langgraph.types import Command +from yieldagent.env import load_dotenv + from .graph import build_graph from .nodes import DEFAULT_MODEL @@ -100,6 +102,7 @@ async def _run(brief_path: Path, *, auto_approve: bool, dry_run: bool, model_nam def main() -> int: + load_dotenv() parser = argparse.ArgumentParser(prog="yieldagent-campaign-setup") parser.add_argument("brief", type=Path, help="Path to a markdown campaign brief") parser.add_argument( @@ -111,7 +114,8 @@ def main() -> int: "--dry-run", action="store_true", help="Replace the Meta MCP server with a stub. No Meta credentials needed; " - "nothing is sent to Meta. Use this to try the agent end-to-end before wiring up real ad accounts.", + "nothing is sent to Meta. Use this to try the agent end-to-end before " + "wiring up real ad accounts.", ) parser.add_argument("--model", default=DEFAULT_MODEL, help="Claude model name") args = parser.parse_args() diff --git a/src/yieldagent/env.py b/src/yieldagent/env.py new file mode 100644 index 0000000..e3ed5a4 --- /dev/null +++ b/src/yieldagent/env.py @@ -0,0 +1,57 @@ +"""Small `.env` loader for local CLI and MCP server entry points.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _find_dotenv(start: Path | None = None) -> Path | None: + current = (start or Path.cwd()).resolve() + if current.is_file(): + current = current.parent + for directory in (current, *current.parents): + candidate = directory / ".env" + if candidate.is_file(): + return candidate + return None + + +def _parse_value(raw: str) -> str: + value = raw.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + + for index, char in enumerate(value): + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].strip() + return value + + +def load_dotenv(path: Path | None = None, *, override: bool = False) -> Path | None: + """Load KEY=VALUE pairs from `.env` into `os.environ`. + + This intentionally handles the simple format used by this repo without + adding a runtime dependency. Existing shell variables win unless + ``override=True`` is passed. + """ + dotenv_path = path or _find_dotenv() + if dotenv_path is None: + return None + + for line in dotenv_path.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped.removeprefix("export ").lstrip() + if "=" not in stripped: + continue + key, raw_value = stripped.split("=", 1) + key = key.strip() + if not key or not key.replace("_", "").isalnum() or key[0].isdigit(): + continue + if override or key not in os.environ: + os.environ[key] = _parse_value(raw_value) + + return dotenv_path diff --git a/src/yieldagent/integrations/meta/server.py b/src/yieldagent/integrations/meta/server.py index b49e880..76cd946 100644 --- a/src/yieldagent/integrations/meta/server.py +++ b/src/yieldagent/integrations/meta/server.py @@ -14,14 +14,15 @@ from mcp.server.fastmcp import FastMCP from yieldagent.domain import Campaign +from yieldagent.env import load_dotenv from .client import MetaClient from .config import MetaConfig from .mapping import ( + audience_to_targeting, campaign_objective, creative_payload, flight_to_meta_times, - audience_to_targeting, to_minor_units, ) @@ -138,6 +139,7 @@ async def publish_draft_campaign(campaign: dict[str, Any]) -> dict[str, Any]: def main() -> None: + load_dotenv() asyncio.run(mcp.run_stdio_async())