diff --git a/CHANGELOG.md b/CHANGELOG.md index 981e2464f..ea197f037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Gemini CLI: `apm install -g --mcp NAME` now correctly writes to `~/.gemini/settings.json` (user scope) and `apm install` from outside the target project writes to `/.gemini/settings.json` instead of `cwd`. Previously `--global` had no effect on Gemini and project-scope writes silently landed in the wrong directory. The matching opt-in gate and cleanup paths in `MCPIntegrator` are aligned in the same change. (#1299) - `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222) - Non-skill integrators (agent, instruction, prompt, command, hook script-copy) silently adopt byte-identical pre-existing files so a degraded `deployed_files=[]` lockfile no longer permanently blocks installs gated by `required-packages-deployed`. (#1313) - `apm audit` drift check now returns skip-with-info (`passed=True`) when the install cache is cold, instead of failing the audit; bare `apm audit` surfaces the skip reason on stderr so CI pipelines that have not yet run `apm install` are not incorrectly red-marked. (#1289) diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index f361a82b3..df0f60a30 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -82,18 +82,23 @@ writes a runtime-specific MCP config file. The schemas differ; the | Claude Code | `.mcp.json` (project) or `~/.claude.json` (`-g`) | both | JSON `mcpServers` | | Cursor | `.cursor/mcp.json` | project (only if `.cursor/` exists) | JSON `mcpServers` | | Codex CLI | `~/.codex/config.toml` | global | TOML `[mcp_servers.*]` | -| Gemini CLI | `.gemini/settings.json` | project (only if `.gemini/` exists) | JSON `mcpServers` | +| Gemini CLI | `.gemini/settings.json` (project, only if `.gemini/` exists) or `~/.gemini/settings.json` (`-g`) | both | JSON `mcpServers` | | OpenCode | `opencode.json` | project (only if `.opencode/` exists) | JSON `mcp` | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` | -Cursor, Gemini, and OpenCode are opt-in by directory: APM only writes -their config when the corresponding `.cursor/`, `.gemini/`, or -`.opencode/` directory already exists in the project. This avoids -creating runtime artifacts for tools you do not use. - -`apm install -g --mcp NAME` is restricted to the two harnesses with -true global MCP support: Copilot CLI and Codex CLI. The other -runtimes are project-scoped. +Cursor, Gemini, and OpenCode are opt-in by directory in project scope: +APM only writes their config when the corresponding `.cursor/`, +`.gemini/`, or `.opencode/` directory already exists in the project. +This avoids creating runtime artifacts for tools you do not use. +Gemini's user-scope path (`~/.gemini/settings.json`, selected with +`-g`) is unconditional and creates `~/.gemini/` if needed. + +`apm install -g --mcp NAME` routes the write to each runtime's +user-scope MCP config when that runtime supports user scope -- for +example Copilot CLI writes to `~/.copilot/mcp-config.json`, Codex +CLI to `~/.codex/config.toml`, and Gemini CLI to +`~/.gemini/settings.json`. Workspace-only runtimes (VS Code, Cursor, +OpenCode) are skipped at user scope. ## stdio vs HTTP servers diff --git a/src/apm_cli/adapters/client/gemini.py b/src/apm_cli/adapters/client/gemini.py index 78416a39c..091c5fbf4 100644 --- a/src/apm_cli/adapters/client/gemini.py +++ b/src/apm_cli/adapters/client/gemini.py @@ -1,9 +1,9 @@ """Gemini CLI implementation of MCP client adapter. -Gemini CLI uses ``.gemini/settings.json`` at the project root with an -``mcpServers`` key. Unlike Copilot, Gemini infers transport from which -key is present (``command`` for stdio, ``url`` for SSE, ``httpUrl`` for -streamable HTTP) and does not use ``type``, ``tools``, or ``id`` fields. +Gemini CLI reads ``.gemini/settings.json`` with an ``mcpServers`` key. +Unlike Copilot, Gemini infers transport from which key is present +(``command`` for stdio, ``url`` for SSE, ``httpUrl`` for streamable +HTTP) and does not use ``type``, ``tools``, or ``id`` fields. .. code-block:: json @@ -17,15 +17,17 @@ } } -APM only writes to ``.gemini/settings.json`` when the ``.gemini/`` -directory already exists -- Gemini CLI support is opt-in. +Scope resolution follows the shared adapter contract: project scope +writes to ``/.gemini/settings.json`` and is opt-in -- +the directory must already exist or the write is skipped silently. +User scope writes to ``~/.gemini/settings.json`` unconditionally and +creates the directory if needed. Ref: https://geminicli.com/docs/reference/configuration/ """ import json import logging -import os from pathlib import Path from ...core.docker_args import DockerArgsProcessor @@ -41,6 +43,11 @@ class GeminiClientAdapter(CopilotClientAdapter): Inherits Copilot's helper methods for package selection, env-var resolution, and argument processing but fully reimplements ``_format_server_config`` to emit Gemini-valid JSON. + + Scope routing is governed by ``user_scope``/``project_root`` inherited + from :class:`MCPClientAdapter`: project scope reads/writes + ``/.gemini/settings.json`` (opt-in -- the directory must + already exist), and user scope reads/writes ``~/.gemini/settings.json``. """ supports_user_scope: bool = True @@ -53,21 +60,31 @@ class GeminiClientAdapter(CopilotClientAdapter): # revisit in a follow-up. _supports_runtime_env_substitution: bool = False + def _get_gemini_dir(self) -> Path: + """Return the ``.gemini`` directory for the active scope.""" + if self.user_scope: + return Path.home() / ".gemini" + return self.project_root / ".gemini" + def get_config_path(self): - """Return the path to ``.gemini/settings.json`` in the repository root.""" - return str(Path(os.getcwd()) / ".gemini" / "settings.json") + """Return the path to ``settings.json`` for the active scope.""" + return str(self._get_gemini_dir() / "settings.json") def update_config(self, config_updates): """Merge *config_updates* into the ``mcpServers`` section of settings.json. - The ``.gemini/`` directory must already exist; if it does not, this - method returns silently (opt-in behaviour). + Project scope is opt-in: if ``/.gemini/`` does not + exist, this method returns silently. User scope always writes, + creating ``~/.gemini/`` if needed. Preserves all other top-level keys in settings.json (theme, tools, hooks, etc.). """ - gemini_dir = Path(os.getcwd()) / ".gemini" - if not gemini_dir.is_dir(): + gemini_dir = self._get_gemini_dir() + if not self.user_scope and not gemini_dir.is_dir(): + logger.debug( + "Skipping Gemini project-scope write -- %s does not exist (opt-in)", gemini_dir + ) return config_path = Path(self.get_config_path()) @@ -78,19 +95,22 @@ def update_config(self, config_updates): for name, entry in config_updates.items(): current_config["mcpServers"][name] = entry + if not config_path.parent.is_dir(): + logger.debug("Creating %s for Gemini CLI user configuration", config_path.parent) config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w", encoding="utf-8") as f: json.dump(current_config, f, indent=2) def get_current_config(self): - """Read the current ``.gemini/settings.json`` contents.""" - config_path = self.get_config_path() - if not os.path.exists(config_path): + """Read the current ``settings.json`` contents for the active scope.""" + config_path = Path(self.get_config_path()) + if not config_path.exists(): return {} try: with open(config_path, encoding="utf-8") as f: return json.load(f) - except (OSError, json.JSONDecodeError): + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Could not read %s: %s", config_path, exc) return {} def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None): @@ -230,8 +250,11 @@ def configure_mcp_server( _rich_error("server_url cannot be empty", symbol="error") return False - gemini_dir = Path(os.getcwd()) / ".gemini" - if not gemini_dir.is_dir(): + if not self.user_scope and not self._get_gemini_dir().is_dir(): + logger.debug( + "Gemini opt-in gate: %s absent, skipping configure_mcp_server", + self._get_gemini_dir(), + ) return True try: diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index f8827041e..f1df337ca 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -663,7 +663,7 @@ def remove_stale( # Clean .gemini/settings.json (only if .gemini/ directory exists) if "gemini" in target_runtimes: - gemini_cfg = Path.cwd() / ".gemini" / "settings.json" + gemini_cfg = project_root_path / ".gemini" / "settings.json" if gemini_cfg.exists(): try: import json as _json diff --git a/src/apm_cli/integration/mcp_integrator_install.py b/src/apm_cli/integration/mcp_integrator_install.py index c086f98c9..322a5feef 100644 --- a/src/apm_cli/integration/mcp_integrator_install.py +++ b/src/apm_cli/integration/mcp_integrator_install.py @@ -172,7 +172,7 @@ def run_mcp_install( # noqa: PLR0915 installed_runtimes.append(runtime_name) elif runtime_name == "gemini": # Gemini CLI is opt-in: only target when .gemini/ exists - if (Path.cwd() / ".gemini").is_dir(): + if (project_root_path / ".gemini").is_dir(): ClientFactory.create_client(runtime_name) installed_runtimes.append(runtime_name) elif runtime_name == "windsurf": @@ -210,7 +210,7 @@ def run_mcp_install( # noqa: PLR0915 if (project_root_path / ".opencode").is_dir(): installed_runtimes.append("opencode") # Gemini CLI is directory-presence based - if (Path.cwd() / ".gemini").is_dir(): + if (project_root_path / ".gemini").is_dir(): installed_runtimes.append("gemini") # Windsurf is directory-presence based if (project_root_path / ".windsurf").is_dir(): @@ -317,7 +317,7 @@ def run_mcp_install( # noqa: PLR0915 logger.warning(msg) if not target_runtimes: logger.warning( - "No runtimes support user-scope MCP installation (supported: copilot, codex)" + "No runtimes support user-scope MCP installation (supported: copilot, codex, gemini)" ) return 0 diff --git a/tests/integration/test_gemini_integration.py b/tests/integration/test_gemini_integration.py index 3de6a0716..593d9f4ae 100644 --- a/tests/integration/test_gemini_integration.py +++ b/tests/integration/test_gemini_integration.py @@ -161,9 +161,7 @@ def setup_method(self): def teardown_method(self): shutil.rmtree(self.tmp, ignore_errors=True) - def test_adds_server_preserving_existing_keys(self, monkeypatch): - monkeypatch.chdir(self.root) - + def test_adds_server_preserving_existing_keys(self): settings = self.gemini_dir / "settings.json" settings.write_text( json.dumps( @@ -175,7 +173,7 @@ def test_adds_server_preserving_existing_keys(self, monkeypatch): ) ) - adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + adapter = GeminiClientAdapter(project_root=self.root) adapter.update_config( { "my-server": { @@ -192,13 +190,11 @@ def test_adds_server_preserving_existing_keys(self, monkeypatch): assert result["theme"] == "dark" assert result["tools"] == {"enabled": True} - def test_creates_mcp_servers_key_if_missing(self, monkeypatch): - monkeypatch.chdir(self.root) - + def test_creates_mcp_servers_key_if_missing(self): settings = self.gemini_dir / "settings.json" settings.write_text(json.dumps({"theme": "light"})) - adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + adapter = GeminiClientAdapter(project_root=self.root) adapter.update_config({"srv": {"command": "echo"}}) result = json.loads(settings.read_text()) @@ -206,6 +202,59 @@ def test_creates_mcp_servers_key_if_missing(self, monkeypatch): assert "srv" in result["mcpServers"] assert result["theme"] == "light" + def test_install_via_mcp_integrator_uses_project_root_not_cwd(self, monkeypatch): + """Regression for #1299: when ``MCPIntegrator.install`` is called + with ``project_root`` distinct from the current process cwd, the + Gemini opt-in detection gate must check ``project_root/.gemini/`` + (not ``cwd/.gemini/``), and the MCP write must land at + ``project_root/.gemini/settings.json``. + + Pre-fix: the detection gate at ``mcp_integrator.py`` read + ``Path.cwd() / .gemini`` for Gemini only (every other opt-in + runtime used ``project_root_path``), so when cwd lacked + ``.gemini/`` Gemini was excluded from ``installed_runtimes`` and + no write occurred even though ``project_root/.gemini/`` existed. + """ + from apm_cli.integration.mcp_integrator import MCPIntegrator + from apm_cli.models.dependency.mcp import MCPDependency + + # cwd is a fresh tmp dir with NO .gemini/ -- mirrors the issue's + # "checkout that is not the target project" premise. + other_cwd = tempfile.mkdtemp(prefix="apm-not-project-") + try: + monkeypatch.chdir(other_cwd) + + dep = MCPDependency.from_dict( + { + "name": "regression-1299-srv", + "registry": False, + "transport": "stdio", + "command": "echo", + "args": ["regression-1299"], + } + ) + + # Intentionally do NOT pass runtime= so the auto-detection + # block at mcp_integrator.py exercises the opt-in gate that + # was the bug site (Path.cwd() vs project_root_path). + MCPIntegrator.install( + [dep], + project_root=self.root, + ) + + settings = self.gemini_dir / "settings.json" + assert settings.exists(), ( + "MCPIntegrator.install must write Gemini config at project_root/.gemini/, " + "not silently drop it because the cwd-based opt-in gate misclassified " + "Gemini as unavailable." + ) + data = json.loads(settings.read_text()) + assert "regression-1299-srv" in data.get("mcpServers", {}), ( + "Self-defined MCP server should be written to project_root/.gemini/settings.json" + ) + finally: + shutil.rmtree(other_cwd, ignore_errors=True) + @pytest.mark.integration class TestGeminiOptInBehavior: @@ -248,10 +297,8 @@ def test_instructions_not_deployed_without_gemini_dir(self): assert result.files_integrated == 0 assert not (self.root / ".gemini").exists() - def test_mcp_update_noop_without_gemini_dir(self, monkeypatch): - monkeypatch.chdir(self.root) - - adapter = GeminiClientAdapter.__new__(GeminiClientAdapter) + def test_mcp_update_noop_without_gemini_dir(self): + adapter = GeminiClientAdapter(project_root=self.root) adapter.update_config({"srv": {"command": "echo"}}) assert not (self.root / ".gemini").exists() @@ -468,3 +515,44 @@ def test_uninstall_transitive_dep_cleans_skill(self): assert stats["files_removed"] == 1 assert not skill_dir.exists() + + +@pytest.mark.integration +class TestRemoveStaleGeminiUsesProjectRoot: + """Verify remove_stale reads .gemini/settings.json from project_root, not cwd.""" + + def setup_method(self): + self.tmp = tempfile.mkdtemp() + self.root = Path(self.tmp) + self.gemini_dir = self.root / ".gemini" + self.gemini_dir.mkdir() + self.settings_json = self.gemini_dir / "settings.json" + + def teardown_method(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_remove_stale_gemini_uses_project_root_not_cwd(self, monkeypatch): + """remove_stale must resolve .gemini/settings.json via project_root, + not Path.cwd(), so stale cleanup works when cwd != project_root.""" + from apm_cli.integration.mcp_integrator import MCPIntegrator + + self.settings_json.write_text( + json.dumps( + {"mcpServers": {"stale-srv": {"command": "echo"}, "keep-srv": {"command": "cat"}}} + ) + ) + + other_cwd = tempfile.mkdtemp(prefix="apm-not-project-") + try: + monkeypatch.chdir(other_cwd) + MCPIntegrator.remove_stale( + {"stale-srv"}, + runtime="gemini", + project_root=self.root, + ) + finally: + shutil.rmtree(other_cwd, ignore_errors=True) + + data = json.loads(self.settings_json.read_text()) + assert "stale-srv" not in data.get("mcpServers", {}) + assert "keep-srv" in data.get("mcpServers", {}) diff --git a/tests/integration/test_registry.py b/tests/integration/test_registry.py index abab11426..84372f95c 100644 --- a/tests/integration/test_registry.py +++ b/tests/integration/test_registry.py @@ -1,5 +1,6 @@ """Integration tests for MCP registry client.""" +import contextlib import gc import json import os @@ -62,10 +63,8 @@ def teardown_method(self): # Leave the temp tree before unlinking it. Otherwise cwd can still # reference the directory inode and os.getcwd() raises FileNotFoundError # on POSIX — breaking later tests on the same xdist worker. - try: + with contextlib.suppress(FileNotFoundError, OSError): os.chdir(tempfile.gettempdir()) - except (FileNotFoundError, OSError): - pass # First, try the standard cleanup try: diff --git a/tests/unit/test_gemini_mcp.py b/tests/unit/test_gemini_mcp.py index cb2b914e6..0be0b5deb 100644 --- a/tests/unit/test_gemini_mcp.py +++ b/tests/unit/test_gemini_mcp.py @@ -1,7 +1,6 @@ """Tests for the Gemini CLI MCP client adapter.""" import json -import os # noqa: F401 import shutil import tempfile import unittest @@ -21,23 +20,21 @@ def test_factory_creates_gemini_adapter(self): class TestGeminiClientAdapter(unittest.TestCase): - """Core config operations for GeminiClientAdapter.""" + """Core config operations for GeminiClientAdapter under project scope.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() self.settings_json = self.gemini_dir / "settings.json" - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.tmp.cleanup() def test_config_path(self): - expected = str(Path(self.tmp.name) / ".gemini" / "settings.json") + expected = str(self.project_root / ".gemini" / "settings.json") self.assertEqual(self.adapter.get_config_path(), expected) def test_get_current_config_empty(self): @@ -54,6 +51,12 @@ def test_get_current_config_invalid_json(self): config = self.adapter.get_current_config() self.assertEqual(config, {}) + def test_get_current_config_returns_empty_dict_when_no_dir(self): + """get_current_config returns {} when the .gemini directory does not exist.""" + adapter = GeminiClientAdapter(project_root=Path(tempfile.mkdtemp())) + config = adapter.get_current_config() + self.assertEqual(config, {}) + def test_update_config_creates_file(self): self.adapter.update_config({"my-server": {"command": "npx", "args": ["-y", "pkg"]}}) data = json.loads(self.settings_json.read_text()) @@ -89,16 +92,117 @@ def test_update_config_noop_when_no_gemini_dir(self): self.assertFalse(self.settings_json.exists()) +class TestGeminiProjectRootRouting(unittest.TestCase): + """Regression coverage for #1299: adapter must honour ``project_root`` and + never read or write through ``os.getcwd()``.""" + + def setUp(self): + self.project_tmp = tempfile.TemporaryDirectory() + self.cwd_tmp = tempfile.TemporaryDirectory() + self.project_root = Path(self.project_tmp.name) + self.cwd_root = Path(self.cwd_tmp.name) + (self.project_root / ".gemini").mkdir() + + def tearDown(self): + self.project_tmp.cleanup() + self.cwd_tmp.cleanup() + + def test_writes_to_project_root_when_cwd_lacks_gemini(self): + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter(project_root=self.project_root) + adapter.update_config({"srv": {"command": "node"}}) + + project_settings = self.project_root / ".gemini" / "settings.json" + self.assertTrue(project_settings.exists()) + data = json.loads(project_settings.read_text()) + self.assertEqual(data["mcpServers"]["srv"]["command"], "node") + + def test_does_not_pollute_cwd_when_cwd_also_has_gemini(self): + (self.cwd_root / ".gemini").mkdir() + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter(project_root=self.project_root) + adapter.update_config({"srv": {"command": "node"}}) + + self.assertTrue((self.project_root / ".gemini" / "settings.json").exists()) + self.assertFalse((self.cwd_root / ".gemini" / "settings.json").exists()) + + def test_falls_back_to_cwd_when_project_root_not_passed(self): + (self.cwd_root / ".gemini").mkdir() + with patch("os.getcwd", return_value=str(self.cwd_root)): + adapter = GeminiClientAdapter() + adapter.update_config({"srv": {"command": "node"}}) + + self.assertTrue((self.cwd_root / ".gemini" / "settings.json").exists()) + + +class TestGeminiUserScope(unittest.TestCase): + """Cover the user-scope path: ``~/.gemini/settings.json``.""" + + def setUp(self): + self.home_tmp = tempfile.TemporaryDirectory() + self.home_root = Path(self.home_tmp.name) + self._home_patcher = patch("pathlib.Path.home", return_value=self.home_root) + self._home_patcher.start() + + def tearDown(self): + self._home_patcher.stop() + self.home_tmp.cleanup() + + def test_user_scope_config_path_points_at_home(self): + adapter = GeminiClientAdapter(user_scope=True) + expected = str(self.home_root / ".gemini" / "settings.json") + self.assertEqual(adapter.get_config_path(), expected) + + def test_user_scope_writes_without_requiring_existing_dir(self): + # ``~/.gemini/`` does not yet exist; user scope is not opt-in. + adapter = GeminiClientAdapter(user_scope=True) + adapter.update_config({"srv": {"command": "node"}}) + + home_settings = self.home_root / ".gemini" / "settings.json" + self.assertTrue(home_settings.exists()) + data = json.loads(home_settings.read_text()) + self.assertEqual(data["mcpServers"]["srv"]["command"], "node") + + def test_user_scope_ignores_project_root(self): + project = self.home_root.parent / "elsewhere" + adapter = GeminiClientAdapter(project_root=project, user_scope=True) + self.assertEqual( + adapter.get_config_path(), + str(self.home_root / ".gemini" / "settings.json"), + ) + + def test_user_scope_configure_mcp_server_does_not_short_circuit(self): + """``configure_mcp_server`` must not early-return in user scope just + because ``~/.gemini/`` is missing -- user scope is not opt-in.""" + with ( + patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") as registry_cls, + patch("apm_cli.adapters.client.copilot.RegistryIntegration"), + ): + registry = MagicMock() + registry.find_server_by_reference.return_value = { + "packages": [{"name": "pkg", "registry_name": "npm", "runtime_hint": "npx"}] + } + registry_cls.return_value = registry + + adapter = GeminiClientAdapter(user_scope=True) + result = adapter.configure_mcp_server("some/server", server_name="srv") + + self.assertTrue(result) + home_settings = self.home_root / ".gemini" / "settings.json" + self.assertTrue(home_settings.exists()) + data = json.loads(home_settings.read_text()) + self.assertIn("srv", data["mcpServers"]) + + class TestGeminiConfigureMCPServer(unittest.TestCase): """Test configure_mcp_server() for GeminiClientAdapter.""" def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() self.settings_json = self.gemini_dir / "settings.json" - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() self.mock_registry_patcher = patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") self.mock_registry_class = self.mock_registry_patcher.start() @@ -108,10 +212,9 @@ def setUp(self): self.mock_integration_patcher = patch("apm_cli.adapters.client.copilot.RegistryIntegration") self.mock_integration_class = self.mock_integration_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.mock_registry_patcher.stop() self.mock_integration_patcher.stop() self.tmp.cleanup() @@ -173,10 +276,9 @@ class TestGeminiFormatServerConfig(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() - self.gemini_dir = Path(self.tmp.name) / ".gemini" + self.project_root = Path(self.tmp.name) + self.gemini_dir = self.project_root / ".gemini" self.gemini_dir.mkdir() - self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name) - self._cwd_patcher.start() self.mock_registry_patcher = patch("apm_cli.adapters.client.copilot.SimpleRegistryClient") self.mock_registry_class = self.mock_registry_patcher.start() @@ -184,10 +286,9 @@ def setUp(self): self.mock_integration_patcher = patch("apm_cli.adapters.client.copilot.RegistryIntegration") self.mock_integration_class = self.mock_integration_patcher.start() - self.adapter = GeminiClientAdapter() + self.adapter = GeminiClientAdapter(project_root=self.project_root) def tearDown(self): - self._cwd_patcher.stop() self.mock_registry_patcher.stop() self.mock_integration_patcher.stop() self.tmp.cleanup()