Skip to content

Commit ea30ce3

Browse files
committed
feat(plugin): first-session restart UX and enriched standalone output (#1214)
session-start.py: - Add i18n "restart_needed" message to all 5 languages - Print restart hint when hook is newly installed so users know mode detection activates on next session user-prompt-submit.py: - Import runtime_mode.is_mcp_available() for MCP/standalone detection - MCP mode: minimal output (mode header + parse_mode hint) - Standalone mode: full enriched ModeEngine.build_instructions() output - Fallback: minimal output if imports fail Tests: - 3 new tests for restart message (i18n keys, new install output, no output when already installed) - 3 new tests for MCP vs standalone output branching
1 parent 2d5a535 commit ea30ce3

4 files changed

Lines changed: 174 additions & 34 deletions

File tree

packages/claude-code-plugin/hooks/session-start.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"en": {
4040
"installed": "CodingBuddy mode detection hook installed",
4141
"patterns": " PLAN:/ACT:/EVAL:/AUTO: patterns will be auto-detected",
42+
"restart_needed": " Restart Claude Code to activate PLAN/ACT/EVAL/AUTO mode detection.",
4243
"source_not_found": "CodingBuddy: Could not find hook source file. Please reinstall the plugin or check the installation.",
4344
"permission_error": "CodingBuddy: Permission error - {error}",
4445
"permission_hint": "Try running: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py",
@@ -48,6 +49,7 @@
4849
"ko": {
4950
"installed": "CodingBuddy 모드 감지 훅이 설치되었습니다",
5051
"patterns": " PLAN:/ACT:/EVAL:/AUTO: 패턴이 자동 감지됩니다",
52+
"restart_needed": " PLAN/ACT/EVAL/AUTO 모드 감지를 활성화하려면 Claude Code를 재시작하세요.",
5153
"source_not_found": "CodingBuddy: 훅 소스 파일을 찾을 수 없습니다. 플러그인을 재설치하거나 설치를 확인하세요.",
5254
"permission_error": "CodingBuddy: 권한 오류 - {error}",
5355
"permission_hint": "실행: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py",
@@ -57,6 +59,7 @@
5759
"ja": {
5860
"installed": "CodingBuddyモード検出フックがインストールされました",
5961
"patterns": " PLAN:/ACT:/EVAL:/AUTO: パターンが自動検出されます",
62+
"restart_needed": " PLAN/ACT/EVAL/AUTOモード検出を有効にするには、Claude Codeを再起動してください。",
6063
"source_not_found": "CodingBuddy: フックソースファイルが見つかりません。プラグインを再インストールするか、インストールを確認してください。",
6164
"permission_error": "CodingBuddy: 権限エラー - {error}",
6265
"permission_hint": "実行: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py",
@@ -66,6 +69,7 @@
6669
"zh": {
6770
"installed": "CodingBuddy模式检测钩子已安装",
6871
"patterns": " PLAN:/ACT:/EVAL:/AUTO: 模式将被自动检测",
72+
"restart_needed": " 请重启Claude Code以激活PLAN/ACT/EVAL/AUTO模式检测。",
6973
"source_not_found": "CodingBuddy: 找不到钩子源文件。请重新安装插件或检查安装。",
7074
"permission_error": "CodingBuddy: 权限错误 - {error}",
7175
"permission_hint": "执行: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py",
@@ -75,6 +79,7 @@
7579
"es": {
7680
"installed": "Hook de detección de modo CodingBuddy instalado",
7781
"patterns": " PLAN:/ACT:/EVAL:/AUTO: los patrones serán detectados automáticamente",
82+
"restart_needed": " Reinicie Claude Code para activar la detección de modos PLAN/ACT/EVAL/AUTO.",
7883
"source_not_found": "CodingBuddy: No se pudo encontrar el archivo fuente del hook. Por favor reinstale el plugin o verifique la instalación.",
7984
"permission_error": "CodingBuddy: Error de permisos - {error}",
8085
"permission_hint": "Ejecute: chmod +x ~/.claude/hooks/codingbuddy-mode-detect.py",
@@ -717,6 +722,8 @@ def main():
717722
if installed_hook or registered_settings:
718723
print(msg("installed"))
719724
print(msg("patterns"))
725+
if installed_hook:
726+
print(msg("restart_needed"))
720727

721728
# Step 2.5: Install codingbuddy statusLine (#1089, #1092)
722729
try:

packages/claude-code-plugin/hooks/test_session_start.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,68 @@ def test_works_when_source_has_no_lib(self):
453453
assert not (hooks_dir / "lib").exists()
454454

455455

456+
class TestRestartMessage:
457+
"""Tests for restart message when hook is newly installed (#1214)."""
458+
459+
def test_restart_message_key_exists_in_all_languages(self):
460+
"""Test that 'restart_needed' key exists in all language dicts."""
461+
for lang, messages in session_hook.MESSAGES.items():
462+
assert "restart_needed" in messages, (
463+
f"Missing 'restart_needed' in {lang} messages"
464+
)
465+
466+
def test_restart_message_output_on_new_install(self, capsys):
467+
"""Test restart message is printed when hook is newly installed."""
468+
with tempfile.TemporaryDirectory() as tmpdir:
469+
home = Path(tmpdir)
470+
hooks_dir = home / ".claude" / "hooks"
471+
hooks_dir.mkdir(parents=True)
472+
settings_file = home / ".claude" / "settings.json"
473+
474+
# Create mock source
475+
plugin_dir = Path(tmpdir) / "plugin" / "hooks"
476+
plugin_dir.mkdir(parents=True)
477+
source_file = plugin_dir / "user-prompt-submit.py"
478+
source_file.write_text("# mock hook")
479+
(plugin_dir / "lib").mkdir()
480+
(plugin_dir / "lib" / "__init__.py").write_text("")
481+
482+
with patch.object(session_hook, "find_plugin_source", return_value=source_file):
483+
with patch.object(Path, "home", return_value=home):
484+
# Simulate the main() install logic
485+
target_file = hooks_dir / session_hook.HOOK_FILENAME
486+
installed_hook = False
487+
if not target_file.exists():
488+
session_hook._install_hook_with_lib(
489+
source_file, hooks_dir, target_file
490+
)
491+
installed_hook = True
492+
493+
if installed_hook:
494+
print(session_hook.msg("installed"))
495+
print(session_hook.msg("patterns"))
496+
print(session_hook.msg("restart_needed"))
497+
498+
captured = capsys.readouterr()
499+
assert "restart" in captured.out.lower() or "재시작" in captured.out or "再起動" in captured.out
500+
501+
def test_no_restart_message_when_already_installed(self, capsys):
502+
"""Test no restart message when hook already exists."""
503+
with tempfile.TemporaryDirectory() as tmpdir:
504+
home = Path(tmpdir)
505+
hooks_dir = home / ".claude" / "hooks"
506+
hooks_dir.mkdir(parents=True)
507+
target_file = hooks_dir / session_hook.HOOK_FILENAME
508+
target_file.write_text("# existing hook")
509+
510+
installed_hook = not target_file.exists() # False
511+
if installed_hook:
512+
print(session_hook.msg("restart_needed"))
513+
514+
captured = capsys.readouterr()
515+
assert captured.out == ""
516+
517+
456518
if __name__ == "__main__":
457519
import pytest
458520
pytest.main([__file__, "-v"])

packages/claude-code-plugin/hooks/test_user_prompt_submit.py

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -115,66 +115,123 @@ def test_handles_mixed_case(self):
115115
class TestMainFunction:
116116
"""Integration tests for the main hook function."""
117117

118-
def test_outputs_context_when_plan_detected(self):
119-
"""Test that self-contained mode instructions are output when PLAN keyword is detected."""
120-
hook_path = Path(__file__).parent / "user-prompt-submit.py"
121-
input_data = json.dumps({"prompt": "PLAN: test feature"})
118+
def _run_hook(self, prompt, env_extra=None):
119+
"""Helper to run hook subprocess with optional env overrides."""
120+
import os as _os
122121

123-
result = subprocess.run(
122+
hook_path = Path(__file__).parent / "user-prompt-submit.py"
123+
input_data = json.dumps({"prompt": prompt})
124+
env = _os.environ.copy()
125+
if env_extra:
126+
env.update(env_extra)
127+
return subprocess.run(
124128
[sys.executable, str(hook_path)],
125129
input=input_data,
126130
capture_output=True,
127-
text=True
131+
text=True,
132+
env=env,
128133
)
129134

135+
def test_outputs_context_when_plan_detected(self):
136+
"""Test that mode instructions are output when PLAN keyword is detected."""
137+
result = self._run_hook("PLAN: test feature")
138+
130139
assert result.returncode == 0
131140
assert "# Mode: PLAN" in result.stdout
132-
assert "technical-planner" in result.stdout
133-
assert "mcp__codingbuddy__parse_mode" in result.stdout
134141

135142
def test_no_output_when_no_keyword(self):
136143
"""Test that no output when no keyword is detected."""
137-
hook_path = Path(__file__).parent / "user-prompt-submit.py"
138-
input_data = json.dumps({"prompt": "Hello, how are you?"})
139-
140-
result = subprocess.run(
141-
[sys.executable, str(hook_path)],
142-
input=input_data,
143-
capture_output=True,
144-
text=True
145-
)
144+
result = self._run_hook("Hello, how are you?")
146145

147146
assert result.returncode == 0
148147
assert result.stdout == ""
149148

150149
def test_handles_invalid_json(self):
151150
"""Test that invalid JSON is handled gracefully."""
152151
hook_path = Path(__file__).parent / "user-prompt-submit.py"
153-
input_data = "not valid json"
154-
155152
result = subprocess.run(
156153
[sys.executable, str(hook_path)],
157-
input=input_data,
154+
input="not valid json",
158155
capture_output=True,
159-
text=True
156+
text=True,
160157
)
161-
162158
assert result.returncode == 0
163159

164160
def test_handles_missing_prompt_field(self):
165161
"""Test that missing prompt field is handled gracefully."""
166-
hook_path = Path(__file__).parent / "user-prompt-submit.py"
167-
input_data = json.dumps({"other_field": "value"})
162+
result = self._run_hook("")
163+
assert result.returncode == 0
168164

169-
result = subprocess.run(
165+
166+
class TestMcpVsStandaloneOutput:
167+
"""Tests for MCP vs standalone output branching (#1214)."""
168+
169+
def _run_hook_with_home(self, prompt, home_dir):
170+
"""Run hook with a custom HOME to control MCP detection."""
171+
import os as _os
172+
173+
hook_path = Path(__file__).parent / "user-prompt-submit.py"
174+
input_data = json.dumps({"prompt": prompt})
175+
env = _os.environ.copy()
176+
env["HOME"] = str(home_dir)
177+
# Prevent env override from interfering
178+
env.pop("CODINGBUDDY_RULES_DIR", None)
179+
return subprocess.run(
170180
[sys.executable, str(hook_path)],
171181
input=input_data,
172182
capture_output=True,
173-
text=True
183+
text=True,
184+
env=env,
174185
)
175186

176-
assert result.returncode == 0
177-
assert result.stdout == ""
187+
def test_standalone_outputs_enriched_instructions(self):
188+
"""Standalone mode: full ModeEngine output with agent info."""
189+
import tempfile
190+
191+
with tempfile.TemporaryDirectory() as tmpdir:
192+
# No .claude/mcp.json → standalone
193+
claude_dir = Path(tmpdir) / ".claude"
194+
claude_dir.mkdir()
195+
196+
result = self._run_hook_with_home("PLAN: test", tmpdir)
197+
assert result.returncode == 0
198+
assert "# Mode: PLAN" in result.stdout
199+
# Standalone should include full template content
200+
assert "technical-planner" in result.stdout
201+
202+
def test_mcp_outputs_minimal(self):
203+
"""MCP mode: minimal output, delegate to parse_mode."""
204+
import tempfile
205+
206+
with tempfile.TemporaryDirectory() as tmpdir:
207+
# Create mcp.json with codingbuddy entry → MCP mode
208+
claude_dir = Path(tmpdir) / ".claude"
209+
claude_dir.mkdir()
210+
mcp_json = claude_dir / "mcp.json"
211+
mcp_json.write_text(json.dumps({
212+
"mcpServers": {
213+
"codingbuddy": {"command": "codingbuddy", "args": ["mcp"]}
214+
}
215+
}))
216+
217+
result = self._run_hook_with_home("PLAN: test", tmpdir)
218+
assert result.returncode == 0
219+
assert "# Mode: PLAN" in result.stdout
220+
assert "mcp__codingbuddy__parse_mode" in result.stdout
221+
# MCP mode should NOT have full template content
222+
assert "Checklist:" not in result.stdout
223+
224+
def test_no_output_without_keyword(self):
225+
"""No mode keyword → no output regardless of MCP status."""
226+
import tempfile
227+
228+
with tempfile.TemporaryDirectory() as tmpdir:
229+
claude_dir = Path(tmpdir) / ".claude"
230+
claude_dir.mkdir()
231+
232+
result = self._run_hook_with_home("Hello world", tmpdir)
233+
assert result.returncode == 0
234+
assert result.stdout == ""
178235

179236

180237
if __name__ == "__main__":

packages/claude-code-plugin/hooks/user-prompt-submit.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,35 @@ def main():
6060
detected_mode = detect_mode(prompt)
6161

6262
if detected_mode:
63-
# Self-contained mode instructions via ModeEngine
63+
# Ensure lib/ is importable
6464
_hooks_dir = os.path.dirname(os.path.abspath(__file__))
6565
_lib_dir = os.path.join(_hooks_dir, "lib")
6666
if _lib_dir not in sys.path:
6767
sys.path.insert(0, _lib_dir)
6868

6969
try:
70+
from runtime_mode import is_mcp_available
7071
from mode_engine import ModeEngine
71-
engine = ModeEngine()
72-
instructions = engine.build_instructions(detected_mode)
73-
print(instructions)
72+
73+
if is_mcp_available():
74+
# MCP mode: minimal output, parse_mode handles the rest
75+
print(f"# Mode: {detected_mode}")
76+
print(
77+
"If mcp__codingbuddy__parse_mode is available, "
78+
"call it for enhanced features."
79+
)
80+
else:
81+
# Standalone mode: full enriched instructions
82+
engine = ModeEngine()
83+
instructions = engine.build_instructions(detected_mode)
84+
print(instructions)
7485
except Exception:
75-
# Fallback: minimal instruction if ModeEngine fails
86+
# Fallback: minimal instruction if imports fail
7687
print(f"# Mode: {detected_mode}")
77-
print("If mcp__codingbuddy__parse_mode is available, call it for enhanced features.")
88+
print(
89+
"If mcp__codingbuddy__parse_mode is available, "
90+
"call it for enhanced features."
91+
)
7892

7993
# Update HUD state with detected mode (#1090)
8094
try:

0 commit comments

Comments
 (0)