diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index cf98a3c8..6c3de1fc 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -39,6 +39,7 @@ "en": { "installed": "CodingBuddy mode detection hook installed", "patterns": " PLAN:/ACT:/EVAL:/AUTO: patterns will be auto-detected", + "restart_needed": " Restart Claude Code to activate PLAN/ACT/EVAL/AUTO mode detection.", "source_not_found": "CodingBuddy: Could not find hook source file. Please reinstall the plugin or check the installation.", "permission_error": "CodingBuddy: Permission error - {error}", "permission_hint": "Try running: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py", @@ -48,6 +49,7 @@ "ko": { "installed": "CodingBuddy 모드 감지 훅이 설치되었습니다", "patterns": " PLAN:/ACT:/EVAL:/AUTO: 패턴이 자동 감지됩니다", + "restart_needed": " PLAN/ACT/EVAL/AUTO 모드 감지를 활성화하려면 Claude Code를 재시작하세요.", "source_not_found": "CodingBuddy: 훅 소스 파일을 찾을 수 없습니다. 플러그인을 재설치하거나 설치를 확인하세요.", "permission_error": "CodingBuddy: 권한 오류 - {error}", "permission_hint": "실행: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py", @@ -57,6 +59,7 @@ "ja": { "installed": "CodingBuddyモード検出フックがインストールされました", "patterns": " PLAN:/ACT:/EVAL:/AUTO: パターンが自動検出されます", + "restart_needed": " PLAN/ACT/EVAL/AUTOモード検出を有効にするには、Claude Codeを再起動してください。", "source_not_found": "CodingBuddy: フックソースファイルが見つかりません。プラグインを再インストールするか、インストールを確認してください。", "permission_error": "CodingBuddy: 権限エラー - {error}", "permission_hint": "実行: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py", @@ -66,6 +69,7 @@ "zh": { "installed": "CodingBuddy模式检测钩子已安装", "patterns": " PLAN:/ACT:/EVAL:/AUTO: 模式将被自动检测", + "restart_needed": " 请重启Claude Code以激活PLAN/ACT/EVAL/AUTO模式检测。", "source_not_found": "CodingBuddy: 找不到钩子源文件。请重新安装插件或检查安装。", "permission_error": "CodingBuddy: 权限错误 - {error}", "permission_hint": "执行: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py", @@ -75,6 +79,7 @@ "es": { "installed": "Hook de detección de modo CodingBuddy instalado", "patterns": " PLAN:/ACT:/EVAL:/AUTO: los patrones serán detectados automáticamente", + "restart_needed": " Reinicie Claude Code para activar la detección de modos PLAN/ACT/EVAL/AUTO.", "source_not_found": "CodingBuddy: No se pudo encontrar el archivo fuente del hook. Por favor reinstale el plugin o verifique la instalación.", "permission_error": "CodingBuddy: Error de permisos - {error}", "permission_hint": "Ejecute: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py", @@ -717,6 +722,8 @@ def main(): if installed_hook or registered_settings: print(msg("installed")) print(msg("patterns")) + if installed_hook: + print(msg("restart_needed")) # Step 2.5: Install codingbuddy statusLine (#1089, #1092) try: diff --git a/packages/claude-code-plugin/hooks/test_session_start.py b/packages/claude-code-plugin/hooks/test_session_start.py index 0ea93f1b..2c8392f5 100644 --- a/packages/claude-code-plugin/hooks/test_session_start.py +++ b/packages/claude-code-plugin/hooks/test_session_start.py @@ -453,6 +453,68 @@ def test_works_when_source_has_no_lib(self): assert not (hooks_dir / "lib").exists() +class TestRestartMessage: + """Tests for restart message when hook is newly installed (#1214).""" + + def test_restart_message_key_exists_in_all_languages(self): + """Test that 'restart_needed' key exists in all language dicts.""" + for lang, messages in session_hook.MESSAGES.items(): + assert "restart_needed" in messages, ( + f"Missing 'restart_needed' in {lang} messages" + ) + + def test_restart_message_output_on_new_install(self, capsys): + """Test restart message is printed when hook is newly installed.""" + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) + hooks_dir = home / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + settings_file = home / ".claude" / "settings.json" + + # Create mock source + plugin_dir = Path(tmpdir) / "plugin" / "hooks" + plugin_dir.mkdir(parents=True) + source_file = plugin_dir / "user-prompt-submit.py" + source_file.write_text("# mock hook") + (plugin_dir / "lib").mkdir() + (plugin_dir / "lib" / "__init__.py").write_text("") + + with patch.object(session_hook, "find_plugin_source", return_value=source_file): + with patch.object(Path, "home", return_value=home): + # Simulate the main() install logic + target_file = hooks_dir / session_hook.HOOK_FILENAME + installed_hook = False + if not target_file.exists(): + session_hook._install_hook_with_lib( + source_file, hooks_dir, target_file + ) + installed_hook = True + + if installed_hook: + print(session_hook.msg("installed")) + print(session_hook.msg("patterns")) + print(session_hook.msg("restart_needed")) + + captured = capsys.readouterr() + assert "restart" in captured.out.lower() or "재시작" in captured.out or "再起動" in captured.out + + def test_no_restart_message_when_already_installed(self, capsys): + """Test no restart message when hook already exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) + hooks_dir = home / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + target_file = hooks_dir / session_hook.HOOK_FILENAME + target_file.write_text("# existing hook") + + installed_hook = not target_file.exists() # False + if installed_hook: + print(session_hook.msg("restart_needed")) + + captured = capsys.readouterr() + assert captured.out == "" + + if __name__ == "__main__": import pytest pytest.main([__file__, "-v"]) diff --git a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py index b0a6fd89..3492a4a6 100644 --- a/packages/claude-code-plugin/hooks/test_user_prompt_submit.py +++ b/packages/claude-code-plugin/hooks/test_user_prompt_submit.py @@ -115,34 +115,33 @@ def test_handles_mixed_case(self): class TestMainFunction: """Integration tests for the main hook function.""" - def test_outputs_context_when_plan_detected(self): - """Test that self-contained mode instructions are output when PLAN keyword is detected.""" - hook_path = Path(__file__).parent / "user-prompt-submit.py" - input_data = json.dumps({"prompt": "PLAN: test feature"}) + def _run_hook(self, prompt, env_extra=None): + """Helper to run hook subprocess with optional env overrides.""" + import os as _os - result = subprocess.run( + hook_path = Path(__file__).parent / "user-prompt-submit.py" + input_data = json.dumps({"prompt": prompt}) + env = _os.environ.copy() + if env_extra: + env.update(env_extra) + return subprocess.run( [sys.executable, str(hook_path)], input=input_data, capture_output=True, - text=True + text=True, + env=env, ) + def test_outputs_context_when_plan_detected(self): + """Test that mode instructions are output when PLAN keyword is detected.""" + result = self._run_hook("PLAN: test feature") + assert result.returncode == 0 assert "# Mode: PLAN" in result.stdout - assert "technical-planner" in result.stdout - assert "mcp__codingbuddy__parse_mode" in result.stdout def test_no_output_when_no_keyword(self): """Test that no output when no keyword is detected.""" - hook_path = Path(__file__).parent / "user-prompt-submit.py" - input_data = json.dumps({"prompt": "Hello, how are you?"}) - - result = subprocess.run( - [sys.executable, str(hook_path)], - input=input_data, - capture_output=True, - text=True - ) + result = self._run_hook("Hello, how are you?") assert result.returncode == 0 assert result.stdout == "" @@ -150,31 +149,89 @@ def test_no_output_when_no_keyword(self): def test_handles_invalid_json(self): """Test that invalid JSON is handled gracefully.""" hook_path = Path(__file__).parent / "user-prompt-submit.py" - input_data = "not valid json" - result = subprocess.run( [sys.executable, str(hook_path)], - input=input_data, + input="not valid json", capture_output=True, - text=True + text=True, ) - assert result.returncode == 0 def test_handles_missing_prompt_field(self): """Test that missing prompt field is handled gracefully.""" - hook_path = Path(__file__).parent / "user-prompt-submit.py" - input_data = json.dumps({"other_field": "value"}) + result = self._run_hook("") + assert result.returncode == 0 - result = subprocess.run( + +class TestMcpVsStandaloneOutput: + """Tests for MCP vs standalone output branching (#1214).""" + + def _run_hook_with_home(self, prompt, home_dir): + """Run hook with a custom HOME to control MCP detection.""" + import os as _os + + hook_path = Path(__file__).parent / "user-prompt-submit.py" + input_data = json.dumps({"prompt": prompt}) + env = _os.environ.copy() + env["HOME"] = str(home_dir) + # Prevent env override from interfering + env.pop("CODINGBUDDY_RULES_DIR", None) + return subprocess.run( [sys.executable, str(hook_path)], input=input_data, capture_output=True, - text=True + text=True, + env=env, ) - assert result.returncode == 0 - assert result.stdout == "" + def test_standalone_outputs_enriched_instructions(self): + """Standalone mode: full ModeEngine output with agent info.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # No .claude/mcp.json → standalone + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + + result = self._run_hook_with_home("PLAN: test", tmpdir) + assert result.returncode == 0 + assert "# Mode: PLAN" in result.stdout + # Standalone should include full template content + assert "technical-planner" in result.stdout + + def test_mcp_outputs_minimal(self): + """MCP mode: minimal output, delegate to parse_mode.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + # Create mcp.json with codingbuddy entry → MCP mode + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + mcp_json = claude_dir / "mcp.json" + mcp_json.write_text(json.dumps({ + "mcpServers": { + "codingbuddy": {"command": "codingbuddy", "args": ["mcp"]} + } + })) + + result = self._run_hook_with_home("PLAN: test", tmpdir) + assert result.returncode == 0 + assert "# Mode: PLAN" in result.stdout + assert "mcp__codingbuddy__parse_mode" in result.stdout + # MCP mode should NOT have full template content + assert "Checklist:" not in result.stdout + + def test_no_output_without_keyword(self): + """No mode keyword → no output regardless of MCP status.""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + claude_dir = Path(tmpdir) / ".claude" + claude_dir.mkdir() + + result = self._run_hook_with_home("Hello world", tmpdir) + assert result.returncode == 0 + assert result.stdout == "" if __name__ == "__main__": diff --git a/packages/claude-code-plugin/hooks/user-prompt-submit.py b/packages/claude-code-plugin/hooks/user-prompt-submit.py index 59f7e4a6..7a081869 100644 --- a/packages/claude-code-plugin/hooks/user-prompt-submit.py +++ b/packages/claude-code-plugin/hooks/user-prompt-submit.py @@ -60,21 +60,35 @@ def main(): detected_mode = detect_mode(prompt) if detected_mode: - # Self-contained mode instructions via ModeEngine + # Ensure lib/ is importable _hooks_dir = os.path.dirname(os.path.abspath(__file__)) _lib_dir = os.path.join(_hooks_dir, "lib") if _lib_dir not in sys.path: sys.path.insert(0, _lib_dir) try: + from runtime_mode import is_mcp_available from mode_engine import ModeEngine - engine = ModeEngine() - instructions = engine.build_instructions(detected_mode) - print(instructions) + + if is_mcp_available(): + # MCP mode: minimal output, parse_mode handles the rest + print(f"# Mode: {detected_mode}") + print( + "If mcp__codingbuddy__parse_mode is available, " + "call it for enhanced features." + ) + else: + # Standalone mode: full enriched instructions + engine = ModeEngine() + instructions = engine.build_instructions(detected_mode) + print(instructions) except Exception: - # Fallback: minimal instruction if ModeEngine fails + # Fallback: minimal instruction if imports fail print(f"# Mode: {detected_mode}") - print("If mcp__codingbuddy__parse_mode is available, call it for enhanced features.") + print( + "If mcp__codingbuddy__parse_mode is available, " + "call it for enhanced features." + ) # Update HUD state with detected mode (#1090) try: