diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index aa2b6f2..4642935 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -151,13 +151,30 @@ def project_slug(self) -> str: def base_path(self) -> Path: """Resolved base path for project storage. - Chain: config['base_path'] → coordinator.config['base_path'] → default. - Tilde is expanded. Result is cached after first access. + Resolution order (first truthy value wins): + 1. ``config['base_path']`` — explicit hook config / settings.yaml override + 2. ``coordinator.config['base_path']`` — coordinator-level config + 3. ``AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH`` env var + 4. default (``~/.amplifier/projects``) + + Mirrors the named-env-var fallback already used by + :attr:`context_intelligence_server_url` and + :attr:`context_intelligence_api_key` in this resolver — the env var + name is fixed and documented, and lets hosts (e.g. amplifier-agent + relocating its storage root via ``AMPLIFIER_AGENT_HOME``) point the + hook at a host-specific path without changing the vendored + ``bundle.md``. No string interpolation of arbitrary ``$VAR`` + references inside config values — the env var name is the contract. + + Tilde is expanded on the resolved value, regardless of source + (config, coordinator, env, or default). Result is cached after + first access. """ if self._base_path is None: raw = ( self._config.get("base_path") or self._coordinator_config_get("base_path") + or _env("BASE_PATH") or _DEFAULT_BASE_PATH ) self._base_path = Path(raw).expanduser() diff --git a/modules/hook-context-intelligence/tests/test_config_resolver.py b/modules/hook-context-intelligence/tests/test_config_resolver.py index ee9c47c..8dff3d8 100644 --- a/modules/hook-context-intelligence/tests/test_config_resolver.py +++ b/modules/hook-context-intelligence/tests/test_config_resolver.py @@ -85,6 +85,67 @@ def test_returns_path_type(self) -> None: assert isinstance(resolver.base_path, Path) +class TestBasePathEnvFallback: + """``AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH`` env-var fallback for ``base_path``. + + Mirrors the named-env-var fallback already used by ``server_url`` and + ``api_key`` (``_env("SERVER_URL")``/``_env("API_KEY")``). The env var + sits between coordinator config and the hard-coded default in the chain. + """ + + def test_base_path_falls_back_to_env_var(self, monkeypatch) -> None: + """When config and coordinator have no base_path, the env var is used.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/var/lib/from-env") + coordinator = _make_coordinator(config={}) + resolver = ConfigResolver(config={}, coordinator=coordinator) + + assert resolver.base_path == Path("/var/lib/from-env") + + def test_config_wins_over_env_var(self, monkeypatch) -> None: + """Explicit hook config base_path beats the env var (chain priority).""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/var/lib/from-env") + coordinator = _make_coordinator(config={}) + resolver = ConfigResolver(config={"base_path": "/from-config"}, coordinator=coordinator) + + assert resolver.base_path == Path("/from-config") + + def test_coordinator_config_wins_over_env_var(self, monkeypatch) -> None: + """coordinator.config['base_path'] beats the env var (chain priority).""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/var/lib/from-env") + coordinator = _make_coordinator(config={"base_path": "/from-coordinator"}) + resolver = ConfigResolver(config={}, coordinator=coordinator) + + assert resolver.base_path == Path("/from-coordinator") + + def test_env_var_wins_over_default(self, monkeypatch) -> None: + """The env var beats the hard-coded default (chain priority).""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "/var/lib/from-env") + coordinator = _make_coordinator(config={}) + resolver = ConfigResolver(config={}, coordinator=coordinator) + + # Confirms the env-var value is used (not ~/.amplifier/projects). + assert resolver.base_path == Path("/var/lib/from-env") + assert resolver.base_path != Path("~/.amplifier/projects").expanduser() + + def test_env_var_value_is_tilde_expanded(self, monkeypatch) -> None: + """``~`` in the env var value is expanded, same as for other sources.""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "~/from-env-tilde") + coordinator = _make_coordinator(config={}) + resolver = ConfigResolver(config={}, coordinator=coordinator) + + assert "~" not in str(resolver.base_path) + assert resolver.base_path == Path("~/from-env-tilde").expanduser() + + def test_empty_env_var_treated_as_absent(self, monkeypatch) -> None: + """An empty env var falls through to the next source (default).""" + monkeypatch.setenv("AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH", "") + coordinator = _make_coordinator(config={}) + resolver = ConfigResolver(config={}, coordinator=coordinator) + + # Empty value is falsy in _env(); chain falls through to default. + assert resolver.base_path == Path("~/.amplifier/projects").expanduser() + + class TestProjectSlugResolution: def test_config_value_wins(self) -> None: """Explicit hook config project_slug wins over coordinator config."""