diff --git a/packages/claude-code-plugin/hooks/lib/runtime_mode.py b/packages/claude-code-plugin/hooks/lib/runtime_mode.py new file mode 100644 index 00000000..a6af9c65 --- /dev/null +++ b/packages/claude-code-plugin/hooks/lib/runtime_mode.py @@ -0,0 +1,40 @@ +"""Runtime mode detection — MCP vs standalone. + +Checks ~/.claude/mcp.json for codingbuddy MCP server entry. +Used by prompt_injection, user-prompt-submit, and health_check. +""" +import json +import os +from typing import Optional + + +def detect_runtime_mode(home_dir: Optional[str] = None) -> str: + """Detect if CodingBuddy MCP server is configured. + + Checks ~/.claude/mcp.json for an entry containing 'codingbuddy'. + + Args: + home_dir: Override home directory (for testing). + + Returns: + 'mcp' if codingbuddy MCP server found, 'standalone' otherwise. + """ + home = home_dir or os.path.expanduser("~") + mcp_json_path = os.path.join(home, ".claude", "mcp.json") + + try: + with open(mcp_json_path, "r", encoding="utf-8") as f: + data = json.load(f) + + mcp_servers = data.get("mcpServers", {}) + for key in mcp_servers: + if "codingbuddy" in key.lower(): + return "mcp" + return "standalone" + except (OSError, json.JSONDecodeError, TypeError): + return "standalone" + + +def is_mcp_available(home_dir: Optional[str] = None) -> bool: + """Convenience wrapper: True if MCP mode detected.""" + return detect_runtime_mode(home_dir) == "mcp" diff --git a/packages/claude-code-plugin/tests/test_runtime_mode.py b/packages/claude-code-plugin/tests/test_runtime_mode.py new file mode 100644 index 00000000..b4e67a97 --- /dev/null +++ b/packages/claude-code-plugin/tests/test_runtime_mode.py @@ -0,0 +1,74 @@ +"""Tests for runtime_mode detection utility.""" +import json +import os + +import pytest + +from hooks.lib.runtime_mode import detect_runtime_mode, is_mcp_available + + +@pytest.fixture() +def fake_home(tmp_path): + """Create a fake home directory with .claude/ structure.""" + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + return tmp_path + + +def _write_mcp_json(home_path, data): + mcp_path = os.path.join(str(home_path), ".claude", "mcp.json") + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(data, f) + + +class TestDetectRuntimeMode: + def test_returns_mcp_when_codingbuddy_entry_exists(self, fake_home): + _write_mcp_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx", "args": ["codingbuddy"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_returns_mcp_for_codingbuddy_rules_entry(self, fake_home): + """Case-insensitive match on key containing 'codingbuddy'.""" + _write_mcp_json(fake_home, { + "mcpServers": { + "CodingBuddy-Rules": {"command": "npx", "args": ["codingbuddy-rules"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "mcp" + + def test_returns_standalone_without_codingbuddy(self, fake_home): + _write_mcp_json(fake_home, { + "mcpServers": { + "other-server": {"command": "npx", "args": ["other"]} + } + }) + assert detect_runtime_mode(str(fake_home)) == "standalone" + + def test_returns_standalone_when_mcp_json_missing(self, tmp_path): + assert detect_runtime_mode(str(tmp_path)) == "standalone" + + def test_returns_standalone_on_parse_error(self, fake_home): + mcp_path = os.path.join(str(fake_home), ".claude", "mcp.json") + with open(mcp_path, "w", encoding="utf-8") as f: + f.write("{invalid json!!!") + assert detect_runtime_mode(str(fake_home)) == "standalone" + + def test_returns_standalone_when_mcp_servers_missing(self, fake_home): + _write_mcp_json(fake_home, {"otherKey": "value"}) + assert detect_runtime_mode(str(fake_home)) == "standalone" + + +class TestIsMcpAvailable: + def test_returns_true_when_mcp(self, fake_home): + _write_mcp_json(fake_home, { + "mcpServers": { + "codingbuddy": {"command": "npx"} + } + }) + assert is_mcp_available(str(fake_home)) is True + + def test_returns_false_when_standalone(self, tmp_path): + assert is_mcp_available(str(tmp_path)) is False