diff --git a/src/agent_learner/adapters/claude.py b/src/agent_learner/adapters/claude.py index 78dbf4f..ba05d94 100644 --- a/src/agent_learner/adapters/claude.py +++ b/src/agent_learner/adapters/claude.py @@ -3,7 +3,7 @@ import shlex from pathlib import Path -from .common import ensure_dir, merge_json_file, write_text +from .common import ensure_dir, merge_json_file, upsert_hook, write_text AUTO_SESSION_LEARNING = """#!/usr/bin/env python3 @@ -146,3 +146,27 @@ def install_claude_adapter_with_scope(target_root: Path, *, scope: str = "projec def install_claude_adapter(target_root: Path) -> list[Path]: return install_claude_adapter_with_scope(target_root, scope="project") + + +# --------------------------------------------------------------------------- +# Phase 2: lightweight hook installer (mirrors hermit adapter pattern) +# --------------------------------------------------------------------------- + +_CLAUDE_HOOK_COMMAND = ( + "agent-learner process --adapter claude" + " --session-id $CLAUDE_SESSION_ID" + " --cwd $CWD" + " --model-id $CLAUDE_MODEL" + " --auto" +) + + +def install_claude_hooks(project_root: Path, *, scope: str = "project") -> Path: + """Install agent-learner Stop hook into .claude/settings.json. + + - scope="project": {project_root}/.claude/settings.json + - Idempotent: updates existing agent-learner hook if present. + - Preserves other Stop hooks. + """ + settings_path = project_root / ".claude" / "settings.json" + return upsert_hook(settings_path, "Stop", _CLAUDE_HOOK_COMMAND, hook_format="claude") diff --git a/src/agent_learner/adapters/common.py b/src/agent_learner/adapters/common.py index dc2e384..535a46b 100644 --- a/src/agent_learner/adapters/common.py +++ b/src/agent_learner/adapters/common.py @@ -61,3 +61,57 @@ def merge_lists(existing: list, incoming: list) -> list: result.append(item) seen.add(key) return result + + +def is_agent_learner_hook(hook: dict) -> bool: + """Check whether a hook entry was installed by agent-learner (both old and new formats).""" + if "agent-learner" in hook.get("command", ""): + return True + for inner in hook.get("hooks", []): + if "agent-learner" in inner.get("command", ""): + return True + return False + + +def _make_hook_entry(command: str, *, hook_format: str) -> dict: + if hook_format == "claude": + return { + "matcher": ".*", + "hooks": [{"type": "command", "command": command}], + } + return {"command": command} + + +def upsert_hook(settings_path: Path, event_name: str, command: str, *, hook_format: str = "simple") -> Path: + """Idempotently install or update an agent-learner hook in a JSON settings file. + + hook_format: + "simple" — {"command": "..."} (Hermit, Codex) + "claude" — {"matcher": ".*", "hooks": [{"type": "command", "command": "..."}]} (Claude Code) + """ + ensure_dir(settings_path.parent) + + if settings_path.exists(): + data = json.loads(settings_path.read_text(encoding="utf-8")) + else: + data = {} + + hooks = data.setdefault("hooks", {}) + event_hooks: list[dict] = hooks.setdefault(event_name, []) + + new_hook = _make_hook_entry(command, hook_format=hook_format) + replaced = False + for i, hook in enumerate(event_hooks): + if is_agent_learner_hook(hook): + event_hooks[i] = new_hook + replaced = True + break + if not replaced: + event_hooks.append(new_hook) + + hooks[event_name] = event_hooks + settings_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return settings_path