feat(system): feat(seedgo): hook bridge installer — install/uninstall AIPass hooks in settings.json (DPLAN-0141 Phase 2)
Co-Authored-By: @seedgo <seedgo@aipass>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
**Purpose:** Centralized external API gateway — authenticated service clients for all external APIs (OpenRouter, Google, future providers).
|
||||
**Module:** `aipass.api`
|
||||
**Last Updated:** 2026-04-07
|
||||
**Last Updated:** 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
@@ -47,6 +47,8 @@ drone @api stats # Display API usage statistics
|
||||
drone @api session # Show session usage data
|
||||
drone @api caller-usage <caller> # Show usage by caller module
|
||||
drone @api cleanup [days] # Clean up old usage data *(not operational — fails with no data)*
|
||||
drone @api integrations list # List registered integration contracts
|
||||
drone @api integrations call <contract> [args...] # Call a registered contract
|
||||
drone @api --help # Full help output
|
||||
drone @api --version # Show version
|
||||
```
|
||||
@@ -88,7 +90,10 @@ api/
|
||||
│ │ ├── api_key.py # Key retrieval and validation logic
|
||||
│ │ ├── openrouter_client.py # OpenRouter API client
|
||||
│ │ ├── google_client.py # Google API services (Drive, Calendar, etc.)
|
||||
│ │ └── usage_tracker.py # Usage metrics tracking
|
||||
│ │ ├── usage_tracker.py # Usage metrics tracking
|
||||
│ │ ├── bridge.py # Generic contract registry (register/resolve)
|
||||
│ │ ├── integrations_manager.py # Integration contract orchestration
|
||||
│ │ └── registry.py # Driver auto-discovery (load_drivers)
|
||||
│ └── handlers/
|
||||
│ ├── auth/
|
||||
│ │ ├── env.py # Environment variable credential loading
|
||||
@@ -106,6 +111,9 @@ api/
|
||||
│ │ ├── client.py # OpenRouter client implementation
|
||||
│ │ ├── models.py # Model discovery and listing
|
||||
│ │ └── provision.py # Provider provisioning
|
||||
│ ├── integrations/
|
||||
│ │ ├── list.py # List registered contracts handler
|
||||
│ │ └── call.py # Call registered contract handler
|
||||
│ └── usage/
|
||||
│ ├── aggregation.py # Usage data aggregation
|
||||
│ ├── cleanup.py # Usage data cleanup
|
||||
@@ -132,7 +140,7 @@ api/
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
*Last Updated: 2026-04-22*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -15,6 +15,7 @@ Bridge itself is stateless beyond the registry dict — no threading, no startup
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from aipass.prax import logger # noqa: F401
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.cli.apps.modules import console, header
|
||||
|
||||
|
||||
@@ -105,6 +105,51 @@
|
||||
"standard": "encapsulation",
|
||||
"file": "tests/test_watchdog_module.py",
|
||||
"reason": "Test file imports aipass.devpulse.apps.modules.watchdog for direct router testing (same-branch import). Encapsulation check pattern-matches on 'from aipass.*.apps.*' shape regardless of same- vs cross-branch. Inherits branch-level gap (manager branch, no apps/handlers/__init__.py inspect.stack guard)."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "apps/modules/feedback.py",
|
||||
"reason": "Same-branch import from devpulse handlers. Manager branch has no apps/handlers/__init__.py inspect.stack guard."
|
||||
},
|
||||
{
|
||||
"standard": "json_structure",
|
||||
"file": "apps/modules/feedback.py",
|
||||
"reason": "Devpulse is a manager branch with no json_handler. Feedback module logs through prax system_logger."
|
||||
},
|
||||
{
|
||||
"standard": "introspection",
|
||||
"file": "apps/modules/feedback.py",
|
||||
"reason": "Feedback is a subcommand module routed via devpulse.py handle_command(), not a standalone entry point. Introspection is handled at the devpulse.py level."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "apps/handlers/feedback/compose.py",
|
||||
"reason": "Same-branch handler import. Manager branch has no apps/handlers/__init__.py inspect.stack guard."
|
||||
},
|
||||
{
|
||||
"standard": "json_structure",
|
||||
"file": "apps/handlers/feedback/compose.py",
|
||||
"reason": "Devpulse is a manager branch with no json_handler. Compose handler logs through prax system_logger."
|
||||
},
|
||||
{
|
||||
"standard": "cli",
|
||||
"file": "apps/handlers/feedback/compose.py",
|
||||
"reason": "Feedback handlers use console.print for Rich-formatted output routed through err_console. This is the intended output path for a manager-branch utility."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "apps/handlers/feedback/inbox.py",
|
||||
"reason": "Same-branch handler import. Manager branch has no apps/handlers/__init__.py inspect.stack guard."
|
||||
},
|
||||
{
|
||||
"standard": "json_structure",
|
||||
"file": "apps/handlers/feedback/inbox.py",
|
||||
"reason": "Devpulse is a manager branch with no json_handler. Inbox handler uses Rich console output."
|
||||
},
|
||||
{
|
||||
"standard": "cli",
|
||||
"file": "apps/handlers/feedback/inbox.py",
|
||||
"reason": "Feedback handlers use console.print for Rich-formatted output routed through err_console. This is the intended output path for a manager-branch utility."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -56,7 +56,8 @@ def discover_modules() -> list[Any]:
|
||||
modules.append(module)
|
||||
loaded = True
|
||||
break
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.info(f"[DEVPULSE] Module {module_name} not found: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"[DEVPULSE] Failed to load module {module_name}: {e}")
|
||||
|
||||
@@ -15,8 +15,6 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.devpulse.apps.handlers.feedback.storage import (
|
||||
load_inbox,
|
||||
@@ -24,7 +22,9 @@ from aipass.devpulse.apps.handlers.feedback.storage import (
|
||||
generate_id,
|
||||
)
|
||||
|
||||
console = Console(stderr=True)
|
||||
from aipass.cli.apps.modules import err_console
|
||||
|
||||
console = err_console
|
||||
|
||||
# AIPass src/aipass/ directory (four levels up from compose.py)
|
||||
_AIPASS_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
@@ -10,12 +10,13 @@ Provides list, view, clear, and summary operations for
|
||||
devpulse's personal feedback mailbox.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from aipass.devpulse.apps.handlers.feedback.storage import load_inbox, save_inbox
|
||||
|
||||
console = Console(stderr=True)
|
||||
from aipass.cli.apps.modules import err_console
|
||||
|
||||
console = err_console
|
||||
|
||||
|
||||
def list_messages() -> None:
|
||||
|
||||
@@ -10,8 +10,6 @@ Auto-discovered by devpulse.py via handle_command() convention.
|
||||
Routes feedback subcommands to the appropriate handler functions.
|
||||
"""
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from aipass.devpulse.apps.handlers.feedback.inbox import (
|
||||
list_messages,
|
||||
view_message,
|
||||
@@ -25,7 +23,10 @@ from aipass.devpulse.apps.handlers.feedback.compose import (
|
||||
_resolve_sender,
|
||||
)
|
||||
|
||||
console = Console(stderr=True)
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import err_console
|
||||
|
||||
console = err_console
|
||||
|
||||
HELP_TEXT = """\
|
||||
[bold cyan]feedback[/bold cyan] — DevPulse personal feedback mailbox
|
||||
@@ -95,7 +96,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
|
||||
if subcommand == "clear":
|
||||
if not sub_args:
|
||||
console.print("[red]Usage: feedback clear <id> | feedback clear --all[/red]")
|
||||
logger.error("Usage: feedback clear <id> | feedback clear --all")
|
||||
return True
|
||||
if sub_args[0] == "--all":
|
||||
clear_all_read()
|
||||
|
||||
@@ -94,10 +94,10 @@ class PythonFileWatcher(FileSystemEventHandler):
|
||||
save_module_registry(modules)
|
||||
|
||||
# Fire trigger event for module discovery
|
||||
if _HAS_TRIGGER:
|
||||
if _HAS_TRIGGER and trigger is not None:
|
||||
try:
|
||||
trigger.fire(
|
||||
"module_discovered", # type: ignore[union-attr]
|
||||
"module_discovered",
|
||||
module_name=module_name,
|
||||
file_path=str(py_file),
|
||||
relative_path=str(relative_path),
|
||||
|
||||
@@ -408,7 +408,7 @@ class LogFileWatcher(FileSystemEventHandler):
|
||||
current_time = datetime.now()
|
||||
|
||||
# Fire trigger event for ERROR level logs
|
||||
if HAS_TRIGGER and level == "error":
|
||||
if HAS_TRIGGER and trigger is not None and level == "error":
|
||||
# Extract module name from log line if possible
|
||||
module_name = "unknown"
|
||||
if " | " in log_line:
|
||||
@@ -417,7 +417,7 @@ class LogFileWatcher(FileSystemEventHandler):
|
||||
module_name = parts[1].strip()
|
||||
|
||||
trigger.fire(
|
||||
"error_detected", # type: ignore[union-attr]
|
||||
"error_detected",
|
||||
branch=branch,
|
||||
message=clean_message,
|
||||
error_hash=_generate_error_hash(module_name, clean_message),
|
||||
|
||||
@@ -239,6 +239,26 @@
|
||||
"file": "apps/modules/permissions.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "is_trusted_caller() and identify_caller() are public API consumed by pre_edit_gate.py (hook layer) and drone auth.py. Checker cannot trace cross-file dynamic dispatch to the hook scripts."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/hooks/bridge_handler.py",
|
||||
"standard": "help_text",
|
||||
"reason": "python3 references appear in AIPASS_HOOK_MANIFEST command strings (hook definitions), not user-facing help text. Data constants, not documentation."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them in isolation. Same pattern as test_checkers_batch5.py."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_hook_bridge.py",
|
||||
"standard": "help_text",
|
||||
"reason": "python3 references appear in test data strings (hook command fixtures), not user-facing help text."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -68,6 +68,7 @@ seedgo/
|
||||
│ │ ├── checklist.py # Per-file standards checklist (hook consumption)
|
||||
│ │ ├── seedgo_proof.py # Proof orchestrator
|
||||
│ │ ├── proof_query.py # Proof content query
|
||||
│ │ ├── hook_bridge.py # Hook bridge installer (install/uninstall AIPass hooks)
|
||||
│ │ ├── hooks.py # Hook probe display and testing
|
||||
│ │ ├── hooks_ext.py # Hook test + list subcommands (split from hooks.py)
|
||||
│ │ ├── inbox_audit.py # Inbox message-ID validation (drone @seedgo audit inbox-ids)
|
||||
@@ -90,7 +91,7 @@ seedgo/
|
||||
│ ├── config/ # Configuration handlers
|
||||
│ ├── diagnostics/ # Pyright integration
|
||||
│ ├── file/ # File operations
|
||||
│ ├── hooks/ # Hook test runner (subprocess execution)
|
||||
│ ├── hooks/ # Hook test runner + bridge installer
|
||||
│ ├── json/ # JSON tracking
|
||||
│ └── test_map/ # Function test coverage scanner
|
||||
├── drone_adapter.py # Drone routing bridge
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: bridge_handler.py
|
||||
# Description: Hook bridge — settings.json I/O for hook installation
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
|
||||
"""Hook bridge handler — reads/writes Claude Code settings.json for hook management.
|
||||
|
||||
Provides AIPASS_HOME resolution, hook manifest, install/uninstall logic.
|
||||
The module layer (hook_bridge.py) handles command routing and display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AIPASS_HOME resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_aipass_home() -> Path | None:
|
||||
"""Resolve AIPASS_HOME from env var or importlib fallback."""
|
||||
env_home = os.environ.get("AIPASS_HOME")
|
||||
if env_home:
|
||||
p = Path(env_home)
|
||||
if p.is_dir():
|
||||
return p
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec("aipass")
|
||||
if spec and spec.submodule_search_locations:
|
||||
pkg_dir = Path(list(spec.submodule_search_locations)[0])
|
||||
project_root = pkg_dir.parents[1]
|
||||
if (project_root / ".aipass").exists():
|
||||
return project_root
|
||||
except Exception as exc:
|
||||
logger.info("bridge_handler: importlib fallback failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_settings(path: Path) -> dict:
|
||||
"""Read and parse settings.json. Returns {} on failure."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.warning("bridge_handler: could not read %s: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def write_settings(path: Path, data: dict) -> bool:
|
||||
"""Write settings.json with backup and pretty formatting."""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists():
|
||||
shutil.copy2(path, path.with_suffix(".json.bak"))
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("bridge_handler: could not write %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AIPASS_COMMAND_MARKERS = ("AIPass/.claude/hooks/", "$AIPASS_HOME", "aipass_global_prompt")
|
||||
|
||||
|
||||
def is_aipass_hook_entry(entry: dict) -> bool:
|
||||
"""Check if a hook entry is an AIPass hook (marked or pattern-matched)."""
|
||||
if entry.get("_aipass"):
|
||||
return True
|
||||
for hook in entry.get("hooks", []):
|
||||
cmd = hook.get("command", "")
|
||||
if any(marker in cmd for marker in _AIPASS_COMMAND_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def count_aipass_hooks(settings: dict) -> int:
|
||||
"""Count AIPass hook entries across all events."""
|
||||
count = 0
|
||||
for entries in settings.get("hooks", {}).values():
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and is_aipass_hook_entry(entry):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook manifest — the canonical set of AIPass hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AIPASS_HOOK_MANIFEST: dict[str, list[dict]] = {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "cat $AIPASS_HOME/.aipass/aipass_global_prompt.md 2>/dev/null || true",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/branch_prompt_loader.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/identity_injector.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/email_notification.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/tool_use_sound.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_edit_gate.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/auto_fix_diagnostics.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"SubagentStop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/subagent_stop_gate.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/stop_sound.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/notification_sound.py",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py",
|
||||
"timeout": 60,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_aipass": True,
|
||||
"matcher": "auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/pre_compact.py",
|
||||
"timeout": 60,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install / uninstall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_aipass_env(settings: dict, aipass_home: str) -> bool:
|
||||
"""Ensure AIPASS_HOME is in the env section. Returns True if added."""
|
||||
env = settings.setdefault("env", {})
|
||||
if "AIPASS_HOME" in env:
|
||||
return False
|
||||
env["AIPASS_HOME"] = aipass_home
|
||||
return True
|
||||
|
||||
|
||||
def install_hooks(settings: dict) -> tuple[dict, int]:
|
||||
"""Add AIPass hooks to settings. Skips events that already have AIPass entries."""
|
||||
hooks = settings.setdefault("hooks", {})
|
||||
added = 0
|
||||
for event_name, manifest_entries in AIPASS_HOOK_MANIFEST.items():
|
||||
existing = hooks.get(event_name, [])
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
has_aipass = any(is_aipass_hook_entry(e) for e in existing if isinstance(e, dict))
|
||||
if has_aipass:
|
||||
continue
|
||||
existing.extend(manifest_entries)
|
||||
hooks[event_name] = existing
|
||||
added += len(manifest_entries)
|
||||
json_handler.log_operation("bridge_install_hooks", {"added": added})
|
||||
return settings, added
|
||||
|
||||
|
||||
def uninstall_hooks(settings: dict) -> tuple[dict, int]:
|
||||
"""Remove AIPass hook entries only. Preserves all non-AIPass entries."""
|
||||
hooks = settings.get("hooks", {})
|
||||
removed = 0
|
||||
for event_name in list(hooks.keys()):
|
||||
entries = hooks[event_name]
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
filtered = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and is_aipass_hook_entry(entry):
|
||||
removed += 1
|
||||
else:
|
||||
filtered.append(entry)
|
||||
if filtered:
|
||||
hooks[event_name] = filtered
|
||||
else:
|
||||
del hooks[event_name]
|
||||
json_handler.log_operation("bridge_uninstall_hooks", {"removed": removed})
|
||||
return settings, removed
|
||||
@@ -0,0 +1,182 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: hook_bridge.py
|
||||
# Description: Hook bridge installer — adds/removes AIPass hooks in settings
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
|
||||
"""Hook bridge installer — adds/removes AIPass hooks in Claude Code settings.
|
||||
|
||||
bridge install Add AIPass hooks to ~/.claude/settings.json (idempotent)
|
||||
bridge uninstall Remove only AIPass-added hook entries
|
||||
bridge reinstall Remove + re-add (upgrade path)
|
||||
bridge status Show current installation state
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli import console
|
||||
from aipass.cli.apps.modules import error, warning
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import (
|
||||
count_aipass_hooks,
|
||||
ensure_aipass_env,
|
||||
install_hooks,
|
||||
read_settings,
|
||||
resolve_aipass_home,
|
||||
uninstall_hooks,
|
||||
write_settings,
|
||||
)
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_install() -> None:
|
||||
"""Install AIPass hooks into global settings."""
|
||||
aipass_home = resolve_aipass_home()
|
||||
if not aipass_home:
|
||||
error("Could not resolve AIPASS_HOME — set env var or install aipass package")
|
||||
return
|
||||
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
existing = count_aipass_hooks(settings)
|
||||
if existing > 0:
|
||||
warning(f"AIPass hooks already installed ({existing} entries). Use 'bridge reinstall' to replace.")
|
||||
return
|
||||
|
||||
ensure_aipass_env(settings, str(aipass_home))
|
||||
settings, added = install_hooks(settings)
|
||||
|
||||
if added == 0:
|
||||
warning("No hooks to add (all events already populated).")
|
||||
return
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Installed {added} AIPass hook entries into {SETTINGS_PATH}[/green]")
|
||||
console.print(f"[dim]AIPASS_HOME={aipass_home}[/dim]")
|
||||
json_handler.log_operation("bridge_install", {"added": added, "aipass_home": str(aipass_home)})
|
||||
|
||||
|
||||
def _run_uninstall() -> None:
|
||||
"""Remove AIPass hooks from global settings."""
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
existing = count_aipass_hooks(settings)
|
||||
if existing == 0:
|
||||
warning("No AIPass hooks found in settings.")
|
||||
return
|
||||
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Removed {removed} AIPass hook entries from {SETTINGS_PATH}[/green]")
|
||||
json_handler.log_operation("bridge_uninstall", {"removed": removed})
|
||||
|
||||
|
||||
def _run_reinstall() -> None:
|
||||
"""Uninstall then install AIPass hooks."""
|
||||
aipass_home = resolve_aipass_home()
|
||||
if not aipass_home:
|
||||
error("Could not resolve AIPASS_HOME")
|
||||
return
|
||||
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
ensure_aipass_env(settings, str(aipass_home))
|
||||
settings, added = install_hooks(settings)
|
||||
|
||||
if not write_settings(SETTINGS_PATH, settings):
|
||||
error("Failed to write settings.json")
|
||||
return
|
||||
|
||||
console.print(f"[green]Reinstalled: removed {removed}, added {added} hook entries[/green]")
|
||||
json_handler.log_operation("bridge_reinstall", {"removed": removed, "added": added})
|
||||
|
||||
|
||||
def _run_status() -> None:
|
||||
"""Show current bridge installation status."""
|
||||
settings = read_settings(SETTINGS_PATH)
|
||||
count = count_aipass_hooks(settings)
|
||||
aipass_home = resolve_aipass_home()
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]Hook Bridge Status[/bold cyan]")
|
||||
console.print(f" Settings: {SETTINGS_PATH}")
|
||||
console.print(f" AIPASS_HOME: {aipass_home or '[red]not resolved[/red]'}")
|
||||
status = "[green](installed)[/green]" if count > 0 else "[dim](not installed)[/dim]"
|
||||
console.print(f" AIPass hooks: {count} entries {status}")
|
||||
console.print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Introspection + routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""CLI --help entry point."""
|
||||
print_introspection()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module info and usage."""
|
||||
console.print()
|
||||
console.print("[bold cyan]hook_bridge Module[/bold cyan]")
|
||||
console.print("Installs/removes AIPass hooks in Claude Code settings.json")
|
||||
console.print()
|
||||
console.print("[yellow]Subcommands:[/yellow]")
|
||||
console.print(" [green]drone @seedgo bridge install[/green] [dim]# Add AIPass hooks (idempotent)[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge uninstall[/green] [dim]# Remove AIPass hooks only[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge reinstall[/green] [dim]# Remove + re-add (upgrade)[/dim]")
|
||||
console.print(" [green]drone @seedgo bridge status[/green] [dim]# Show installation status[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/hooks/[/cyan]")
|
||||
console.print(" [dim]- bridge_handler.py (settings I/O, hook manifest)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle 'bridge' command — hook installation management."""
|
||||
if command != "bridge":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0]
|
||||
|
||||
if subcommand == "install":
|
||||
_run_install()
|
||||
return True
|
||||
if subcommand == "uninstall":
|
||||
_run_uninstall()
|
||||
return True
|
||||
if subcommand == "reinstall":
|
||||
_run_reinstall()
|
||||
return True
|
||||
if subcommand == "status":
|
||||
_run_status()
|
||||
return True
|
||||
|
||||
logger.info("hook_bridge: unknown subcommand %r", subcommand)
|
||||
console.print(f"[dim]Unknown subcommand: {subcommand!r}[/dim]")
|
||||
print_introspection()
|
||||
return True
|
||||
@@ -0,0 +1,311 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_hook_bridge.py
|
||||
# Description: Tests for hook bridge installer (DPLAN-0141 Phase 2)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-22
|
||||
# Modified: 2026-04-22
|
||||
# =============================================
|
||||
"""Tests for hook bridge installer — bridge_handler.py + hook_bridge.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_handler tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_aipass_home_from_env(tmp_path):
|
||||
"""resolve_aipass_home returns path from AIPASS_HOME env var."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import resolve_aipass_home
|
||||
|
||||
with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}):
|
||||
result = resolve_aipass_home()
|
||||
assert result == tmp_path
|
||||
|
||||
|
||||
def test_resolve_aipass_home_returns_none_when_all_fail():
|
||||
"""resolve_aipass_home returns None when env var is bad and importlib fallback disabled."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import resolve_aipass_home
|
||||
|
||||
with (
|
||||
patch.dict("os.environ", {"AIPASS_HOME": "/nonexistent/path/xyz"}),
|
||||
patch("importlib.util.find_spec", return_value=None),
|
||||
):
|
||||
result = resolve_aipass_home()
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_marker():
|
||||
"""is_aipass_hook_entry returns True for entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"_aipass": True, "hooks": [{"type": "command", "command": "echo test"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_pattern():
|
||||
"""is_aipass_hook_entry returns True for entries with AIPass path patterns."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "python3 /home/user/AIPass/.claude/hooks/foo.py"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_detects_env_var():
|
||||
"""is_aipass_hook_entry returns True for entries using $AIPASS_HOME."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/bar.py"}]}
|
||||
assert is_aipass_hook_entry(entry) is True
|
||||
|
||||
|
||||
def test_is_aipass_hook_entry_ignores_non_aipass():
|
||||
"""is_aipass_hook_entry returns False for unrelated entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import is_aipass_hook_entry
|
||||
|
||||
entry = {"hooks": [{"type": "command", "command": "echo hello"}]}
|
||||
assert is_aipass_hook_entry(entry) is False
|
||||
|
||||
|
||||
def test_count_aipass_hooks_empty():
|
||||
"""count_aipass_hooks returns 0 for empty settings."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import count_aipass_hooks
|
||||
|
||||
assert count_aipass_hooks({}) == 0
|
||||
assert count_aipass_hooks({"hooks": {}}) == 0
|
||||
|
||||
|
||||
def test_count_aipass_hooks_counts_marked():
|
||||
"""count_aipass_hooks counts entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import count_aipass_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo"}]},
|
||||
{"hooks": [{"type": "command", "command": "other"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
assert count_aipass_hooks(settings) == 1
|
||||
|
||||
|
||||
def test_install_hooks_adds_entries():
|
||||
"""install_hooks adds manifest entries to empty settings."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import (
|
||||
AIPASS_HOOK_MANIFEST,
|
||||
install_hooks,
|
||||
)
|
||||
|
||||
settings = {"hooks": {}}
|
||||
settings, added = install_hooks(settings)
|
||||
assert added > 0
|
||||
total_manifest = sum(len(v) for v in AIPASS_HOOK_MANIFEST.values())
|
||||
assert added == total_manifest
|
||||
|
||||
|
||||
def test_install_hooks_idempotent():
|
||||
"""install_hooks does not duplicate entries on second call."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
settings = {"hooks": {}}
|
||||
settings, first_added = install_hooks(settings)
|
||||
settings, second_added = install_hooks(settings)
|
||||
assert first_added > 0
|
||||
assert second_added == 0
|
||||
|
||||
|
||||
def test_install_hooks_preserves_existing():
|
||||
"""install_hooks preserves non-AIPass hooks in same event."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
user_hook = {"hooks": [{"type": "command", "command": "echo user-hook"}]}
|
||||
settings = {"hooks": {"Stop": [user_hook]}}
|
||||
settings, added = install_hooks(settings)
|
||||
stop_entries = settings["hooks"]["Stop"]
|
||||
assert user_hook in stop_entries
|
||||
assert added > 0
|
||||
|
||||
|
||||
def test_install_hooks_skips_event_with_existing_aipass():
|
||||
"""install_hooks skips events that already contain AIPass entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import install_hooks
|
||||
|
||||
existing = {
|
||||
"_aipass": True,
|
||||
"hooks": [{"type": "command", "command": "python3 $AIPASS_HOME/.claude/hooks/stop_sound.py"}],
|
||||
}
|
||||
settings = {"hooks": {"Stop": [existing]}}
|
||||
settings, added = install_hooks(settings)
|
||||
assert len(settings["hooks"]["Stop"]) == 1
|
||||
|
||||
|
||||
def test_uninstall_hooks_removes_marked():
|
||||
"""uninstall_hooks removes entries with _aipass marker."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo aipass"}]},
|
||||
{"hooks": [{"type": "command", "command": "echo user"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 1
|
||||
assert len(settings["hooks"]["Stop"]) == 1
|
||||
assert settings["hooks"]["Stop"][0]["hooks"][0]["command"] == "echo user"
|
||||
|
||||
|
||||
def test_uninstall_hooks_removes_empty_events():
|
||||
"""uninstall_hooks deletes event keys that become empty."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
settings = {
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{"_aipass": True, "hooks": [{"type": "command", "command": "echo"}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 1
|
||||
assert "Stop" not in settings["hooks"]
|
||||
|
||||
|
||||
def test_uninstall_hooks_preserves_non_aipass():
|
||||
"""uninstall_hooks never touches non-AIPass entries."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import uninstall_hooks
|
||||
|
||||
user_hook = {"hooks": [{"type": "command", "command": "echo user"}]}
|
||||
settings = {"hooks": {"Stop": [user_hook]}}
|
||||
settings, removed = uninstall_hooks(settings)
|
||||
assert removed == 0
|
||||
assert settings["hooks"]["Stop"] == [user_hook]
|
||||
|
||||
|
||||
def test_ensure_aipass_env_adds_when_missing():
|
||||
"""ensure_aipass_env adds AIPASS_HOME to env section."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import ensure_aipass_env
|
||||
|
||||
settings = {}
|
||||
added = ensure_aipass_env(settings, "/home/user/AIPass")
|
||||
assert added is True
|
||||
assert settings["env"]["AIPASS_HOME"] == "/home/user/AIPass"
|
||||
|
||||
|
||||
def test_ensure_aipass_env_skips_when_present():
|
||||
"""ensure_aipass_env does not overwrite existing AIPASS_HOME."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import ensure_aipass_env
|
||||
|
||||
settings = {"env": {"AIPASS_HOME": "/existing/path"}}
|
||||
added = ensure_aipass_env(settings, "/new/path")
|
||||
assert added is False
|
||||
assert settings["env"]["AIPASS_HOME"] == "/existing/path"
|
||||
|
||||
|
||||
def test_read_settings_returns_empty_for_missing(tmp_path):
|
||||
"""read_settings returns {} when file does not exist."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import read_settings
|
||||
|
||||
result = read_settings(tmp_path / "missing.json")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_settings_parses_json(tmp_path):
|
||||
"""read_settings parses valid JSON."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import read_settings
|
||||
|
||||
f = tmp_path / "settings.json"
|
||||
f.write_text(json.dumps({"hooks": {}}), encoding="utf-8")
|
||||
result = read_settings(f)
|
||||
assert result == {"hooks": {}}
|
||||
|
||||
|
||||
def test_write_settings_creates_backup(tmp_path):
|
||||
"""write_settings creates .json.bak before overwriting."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import write_settings
|
||||
|
||||
f = tmp_path / "settings.json"
|
||||
f.write_text('{"old": true}', encoding="utf-8")
|
||||
write_settings(f, {"new": True})
|
||||
bak = tmp_path / "settings.json.bak"
|
||||
assert bak.exists()
|
||||
assert json.loads(bak.read_text()) == {"old": True}
|
||||
assert json.loads(f.read_text()) == {"new": True}
|
||||
|
||||
|
||||
def test_write_settings_creates_parent_dirs(tmp_path):
|
||||
"""write_settings creates parent directories if needed."""
|
||||
from aipass.seedgo.apps.handlers.hooks.bridge_handler import write_settings
|
||||
|
||||
f = tmp_path / "sub" / "dir" / "settings.json"
|
||||
result = write_settings(f, {"test": True})
|
||||
assert result is True
|
||||
assert f.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hook_bridge module tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_handle_command_rejects_non_bridge():
|
||||
"""handle_command returns False for non-bridge commands."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("audit", ["install"]) is False
|
||||
assert handle_command("hooks", ["install"]) is False
|
||||
|
||||
|
||||
def test_handle_command_accepts_bridge():
|
||||
"""handle_command returns True for bridge command."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", []) is True
|
||||
|
||||
|
||||
def test_handle_command_accepts_help():
|
||||
"""handle_command returns True for bridge --help."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", ["--help"]) is True
|
||||
assert handle_command("bridge", ["-h"]) is True
|
||||
assert handle_command("bridge", ["help"]) is True
|
||||
|
||||
|
||||
def test_handle_command_routes_install():
|
||||
"""handle_command routes 'install' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_install"):
|
||||
result = handle_command("bridge", ["install"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_routes_uninstall():
|
||||
"""handle_command routes 'uninstall' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_uninstall"):
|
||||
result = handle_command("bridge", ["uninstall"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_routes_status():
|
||||
"""handle_command routes 'status' subcommand."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
with patch("aipass.seedgo.apps.modules.hook_bridge._run_status"):
|
||||
result = handle_command("bridge", ["status"])
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_handle_command_unknown_subcommand():
|
||||
"""handle_command returns True for unknown subcommand (shows help)."""
|
||||
from aipass.seedgo.apps.modules.hook_bridge import handle_command
|
||||
|
||||
assert handle_command("bridge", ["bogus"]) is True
|
||||
Reference in New Issue
Block a user