Skip to content
Open
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
22 changes: 21 additions & 1 deletion plugins/hookify/core/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,26 @@ def extract_frontmatter(content: str) -> tuple[Dict[str, Any], str]:
return frontmatter, message


def resolve_project_claude_dir() -> str:
"""Resolve the project's .claude directory for hookify rule discovery."""
project_dir = os.environ.get('CLAUDE_PROJECT_DIR')
if project_dir:
return os.path.join(project_dir, '.claude')

current_dir = os.getcwd()
while True:
candidate = os.path.join(current_dir, '.claude')
if os.path.isdir(candidate):
return candidate

parent_dir = os.path.dirname(current_dir)
if parent_dir == current_dir:
break
current_dir = parent_dir

return os.path.join(os.getcwd(), '.claude')


def load_rules(event: Optional[str] = None) -> List[Rule]:
"""Load all hookify rules from .claude directory.

Expand All @@ -207,7 +227,7 @@ def load_rules(event: Optional[str] = None) -> List[Rule]:
rules = []

# Find all hookify.*.local.md files
pattern = os.path.join('.claude', 'hookify.*.local.md')
pattern = os.path.join(resolve_project_claude_dir(), 'hookify.*.local.md')
files = glob.glob(pattern)

for file_path in files:
Expand Down
83 changes: 83 additions & 0 deletions plugins/hookify/tests/test_config_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import os
import sys
import tempfile
import unittest
from pathlib import Path


sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from hookify.core.config_loader import load_rules, resolve_project_claude_dir


RULE_MARKDOWN = """---
name: project-rule
enabled: true
event: bash
conditions:
- field: command
operator: regex_match
pattern: "rm"
action: warn
---
Warn about rm usage.
"""


class ConfigLoaderTests(unittest.TestCase):
def test_load_rules_uses_claude_project_dir_when_available(self):
with tempfile.TemporaryDirectory() as td:
project_root = Path(td)
rules_dir = project_root / ".claude"
rules_dir.mkdir()
(rules_dir / "hookify.project.local.md").write_text(RULE_MARKDOWN)
subdir = project_root / "subdir"
subdir.mkdir()

original_cwd = Path.cwd()
original_project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
try:
os.environ["CLAUDE_PROJECT_DIR"] = str(project_root)
os.chdir(subdir)
self.assertEqual(
Path(resolve_project_claude_dir()).resolve(),
rules_dir.resolve(),
)
self.assertEqual(len(load_rules("bash")), 1)
finally:
os.chdir(original_cwd)
if original_project_dir is None:
os.environ.pop("CLAUDE_PROJECT_DIR", None)
else:
os.environ["CLAUDE_PROJECT_DIR"] = original_project_dir

def test_load_rules_finds_project_root_from_nested_directory(self):
with tempfile.TemporaryDirectory() as td:
project_root = Path(td)
rules_dir = project_root / ".claude"
rules_dir.mkdir()
(rules_dir / "hookify.project.local.md").write_text(RULE_MARKDOWN)

nested_dir = project_root / "packages" / "app"
nested_dir.mkdir(parents=True)

original_cwd = Path.cwd()
original_project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
try:
os.environ.pop("CLAUDE_PROJECT_DIR", None)
os.chdir(nested_dir)
self.assertEqual(
Path(resolve_project_claude_dir()).resolve(),
rules_dir.resolve(),
)
self.assertEqual(len(load_rules("bash")), 1)
finally:
os.chdir(original_cwd)
if original_project_dir is None:
os.environ.pop("CLAUDE_PROJECT_DIR", None)
else:
os.environ["CLAUDE_PROJECT_DIR"] = original_project_dir


if __name__ == "__main__":
unittest.main()