diff --git a/packages/claude-code-plugin/hooks/lib/onboarding_tour.py b/packages/claude-code-plugin/hooks/lib/onboarding_tour.py index 9579f4a5..9a92263d 100644 --- a/packages/claude-code-plugin/hooks/lib/onboarding_tour.py +++ b/packages/claude-code-plugin/hooks/lib/onboarding_tour.py @@ -5,7 +5,7 @@ """ import os from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from buddy_renderer import ( ANSI_COLORS, @@ -164,6 +164,275 @@ def get_tour_skip_message(lang: str) -> str: _STEP_NUMBERS = {1: "\u2460", 2: "\u2461", 3: "\u2462"} +# ── Suggestion Templates (i18n) ─────────────────────────────────── + +_SUGGESTION_TEMPLATES: Dict[str, Dict[str, Dict[str, str]]] = { + "low_coverage": { + "en": { + "mode": "AUTO", + "prompt": "AUTO improve test coverage", + "reason": "Test coverage is {coverage}%", + }, + "ko": { + "mode": "AUTO", + "prompt": "AUTO 테스트 커버리지 개선", + "reason": "테스트 커버리지가 {coverage}%입니다", + }, + "ja": { + "mode": "AUTO", + "prompt": "AUTO テストカバレッジを改善", + "reason": "テストカバレッジは{coverage}%です", + }, + "zh": { + "mode": "AUTO", + "prompt": "AUTO 提高测试覆盖率", + "reason": "测试覆盖率为{coverage}%", + }, + "es": { + "mode": "AUTO", + "prompt": "AUTO mejorar cobertura de tests", + "reason": "Cobertura de tests es {coverage}%", + }, + }, + "no_coverage_with_files": { + "en": { + "mode": "PLAN", + "prompt": "PLAN add test coverage for the project", + "reason": "{file_count} source files with no test coverage data", + }, + "ko": { + "mode": "PLAN", + "prompt": "PLAN 프로젝트 테스트 커버리지 추가", + "reason": "{file_count}개 소스 파일에 테스트 커버리지 데이터 없음", + }, + "ja": { + "mode": "PLAN", + "prompt": "PLAN プロジェクトのテストカバレッジを追加", + "reason": "{file_count}個のソースファイルにテストカバレッジデータなし", + }, + "zh": { + "mode": "PLAN", + "prompt": "PLAN 添加项目测试覆盖率", + "reason": "{file_count}个源文件没有测试覆盖率数据", + }, + "es": { + "mode": "PLAN", + "prompt": "PLAN agregar cobertura de tests al proyecto", + "reason": "{file_count} archivos fuente sin datos de cobertura", + }, + }, + "api_endpoints": { + "en": { + "mode": "EVAL", + "prompt": "EVAL review API security", + "reason": "{api_endpoints} API endpoint(s) to review", + }, + "ko": { + "mode": "EVAL", + "prompt": "EVAL API 보안 검토", + "reason": "{api_endpoints}개 API 엔드포인트 검토 필요", + }, + "ja": { + "mode": "EVAL", + "prompt": "EVAL APIセキュリティをレビュー", + "reason": "{api_endpoints}個のAPIエンドポイントをレビュー", + }, + "zh": { + "mode": "EVAL", + "prompt": "EVAL 审查API安全", + "reason": "{api_endpoints}个API端点需要审查", + }, + "es": { + "mode": "EVAL", + "prompt": "EVAL revisar seguridad de API", + "reason": "{api_endpoints} endpoint(s) de API para revisar", + }, + }, +} + +# Framework-specific suggestion templates +_FRAMEWORK_SUGGESTIONS: Dict[str, Dict[str, Dict[str, str]]] = { + "Next.js": { + "en": { + "prompt": "PLAN add Server Components optimization", + "reason": "Next.js project detected", + }, + "ko": { + "prompt": "PLAN Server Components 최적화 추가", + "reason": "Next.js 프로젝트 감지됨", + }, + "ja": { + "prompt": "PLAN Server Components最適化を追加", + "reason": "Next.jsプロジェクトを検出", + }, + "zh": { + "prompt": "PLAN 添加Server Components优化", + "reason": "检测到Next.js项目", + }, + "es": { + "prompt": "PLAN agregar optimizacion de Server Components", + "reason": "Proyecto Next.js detectado", + }, + }, + "NestJS": { + "en": { + "prompt": "PLAN add API validation with class-validator", + "reason": "NestJS project detected", + }, + "ko": { + "prompt": "PLAN class-validator로 API 유효성 검증 추가", + "reason": "NestJS 프로젝트 감지됨", + }, + "ja": { + "prompt": "PLAN class-validatorでAPIバリデーションを追加", + "reason": "NestJSプロジェクトを検出", + }, + "zh": { + "prompt": "PLAN 使用class-validator添加API验证", + "reason": "检测到NestJS项目", + }, + "es": { + "prompt": "PLAN agregar validacion de API con class-validator", + "reason": "Proyecto NestJS detectado", + }, + }, + "Vue": { + "en": { + "prompt": "PLAN add Composition API refactoring", + "reason": "Vue project detected", + }, + "ko": { + "prompt": "PLAN Composition API 리팩토링 추가", + "reason": "Vue 프로젝트 감지됨", + }, + "ja": { + "prompt": "PLAN Composition APIリファクタリングを追加", + "reason": "Vueプロジェクトを検出", + }, + "zh": { + "prompt": "PLAN 添加Composition API重构", + "reason": "检测到Vue项目", + }, + "es": { + "prompt": "PLAN agregar refactorizacion de Composition API", + "reason": "Proyecto Vue detectado", + }, + }, + "React": { + "en": { + "prompt": "PLAN optimize React component performance", + "reason": "React project detected", + }, + "ko": { + "prompt": "PLAN React 컴포넌트 성능 최적화", + "reason": "React 프로젝트 감지됨", + }, + "ja": { + "prompt": "PLAN Reactコンポーネントパフォーマンスを最適化", + "reason": "Reactプロジェクトを検出", + }, + "zh": { + "prompt": "PLAN 优化React组件性能", + "reason": "检测到React项目", + }, + "es": { + "prompt": "PLAN optimizar rendimiento de componentes React", + "reason": "Proyecto React detectado", + }, + }, +} + +_GENERIC_FALLBACK: Dict[str, List[Dict[str, str]]] = { + "en": [ + {"mode": "PLAN", "prompt": "PLAN add user authentication", "reason": "Common starting point for new projects"}, + ], + "ko": [ + {"mode": "PLAN", "prompt": "PLAN 사용자 인증 추가", "reason": "새 프로젝트의 일반적인 시작점"}, + ], + "ja": [ + {"mode": "PLAN", "prompt": "PLAN ユーザー認証を追加", "reason": "新規プロジェクトの一般的な出発点"}, + ], + "zh": [ + {"mode": "PLAN", "prompt": "PLAN 添加用户认证", "reason": "新项目的常见起点"}, + ], + "es": [ + {"mode": "PLAN", "prompt": "PLAN agregar autenticacion de usuario", "reason": "Punto de partida comun para nuevos proyectos"}, + ], +} + + +def generate_suggestions( + scan_result: Dict[str, Any], + language: str = "en", +) -> List[Dict[str, str]]: + """Generate project-specific prompt suggestions from scan data. + + Maps scanner findings (coverage, framework, endpoints, file count) + to mode-specific prompt templates. Falls back to generic suggestions + when scan data is insufficient. + + Args: + scan_result: Output from project_scanner.scan_project(). + language: Language code (en, ko, ja, zh, es). + + Returns: + List of suggestion dicts, each with keys: mode, prompt, reason. + """ + suggestions: List[Dict[str, str]] = [] + lang = language if language in ("en", "ko", "ja", "zh", "es") else "en" + + coverage = scan_result.get("coverage") + framework = scan_result.get("framework", "") + api_endpoints = scan_result.get("api_endpoints", 0) + file_count = scan_result.get("file_count", 0) + + # Low coverage → AUTO improve + if coverage is not None and coverage < 80: + tpl = _SUGGESTION_TEMPLATES["low_coverage"].get(lang, _SUGGESTION_TEMPLATES["low_coverage"]["en"]) + suggestions.append({ + "mode": tpl["mode"], + "prompt": tpl["prompt"], + "reason": tpl["reason"].format(coverage=coverage), + }) + + # Files exist but no coverage data → suggest adding tests + if coverage is None and file_count > 0: + tpl = _SUGGESTION_TEMPLATES["no_coverage_with_files"].get(lang, _SUGGESTION_TEMPLATES["no_coverage_with_files"]["en"]) + suggestions.append({ + "mode": tpl["mode"], + "prompt": tpl["prompt"], + "reason": tpl["reason"].format(file_count=file_count), + }) + + # Framework detected → PLAN framework-specific feature + if framework: + for fw_key, fw_tpl in _FRAMEWORK_SUGGESTIONS.items(): + if fw_key in framework: + tpl = fw_tpl.get(lang, fw_tpl["en"]) + suggestions.append({ + "mode": "PLAN", + "prompt": tpl["prompt"], + "reason": tpl["reason"], + }) + break + + # API endpoints → EVAL security review + if api_endpoints > 0: + tpl = _SUGGESTION_TEMPLATES["api_endpoints"].get(lang, _SUGGESTION_TEMPLATES["api_endpoints"]["en"]) + suggestions.append({ + "mode": tpl["mode"], + "prompt": tpl["prompt"], + "reason": tpl["reason"].format(api_endpoints=api_endpoints), + }) + + # Fallback to generic if no project-specific suggestions + if not suggestions: + fallback = _GENERIC_FALLBACK.get(lang, _GENERIC_FALLBACK["en"]) + suggestions.extend(fallback) + + return suggestions + + def _get_text(mapping: Dict[str, str], language: str) -> str: """Get localized text with English fallback.""" return mapping.get(language, mapping.get("en", "")) @@ -178,12 +447,17 @@ def _get_step(step_num: int, language: str) -> Dict[str, str]: def render_onboarding_tour( language: str = "en", buddy_config: Optional[Dict[str, str]] = None, + scan_result: Optional[Dict[str, Any]] = None, ) -> str: """Render the complete onboarding tour output. + When scan_result is provided, step examples are replaced with + project-specific prompt suggestions from generate_suggestions(). + Args: language: Language code (en, ko, ja, zh, es). buddy_config: Optional buddy customization from get_buddy_config(). + scan_result: Optional project scan data for context-aware suggestions. Returns: Formatted onboarding tour string. @@ -198,6 +472,9 @@ def render_onboarding_tour( magenta = ANSI_COLORS["magenta"] reset = ANSI_COLORS["reset"] + # Generate project-specific suggestions if scan data available + suggestions = generate_suggestions(scan_result, language) if scan_result else [] + lines = [ *render_face_banner(face, f"{cyan}{welcome}{reset}"), "", @@ -213,6 +490,14 @@ def render_onboarding_tour( body = step.get("body", "") example = step.get("example", "") + # Replace example with project-specific suggestion if available + suggestion_idx = step_num - 1 + if suggestions and suggestion_idx < len(suggestions): + s = suggestions[suggestion_idx] + example = s["prompt"] + body_suffix = f" ({s['reason']})" + body = body + body_suffix + lines.append(f"") lines.append(f" {yellow}{circled}{reset} {green}{title}{reset}") lines.append(f" {body}") diff --git a/packages/claude-code-plugin/hooks/session-start.py b/packages/claude-code-plugin/hooks/session-start.py index 7a67fb82..46f81992 100644 --- a/packages/claude-code-plugin/hooks/session-start.py +++ b/packages/claude-code-plugin/hooks/session-start.py @@ -929,6 +929,7 @@ def main(): if is_first_run() and not previous_session: tour_output = render_onboarding_tour( language=language, buddy_config=buddy_cfg, + scan_result=scan_data if scan_data else None, ) if tour_output: print(tour_output, file=sys.stderr) diff --git a/packages/claude-code-plugin/hooks/tests/test_onboarding_tour.py b/packages/claude-code-plugin/hooks/tests/test_onboarding_tour.py new file mode 100644 index 00000000..4aaef0fb --- /dev/null +++ b/packages/claude-code-plugin/hooks/tests/test_onboarding_tour.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Unit tests for onboarding_tour.py — Smart First Prompt (#1438). + +Tests generate_suggestions() and scan-aware onboarding integration. +""" +import os +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) + +from onboarding_tour import ( + generate_suggestions, + is_first_run, + mark_onboarded, + render_onboarding_tour, +) + + +# ── generate_suggestions tests ────────────────────────────────────── + + +class TestGenerateSuggestions: + """Tests for generate_suggestions mapping scan data to prompts.""" + + def test_low_coverage_suggests_auto_improve(self): + scan = {"coverage": 42, "name": "my-app"} + suggestions = generate_suggestions(scan) + assert len(suggestions) >= 1 + cov_suggestion = next( + (s for s in suggestions if "coverage" in s["reason"].lower()), None + ) + assert cov_suggestion is not None + assert cov_suggestion["mode"] == "AUTO" + assert "42%" in cov_suggestion["reason"] + + def test_high_coverage_no_coverage_suggestion(self): + scan = {"coverage": 95, "name": "my-app"} + suggestions = generate_suggestions(scan) + cov_suggestion = next( + (s for s in suggestions if "coverage" in s.get("reason", "").lower()), + None, + ) + assert cov_suggestion is None + + def test_framework_suggests_plan(self): + scan = {"framework": "Next.js 15 + TypeScript", "name": "my-app"} + suggestions = generate_suggestions(scan) + fw_suggestion = next( + (s for s in suggestions if s["mode"] == "PLAN"), None + ) + assert fw_suggestion is not None + assert "Next.js" in fw_suggestion["prompt"] or "Next.js" in fw_suggestion["reason"] + + def test_api_endpoints_suggests_eval(self): + scan = {"api_endpoints": 5, "name": "my-app"} + suggestions = generate_suggestions(scan) + api_suggestion = next( + (s for s in suggestions if "endpoint" in s["reason"].lower() + or "API" in s["reason"]), + None, + ) + assert api_suggestion is not None + assert api_suggestion["mode"] == "EVAL" + + def test_empty_scan_returns_generic_fallback(self): + scan = {} + suggestions = generate_suggestions(scan) + assert len(suggestions) >= 1 + # Fallback suggestions should still have mode and prompt + for s in suggestions: + assert "mode" in s + assert "prompt" in s + + def test_minimal_scan_returns_generic_fallback(self): + scan = {"name": "unknown"} + suggestions = generate_suggestions(scan) + assert len(suggestions) >= 1 + + def test_suggestion_structure(self): + scan = {"coverage": 50, "framework": "React 18", "api_endpoints": 3} + suggestions = generate_suggestions(scan) + for s in suggestions: + assert "mode" in s + assert s["mode"] in ("PLAN", "ACT", "AUTO", "EVAL") + assert "prompt" in s + assert "reason" in s + assert isinstance(s["prompt"], str) + assert len(s["prompt"]) > 0 + + def test_multiple_findings_produce_multiple_suggestions(self): + scan = { + "coverage": 40, + "framework": "Next.js 15", + "api_endpoints": 3, + } + suggestions = generate_suggestions(scan) + assert len(suggestions) >= 2 + + def test_localized_suggestions_ko(self): + scan = {"coverage": 42} + suggestions = generate_suggestions(scan, language="ko") + assert len(suggestions) >= 1 + # Korean suggestions should exist + cov = next( + (s for s in suggestions if "coverage" in s["reason"].lower() + or "커버리지" in s["reason"]), + None, + ) + assert cov is not None + + def test_files_without_coverage_suggests_adding_tests(self): + scan = {"file_count": 30, "name": "my-app"} + suggestions = generate_suggestions(scan) + assert len(suggestions) >= 1 + test_suggestion = next( + (s for s in suggestions if "test" in s["prompt"].lower() + or "test" in s["reason"].lower()), + None, + ) + assert test_suggestion is not None + + def test_nestjs_framework_suggestion(self): + scan = {"framework": "NestJS 10 + TypeScript"} + suggestions = generate_suggestions(scan) + fw = next((s for s in suggestions if s["mode"] == "PLAN"), None) + assert fw is not None + assert "NestJS" in fw["prompt"] or "NestJS" in fw["reason"] + + def test_vue_framework_suggestion(self): + scan = {"framework": "Vue 3 + TypeScript"} + suggestions = generate_suggestions(scan) + fw = next((s for s in suggestions if s["mode"] == "PLAN"), None) + assert fw is not None + + +# ── render_onboarding_tour integration tests ──────────────────────── + + +class TestRenderOnboardingTourWithScan: + """Tests for scan-aware onboarding tour rendering.""" + + def test_tour_with_scan_replaces_examples(self): + scan = {"coverage": 42, "framework": "React 18"} + output = render_onboarding_tour(language="en", scan_result=scan) + # Should contain project-specific content + assert "42%" in output or "coverage" in output.lower() + + def test_tour_without_scan_shows_generic(self): + output = render_onboarding_tour(language="en") + # Generic example should still appear + assert "PLAN" in output + + def test_tour_with_empty_scan_shows_generic(self): + output = render_onboarding_tour(language="en", scan_result={}) + assert "PLAN" in output + + def test_tour_with_scan_still_has_structure(self): + scan = {"coverage": 50, "api_endpoints": 3} + output = render_onboarding_tour(language="en", scan_result=scan) + # Tour structure should be preserved + assert "Welcome" in output or "welcome" in output.lower() + # Step numbers should exist + assert "\u2460" in output # circled 1 + + def test_tour_korean_with_scan(self): + scan = {"coverage": 42} + output = render_onboarding_tour(language="ko", scan_result=scan) + assert "환영" in output # Welcome in Korean + + +# ── is_first_run / mark_onboarded tests ───────────────────────────── + + +class TestFirstRunDetection: + """Tests for first-run detection and marking.""" + + def test_first_run_when_no_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch("onboarding_tour._onboarded_dir", return_value=tmpdir): + # Remove flag if exists + flag = os.path.join(tmpdir, "onboarded") + if os.path.exists(flag): + os.remove(flag) + assert is_first_run() is True + + def test_not_first_run_when_flag_exists(self): + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "onboarded").touch() + with patch("onboarding_tour._onboarded_dir", return_value=tmpdir): + assert is_first_run() is False + + def test_skip_env_var(self): + with patch.dict(os.environ, {"CODINGBUDDY_SKIP_TOUR": "1"}): + assert is_first_run() is False + + def test_mark_onboarded_creates_flag(self): + with tempfile.TemporaryDirectory() as tmpdir: + with patch("onboarding_tour._onboarded_dir", return_value=tmpdir): + mark_onboarded() + assert os.path.isfile(os.path.join(tmpdir, "onboarded")) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])