Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
33 changes: 31 additions & 2 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
94 changes: 94 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"