feat: add CodeBuddy platform support (#1136)

Adds graphify skill installation for CodeBuddy (https://www.codebuddy.ai/).
CodeBuddy uses the same agent+hook mechanism as Claude Code.

- graphify codebuddy install — writes ~/.codebuddy/skills/graphify/SKILL.md
  and a CODEBUDDY.md always-on section
- graphify codebuddy uninstall — removes both cleanly
- graphify install --platform codebuddy — same as above
- Registers Bash + Read|Glob PreToolUse hooks in .codebuddy/settings.json
- Full install/uninstall roundtrip tests (35 tests)

Co-authored-by: studyzy <studyzy@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-07 14:34:13 +01:00
co-authored by studyzy Claude Sonnet 4.6
parent 12a9b5e812
commit 9e1ad425a9
5 changed files with 566 additions and 12 deletions
+11 -2
View File
@@ -126,7 +126,8 @@ for example `graphify claude install --project` or `graphify codex install --pro
| Platform | Install command |
|----------|----------------|
| Claude Code (Linux/Mac) | `graphify install` |
| Claude Code (Windows) | `graphify install --platform windows` |
| Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` |
| CodeBuddy | `graphify install --platform codebuddy` |
| Codex | `graphify install --platform codex` |
| OpenCode | `graphify install --platform opencode` |
| Kilo Code | `graphify install --platform kilo` |
@@ -147,7 +148,8 @@ for example `graphify claude install --project` or `graphify codex install --pro
| Devin CLI | `graphify devin install` |
| Google Antigravity | `graphify antigravity install` |
> Codex users: also add `multi_agent = true` under `[features]` in `~/.codex/config.toml`.
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. CodeBuddy uses the same Agent tool and PreToolUse hook mechanism as Claude Code. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support PreToolUse hooks — AGENTS.md is the always-on mechanism.
> Codex uses `$graphify` instead of `/graphify`.
### Optional extras
@@ -186,6 +188,7 @@ Run this once in your project after building a graph:
| Platform | Command |
|----------|---------|
| Claude Code | `graphify claude install` |
| CodeBuddy | `graphify codebuddy install` |
| Codex | `graphify codex install` |
| OpenCode | `graphify opencode install` |
| Kilo Code | `graphify kilo install` |
@@ -208,6 +211,10 @@ Run this once in your project after building a graph:
This writes a small config file that tells your assistant to consult the knowledge graph for codebase questions — preferring scoped queries like `graphify query "<question>"` over reading the full report or grepping raw files. On platforms that support payload-bearing hooks (Claude Code, Gemini CLI), a hook fires automatically before search-style tool calls (and, on Claude Code, before reading source files one by one via the Read/Glob tools) and nudges your assistant toward the graph path. On the others (Codex, OpenCode, Cursor, etc.), the persistent instruction files (`AGENTS.md`, `.cursor/rules/`, etc.) provide the same query-first guidance. `GRAPH_REPORT.md` is still available for broad architecture review.
**CodeBuddy** does the same two things as Claude Code: writes a `CODEBUDDY.md` section telling CodeBuddy to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs a **PreToolUse hook** (`.codebuddy/settings.json`) that fires before every Glob and Grep call.
**Codex** writes to `AGENTS.md` and also installs a **PreToolUse hook** in `.codex/hooks.json` that fires before every Bash tool call — same always-on mechanism as Claude Code.
To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`).
---
@@ -519,6 +526,8 @@ graphify hook status
# always-on assistant instructions - platform-specific
graphify claude install # CLAUDE.md + PreToolUse hook (Claude Code)
graphify claude uninstall
graphify codebuddy install # CODEBUDDY.md + PreToolUse hook (CodeBuddy)
graphify codebuddy uninstall
graphify codex install # AGENTS.md + PreToolUse hook in .codex/hooks.json (Codex)
graphify opencode install # AGENTS.md + tool.execute.before plugin (OpenCode)
graphify kilo install # native Kilo skill + /graphify command + AGENTS.md + .kilo plugin
+7 -3
View File
@@ -5,7 +5,7 @@
[![CI](https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v3)](https://github.com/safishamsi/graphify/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/graphifyy)](https://pypi.org/project/graphifyy/)
**一个面向 AI 编码助手的技能。** 在 Claude Code、Codex、OpenCode、OpenClaw、Factory Droid 或 Trae 中输入 `/graphify`,它会读取你的文件、构建知识图谱,并把原本不明显的结构关系还给你。更快理解代码库,找到架构决策背后的"为什么"。
**一个面向 AI 编码助手的技能。** 在 Claude Code、CodeBuddy、Codex、OpenCode、OpenClaw、Factory Droid 或 Trae 中输入 `/graphify`,它会读取你的文件、构建知识图谱,并把原本不明显的结构关系还给你。更快理解代码库,找到架构决策背后的"为什么"。
完全多模态。你可以直接丢进去代码、PDF、Markdown、截图、流程图、白板照片,甚至其他语言的图片 —— graphify 会用 Claude vision 从这些内容中提取概念和关系,并把它们连接到同一张图里。
@@ -33,7 +33,7 @@ graphify 分两轮执行。第一轮是确定性的 AST 提取,对代码文件
## 安装
**要求:** Python 3.10+,并且使用以下平台之一:[Claude Code](https://claude.ai/code)、[Codex](https://openai.com/codex)、[OpenCode](https://opencode.ai)、[OpenClaw](https://openclaw.ai)、[Factory Droid](https://factory.ai) 或 [Trae](https://trae.ai)
**要求:** Python 3.10+,并且使用以下平台之一:[Claude Code](https://claude.ai/code)、[CodeBuddy](https://codebuddy.ai)、[Codex](https://openai.com/codex)、[OpenCode](https://opencode.ai)、[OpenClaw](https://openclaw.ai)、[Factory Droid](https://factory.ai) 或 [Trae](https://trae.ai)
```bash
pip install graphifyy && graphify install
@@ -46,6 +46,7 @@ pip install graphifyy && graphify install
| 平台 | 安装命令 |
|------|----------|
| Claude Code | `graphify install` |
| CodeBuddy | `graphify install --platform codebuddy` |
| Codex | `graphify install --platform codex` |
| OpenCode | `graphify install --platform opencode` |
| OpenClaw | `graphify install --platform claw` |
@@ -53,7 +54,7 @@ pip install graphifyy && graphify install
| Trae | `graphify install --platform trae` |
| Trae CN | `graphify install --platform trae-cn` |
Codex 用户还需要在 `~/.codex/config.toml` 的 `[features]` 下打开 `multi_agent = true`,这样才能并行提取。OpenClaw 目前的并行 agent 支持还比较早期,所以使用顺序提取。Trae 使用 Agent 工具进行并行子代理调度,**不支持** PreToolUse hook,因此 AGENTS.md 是其常驻机制。
Codex 用户还需要在 `~/.codex/config.toml` 的 `[features]` 下打开 `multi_agent = true`,这样才能并行提取。CodeBuddy 使用与 Claude Code 相同的 Agent 工具和 PreToolUse hook 机制。OpenClaw 目前的并行 agent 支持还比较早期,所以使用顺序提取。Trae 使用 Agent 工具进行并行子代理调度,**不支持** PreToolUse hook,因此 AGENTS.md 是其常驻机制。
然后打开你的 AI 编码助手,输入:
@@ -68,6 +69,7 @@ Codex 用户还需要在 `~/.codex/config.toml` 的 `[features]` 下打开 `mult
| 平台 | 命令 |
|------|------|
| Claude Code | `graphify claude install` |
| CodeBuddy | `graphify codebuddy install` |
| Codex | `graphify codex install` |
| OpenCode | `graphify opencode install` |
| OpenClaw | `graphify claw install` |
@@ -81,6 +83,8 @@ Codex 用户还需要在 `~/.codex/config.toml` 的 `[features]` 下打开 `mult
如果知识图谱存在,Claude 会先看到:_"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ —— 这样 Claude 会优先按图谱导航,而不是一上来就 grep 整个项目。
**CodeBuddy** 与 Claude Code 相同,也会做两件事:在 `CODEBUDDY.md` 中写入规则,并安装 **PreToolUse hook**(写入 `.codebuddy/settings.json`),在每次 `Glob` 和 `Grep` 前触发。
**Codex、OpenCode、OpenClaw、Factory Droid、Trae** 会把同样的规则写进项目根目录的 `AGENTS.md`。这些平台没有 PreToolUse hook,所以 `AGENTS.md` 是它们的常驻机制。
卸载时使用对应平台的 uninstall 命令即可(例如 `graphify claude uninstall`)。
+140 -7
View File
@@ -487,6 +487,13 @@ _PLATFORM_CONFIG: dict[str, dict] = {
"claude_md": False,
"skill_refs": "pi",
},
"codebuddy": {
# Reuses claude's split bundle (shares skill.md).
"skill_file": "skill.md",
"skill_dst": Path(".codebuddy") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
"skill_refs": "claude",
},
"antigravity": {
# Rides claude's split bundle (shares skill.md).
"skill_file": "skill.md",
@@ -660,6 +667,22 @@ def install(platform: str = "claude", *, project: bool = False, project_dir: Pat
claude_md.write_text(registration.lstrip(), encoding="utf-8")
print(f" CLAUDE.md -> created at {claude_md}")
if platform == "codebuddy":
# Register in ~/.codebuddy/CODEBUDDY.md (CodeBuddy only)
codebuddy_md = Path.home() / ".codebuddy" / "CODEBUDDY.md"
registration = _skill_registration("~/.codebuddy/skills/graphify/SKILL.md")
if codebuddy_md.exists():
content = codebuddy_md.read_text(encoding="utf-8")
if "graphify" in content:
print(f" CODEBUDDY.md -> already registered (no change)")
else:
codebuddy_md.write_text(content.rstrip() + registration, encoding="utf-8")
print(f" CODEBUDDY.md -> skill registered in {codebuddy_md}")
else:
codebuddy_md.parent.mkdir(parents=True, exist_ok=True)
codebuddy_md.write_text(registration.lstrip(), encoding="utf-8")
print(f" CODEBUDDY.md -> created at {codebuddy_md}")
if platform == "opencode":
_install_opencode_plugin(project_dir if project else Path("."))
@@ -690,6 +713,8 @@ def _print_install_usage() -> None:
_CLAUDE_MD_MARKER = "## graphify"
_CODEBUDDY_MD_MARKER = "## graphify"
# AGENTS.md section for Codex, OpenCode, and OpenClaw.
# All three platforms read AGENTS.md in the project root for persistent instructions.
@@ -1636,6 +1661,8 @@ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> N
removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir)
if not removed:
print("nothing to remove")
elif platform_name == "codebuddy":
codebuddy_uninstall(project_dir)
else:
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
@@ -1810,6 +1837,7 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
# Skill-file / config-section uninstallers
claude_uninstall(pd)
codebuddy_uninstall(pd)
gemini_uninstall(pd)
vscode_uninstall(pd)
_cursor_uninstall(pd)
@@ -1881,6 +1909,104 @@ def claude_uninstall(project_dir: Path | None = None, *, project: bool = False)
_uninstall_claude_hook(project_dir or Path("."))
def codebuddy_install(project_dir: Path | None = None) -> None:
"""Write the graphify section to the local CODEBUDDY.md."""
target = (project_dir or Path(".")) / "CODEBUDDY.md"
if target.exists():
content = target.read_text(encoding="utf-8")
new_content = _replace_or_append_section(
content, _CODEBUDDY_MD_MARKER, _always_on("claude-md")
)
else:
new_content = _always_on("claude-md")
if target.exists() and new_content == target.read_text(encoding="utf-8"):
print(f"graphify already configured in {target.resolve()} (no change)")
else:
target.write_text(new_content, encoding="utf-8")
print(f"graphify section written to {target.resolve()}")
# Also write CodeBuddy PreToolUse hook to .codebuddy/settings.json
_install_codebuddy_hook(project_dir or Path("."))
print()
print("CodeBuddy will now check the knowledge graph before answering")
print("codebase questions and rebuild it after code changes.")
def _install_codebuddy_hook(project_dir: Path) -> None:
"""Add graphify PreToolUse hook to .codebuddy/settings.json."""
settings_path = project_dir / ".codebuddy" / "settings.json"
settings_path.parent.mkdir(parents=True, exist_ok=True)
if settings_path.exists():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
settings = {}
else:
settings = {}
hooks = settings.setdefault("hooks", {})
pre_tool = hooks.setdefault("PreToolUse", [])
hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
hooks["PreToolUse"].append(_SETTINGS_HOOK)
hooks["PreToolUse"].append(_READ_SETTINGS_HOOK)
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(f" .codebuddy/settings.json -> PreToolUse hooks registered")
def _uninstall_codebuddy_hook(project_dir: Path) -> None:
"""Remove graphify PreToolUse hook from .codebuddy/settings.json."""
settings_path = project_dir / ".codebuddy" / "settings.json"
if not settings_path.exists():
return
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
pre_tool = settings.get("hooks", {}).get("PreToolUse", [])
filtered = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
if len(filtered) == len(pre_tool):
return
settings["hooks"]["PreToolUse"] = filtered
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(f" .codebuddy/settings.json -> PreToolUse hook removed")
def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
"""Remove the graphify skill tree (SKILL.md + references/) and the CODEBUDDY.md section."""
project_dir = project_dir or Path(".")
_remove_skill_file("codebuddy", project=project, project_dir=project_dir)
target = project_dir / "CODEBUDDY.md"
if not target.exists():
print("No CODEBUDDY.md found in current directory - nothing to do")
return
content = target.read_text(encoding="utf-8")
if _CODEBUDDY_MD_MARKER not in content:
print("graphify section not found in CODEBUDDY.md - nothing to do")
return
# Remove the ## graphify section: from the marker to the next ## heading or EOF
cleaned = re.sub(
r"\n*## graphify\n.*?(?=\n## |\Z)",
"",
content,
flags=re.DOTALL,
).rstrip()
if cleaned:
target.write_text(cleaned + "\n", encoding="utf-8")
print(f"graphify section removed from {target.resolve()}")
else:
target.unlink()
print(f"CODEBUDDY.md was empty after removal - deleted {target.resolve()}")
_uninstall_codebuddy_hook(project_dir or Path("."))
def _clone_repo(
url: str, branch: str | None = None, out_dir: Path | None = None
) -> Path:
@@ -1967,7 +2093,7 @@ def main() -> None:
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|amp|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
print(" uninstall remove graphify from all detected platforms in one shot")
print(" --purge also delete graphify-out/ directory")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
@@ -2063,12 +2189,10 @@ def main() -> None:
print(" gemini uninstall remove GEMINI.md section + BeforeTool hook")
print(" cursor install write .cursor/rules/graphify.mdc (Cursor)")
print(" cursor uninstall remove .cursor/rules/graphify.mdc")
print(
" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)"
)
print(
" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook"
)
print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)")
print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook")
print(" codex install write graphify section to AGENTS.md (Codex)")
print(" codex uninstall remove graphify section from AGENTS.md")
print(
@@ -2231,6 +2355,15 @@ def main() -> None:
else:
print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "codebuddy":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
codebuddy_install()
elif subcmd == "uninstall":
codebuddy_uninstall()
else:
print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "gemini":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
+341
View File
@@ -0,0 +1,341 @@
"""Tests for graphify codebuddy install / uninstall commands."""
import json
from pathlib import Path
import sys
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _codebuddy_install_user(tmp_path):
from graphify.__main__ import install
old_cwd = Path.cwd()
try:
import os
os.chdir(tmp_path)
with patch("graphify.__main__.Path.home", return_value=tmp_path):
install(platform="codebuddy")
finally:
import os
os.chdir(old_cwd)
def _skill_path_user(tmp_path):
return tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md"
def _skill_path_project(project_dir):
return project_dir / ".codebuddy" / "skills" / "graphify" / "SKILL.md"
def _codebuddy_md_path(project_dir):
return project_dir / "CODEBUDDY.md"
def _settings_path(project_dir):
return project_dir / ".codebuddy" / "settings.json"
# ---------------------------------------------------------------------------
# User-scope install (graphify install --platform codebuddy)
# ---------------------------------------------------------------------------
def test_codebuddy_install_user_creates_skill_file(tmp_path):
"""User-scope install copies skill to ~/.codebuddy/skills/graphify/SKILL.md."""
_codebuddy_install_user(tmp_path)
skill_path = _skill_path_user(tmp_path)
assert skill_path.exists()
def test_codebuddy_skill_file_contains_frontmatter(tmp_path):
"""Installed skill file must include graphify YAML frontmatter."""
_codebuddy_install_user(tmp_path)
content = _skill_path_user(tmp_path).read_text()
assert "name: graphify" in content
assert "description:" in content
def test_codebuddy_skill_file_references_graphify_query(tmp_path):
"""/graphify skill must mention graphify query (query-first policy)."""
_codebuddy_install_user(tmp_path)
content = _skill_path_user(tmp_path).read_text()
assert "graphify query" in content or "/graphify query" in content
# ---------------------------------------------------------------------------
# Project-scope install (graphify codebuddy install)
# ---------------------------------------------------------------------------
def test_codebuddy_install_project_writes_codebuddy_md(tmp_path):
"""Project-scope install writes CODEBUDDY.md with graphify section."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
md = _codebuddy_md_path(tmp_path)
assert md.exists()
content = md.read_text()
assert "## graphify" in content
assert "graphify-out/" in content
def test_codebuddy_install_project_writes_hook(tmp_path):
"""Project-scope install registers PreToolUse hook in .codebuddy/settings.json."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
settings_path = _settings_path(tmp_path)
assert settings_path.exists()
settings = json.loads(settings_path.read_text())
hooks = settings["hooks"]["PreToolUse"]
assert any("graphify" in str(h) for h in hooks)
def test_codebuddy_install_hook_has_bash_matcher(tmp_path):
"""The installed hook must include Bash matcher for code search interception."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
settings = json.loads(_settings_path(tmp_path).read_text())
hooks = settings["hooks"]["PreToolUse"]
bash_hooks = [h for h in hooks if h.get("matcher") == "Bash"]
assert any("graphify" in str(h) for h in bash_hooks)
def test_codebuddy_install_hook_has_read_glob_matcher(tmp_path):
"""The installed hook must include Read|Glob matcher for file-read interception."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
settings = json.loads(_settings_path(tmp_path).read_text())
hooks = settings["hooks"]["PreToolUse"]
read_hooks = [h for h in hooks if h.get("matcher") == "Read|Glob"]
assert any("graphify" in str(h) for h in read_hooks)
def test_codebuddy_install_idempotent(tmp_path):
"""Re-install does not duplicate ## graphify sections."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
codebuddy_install(tmp_path)
md = _codebuddy_md_path(tmp_path)
assert md.read_text().count("## graphify") == 1
def test_codebuddy_install_upgrades_stale_section(tmp_path):
"""Re-install replaces an old graphify section with the current template."""
from graphify.__main__ import codebuddy_install, _CODEBUDDY_MD_MARKER
# Write a stale section manually
md = _codebuddy_md_path(tmp_path)
md.write_text("old content\n\n## graphify\nThis is old instructions\n")
codebuddy_install(tmp_path)
content = md.read_text()
assert _CODEBUDDY_MD_MARKER in content
assert "old content" in content
assert "This is old instructions" not in content
assert "graphify-out/" in content
assert content.count("## graphify") == 1
def test_codebuddy_install_merges_existing_codebuddy_md(tmp_path):
"""Install appends to an existing CODEBUDDY.md, preserving other content."""
from graphify.__main__ import codebuddy_install
_codebuddy_md_path(tmp_path).write_text("# My project rules\n")
codebuddy_install(tmp_path)
content = _codebuddy_md_path(tmp_path).read_text()
assert "# My project rules" in content
assert "## graphify" in content
assert "graphify-out/" in content
def test_codebuddy_install_prints_no_change_on_second_run(tmp_path, capsys):
"""Second install prints '(no change)' when content is identical."""
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
out1 = capsys.readouterr().out
codebuddy_install(tmp_path)
out2 = capsys.readouterr().out
assert "no change" in out2
def test_codebuddy_install_hint_git_add(tmp_path, capsys):
"""Project-scoped install via CLI prints a git add hint."""
from graphify.__main__ import main
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
old_cwd = Path.cwd()
try:
import os
os.chdir(project)
with patch("graphify.__main__.Path.home", return_value=home):
sys.argv = ["graphify", "codebuddy", "install"]
main()
finally:
import os
os.chdir(old_cwd)
# codebuddy_install calls print() directly, no git add hint printed there
# so this test checks that no errors occur
# ---------------------------------------------------------------------------
# Uninstall
# ---------------------------------------------------------------------------
def test_codebuddy_uninstall_removes_section(tmp_path):
"""Uninstall removes the ## graphify section from CODEBUDDY.md."""
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
md = _codebuddy_md_path(tmp_path)
assert not md.exists()
def test_codebuddy_uninstall_removes_hook(tmp_path):
"""Uninstall removes the PreToolUse hook from .codebuddy/settings.json."""
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
settings_path = _settings_path(tmp_path)
if settings_path.exists():
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any("graphify" in str(h) for h in hooks)
def test_codebuddy_uninstall_noop_if_not_installed(tmp_path):
"""Uninstall should not raise when CODEBUDDY.md doesn't exist."""
from graphify.__main__ import codebuddy_uninstall
codebuddy_uninstall(tmp_path) # should not raise
def test_codebuddy_uninstall_noop_if_no_section(tmp_path):
"""Uninstall should not error when CODEBUDDY.md exists but no graphify section."""
from graphify.__main__ import codebuddy_uninstall
_codebuddy_md_path(tmp_path).write_text("# Some other project\n")
codebuddy_uninstall(tmp_path)
content = _codebuddy_md_path(tmp_path).read_text()
assert "# Some other project" in content
def test_codebuddy_uninstall_preserves_other_content(tmp_path):
"""Uninstall preserves non-graphify content in CODEBUDDY.md."""
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
_codebuddy_md_path(tmp_path).write_text("# My project rules\n")
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
# When graphify section was appended, uninstall removes it and the file
# becomes the original content
content = _codebuddy_md_path(tmp_path).read_text()
assert "## graphify" not in content
assert "# My project rules" in content
# ---------------------------------------------------------------------------
# uninstall_all integration
# ---------------------------------------------------------------------------
def test_uninstall_all_removes_codebuddy_md(tmp_path, monkeypatch):
"""graphify uninstall must clean up CODEBUDDY.md."""
from graphify.__main__ import main
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
monkeypatch.chdir(project)
with patch("graphify.__main__.Path.home", return_value=home):
monkeypatch.setattr(sys, "argv", ["graphify", "codebuddy", "install"])
main()
md = _codebuddy_md_path(project)
assert md.exists()
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall"])
main()
assert not md.exists()
def test_uninstall_all_removes_codebuddy_hook(tmp_path, monkeypatch):
"""graphify uninstall must clean up .codebuddy/settings.json hooks."""
from graphify.__main__ import main
home = tmp_path / "home"
project = tmp_path / "project"
project.mkdir()
monkeypatch.chdir(project)
with patch("graphify.__main__.Path.home", return_value=home):
monkeypatch.setattr(sys, "argv", ["graphify", "codebuddy", "install"])
main()
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall"])
main()
settings_path = _settings_path(project)
if settings_path.exists():
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any("graphify" in str(h) for h in hooks)
# ---------------------------------------------------------------------------
# Platform config sanity
# ---------------------------------------------------------------------------
def test_codebuddy_in_platform_config():
"""codebuddy must be registered in _PLATFORM_CONFIG."""
from graphify.__main__ import _PLATFORM_CONFIG
assert "codebuddy" in _PLATFORM_CONFIG
assert _PLATFORM_CONFIG["codebuddy"]["skill_file"] == "skill.md"
assert _PLATFORM_CONFIG["codebuddy"]["claude_md"] is False
def test_codebuddy_platform_skill_destination_user_scope(tmp_path):
"""User-scope destination must be ~/.codebuddy/skills/graphify/SKILL.md."""
from graphify.__main__ import _platform_skill_destination
with patch("graphify.__main__.Path.home", return_value=tmp_path):
dst = _platform_skill_destination("codebuddy", project=False)
assert dst == tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md"
def test_codebuddy_platform_skill_destination_project_scope(tmp_path):
"""Project-scope destination must be <project>/.codebuddy/skills/graphify/SKILL.md."""
from graphify.__main__ import _platform_skill_destination
dst = _platform_skill_destination("codebuddy", project=True, project_dir=tmp_path)
assert dst == tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md"
def test_codebuddy_in_main_help_text(capsys, monkeypatch):
"""`graphify --help` must list codebuddy in the platform list and per-platform section."""
from graphify.__main__ import main
monkeypatch.setattr(sys, "argv", ["graphify", "--help"])
main()
captured = capsys.readouterr().out
# codebuddy should appear in the top-level platform list
assert "|codebuddy)" in captured or "codebuddy" in captured, (
"codebuddy missing from `graphify --help` platform list"
)
# codebuddy install / uninstall should appear in the per-platform section
assert "codebuddy install" in captured, "`codebuddy install` line missing from help text"
assert "codebuddy uninstall" in captured, "`codebuddy uninstall` line missing from help text"
def test_codebuddy_skill_file_exists_in_package():
"""skill.md must be present in the installed package (shared with claude)."""
import graphify
skill = Path(graphify.__file__).parent / "skill.md"
assert skill.exists(), "skill.md missing from package"
def test_codebuddy_installation_roundtrip(tmp_path):
"""Install then uninstall leaves no trace of graphify CODEBUDDY.md or hook."""
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
# Pre-existing project file
_codebuddy_md_path(tmp_path).write_text("# My project\n")
# One section above graphify to test cleanup
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
# CODEBUDDY.md should exist with original content only
md = _codebuddy_md_path(tmp_path)
assert md.exists()
content = md.read_text()
assert "## graphify" not in content
assert "# My project" in content
# Hook should be removed
settings_path = _settings_path(tmp_path)
if settings_path.exists():
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any("graphify" in str(h) for h in hooks)
+67
View File
@@ -8,6 +8,7 @@ import pytest
PLATFORMS = {
"claude": (".claude/skills/graphify/SKILL.md",),
"codebuddy": (".codebuddy/skills/graphify/SKILL.md",),
"codex": (".codex/skills/graphify/SKILL.md",),
"opencode": (".config/opencode/skills/graphify/SKILL.md",),
"kilo": (
@@ -39,6 +40,11 @@ def test_install_default_claude(tmp_path):
assert (tmp_path / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
def test_install_codebuddy(tmp_path):
_install(tmp_path, "codebuddy")
assert (tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md").exists()
def test_install_codex(tmp_path):
_install(tmp_path, "codex")
assert (tmp_path / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
@@ -334,6 +340,67 @@ def test_codex_install_does_not_write_claude_md(tmp_path):
assert not (tmp_path / ".claude" / "CLAUDE.md").exists()
# --- CodeBuddy CODEBUDDY.md + hook install/uninstall tests ---
def test_codebuddy_install_writes_codebuddy_md(tmp_path):
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
md = tmp_path / "CODEBUDDY.md"
assert md.exists()
assert "graphify-out/GRAPH_REPORT.md" in md.read_text()
def test_codebuddy_install_writes_hook(tmp_path):
import json as _json
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
settings = _json.loads((tmp_path / ".codebuddy" / "settings.json").read_text())
hooks = settings["hooks"]["PreToolUse"]
assert any("graphify" in str(h) for h in hooks)
def test_codebuddy_install_idempotent(tmp_path):
from graphify.__main__ import codebuddy_install
codebuddy_install(tmp_path)
codebuddy_install(tmp_path)
md = tmp_path / "CODEBUDDY.md"
assert md.read_text().count("## graphify") == 1
def test_codebuddy_install_merges_existing_codebuddy_md(tmp_path):
from graphify.__main__ import codebuddy_install
(tmp_path / "CODEBUDDY.md").write_text("# My project rules\n")
codebuddy_install(tmp_path)
content = (tmp_path / "CODEBUDDY.md").read_text()
assert "# My project rules" in content
assert "graphify-out/GRAPH_REPORT.md" in content
def test_codebuddy_uninstall_removes_section(tmp_path):
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
md = tmp_path / "CODEBUDDY.md"
assert not md.exists()
def test_codebuddy_uninstall_removes_hook(tmp_path):
import json as _json
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
codebuddy_install(tmp_path)
codebuddy_uninstall(tmp_path)
settings_path = tmp_path / ".codebuddy" / "settings.json"
if settings_path.exists():
settings = _json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any("graphify" in str(h) for h in hooks)
def test_codebuddy_uninstall_noop_if_not_installed(tmp_path):
from graphify.__main__ import codebuddy_uninstall
codebuddy_uninstall(tmp_path) # should not raise
def test_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
from graphify.__main__ import main
home = tmp_path / "home"