TradingAgents/.claude/scripts/migrate_hook_paths.py

308 lines
10 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Migration script for Issue #113: Make PreToolUse hook path dynamic.
Detects hardcoded absolute paths in settings.json and replaces them with
portable ~/.claude/hooks/pre_tool_use.py path.
Usage:
python migrate_hook_paths.py
python migrate_hook_paths.py --settings-path ~/.claude/settings.json
python migrate_hook_paths.py --dry-run
python migrate_hook_paths.py --verbose
"""
import argparse
import json
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional
def detect_hardcoded_paths(settings: Dict[str, Any]) -> list:
"""
Detect hardcoded absolute paths in settings.
Args:
settings: Settings dictionary to check
Returns:
List of detected hardcoded path patterns
"""
hardcoded_patterns = []
if "hooks" not in settings:
return hardcoded_patterns
for hook_type, hook_configs in settings["hooks"].items():
if not isinstance(hook_configs, list):
continue
for hook_config in hook_configs:
if not isinstance(hook_config, dict):
continue
hooks = hook_config.get("hooks", [])
if not isinstance(hooks, list):
continue
for hook in hooks:
if not isinstance(hook, dict):
continue
command = hook.get("command", "")
# Detect various hardcoded path patterns
# Match any absolute path ending in autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use.py
patterns = [
r'/Users/[^/\s]+/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'/home/[^/\s]+/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'/opt/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'[A-Za-z]:[/\\].*?autonomous-dev[/\\]plugins[/\\]autonomous-dev[/\\]hooks[/\\]pre_tool_use\.py',
]
for pattern in patterns:
if re.search(pattern, command):
hardcoded_patterns.append({
"hook_type": hook_type,
"command": command,
"pattern": pattern
})
return hardcoded_patterns
def migrate_settings_file(settings_path: Path, dry_run: bool = False, verbose: bool = False) -> Dict[str, Any]:
"""
Migrate settings.json to use portable hook paths.
Args:
settings_path: Path to settings.json file
dry_run: If True, don't modify files (just report)
verbose: If True, output detailed information
Returns:
Dictionary with migration results:
- migrated: bool (True if changes were made)
- changes: int (number of paths migrated)
- summary: str (human-readable summary)
- backup_path: str or None (path to backup file)
"""
result = {
"migrated": False,
"changes": 0,
"summary": "",
"backup_path": None
}
# Check if file exists
if not settings_path.exists():
result["summary"] = f"Settings file not found: {settings_path}"
if verbose:
print(f"{result['summary']}")
return result
# Read settings
try:
settings = json.loads(settings_path.read_text())
except json.JSONDecodeError as e:
result["summary"] = f"Invalid JSON in settings file: {e}"
if verbose:
print(f"{result['summary']}")
return result
except Exception as e:
result["summary"] = f"Error reading settings file: {e}"
if verbose:
print(f"{result['summary']}")
return result
# Detect hardcoded paths
hardcoded = detect_hardcoded_paths(settings)
if not hardcoded:
result["summary"] = "All hook paths are already portable"
if verbose:
print(f"{result['summary']}")
return result
if verbose:
print(f"🔍 Found {len(hardcoded)} hardcoded path(s) to migrate")
# Create backup before modifying (unless dry-run)
if not dry_run:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = settings_path.parent / f"{settings_path.name}.backup.{timestamp}"
shutil.copy2(settings_path, backup_path)
result["backup_path"] = str(backup_path)
if verbose:
print(f"💾 Created backup: {backup_path}")
# Migrate paths
changes = 0
portable_path = "~/.claude/hooks/pre_tool_use.py"
if "hooks" in settings:
for hook_type, hook_configs in settings["hooks"].items():
if not isinstance(hook_configs, list):
continue
for hook_config in hook_configs:
if not isinstance(hook_config, dict):
continue
hooks = hook_config.get("hooks", [])
if not isinstance(hooks, list):
continue
for hook in hooks:
if not isinstance(hook, dict):
continue
command = hook.get("command", "")
# Replace hardcoded paths with portable path
patterns = [
r'/Users/[^/\s]+/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'/home/[^/\s]+/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'/opt/.*?autonomous-dev/plugins/autonomous-dev/hooks/pre_tool_use\.py',
r'[A-Za-z]:[/\\].*?autonomous-dev[/\\]plugins[/\\]autonomous-dev[/\\]hooks[/\\]pre_tool_use\.py',
]
new_command = command
for pattern in patterns:
if re.search(pattern, new_command):
new_command = re.sub(pattern, portable_path, new_command)
changes += 1
if verbose:
print(f"🔄 Migrating {hook_type} hook")
print(f" Old: {command}")
print(f" New: {new_command}")
if new_command != command:
hook["command"] = new_command
# Write migrated settings (unless dry-run)
if changes > 0 and not dry_run:
settings_path.write_text(json.dumps(settings, indent=2))
result["migrated"] = True
result["changes"] = changes
result["summary"] = f"Successfully migrated {changes} hardcoded path(s)"
if verbose:
print(f"{result['summary']}")
elif changes > 0 and dry_run:
result["migrated"] = False
result["changes"] = changes
result["summary"] = f"Dry-run: Would migrate {changes} hardcoded path(s)"
if verbose:
print(f" {result['summary']}")
else:
result["summary"] = "No changes needed"
if verbose:
print(f"{result['summary']}")
return result
def rollback_migration(settings_path: Path, backup_path: Path) -> bool:
"""
Rollback migration by restoring from backup.
Args:
settings_path: Path to settings.json file
backup_path: Path to backup file
Returns:
True if rollback successful, False otherwise
"""
try:
if not backup_path.exists():
print(f"❌ Backup file not found: {backup_path}")
return False
shutil.copy2(backup_path, settings_path)
print(f"✅ Restored settings from backup: {backup_path}")
return True
except Exception as e:
print(f"❌ Rollback failed: {e}")
return False
def migrate_hook_paths(settings_path: Optional[Path] = None, dry_run: bool = False, verbose: bool = False) -> Dict[str, Any]:
"""
Main migration function.
Args:
settings_path: Path to settings.json (defaults to ~/.claude/settings.json)
dry_run: If True, don't modify files
verbose: If True, output detailed information
Returns:
Dictionary with migration results
"""
if settings_path is None:
settings_path = Path.home() / ".claude" / "settings.json"
elif isinstance(settings_path, str):
settings_path = Path(settings_path).expanduser()
return migrate_settings_file(settings_path, dry_run=dry_run, verbose=verbose)
def main():
"""Command-line interface."""
parser = argparse.ArgumentParser(
description="Migrate PreToolUse hook paths from hardcoded to portable (Issue #113)"
)
parser.add_argument(
"--settings-path",
type=str,
default=None,
help="Path to settings.json (default: ~/.claude/settings.json)"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Output detailed information"
)
parser.add_argument(
"--rollback",
type=str,
help="Rollback migration from backup file"
)
args = parser.parse_args()
if args.rollback:
# Rollback mode
settings_path = Path(args.settings_path).expanduser() if args.settings_path else Path.home() / ".claude" / "settings.json"
backup_path = Path(args.rollback).expanduser()
success = rollback_migration(settings_path, backup_path)
sys.exit(0 if success else 1)
else:
# Migration mode
result = migrate_hook_paths(
settings_path=args.settings_path,
dry_run=args.dry_run,
verbose=args.verbose
)
# Print summary (unless verbose already printed it)
if not args.verbose:
icon = "" if result["migrated"] or result["changes"] == 0 else ""
print(f"{icon} {result['summary']}")
if result.get("backup_path"):
print(f"💾 Backup: {result['backup_path']}")
sys.exit(0)
if __name__ == "__main__":
main()