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: 22 additions & 0 deletions examples/protocol.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,25 @@ protocols:
recommendations:
- skills/debugging.md

# claude_hooks: syncs Claude Code hook definitions into ~/.claude/settings.json.
# - Non-yaml files in source dir are copied to ~/.devctl/hooks/ (for hook scripts).
# - hooks.yaml defines which hooks to install; removing it auto-uninstalls on next sync.
- name: claude-hooks
type: claude_hooks
source: hooks
target: ~/.claude/settings.json

# Example hooks/ directory layout in your config repo:
#
# hooks/
# hooks.yaml # hook definitions (managed centrally)
# skill_tracker.py # hook script copied to ~/.devctl/hooks/
#
# hooks/hooks.yaml example:
#
# hooks:
# PreToolUse:
# - matcher: Skill
# hooks:
# - type: command
# command: "python3 ~/.devctl/hooks/skill_tracker.py"
91 changes: 91 additions & 0 deletions src/devctl/core/claude_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""claude_hooks protocol: sync hook definitions into ~/.claude/settings.json."""

import json
import shutil
from pathlib import Path
from typing import Any

import yaml

from devctl.utils.logging import log_verbose
from devctl.utils.shell import get_devctl_home


_HOOKS_YAML_NAME = "hooks.yaml"


def _load_hooks_yaml(source_path: Path) -> dict[str, Any]:
hooks_file = source_path / _HOOKS_YAML_NAME
if not hooks_file.exists():
return {}
with open(hooks_file, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {}


def _load_settings(settings_path: Path) -> dict[str, Any]:
if not settings_path.exists():
return {}
with open(settings_path, encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}


def _write_settings(settings_path: Path, data: dict[str, Any]) -> None:
settings_path.parent.mkdir(parents=True, exist_ok=True)
with open(settings_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")


def _copy_scripts(source_path: Path, scripts_dest: Path) -> None:
"""Copy non-hooks.yaml files from source into scripts_dest."""
scripts_dest.mkdir(parents=True, exist_ok=True)
for item in source_path.iterdir():
if item.name == _HOOKS_YAML_NAME:
continue
if item.is_file():
dest = scripts_dest / item.name
shutil.copy2(item, dest)
log_verbose(f"Copied hook script {item.name} -> {dest}")


def sync_claude_hooks(
source_path: Path,
settings_path: Path,
scripts_dest: Path | None = None,
) -> None:
"""Sync hooks from source_path into settings_path (~/.claude/settings.json).

1. Copies non-hooks.yaml files from source_path into scripts_dest
(defaults to ~/.devctl/hooks/).
2. Reads source_path/hooks.yaml and replaces the 'hooks' key in
settings_path with its content. All other existing settings are
preserved.
3. If hooks.yaml is absent or contains no 'hooks' key, the 'hooks'
key is removed from settings_path (clean uninstall on next sync).
"""
if scripts_dest is None:
scripts_dest = get_devctl_home() / "hooks"

log_verbose(f"Syncing claude hooks: {source_path} -> {settings_path}")

_copy_scripts(source_path, scripts_dest)

hooks_data = _load_hooks_yaml(source_path)
hooks_config = hooks_data.get("hooks")

settings = _load_settings(settings_path)

if hooks_config:
log_verbose(f"Installing hooks config into {settings_path}")
settings["hooks"] = hooks_config
else:
log_verbose(f"No hooks defined — removing 'hooks' key from {settings_path}")
settings.pop("hooks", None)

_write_settings(settings_path, settings)
log_verbose("claude_hooks sync complete")
5 changes: 5 additions & 0 deletions src/devctl/core/protocol_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ def execute_protocol(

if protocol.type == "file_sync":
missing_obl, missing_rec = _file_sync(source_path, target_path, slug, do_backup)
elif protocol.type == "claude_hooks":
from devctl.core.claude_hooks import sync_claude_hooks

sync_claude_hooks(source_path, target_path)
return [], [] # claude_hooks manages its own target; no obligation checking
else:
raise ValueError(f"Unknown protocol type: {protocol.type}")

Expand Down
6 changes: 3 additions & 3 deletions src/devctl/core/repo_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ def clone_or_pull(repo_url: str) -> Path:
["git", "pull"],
cwd=repo_path,
check=True,
capture_output=True,
stdout=subprocess.DEVNULL,
)
log_verbose(f"Pulled to {repo_path}")
else:
log_verbose(f"Cloning {repo_url} -> {repo_path}")
subprocess.run(
["git", "clone", repo_url, str(repo_path)],
["git", "clone", "--progress", repo_url, str(repo_path)],
check=True,
capture_output=True,
stdout=subprocess.DEVNULL,
)
log_verbose(f"Cloned to {repo_path}")

Expand Down
239 changes: 239 additions & 0 deletions tests/test_claude_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Tests for claude_hooks protocol."""

import json
from pathlib import Path

import pytest

from devctl.core.claude_hooks import sync_claude_hooks
from devctl.core.protocol_engine import Protocol, execute_protocol


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_source(tmp_path: Path, hooks_yaml: str | None = None, scripts: dict[str, str] | None = None) -> Path:
source = tmp_path / "hooks"
source.mkdir()
if hooks_yaml is not None:
(source / "hooks.yaml").write_text(hooks_yaml)
for name, content in (scripts or {}).items():
(source / name).write_text(content)
return source


def _read_settings(path: Path) -> dict:
return json.loads(path.read_text())


# ---------------------------------------------------------------------------
# sync_claude_hooks — hooks.yaml present
# ---------------------------------------------------------------------------

def test_hooks_written_into_settings(tmp_path: Path) -> None:
"""hooks from hooks.yaml are written into settings.json."""
source = _make_source(tmp_path, hooks_yaml="""
hooks:
PreToolUse:
- matcher: Skill
hooks:
- type: command
command: "python3 ~/.devctl/hooks/tracker.py"
""")
settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert "hooks" in data
assert "PreToolUse" in data["hooks"]
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Skill"


def test_hooks_preserves_other_settings(tmp_path: Path) -> None:
"""Existing non-hooks settings are preserved after sync."""
source = _make_source(tmp_path, hooks_yaml="hooks:\n PreToolUse: []")
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"model": "claude-opus", "theme": "dark"}))
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert data["model"] == "claude-opus"
assert data["theme"] == "dark"
assert "hooks" in data


def test_hooks_replaces_previous_hooks(tmp_path: Path) -> None:
"""An updated hooks.yaml fully replaces the previous hooks section."""
source = _make_source(tmp_path, hooks_yaml="hooks:\n PostToolUse: []")
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"hooks": {"PreToolUse": [{"old": True}]}}))
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert "PostToolUse" in data["hooks"]
assert "PreToolUse" not in data["hooks"]


# ---------------------------------------------------------------------------
# sync_claude_hooks — hooks.yaml absent or empty hooks key
# ---------------------------------------------------------------------------

def test_no_hooks_yaml_removes_hooks_key(tmp_path: Path) -> None:
"""Absent hooks.yaml removes 'hooks' from existing settings (clean uninstall)."""
source = _make_source(tmp_path) # no hooks.yaml
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"hooks": {"PreToolUse": []}, "other": True}))
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert "hooks" not in data
assert data["other"] is True


def test_empty_hooks_key_removes_hooks(tmp_path: Path) -> None:
"""hooks.yaml with no 'hooks' key removes the hooks section from settings."""
source = _make_source(tmp_path, hooks_yaml="# no hooks defined")
settings = tmp_path / "settings.json"
settings.write_text(json.dumps({"hooks": {"PreToolUse": []}}))
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert "hooks" not in data


def test_no_existing_settings_file(tmp_path: Path) -> None:
"""Works when settings.json does not exist yet — creates it."""
source = _make_source(tmp_path, hooks_yaml="hooks:\n PreToolUse: []")
settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

assert settings.exists()
data = _read_settings(settings)
assert "hooks" in data


def test_corrupt_settings_treated_as_empty(tmp_path: Path) -> None:
"""Corrupt settings.json is treated as empty — hooks written cleanly."""
source = _make_source(tmp_path, hooks_yaml="hooks:\n PreToolUse: []")
settings = tmp_path / "settings.json"
settings.write_text("not valid json {{")
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

data = _read_settings(settings)
assert "hooks" in data


# ---------------------------------------------------------------------------
# sync_claude_hooks — script copying
# ---------------------------------------------------------------------------

def test_scripts_copied_to_dest(tmp_path: Path) -> None:
"""Non-yaml files in source are copied to scripts_dest."""
source = _make_source(
tmp_path,
hooks_yaml="hooks: {}",
scripts={"tracker.py": "print('tracking')", "helper.sh": "#!/bin/sh"},
)
settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

assert (scripts_dest / "tracker.py").exists()
assert (scripts_dest / "tracker.py").read_text() == "print('tracking')"
assert (scripts_dest / "helper.sh").exists()
assert not (scripts_dest / "hooks.yaml").exists() # hooks.yaml not copied


def test_hooks_yaml_not_copied_to_scripts(tmp_path: Path) -> None:
"""hooks.yaml itself is never copied into scripts_dest."""
source = _make_source(tmp_path, hooks_yaml="hooks: {}")
settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

sync_claude_hooks(source, settings, scripts_dest=scripts_dest)

assert not (scripts_dest / "hooks.yaml").exists()


# ---------------------------------------------------------------------------
# protocol_engine integration
# ---------------------------------------------------------------------------

def test_execute_protocol_claude_hooks(tmp_path: Path) -> None:
"""execute_protocol dispatches claude_hooks and returns empty lists."""
source = tmp_path / "hooks"
source.mkdir()
(source / "hooks.yaml").write_text("hooks:\n PreToolUse: []")

settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

protocol = Protocol(
name="test-hooks",
type="claude_hooks",
source="hooks",
target=str(settings),
obligations=[],
recommendations=[],
)

# Patch scripts_dest by monkeypatching get_devctl_home inside claude_hooks
import devctl.core.claude_hooks as ch
original = ch.get_devctl_home
ch.get_devctl_home = lambda: scripts_dest
try:
missing_obl, missing_rec = execute_protocol(protocol, tmp_path, "slug", do_backup=False)
finally:
ch.get_devctl_home = original

assert missing_obl == []
assert missing_rec == []
assert settings.exists()
data = json.loads(settings.read_text())
assert "hooks" in data


def test_execute_protocol_claude_hooks_obligations_ignored(tmp_path: Path) -> None:
"""Obligations/recommendations are not checked for claude_hooks (target is a file)."""
source = tmp_path / "hooks"
source.mkdir()
(source / "hooks.yaml").write_text("hooks: {}")

settings = tmp_path / "settings.json"
scripts_dest = tmp_path / "scripts"

protocol = Protocol(
name="test-hooks",
type="claude_hooks",
source="hooks",
target=str(settings),
obligations=["some/path.json"], # would fail if target-dir checked
recommendations=["other/path.md"],
)

import devctl.core.claude_hooks as ch
original = ch.get_devctl_home
ch.get_devctl_home = lambda: scripts_dest
try:
missing_obl, missing_rec = execute_protocol(protocol, tmp_path, "slug", do_backup=False)
finally:
ch.get_devctl_home = original

assert missing_obl == []
assert missing_rec == []