feat(hooks): ship .aipass/hooks.json on aipass init (DPLAN-0190 Phase A)
- bootstrap.py: write hooks.json from template at init, union-merge on update (preserves user on/off), remove dead _ship_hooks/HOOKS_TO_SHIP - doctor.py: check .aipass/hooks.json presence - .aipass/project_hooks.json: base template (all 14 handlers) - .aipass/.gitignore: whitelist template so it ships in clones - +13 tests, 421 pass, seedgo 99%
This commit is contained in:
+2
-1
@@ -4,4 +4,5 @@
|
||||
!.gitignore
|
||||
!project_CLAUDE.md
|
||||
!project_global_prompt.md
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
!project_hooks.json
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"_comment": "TEMPLATE: base per-project hook config copied into new projects by `aipass init` (DPLAN-0190). Mirrors AIPass's own .aipass/hooks.json. All handlers run from $AIPASS_HOME — projects only flip enabled true/false. Use `drone @hooks enable/disable <hook>` or edit here.",
|
||||
"hooks_enabled": true,
|
||||
|
||||
"UserPromptSubmit": {
|
||||
"identity_injector": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"email_notification": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.email.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"branch_prompt": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"global_prompt": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"PreToolUse": {
|
||||
"tool_use_sound": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.tool_sound.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"
|
||||
},
|
||||
"pre_edit_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.edit_gate.handle",
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit"
|
||||
},
|
||||
"git_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
}
|
||||
},
|
||||
|
||||
"PostToolUse": {
|
||||
"auto_fix_diagnostics": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_fix.handle",
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"timeout": 45
|
||||
},
|
||||
"auto_watchdog": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle",
|
||||
"matcher": "Bash"
|
||||
}
|
||||
},
|
||||
|
||||
"SubagentStop": {
|
||||
"subagent_stop_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.subagent_gate.handle",
|
||||
"matcher": "",
|
||||
"timeout": 60
|
||||
}
|
||||
},
|
||||
|
||||
"Stop": {
|
||||
"stop_sound": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"Notification": {
|
||||
"notification_sound": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.announce.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
"PreCompact": {
|
||||
"pre_compact": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.compact.handle",
|
||||
"matcher": "",
|
||||
"timeout": 60
|
||||
},
|
||||
"pre_compact_rollover": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
### Added
|
||||
|
||||
- **Hooks activate in every project** — `aipass init` now writes
|
||||
`.aipass/hooks.json`, so new projects fire the hook engine out of the box
|
||||
(previously: no config shipped, 0 hooks fired). `aipass init update`
|
||||
union-merges the template, preserving any per-hook on/off choices the user
|
||||
made. `aipass doctor` now checks for the config's presence. Dead hook-script
|
||||
shipping (`_ship_hooks`) removed. (DPLAN-0190 Phase A)
|
||||
- **README logo** — centered logo image replaces plain `# AIPass` header.
|
||||
New `assets/logo.png` added to the repo.
|
||||
- **Registry descriptions** — all 13 branches now have one-liner descriptions
|
||||
|
||||
@@ -76,4 +76,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-05-16
|
||||
Last Updated: 2026-05-28
|
||||
|
||||
@@ -43,60 +43,6 @@ from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hooks are NOT distributed to projects. All hooks fire from provider
|
||||
# settings (~/.claude/settings.json), installed by setup.sh. Provider hooks
|
||||
# use CWD-walking patterns that work from any directory in any project.
|
||||
# Hook files are shipped as reference copies only (for debugging/inspection).
|
||||
HOOKS_TO_SHIP = [
|
||||
"branch_prompt_loader.py",
|
||||
"email_notification.py",
|
||||
"identity_injector.py",
|
||||
"pre_compact.py",
|
||||
"auto_fix_diagnostics.py",
|
||||
"pre_edit_gate.py",
|
||||
"subagent_stop_gate.py",
|
||||
]
|
||||
|
||||
|
||||
def _ship_hooks(aipass_home: str, target: Path) -> list[str]:
|
||||
"""Copy enforcement + injector hooks from AIPass install to target project.
|
||||
|
||||
Copies each hook file to {target}/.claude/hooks/. Looks for hooks in two
|
||||
locations (first match wins):
|
||||
1. {aipass_home}/.claude/hooks/ — dev install (git clone)
|
||||
2. aipass/_hooks/ — pip install (wheel-bundled)
|
||||
|
||||
Skips audio hooks. Overwrites existing files only if source content
|
||||
differs (idempotent re-sync). Returns list of files written.
|
||||
"""
|
||||
source_dir = Path(aipass_home) / ".claude" / "hooks"
|
||||
if not source_dir.is_dir():
|
||||
# Fallback: pip install bundles hooks at aipass/_hooks/ inside the package
|
||||
package_hooks = Path(__file__).resolve().parents[4] / "_hooks"
|
||||
if package_hooks.is_dir():
|
||||
source_dir = package_hooks
|
||||
else:
|
||||
logger.info("No hooks directory at %s or %s — skipping", source_dir, package_hooks)
|
||||
return []
|
||||
|
||||
dest_dir = target / ".claude" / "hooks"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
shipped: list[str] = []
|
||||
|
||||
for hook_name in HOOKS_TO_SHIP:
|
||||
src = source_dir / hook_name
|
||||
dst = dest_dir / hook_name
|
||||
if not src.exists():
|
||||
logger.info("Hook %s not found at %s — skipping", hook_name, src)
|
||||
continue
|
||||
src_content = src.read_bytes()
|
||||
if dst.exists() and dst.read_bytes() == src_content:
|
||||
continue
|
||||
shutil.copy2(src, dst)
|
||||
shipped.append(str(dst))
|
||||
|
||||
return shipped
|
||||
|
||||
|
||||
def _sanitize_name(raw: str) -> str:
|
||||
"""Sanitize a project name for use in filenames.
|
||||
@@ -201,6 +147,52 @@ def _merge_settings(existing: dict, generated: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_hooks_json(existing: dict, template: dict) -> dict:
|
||||
"""Union-merge hooks.json: preserve user enabled values, add new hooks/events."""
|
||||
merged: dict = {}
|
||||
meta_keys = {"_comment", "hooks_enabled"}
|
||||
|
||||
if "_comment" in template:
|
||||
merged["_comment"] = template["_comment"]
|
||||
elif "_comment" in existing:
|
||||
merged["_comment"] = existing["_comment"]
|
||||
|
||||
if "hooks_enabled" in existing:
|
||||
merged["hooks_enabled"] = existing["hooks_enabled"]
|
||||
elif "hooks_enabled" in template:
|
||||
merged["hooks_enabled"] = template["hooks_enabled"]
|
||||
|
||||
all_events: set[str] = set()
|
||||
for key in existing:
|
||||
if key not in meta_keys:
|
||||
all_events.add(key)
|
||||
for key in template:
|
||||
if key not in meta_keys:
|
||||
all_events.add(key)
|
||||
|
||||
for event in sorted(all_events):
|
||||
existing_hooks = existing.get(event, {})
|
||||
template_hooks = template.get(event, {})
|
||||
merged_hooks: dict = {}
|
||||
|
||||
for hook_name, hook_data in existing_hooks.items():
|
||||
merged_hooks[hook_name] = dict(hook_data)
|
||||
|
||||
for hook_name, hook_data in template_hooks.items():
|
||||
if hook_name in merged_hooks:
|
||||
user_enabled = merged_hooks[hook_name].get("enabled")
|
||||
merged_hooks[hook_name] = dict(hook_data)
|
||||
if user_enabled is not None:
|
||||
merged_hooks[hook_name]["enabled"] = user_enabled
|
||||
else:
|
||||
merged_hooks[hook_name] = dict(hook_data)
|
||||
|
||||
if merged_hooks:
|
||||
merged[event] = merged_hooks
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
"""Generate .claude/settings.json — env and permissions only.
|
||||
|
||||
@@ -338,6 +330,16 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8")
|
||||
created.append(str(global_prompt_path))
|
||||
|
||||
# 2b. .aipass/hooks.json — project hook config from template
|
||||
hooks_json_path = aipass_dir / "hooks.json"
|
||||
if not hooks_json_path.exists() and aipass_home:
|
||||
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||
if template.is_file():
|
||||
shutil.copy2(str(template), str(hooks_json_path))
|
||||
created.append(str(hooks_json_path))
|
||||
else:
|
||||
logger.info("hooks template not found at %s — skipping", template)
|
||||
|
||||
# 3-5. CLAUDE.md, AGENTS.md — project templates or AIPass source
|
||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||
dest = target / md_name
|
||||
@@ -396,11 +398,6 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
prep_path.write_text(sc.prep_md(), encoding="utf-8")
|
||||
created.append(str(prep_path))
|
||||
|
||||
# 9d. Ship enforcement + injector hooks from AIPass install
|
||||
if aipass_home:
|
||||
shipped = _ship_hooks(aipass_home, target)
|
||||
created.extend(shipped)
|
||||
|
||||
# 10. src/<project>/ package structure (pip-installable from day one)
|
||||
package_name = raw_name.lower().replace("-", "_").replace(" ", "_")
|
||||
src_dir = target / "src"
|
||||
@@ -532,6 +529,34 @@ def update_project(target: Path) -> dict:
|
||||
else:
|
||||
already_current.append(str(settings_path))
|
||||
|
||||
# hooks.json — union-merge: preserve user enabled, add new hooks from template
|
||||
hooks_json_path = aipass_dir / "hooks.json"
|
||||
hook_home = aipass_home or _detect_aipass_home()
|
||||
template_path = Path(hook_home) / ".aipass" / "project_hooks.json" if hook_home else None
|
||||
if template_path and template_path.is_file():
|
||||
template_data = json.loads(template_path.read_text(encoding="utf-8"))
|
||||
if hooks_json_path.exists():
|
||||
try:
|
||||
existing_hooks = json.loads(hooks_json_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.info("hooks.json parse failed, rebuilding: %s", exc)
|
||||
existing_hooks = {}
|
||||
merged_hooks = _merge_hooks_json(existing_hooks, template_data)
|
||||
merged_hooks_content = json.dumps(merged_hooks, indent=2, ensure_ascii=False) + "\n"
|
||||
if existing_hooks != merged_hooks:
|
||||
hooks_json_path.write_text(merged_hooks_content, encoding="utf-8")
|
||||
updated.append(str(hooks_json_path))
|
||||
else:
|
||||
already_current.append(str(hooks_json_path))
|
||||
else:
|
||||
hooks_json_path.write_text(
|
||||
json.dumps(template_data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
updated.append(str(hooks_json_path))
|
||||
elif hooks_json_path.exists():
|
||||
already_current.append(str(hooks_json_path))
|
||||
|
||||
# CLAUDE.md, AGENTS.md — sync from project templates or AIPass source
|
||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||
dest = target / md_name
|
||||
@@ -558,12 +583,6 @@ def update_project(target: Path) -> dict:
|
||||
else:
|
||||
already_current.append(str(prep_path))
|
||||
|
||||
# Re-sync enforcement + injector hooks from AIPass install
|
||||
hook_home = aipass_home or _detect_aipass_home()
|
||||
if hook_home:
|
||||
shipped = _ship_hooks(hook_home, target)
|
||||
updated.extend(shipped)
|
||||
|
||||
# --- User-owned files: always skip ---
|
||||
for skip_name in (
|
||||
str(registry_path),
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
# Modified: 2026-04-16
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass doctor — system health aggregation
|
||||
|
||||
Run: aipass doctor [--verbose] [--fix] [--fix --json]
|
||||
"""
|
||||
"""aipass doctor — system health aggregation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -60,10 +56,6 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# TYPES
|
||||
# =============================================================================
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@@ -76,9 +68,7 @@ class CheckResult(NamedTuple):
|
||||
remediation: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IDENTITY HELPERS
|
||||
# =============================================================================
|
||||
# --- Identity helpers ---
|
||||
|
||||
|
||||
def _find_registry() -> Path | None:
|
||||
@@ -97,9 +87,7 @@ def _find_registry() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHECK GROUPS
|
||||
# =============================================================================
|
||||
# --- Check groups ---
|
||||
|
||||
|
||||
def _check_system() -> List[CheckResult]:
|
||||
@@ -201,6 +189,20 @@ def _check_identity() -> List[CheckResult]:
|
||||
else:
|
||||
results.append(CheckResult("registry valid", GLYPH_FAIL, "missing 'branches' key", "Re-run 'aipass init'"))
|
||||
|
||||
# hooks.json presence (project-level hook config)
|
||||
hooks_json = reg_path.parent / ".aipass" / "hooks.json"
|
||||
if hooks_json.exists():
|
||||
results.append(CheckResult("hooks.json", GLYPH_PASS, "present", ""))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(
|
||||
"hooks.json",
|
||||
GLYPH_WARN,
|
||||
"not found",
|
||||
"Run 'aipass init update' to create .aipass/hooks.json",
|
||||
)
|
||||
)
|
||||
|
||||
# Passport readable
|
||||
passport = _BRANCH_ROOT / ".trinity" / "passport.json"
|
||||
if passport.exists():
|
||||
@@ -478,9 +480,7 @@ def _check_community() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STRUCTURE CHECK GROUP
|
||||
# =============================================================================
|
||||
# --- Structure check group ---
|
||||
|
||||
|
||||
def _check_structure() -> List[CheckResult]:
|
||||
@@ -558,9 +558,7 @@ def _check_structure() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN DOCTOR RUN
|
||||
# =============================================================================
|
||||
# --- Main doctor run ---
|
||||
|
||||
|
||||
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int:
|
||||
@@ -620,9 +618,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
return error_count
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OUTPUT FORMATTING
|
||||
# =============================================================================
|
||||
# --- Output formatting ---
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
@@ -652,9 +648,7 @@ def print_help() -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMMAND HANDLER
|
||||
# =============================================================================
|
||||
# --- Command handler ---
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
|
||||
@@ -22,6 +22,7 @@ import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_merge_hooks_json,
|
||||
_sanitize_name,
|
||||
init_project,
|
||||
update_project,
|
||||
@@ -126,7 +127,7 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
created_basenames = [Path(f).name for f in result["created_files"]]
|
||||
for f in expected_files:
|
||||
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files"
|
||||
assert len(result["created_files"]) >= 11
|
||||
assert len(result["created_files"]) >= 12
|
||||
|
||||
|
||||
def test_init_project_return_dict_structure(tmp_path):
|
||||
@@ -324,7 +325,7 @@ def test_init_project_auto_creates_target_dir(tmp_path):
|
||||
|
||||
assert target.is_dir()
|
||||
assert result["project_name"] == "NESTED"
|
||||
assert len(result["created_files"]) >= 11
|
||||
assert len(result["created_files"]) >= 12
|
||||
|
||||
|
||||
def test_init_project_defaults_name_from_directory(tmp_path):
|
||||
@@ -470,7 +471,7 @@ def test_update_project_already_current_after_init(tmp_path):
|
||||
result = update_project(target)
|
||||
|
||||
assert len(result["updated_files"]) == 0
|
||||
assert len(result["already_current"]) >= 4
|
||||
assert len(result["already_current"]) >= 5
|
||||
|
||||
|
||||
def test_update_project_idempotent(tmp_path):
|
||||
@@ -549,8 +550,8 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
||||
|
||||
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
|
||||
assert (target / ".claude" / "settings.json").exists()
|
||||
# Managed files in deleted dirs re-written (global_prompt, settings, prep)
|
||||
assert len(result["updated_files"]) == 3
|
||||
# Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep)
|
||||
assert len(result["updated_files"]) == 4
|
||||
assert len(result["already_current"]) >= 2
|
||||
|
||||
|
||||
@@ -629,7 +630,7 @@ def test_update_project_adds_aipass_home_if_missing(tmp_path):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DPLAN-0139: Hook shipping + /memo tests
|
||||
# DPLAN-0190: hooks.json + /memo tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -644,8 +645,8 @@ def test_init_project_no_memo_md(tmp_path):
|
||||
assert not memo_path.exists()
|
||||
|
||||
|
||||
def test_init_project_ships_hooks(tmp_path):
|
||||
"""init_project copies enforcement + injector hooks to target .claude/hooks/."""
|
||||
def test_init_project_creates_hooks_json(tmp_path):
|
||||
"""init_project creates .aipass/hooks.json from project_hooks.json template."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -654,16 +655,46 @@ def test_init_project_ships_hooks(tmp_path):
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
hooks_dir = target / ".claude" / "hooks"
|
||||
# Post DPLAN-0184: hooks are native handlers in src/aipass/hooks/,
|
||||
# no longer shipped as script copies. Directory may or may not exist.
|
||||
if hooks_dir.exists():
|
||||
shipped = [f.name for f in hooks_dir.iterdir()]
|
||||
assert len(shipped) == 0, f"No hook scripts should be shipped post-migration: {shipped}"
|
||||
hooks_json = target / ".aipass" / "hooks.json"
|
||||
assert hooks_json.exists(), ".aipass/hooks.json should be created"
|
||||
data = json.loads(hooks_json.read_text(encoding="utf-8"))
|
||||
assert data.get("hooks_enabled") is True
|
||||
assert "UserPromptSubmit" in data
|
||||
|
||||
|
||||
def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatch):
|
||||
"""When AIPASS_HOME is not detectable, hooks are not shipped."""
|
||||
def test_init_project_hooks_json_matches_template(tmp_path):
|
||||
"""hooks.json content matches the project_hooks.json template."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="tmpl")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
hooks_json = target / ".aipass" / "hooks.json"
|
||||
template = Path(result["aipass_home"]) / ".aipass" / "project_hooks.json"
|
||||
if not template.exists():
|
||||
pytest.skip("project_hooks.json template not found")
|
||||
|
||||
assert hooks_json.read_bytes() == template.read_bytes()
|
||||
|
||||
|
||||
def test_init_project_hooks_json_in_created_files(tmp_path):
|
||||
"""hooks.json path appears in created_files list."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="created")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
assert any("hooks.json" in f for f in result["created_files"])
|
||||
|
||||
|
||||
def test_init_project_no_hooks_json_without_aipass_home(tmp_path, monkeypatch):
|
||||
"""When AIPASS_HOME is not detectable, hooks.json is not created."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
@@ -674,55 +705,20 @@ def test_init_project_hooks_not_shipped_without_aipass_home(tmp_path, monkeypatc
|
||||
|
||||
init_project(target, project_name="nohooks")
|
||||
|
||||
hooks_dir = target / ".claude" / "hooks"
|
||||
assert not hooks_dir.exists() or len(list(hooks_dir.iterdir())) == 0
|
||||
assert not (target / ".aipass" / "hooks.json").exists()
|
||||
|
||||
|
||||
def test_init_project_no_audio_hooks_shipped(tmp_path):
|
||||
"""Audio hooks (notification_sound, tool_use_sound, stop_sound) are never shipped."""
|
||||
def test_init_project_no_hook_scripts_shipped(tmp_path):
|
||||
"""No hook scripts are shipped to .claude/hooks/ (engine runs from $AIPASS_HOME)."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
init_project(target, project_name="noaudio")
|
||||
init_project(target, project_name="noscripts")
|
||||
|
||||
hooks_dir = target / ".claude" / "hooks"
|
||||
if hooks_dir.exists():
|
||||
shipped = [f.name for f in hooks_dir.iterdir()]
|
||||
assert "notification_sound.py" not in shipped
|
||||
assert "tool_use_sound.py" not in shipped
|
||||
assert "stop_sound.py" not in shipped
|
||||
|
||||
|
||||
def test_update_project_resyncs_hooks(tmp_path):
|
||||
"""update_project re-copies hooks when source differs from target."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
result = init_project(target, project_name="resync")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
# Post DPLAN-0184: hooks are native handlers, not shipped as copies.
|
||||
# update_project no longer resyncs hook scripts.
|
||||
result = update_project(target)
|
||||
hooks_marker = str(Path(".claude") / "hooks")
|
||||
hook_paths = [f for f in result["updated_files"] if hooks_marker in f]
|
||||
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
|
||||
|
||||
|
||||
def test_init_project_hooks_idempotent_on_rerun(tmp_path):
|
||||
"""Re-running init does not create hook script copies."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result1 = init_project(target, project_name="idem")
|
||||
|
||||
if result1["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
hooks_marker = str(Path(".claude") / "hooks")
|
||||
hook_paths = [f for f in result1["created_files"] if hooks_marker in f]
|
||||
assert len(hook_paths) == 0, "No hook scripts should be shipped post-migration"
|
||||
assert len(shipped) == 0, f"No hook scripts should be shipped: {shipped}"
|
||||
|
||||
|
||||
def test_init_project_settings_has_no_hook_events(tmp_path):
|
||||
@@ -736,6 +732,130 @@ def test_init_project_settings_has_no_hook_events(tmp_path):
|
||||
assert "hooks" not in settings
|
||||
|
||||
|
||||
def test_update_project_creates_hooks_json_if_missing(tmp_path):
|
||||
"""update_project creates hooks.json from template when missing."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
registry_data = {
|
||||
"metadata": {
|
||||
"id": "test-id",
|
||||
"name": "MISS",
|
||||
"version": "1.0.0",
|
||||
"created": "2026-01-01",
|
||||
"last_updated": "2026-01-01",
|
||||
"total_branches": 0,
|
||||
},
|
||||
"branches": [],
|
||||
}
|
||||
(target / "MISS_REGISTRY.json").write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
|
||||
hooks_json = target / ".aipass" / "hooks.json"
|
||||
assert not hooks_json.exists()
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
assert hooks_json.exists()
|
||||
assert any("hooks.json" in f for f in result["updated_files"])
|
||||
data = json.loads(hooks_json.read_text(encoding="utf-8"))
|
||||
assert data.get("hooks_enabled") is True
|
||||
|
||||
|
||||
def test_update_project_union_merge_preserves_user_enabled(tmp_path):
|
||||
"""update preserves user's enabled=false for existing hooks."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
result = init_project(target, project_name="merge")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
hooks_json = target / ".aipass" / "hooks.json"
|
||||
data = json.loads(hooks_json.read_text(encoding="utf-8"))
|
||||
data["PreToolUse"]["git_gate"]["enabled"] = False
|
||||
hooks_json.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
update_project(target)
|
||||
|
||||
updated = json.loads(hooks_json.read_text(encoding="utf-8"))
|
||||
assert updated["PreToolUse"]["git_gate"]["enabled"] is False
|
||||
|
||||
|
||||
def test_update_project_union_merge_adds_new_hooks(tmp_path):
|
||||
"""update adds hooks from template that user doesn't have."""
|
||||
existing = {
|
||||
"hooks_enabled": True,
|
||||
"UserPromptSubmit": {
|
||||
"identity_injector": {"enabled": True, "handler": "old.handler", "matcher": ""},
|
||||
},
|
||||
}
|
||||
template = {
|
||||
"hooks_enabled": True,
|
||||
"UserPromptSubmit": {
|
||||
"identity_injector": {"enabled": True, "handler": "new.handler", "matcher": ""},
|
||||
"brand_new_hook": {"enabled": True, "handler": "brand.new", "matcher": ""},
|
||||
},
|
||||
"Stop": {
|
||||
"stop_sound": {"enabled": True, "handler": "stop.handler", "matcher": ""},
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_hooks_json(existing, template)
|
||||
|
||||
assert "brand_new_hook" in merged["UserPromptSubmit"]
|
||||
assert "Stop" in merged
|
||||
assert merged["Stop"]["stop_sound"]["enabled"] is True
|
||||
|
||||
|
||||
def test_update_project_union_merge_preserves_user_hooks():
|
||||
"""User's custom hooks not in template are kept."""
|
||||
existing = {
|
||||
"hooks_enabled": True,
|
||||
"UserPromptSubmit": {
|
||||
"my_custom_hook": {"enabled": True, "handler": "custom.handler", "matcher": ""},
|
||||
},
|
||||
}
|
||||
template = {
|
||||
"hooks_enabled": True,
|
||||
"UserPromptSubmit": {
|
||||
"identity_injector": {"enabled": True, "handler": "std.handler", "matcher": ""},
|
||||
},
|
||||
}
|
||||
|
||||
merged = _merge_hooks_json(existing, template)
|
||||
|
||||
assert "my_custom_hook" in merged["UserPromptSubmit"]
|
||||
assert "identity_injector" in merged["UserPromptSubmit"]
|
||||
|
||||
|
||||
def test_merge_hooks_json_preserves_hooks_enabled_false():
|
||||
"""User's hooks_enabled=false is preserved over template's true."""
|
||||
existing = {"hooks_enabled": False}
|
||||
template = {"hooks_enabled": True, "Stop": {"s": {"enabled": True, "handler": "h", "matcher": ""}}}
|
||||
|
||||
merged = _merge_hooks_json(existing, template)
|
||||
|
||||
assert merged["hooks_enabled"] is False
|
||||
|
||||
|
||||
def test_update_project_hooks_json_already_current(tmp_path):
|
||||
"""update reports hooks.json as already_current when unchanged."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
result = init_project(target, project_name="curr")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert not any("hooks.json" in f for f in result["updated_files"])
|
||||
assert any("hooks.json" in f for f in result["already_current"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scaffold_content — global_prompt_md tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -441,10 +441,8 @@ class TestProviderManifest:
|
||||
|
||||
manifest = tmp_path / ".claude" / "provider_manifest.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
cmd_a = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
|
||||
cmd_b = (
|
||||
"$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification"
|
||||
)
|
||||
cmd_a = "$AIPASS_HOME/bin/hook-bridge Stop"
|
||||
cmd_b = "$AIPASS_HOME/bin/hook-bridge Notification"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -487,7 +485,7 @@ class TestProviderManifest:
|
||||
|
||||
manifest = tmp_path / ".claude" / "provider_manifest.json"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
cmd = "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop"
|
||||
cmd = "$AIPASS_HOME/bin/hook-bridge Stop"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -573,3 +571,44 @@ class TestProviderManifest:
|
||||
result = _find_manifest()
|
||||
assert result is not None
|
||||
assert result == manifest
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestHooksJsonCheck
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestHooksJsonCheck:
|
||||
"""Tests for hooks.json presence check in _check_identity (DPLAN-0190)."""
|
||||
|
||||
def test_hooks_json_present_returns_pass(self, tmp_path) -> None:
|
||||
"""When .aipass/hooks.json exists, check returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_identity
|
||||
|
||||
registry = tmp_path / "TEST_REGISTRY.json"
|
||||
registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8")
|
||||
hooks_dir = tmp_path / ".aipass"
|
||||
hooks_dir.mkdir()
|
||||
(hooks_dir / "hooks.json").write_text('{"hooks_enabled": true}', encoding="utf-8")
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry):
|
||||
results = _check_identity()
|
||||
|
||||
hooks_results = [r for r in results if r.label == "hooks.json"]
|
||||
assert len(hooks_results) == 1
|
||||
assert hooks_results[0].glyph == GLYPH_PASS
|
||||
|
||||
def test_hooks_json_missing_returns_warn(self, tmp_path) -> None:
|
||||
"""When .aipass/hooks.json is absent, check returns WARN."""
|
||||
from aipass.aipass.apps.modules.doctor import _check_identity
|
||||
|
||||
registry = tmp_path / "TEST_REGISTRY.json"
|
||||
registry.write_text(json.dumps({"metadata": {"id": "t"}, "branches": []}), encoding="utf-8")
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor._find_registry", return_value=registry):
|
||||
results = _check_identity()
|
||||
|
||||
hooks_results = [r for r in results if r.label == "hooks.json"]
|
||||
assert len(hooks_results) == 1
|
||||
assert hooks_results[0].glyph == GLYPH_WARN
|
||||
assert "init update" in hooks_results[0].remediation
|
||||
|
||||
Reference in New Issue
Block a user