From ebf2c3893e4a184a0327f6321c2582019e77e13a Mon Sep 17 00:00:00 2001 From: Vaibhav Pareek Date: Fri, 3 Jul 2026 11:43:22 +0530 Subject: [PATCH 1/2] Add claude_hooks protocol type for centralized Claude Code hook management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new protocol type alongside the existing file_sync type. When a config repo defines a claude_hooks protocol, devctl will: 1. Copy non-yaml files from the source dir to ~/.devctl/hooks/ (hook scripts) 2. Merge the 'hooks' section from source/hooks.yaml into ~/.claude/settings.json 3. Remove the 'hooks' key from settings.json if hooks.yaml is absent (clean uninstall) This lets teams centrally manage Claude Code hooks (e.g. skill usage tracking) via a config repo. Developers get hooks installed/updated automatically on every devctl ai-kit sync — no per-developer setup needed. 11 new tests, all existing tests pass (67 total). Co-Authored-By: Claude Sonnet 4.6 --- examples/protocol.yaml | 22 +++ src/devctl/core/claude_hooks.py | 91 +++++++++++ src/devctl/core/protocol_engine.py | 5 + tests/test_claude_hooks.py | 239 +++++++++++++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 src/devctl/core/claude_hooks.py create mode 100644 tests/test_claude_hooks.py diff --git a/examples/protocol.yaml b/examples/protocol.yaml index 89462e7..6826897 100644 --- a/examples/protocol.yaml +++ b/examples/protocol.yaml @@ -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" diff --git a/src/devctl/core/claude_hooks.py b/src/devctl/core/claude_hooks.py new file mode 100644 index 0000000..35ad784 --- /dev/null +++ b/src/devctl/core/claude_hooks.py @@ -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") diff --git a/src/devctl/core/protocol_engine.py b/src/devctl/core/protocol_engine.py index 187b925..b8db7b3 100644 --- a/src/devctl/core/protocol_engine.py +++ b/src/devctl/core/protocol_engine.py @@ -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}") diff --git a/tests/test_claude_hooks.py b/tests/test_claude_hooks.py new file mode 100644 index 0000000..19577ee --- /dev/null +++ b/tests/test_claude_hooks.py @@ -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 == [] From fec3853ed8d394331b8d97392bc4d67056f669c9 Mon Sep 17 00:00:00 2001 From: Vaibhav Pareek Date: Mon, 6 Jul 2026 19:16:04 +0530 Subject: [PATCH 2/2] refactor: update git command output handling in repo_manager.py Replaced capture_output with stdout=subprocess.DEVNULL for both git pull and git clone commands to suppress output. Added --progress flag to the git clone command for better visibility during cloning operations. --- src/devctl/core/repo_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/devctl/core/repo_manager.py b/src/devctl/core/repo_manager.py index 9e39e7e..c49f6e3 100644 --- a/src/devctl/core/repo_manager.py +++ b/src/devctl/core/repo_manager.py @@ -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}")