diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 102ad8a8..76f06973 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -50,7 +50,7 @@ _PLATFORM_CHOICES = [ "codex", "claude", "claude-code", "cursor", "windsurf", "zed", "continue", "opencode", "antigravity", "gemini-cli", "qwen", "kiro", "qoder", - "copilot", "copilot-cli", "all", + "copilot", "copilot-cli", "codebuddy", "all", ] @@ -243,6 +243,8 @@ def _handle_init(args: argparse.Namespace) -> None: generate_skills, inject_claude_md, inject_platform_instructions, + install_codebuddy_hooks, + install_codebuddy_skills, install_codex_hooks, install_cursor_hooks, install_gemini_cli_hooks, @@ -290,6 +292,20 @@ def _handle_init(args: argparse.Namespace) -> None: qoder_skills_dir = install_qoder_skills(repo_root) if qoder_skills_dir: print(f"Installed Qoder skills to {qoder_skills_dir}") + + # CodeBuddy Code skills (project-level .codebuddy/skills/) + if not skip_skills and target in ("codebuddy", "all"): + codebuddy_skills_dir = install_codebuddy_skills(repo_root) + print(f"Installed CodeBuddy skills in {codebuddy_skills_dir}") + + # CodeBuddy Code hooks (project-level .codebuddy/settings.json) + if not skip_hooks and target in ("codebuddy", "all"): + try: + codebuddy_settings = install_codebuddy_hooks(repo_root) + print(f"Installed CodeBuddy hooks in {codebuddy_settings}") + except Exception as exc: + logger.warning("Could not install CodeBuddy hooks: %s", exc) + if not skip_hooks and target in ("codex", "all"): hooks_path = install_codex_hooks(repo_root) print(f"Installed Codex hooks in {hooks_path}") diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 8f72daf0..114a3246 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -146,6 +146,14 @@ def _zed_settings_path() -> Path: "format": "object", "needs_type": True, }, + "codebuddy": { + "name": "CodeBuddy Code", + "config_path": lambda root: root / ".mcp.json", + "key": "mcpServers", + "detect": lambda: True, + "format": "object", + "needs_type": True, + }, } @@ -310,11 +318,28 @@ def install_platform_configs( # Workspace-level Kiro detection if "kiro" not in platforms_to_install and (repo_root / ".kiro").is_dir(): platforms_to_install["kiro"] = PLATFORMS["kiro"] + # Deduplicate platforms that share the same config_path (e.g., + # claude and codebuddy both target /.mcp.json). The first + # platform wins and represents the file in the write loop; all + # sharing platforms are credited in `configured`. + seen_paths: dict[Path, list[str]] = {} + deduped: dict[str, dict[str, Any]] = {} + for k, v in platforms_to_install.items(): + cp = v["config_path"](repo_root) + if cp in seen_paths: + seen_paths[cp].append(k) + continue + seen_paths[cp] = [k] + deduped[k] = v + platforms_to_install = deduped else: if target not in PLATFORMS: logger.error("Unknown platform: %s", target) return [] platforms_to_install = {target: PLATFORMS[target]} + # For single-platform installs, seen_paths maps the file to the + # singleton platform list — uniform with the all-branch. + seen_paths = {PLATFORMS[target]["config_path"](repo_root): [target]} configured: list[str] = [] @@ -332,13 +357,19 @@ def install_platform_configs( ) if not changed: print(f" {plat['name']}: already configured in {config_path}") - configured.append(plat["name"]) + # Credit all platforms that share this config_path + written_path = config_path + for name_key in seen_paths.get(written_path, [key]): + configured.append(PLATFORMS[name_key]["name"]) continue if dry_run: print(f" [dry-run] {plat['name']}: would write {config_path}") else: print(f" {plat['name']}: configured {config_path}") - configured.append(plat["name"]) + # Credit all platforms that share this config_path + written_path = config_path + for name_key in seen_paths.get(written_path, [key]): + configured.append(PLATFORMS[name_key]["name"]) continue # Read existing config @@ -364,7 +395,10 @@ def install_platform_configs( # Check if already present if any(isinstance(s, dict) and s.get("name") == "code-review-graph" for s in arr): print(f" {plat['name']}: already configured in {config_path}") - configured.append(plat["name"]) + # Credit all platforms that share this config_path + written_path = config_path + for name_key in seen_paths.get(written_path, [key]): + configured.append(PLATFORMS[name_key]["name"]) continue arr_entry = {"name": "code-review-graph", **server_entry} arr.append(arr_entry) @@ -375,7 +409,10 @@ def install_platform_configs( servers = {} if "code-review-graph" in servers: print(f" {plat['name']}: already configured in {config_path}") - configured.append(plat["name"]) + # Credit all platforms that share this config_path + written_path = config_path + for name_key in seen_paths.get(written_path, [key]): + configured.append(PLATFORMS[name_key]["name"]) continue servers["code-review-graph"] = server_entry existing[server_key] = servers @@ -387,7 +424,10 @@ def install_platform_configs( config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") print(f" {plat['name']}: configured {config_path}") - configured.append(plat["name"]) + # Credit all platforms that share this config_path + written_path = config_path + for name_key in seen_paths.get(written_path, [key]): + configured.append(PLATFORMS[name_key]["name"]) return configured @@ -707,42 +747,41 @@ def install_git_hook(repo_root: Path) -> Path | None: return hook_path -def install_hooks(repo_root: Path, platform: str = "claude") -> None: - """Write hooks config to platform-specific settings.json. +def _merge_hooks_into_settings( + settings_dir: Path, + settings_filename: str, + new_hooks: dict[str, Any], +) -> Path: + """Merge a hooks config dict into ``/``. - Merges new hook entries into existing settings, preserving both - non-hook configuration and user-defined hooks. A backup of the - original file is created before any modifications. + Shared by Claude (``.claude/settings.json``), Qoder + (``.qoder/settings.json``), and CodeBuddy (``.codebuddy/settings.json``) + — all three use the same hooks schema. - Args: - repo_root: Repository root directory. - platform: Target platform ("claude" or "qoder"). + - Preserves existing settings (non-hooks top-level fields) + - Creates a ``.bak`` backup before modification + - Merges hook arrays by entry equality dedup """ - if platform == "qoder": - settings_dir = repo_root / ".qoder" - else: - settings_dir = repo_root / ".claude" settings_dir.mkdir(parents=True, exist_ok=True) - settings_path = settings_dir / "settings.json" + settings_path = settings_dir / settings_filename existing: dict[str, Any] = {} if settings_path.exists(): try: existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace")) - backup_path = settings_dir / "settings.json.bak" + backup_path = settings_dir / f"{settings_filename}.bak" shutil.copy2(settings_path, backup_path) logger.info("Backed up existing settings to %s", backup_path) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read existing %s: %s", settings_path, exc) - hooks_config = generate_hooks_config(repo_root) existing_hooks = existing.get("hooks", {}) if not isinstance(existing_hooks, dict): logger.warning("Existing hooks config is not a dict; replacing with defaults") existing_hooks = {} merged_hooks = dict(existing_hooks) - for hook_name, hook_entries in hooks_config.get("hooks", {}).items(): + for hook_name, hook_entries in new_hooks.items(): if isinstance(merged_hooks.get(hook_name), list): merged_list = list(merged_hooks[hook_name]) for entry in hook_entries: @@ -756,6 +795,40 @@ def install_hooks(repo_root: Path, platform: str = "claude") -> None: settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8") logger.info("Wrote hooks config: %s", settings_path) + return settings_path + + +def install_hooks(repo_root: Path, platform: str = "claude") -> None: + """Write hooks config to platform-specific settings.json. + + Merges new hook entries into existing settings, preserving both + non-hook configuration and user-defined hooks. + + Args: + repo_root: Repository root directory. + platform: Target platform (``"claude"`` or ``"qoder"``). + """ + if platform == "qoder": + settings_dir = repo_root / ".qoder" + else: + settings_dir = repo_root / ".claude" + hooks_config = generate_hooks_config(repo_root) + _merge_hooks_into_settings(settings_dir, "settings.json", hooks_config["hooks"]) + + +def install_codebuddy_hooks(repo_root: Path) -> Path: + """Write hooks config to project-level ``.codebuddy/settings.json``. + + Schema is identical to Claude Code settings.json hooks, so we reuse + ``generate_hooks_config()`` and the shared merge helper. + + Returns: + Path to the settings.json file that was written. + """ + hooks_config = generate_hooks_config(repo_root) + return _merge_hooks_into_settings( + repo_root / ".codebuddy", "settings.json", hooks_config["hooks"], + ) def install_codex_hooks(repo_root: Path) -> Path: @@ -957,6 +1030,7 @@ def inject_claude_md(repo_root: Path) -> None: "QODER.md": ("qoder",), ".kiro/steering/code-review-graph.md": ("kiro",), ".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"), + "CODEBUDDY.md": ("codebuddy",), } @@ -1376,6 +1450,35 @@ def install_qoder_skills(repo_root: Path) -> Path | None: return None +def install_codebuddy_skills(repo_root: Path) -> Path: + """Install CodeBuddy Code skills at .codebuddy/skills//SKILL.md. + + CodeBuddy Code CLI discovers project-level skills from + ``.codebuddy/skills//SKILL.md``. The SKILL.md format (YAML + frontmatter + Markdown body) is identical to Gemini CLI and Qoder. + Reuses the shared ``_SKILLS`` dictionary for body content. + """ + skills_root = repo_root / ".codebuddy" / "skills" + skills_root.mkdir(parents=True, exist_ok=True) + + for filename, skill in _SKILLS.items(): + slug = filename.rsplit(".", 1)[0] + skill_dir = skills_root / slug + skill_dir.mkdir(parents=True, exist_ok=True) + skill_path = skill_dir / "SKILL.md" + content = ( + "---\n" + f"name: {slug}\n" + f"description: {skill['description']}\n" + "---\n\n" + f"{skill['body']}\n" + ) + skill_path.write_text(content, encoding="utf-8") + logger.info("Wrote CodeBuddy skill: %s", skill_path) + + return skills_root + + # --- OpenCode plugin --- diff --git a/tests/test_cli_install.py b/tests/test_cli_install.py index 883e305f..00909654 100644 --- a/tests/test_cli_install.py +++ b/tests/test_cli_install.py @@ -96,3 +96,58 @@ def _install_cursor_hooks(): assert called["cursor_hooks"] is True assert "Installed Cursor hooks" in out + + +def test_handle_init_codebuddy_invokes_codebuddy_installers(monkeypatch, tmp_path, capsys): + monkeypatch.setattr( + "code_review_graph.incremental.find_repo_root", + lambda: tmp_path, + ) + monkeypatch.setattr( + "code_review_graph.incremental.ensure_repo_gitignore_excludes_crg", + lambda repo_root: "created", + ) + monkeypatch.setattr( + "code_review_graph.skills.install_platform_configs", + lambda repo_root, target, dry_run=False: ["CodeBuddy Code"], + ) + + called = { + "codebuddy_skills": False, + "codebuddy_hooks": False, + "codebuddy_instructions": False, + "claude_skills": False, + } + + def _codebuddy_skills(repo_root): + called["codebuddy_skills"] = True + return repo_root / ".codebuddy" / "skills" + + def _codebuddy_hooks(repo_root): + called["codebuddy_hooks"] = True + return repo_root / ".codebuddy" / "settings.json" + + def _inject_platform_instructions(repo_root, target="all"): + if target == "codebuddy": + called["codebuddy_instructions"] = True + return ["CODEBUDDY.md"] + + monkeypatch.setattr( + "code_review_graph.skills.install_codebuddy_skills", _codebuddy_skills, + ) + monkeypatch.setattr( + "code_review_graph.skills.install_codebuddy_hooks", _codebuddy_hooks, + ) + monkeypatch.setattr( + "code_review_graph.skills.inject_platform_instructions", + _inject_platform_instructions, + ) + + args = _args(tmp_path, "codebuddy") + args.no_instructions = False + _handle_init(args) + + assert called["codebuddy_skills"] is True + assert called["codebuddy_hooks"] is True + assert called["codebuddy_instructions"] is True + assert called["claude_skills"] is False diff --git a/tests/test_skills.py b/tests/test_skills.py index 2914ca79..339568dc 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -555,6 +555,7 @@ def test_all_writes_every_file(self, tmp_path): "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md", ".github/code-review-graph.instruction.md", + "CODEBUDDY.md", } def test_default_is_all(self, tmp_path): @@ -563,6 +564,7 @@ def test_default_is_all(self, tmp_path): "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", "QODER.md", ".kiro/steering/code-review-graph.md", ".github/code-review-graph.instruction.md", + "CODEBUDDY.md", } def test_claude_writes_nothing(self, tmp_path): @@ -610,6 +612,177 @@ def test_qoder_writes_only_qoder_md(self, tmp_path): assert not (tmp_path / ".cursorrules").exists() assert not (tmp_path / ".windsurfrules").exists() + def test_codebuddy_writes_only_codebuddy_md(self, tmp_path): + updated = inject_platform_instructions(tmp_path, target="codebuddy") + assert updated == ["CODEBUDDY.md"] + assert (tmp_path / "CODEBUDDY.md").exists() + # 不污染其它平台文件 + assert not (tmp_path / "AGENTS.md").exists() + assert not (tmp_path / "GEMINI.md").exists() + assert not (tmp_path / "CLAUDE.md").exists() + + def test_codebuddy_instruction_uses_shared_marker(self, tmp_path): + inject_platform_instructions(tmp_path, target="codebuddy") + content = (tmp_path / "CODEBUDDY.md").read_text() + assert _CLAUDE_MD_SECTION_MARKER in content + assert "get_minimal_context" in content or "detect_changes" in content + + def test_codebuddy_instruction_idempotent(self, tmp_path): + first = inject_platform_instructions(tmp_path, target="codebuddy") + second = inject_platform_instructions(tmp_path, target="codebuddy") + assert first == ["CODEBUDDY.md"] + assert second == [] + content = (tmp_path / "CODEBUDDY.md").read_text() + assert content.count(_CLAUDE_MD_SECTION_MARKER) == 1 + + +class TestCodeBuddyPlatformEntry: + def test_codebuddy_in_platforms(self): + from code_review_graph.skills import PLATFORMS + assert "codebuddy" in PLATFORMS + plat = PLATFORMS["codebuddy"] + assert plat["name"] == "CodeBuddy Code" + assert plat["format"] == "object" + assert plat["key"] == "mcpServers" + assert plat["needs_type"] is True + # config_path 是 lambda,需要传 repo_root 验证 + from pathlib import Path + fake_root = Path("/tmp/fake-repo") + assert plat["config_path"](fake_root) == fake_root / ".mcp.json" + + def test_codebuddy_in_cli_choices(self): + from code_review_graph.cli import _PLATFORM_CHOICES + assert "codebuddy" in _PLATFORM_CHOICES + + +class TestInstallCodeBuddySkills: + def test_creates_codebuddy_skills_dir(self, tmp_path): + from code_review_graph.skills import install_codebuddy_skills + result = install_codebuddy_skills(tmp_path) + assert result == tmp_path / ".codebuddy" / "skills" + assert result.is_dir() + + def test_creates_four_skill_subdirs(self, tmp_path): + from code_review_graph.skills import install_codebuddy_skills + skills_dir = install_codebuddy_skills(tmp_path) + subdirs = sorted(f.name for f in skills_dir.iterdir() if f.is_dir()) + assert subdirs == [ + "debug-issue", + "explore-codebase", + "refactor-safely", + "review-changes", + ] + + def test_skill_files_have_frontmatter(self, tmp_path): + from code_review_graph.skills import install_codebuddy_skills + skills_dir = install_codebuddy_skills(tmp_path) + for subdir in skills_dir.iterdir(): + path = subdir / "SKILL.md" + assert path.is_file() + content = path.read_text() + assert content.startswith("---\n") + assert "name:" in content + assert "description:" in content + + def test_skill_content_references_graph_tools(self, tmp_path): + from code_review_graph.skills import install_codebuddy_skills + skills_dir = install_codebuddy_skills(tmp_path) + for subdir in skills_dir.iterdir(): + content = (subdir / "SKILL.md").read_text() + # 每个 skill body 都该提到 graph 工具 + assert "get_minimal_context" in content or "detect_changes" in content + + def test_install_codebuddy_skills_idempotent(self, tmp_path): + from code_review_graph.skills import install_codebuddy_skills + install_codebuddy_skills(tmp_path) + # 第二次调用不应报错 + install_codebuddy_skills(tmp_path) + assert (tmp_path / ".codebuddy" / "skills" / "explore-codebase" / "SKILL.md").is_file() + + +class TestInstallCodeBuddyHooks: + def test_creates_codebuddy_settings_with_hooks(self, tmp_path): + from code_review_graph.skills import install_codebuddy_hooks + result = install_codebuddy_hooks(tmp_path) + assert result == tmp_path / ".codebuddy" / "settings.json" + assert result.is_file() + data = json.loads(result.read_text()) + assert "hooks" in data + assert "PostToolUse" in data["hooks"] + assert "SessionStart" in data["hooks"] + + def test_hooks_have_correct_matcher(self, tmp_path): + from code_review_graph.skills import install_codebuddy_hooks + install_codebuddy_hooks(tmp_path) + data = json.loads((tmp_path / ".codebuddy" / "settings.json").read_text()) + post_tool = data["hooks"]["PostToolUse"] + assert any(entry.get("matcher") == "Edit|Write|Bash" for entry in post_tool) + + def test_preserves_existing_settings_fields(self, tmp_path): + from code_review_graph.skills import install_codebuddy_hooks + settings_dir = tmp_path / ".codebuddy" + settings_dir.mkdir(parents=True) + settings_path = settings_dir / "settings.json" + settings_path.write_text(json.dumps({ + "model": "hunyuan-pro", + "language": "简体中文", + }), encoding="utf-8") + + install_codebuddy_hooks(tmp_path) + + data = json.loads(settings_path.read_text()) + assert data["model"] == "hunyuan-pro" + assert data["language"] == "简体中文" + assert "hooks" in data + + def test_merges_with_existing_user_hooks(self, tmp_path): + from code_review_graph.skills import install_codebuddy_hooks + settings_dir = tmp_path / ".codebuddy" + settings_dir.mkdir(parents=True) + settings_path = settings_dir / "settings.json" + user_hook = { + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [{"type": "command", "command": "echo user-hook", "timeout": 5}], + } + ] + } + } + settings_path.write_text(json.dumps(user_hook), encoding="utf-8") + + install_codebuddy_hooks(tmp_path) + + data = json.loads(settings_path.read_text()) + post_tool = data["hooks"]["PostToolUse"] + # 用户原 hook 仍在 + assert len(post_tool) == 2 + commands = [h["command"] for entry in post_tool for h in entry.get("hooks", [])] + assert "echo user-hook" in commands + + def test_creates_backup_when_existing_settings(self, tmp_path): + from code_review_graph.skills import install_codebuddy_hooks + settings_dir = tmp_path / ".codebuddy" + settings_dir.mkdir(parents=True) + settings_path = settings_dir / "settings.json" + settings_path.write_text("{}", encoding="utf-8") + + install_codebuddy_hooks(tmp_path) + + assert (settings_dir / "settings.json.bak").is_file() + + def test_claude_install_hooks_still_works_after_refactor(self, tmp_path): + """Regression: install_hooks (Claude) must keep working after + _merge_hooks_into_settings extraction.""" + from code_review_graph.skills import install_hooks + install_hooks(tmp_path, platform="claude") + claude_settings = tmp_path / ".claude" / "settings.json" + assert claude_settings.is_file() + data = json.loads(claude_settings.read_text()) + assert "hooks" in data + assert "PostToolUse" in data["hooks"] + class TestInstallPlatformConfigs: @_needs_tomllib @@ -924,6 +1097,53 @@ def test_install_qoder_config(self, tmp_path): expected_cmd, _ = _detect_serve_command() assert data["mcpServers"]["code-review-graph"]["command"] == expected_cmd + def test_install_all_dedupes_shared_mcp_json(self, tmp_path): + """When two platforms share the same config_path (e.g., claude and + codebuddy both use /.mcp.json), the file must be written once + and both platform names appear in `configured`.""" + # Force both claude and codebuddy to be detected + with patch.dict( + PLATFORMS, + { + "claude": {**PLATFORMS["claude"], "detect": lambda: True}, + "codebuddy": {**PLATFORMS["codebuddy"], "detect": lambda: True}, + }, + ): + configured = install_platform_configs(tmp_path, target="all") + + # Both platforms reported as configured + assert "Claude Code" in configured + assert "CodeBuddy Code" in configured + + # But .mcp.json was written exactly once (single server entry) + mcp_path = tmp_path / ".mcp.json" + assert mcp_path.exists() + data = json.loads(mcp_path.read_text()) + assert "code-review-graph" in data["mcpServers"] + # The server entry appears only once — no duplicate writes + mcp_text = mcp_path.read_text() + # Count server-key occurrences (the "code-review-graph": entry under + # mcpServers), not the literal string elsewhere (e.g., args list). + # Before dedup, the second pass would append a second mcpServers key. + # After dedup, the dict-merge path guarantees exactly one entry. + assert mcp_text.count('"code-review-graph":') == 1 + + def test_install_all_dedupes_logs_single_write(self, tmp_path, capsys): + """Deduplicated platforms should not produce duplicate 'configured' + log lines.""" + with patch.dict( + PLATFORMS, + { + "claude": {**PLATFORMS["claude"], "detect": lambda: True}, + "codebuddy": {**PLATFORMS["codebuddy"], "detect": lambda: True}, + }, + ): + install_platform_configs(tmp_path, target="all") + out = capsys.readouterr().out + # .mcp.json path should appear exactly once in the output + mcp_path_str = str(tmp_path / ".mcp.json") + assert out.count(mcp_path_str) == 1 + class TestGeminiCLIInstall: def test_install_gemini_cli_hooks_creates_settings_and_scripts(self, tmp_path):