diff --git a/README.md b/README.md index f3a6b4d..5218996 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,18 @@ daemon: > **Note:** The daemon inherits your shell environment. If an API key (e.g. `OPENAI_API_KEY`) is already set as an environment variable, you don't need to duplicate it in `envs`. The `envs` field is only for values that aren't in your environment. +> **Environment variable interpolation:** Any string value in both global and project settings supports `{env:VAR_NAME}` placeholders, which are replaced with the corresponding environment variable at load time. This lets you keep secrets out of config files without relying solely on shell inheritance. For example: +> +> ```yaml +> embedding: +> model: openai/your-model-name +> envs: +> OPENAI_BASE_URL: "https://{env:MY_LLM_HOST}/v1" +> OPENAI_API_KEY: "{env:MY_OPENAI_API_KEY}" +> ``` +> +> Placeholders can appear anywhere in a string and work on every config field. If the referenced variable is unset, it's replaced with an empty string. + > **Idle timeout:** the background daemon holds the embedding model in RAM, so it exits after `daemon.idle_timeout_minutes` without client activity and is restarted automatically on your next `ccc` command or MCP search. A live MCP session sends periodic heartbeats, so the daemon never idles out while your coding agent is connected. Set `0` to keep the daemon running forever. > **Custom location:** set `COCOINDEX_CODE_DIR` to place `global_settings.yml` somewhere other than `~/.cocoindex_code/` — useful if you want the file to live alongside your projects (e.g. on a synced folder). diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index e228839..8550a13 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import re from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -446,6 +447,34 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]: return d +_ENV_PLACEHOLDER_RE = re.compile(r"\{env:([^}]+)\}") + + +def _expand_env_placeholders(value: Any) -> Any: + """Recursively replace ``{env:VAR}`` in strings with ``os.environ[VAR]``. + + Works on any nested structure of dicts, lists, and strings, so every + config value — model name, API key, base URL, etc. — can reference an + environment variable. If the variable is unset the placeholder is + replaced with an empty string. Non-string values are returned unchanged. + + Example:: + + envs: + OPENAI_API_KEY: "{env:OPENAI_API_KEY}" + OPENAI_BASE_URL: "https://{env:MY_HOST}/v1" + """ + if isinstance(value, str): + return _ENV_PLACEHOLDER_RE.sub( + lambda m: os.environ.get(m.group(1), ""), value + ) + if isinstance(value, dict): + return {k: _expand_env_placeholders(v) for k, v in value.items()} + if isinstance(value, list): + return [_expand_env_placeholders(v) for v in value] + return value + + def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings: emb_dict = d.get("embedding") if not emb_dict or "model" not in emb_dict: @@ -521,7 +550,7 @@ def load_user_settings() -> UserSettings: data = _yaml.safe_load(f) if not data: raise ValueError("File is empty") - return _user_settings_from_dict(data) + return _user_settings_from_dict(_expand_env_placeholders(data)) except Exception as e: raise type(e)(f"Error loading {path}: {e}") from e @@ -620,7 +649,7 @@ def load_project_settings(project_root: Path) -> ProjectSettings: data = _yaml.safe_load(f) if not data: return default_project_settings() - return _project_settings_from_dict(data) + return _project_settings_from_dict(_expand_env_placeholders(data)) except Exception as e: raise type(e)(f"Error loading {path}: {e}") from e diff --git a/tests/test_settings.py b/tests/test_settings.py index f9be64b..d2bbe36 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -684,3 +684,97 @@ def test_save_initial_writes_comment_template_for_unknown_litellm() -> None: # `dimensions` is intentionally NOT in the litellm template — it must be # the same on both sides, so we don't expose it as a per-side knob. assert "dimensions" not in content + + +# --------------------------------------------------------------------------- +# Environment variable interpolation ({env:VAR}) +# --------------------------------------------------------------------------- + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_in_envs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """``{env:VAR}`` in envs values is replaced at load time.""" + monkeypatch.setenv("MY_OPENAI_KEY", "sk-secret") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: text-embedding-3-small\n" + "envs:\n OPENAI_API_KEY: '{env:MY_OPENAI_KEY}'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_API_KEY"] == "sk-secret" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_partial_string( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``{env:VAR}`` can be embedded inside a larger string.""" + monkeypatch.setenv("MY_HOST", "my-llm-server.local") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: openai/my-model\n" + "envs:\n OPENAI_BASE_URL: 'https://{env:MY_HOST}/v1'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_BASE_URL"] == "https://my-llm-server.local/v1" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_in_model_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Interpolation works on any config field, not just envs.""" + monkeypatch.setenv("EMB_MODEL", "text-embedding-3-small") + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("embedding:\n model: '{env:EMB_MODEL}'\n") + loaded = load_user_settings() + assert loaded.embedding.model == "text-embedding-3-small" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_unset_var_becomes_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unset variables are replaced with an empty string (no error).""" + monkeypatch.delenv("DEFINITELY_UNSET_VAR", raising=False) + path = tmp_path / ".cocoindex_code" / "global_settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "embedding:\n model: openai/my-model\n" + "envs:\n OPENAI_BASE_URL: 'https://{env:DEFINITELY_UNSET_VAR}/v1'\n" + ) + loaded = load_user_settings() + assert loaded.envs["OPENAI_BASE_URL"] == "https:///v1" + + +@pytest.mark.usefixtures("_patch_user_dir") +def test_env_interpolation_does_not_mutate_saved_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Saving settings after loading with interpolation writes expanded values.""" + monkeypatch.setenv("MY_KEY", "expanded-value") + settings = UserSettings( + embedding=EmbeddingSettings(model="text-embedding-3-small"), + envs={"OPENAI_API_KEY": "{env:MY_KEY}"}, + ) + save_user_settings(settings) + loaded = load_user_settings() + assert loaded.envs["OPENAI_API_KEY"] == "expanded-value" + + +def test_env_interpolation_in_project_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Project settings also support ``{env:VAR}`` interpolation.""" + monkeypatch.setenv("MY_LANG", "php") + settings = ProjectSettings( + include_patterns=["**/*.py"], + exclude_patterns=[], + language_overrides=[LanguageOverride(ext="inc", lang="{env:MY_LANG}")], + ) + save_project_settings(tmp_path, settings) + loaded = load_project_settings(tmp_path) + assert loaded.language_overrides[0].lang == "php"