Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/claude-code-plugin/hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions packages/claude-code-plugin/hooks/test_session_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
113 changes: 85 additions & 28 deletions packages/claude-code-plugin/hooks/test_user_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,66 +115,123 @@ 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 == ""

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__":
Expand Down
26 changes: 20 additions & 6 deletions packages/claude-code-plugin/hooks/user-prompt-submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading