309 lines
9.5 KiB
Python
309 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Configure Global Settings CLI - Fresh install permission configuration
|
|
|
|
Creates or updates ~/.claude/settings.json with correct permission patterns
|
|
for Claude Code 2.0. This script is called by install.sh during fresh install.
|
|
|
|
Features:
|
|
1. Fresh install: Create ~/.claude/settings.json from template
|
|
2. Upgrade: Preserve user customizations while fixing broken patterns
|
|
3. Broken patterns: Replace Bash(:*) with specific safe patterns
|
|
4. Non-blocking: Exit 0 even on errors (installation continues)
|
|
5. JSON output: Return structured data for install.sh consumption
|
|
6. Directory creation: Create ~/.claude/ if missing
|
|
|
|
Security:
|
|
- Path validation (CWE-22, CWE-59)
|
|
- Atomic writes with secure permissions
|
|
- Backup before modification
|
|
- No wildcards (Bash(git:*) NOT Bash(*))
|
|
|
|
Usage:
|
|
# Fresh install (no existing settings)
|
|
python3 configure_global_settings.py --template /path/to/template.json
|
|
|
|
# Upgrade (existing settings, preserve customizations)
|
|
python3 configure_global_settings.py --template /path/to/template.json --home ~/.claude
|
|
|
|
Output:
|
|
JSON to stdout: {"success": bool, "created": bool, "message": str, ...}
|
|
Exit code: Always 0 (non-blocking for install.sh)
|
|
|
|
See Also:
|
|
- plugins/autonomous-dev/lib/settings_generator.py for merge logic
|
|
- plugins/autonomous-dev/config/global_settings_template.json for template
|
|
- tests/unit/scripts/test_configure_global_settings.py for test cases
|
|
- GitHub Issue #116 for requirements
|
|
|
|
Date: 2025-12-13
|
|
Issue: GitHub #116
|
|
Agent: implementer
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Any
|
|
|
|
# Add lib to path for imports
|
|
try:
|
|
# Try package import first
|
|
from autonomous_dev.lib.settings_generator import SettingsGenerator, SettingsGeneratorError
|
|
except ImportError:
|
|
# Fallback for direct script execution
|
|
lib_path = Path(__file__).parent.parent / "lib"
|
|
sys.path.insert(0, str(lib_path))
|
|
from settings_generator import SettingsGenerator, SettingsGeneratorError
|
|
|
|
|
|
def create_fresh_settings(template_path: Path, global_path: Path) -> Dict[str, Any]:
|
|
"""Create fresh settings.json from template.
|
|
|
|
Args:
|
|
template_path: Path to global_settings_template.json
|
|
global_path: Path to ~/.claude/settings.json
|
|
|
|
Returns:
|
|
Result dict with success status and metadata
|
|
"""
|
|
try:
|
|
# Validate template exists
|
|
if not template_path.exists():
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Template file not found: {template_path}",
|
|
"error": "template_not_found"
|
|
}
|
|
|
|
# Ensure ~/.claude/ directory exists
|
|
claude_dir = global_path.parent
|
|
if not ensure_claude_directory(claude_dir):
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Failed to create directory: {claude_dir}",
|
|
"error": "directory_creation_failed"
|
|
}
|
|
|
|
# Read template
|
|
template_content = template_path.read_text()
|
|
template_settings = json.loads(template_content)
|
|
|
|
# Write settings atomically
|
|
global_path.write_text(json.dumps(template_settings, indent=2) + "\n")
|
|
|
|
# Set secure permissions (owner read/write only)
|
|
global_path.chmod(0o600)
|
|
|
|
return {
|
|
"success": True,
|
|
"created": True,
|
|
"message": "Fresh install: Created settings.json from template",
|
|
"path": str(global_path),
|
|
"patterns_count": len(template_settings.get("permissions", {}).get("allow", []))
|
|
}
|
|
|
|
except PermissionError as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Permission denied: {e}",
|
|
"error": "permission_denied"
|
|
}
|
|
except json.JSONDecodeError as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Invalid JSON in template: {e}",
|
|
"error": "invalid_template_json"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Unexpected error: {e}",
|
|
"error": "unexpected_error"
|
|
}
|
|
|
|
|
|
def upgrade_existing_settings(global_path: Path, template_path: Path) -> Dict[str, Any]:
|
|
"""Upgrade existing settings while preserving user customizations.
|
|
|
|
Args:
|
|
global_path: Path to existing ~/.claude/settings.json
|
|
template_path: Path to global_settings_template.json
|
|
|
|
Returns:
|
|
Result dict with success status and metadata
|
|
"""
|
|
try:
|
|
# Validate inputs
|
|
if not global_path.exists():
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Settings file not found: {global_path}",
|
|
"error": "settings_not_found"
|
|
}
|
|
|
|
if not template_path.exists():
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Template file not found: {template_path}",
|
|
"error": "template_not_found"
|
|
}
|
|
|
|
# Use SettingsGenerator to merge settings
|
|
# Pass project_root mode to avoid requiring full plugin structure
|
|
generator = SettingsGenerator(project_root=Path.home())
|
|
|
|
# Call merge_global_settings (handles backup, merge, and write)
|
|
# Returns merged settings dict on success, raises exception on error
|
|
merged_settings = generator.merge_global_settings(
|
|
global_path,
|
|
template_path,
|
|
fix_wildcards=True,
|
|
create_backup=True
|
|
)
|
|
|
|
# Count patterns fixed (check if Bash(:*) was in original)
|
|
patterns_fixed = 0
|
|
backup_path = global_path.with_suffix(".json.backup")
|
|
try:
|
|
if backup_path.exists():
|
|
original_settings = json.loads(backup_path.read_text())
|
|
if "permissions" in original_settings and "allow" in original_settings["permissions"]:
|
|
broken = [p for p in original_settings["permissions"]["allow"] if p in ["Bash(:*)", "Bash(*)"]]
|
|
patterns_fixed = len(broken)
|
|
except:
|
|
pass
|
|
|
|
# Build message based on patterns fixed
|
|
if patterns_fixed > 0:
|
|
message = f"Settings upgraded successfully (fixed {patterns_fixed} broken patterns, preserved customizations)"
|
|
else:
|
|
message = "Settings upgraded successfully (preserved customizations)"
|
|
|
|
return {
|
|
"success": True,
|
|
"created": False,
|
|
"message": message,
|
|
"merged": True,
|
|
"patterns_fixed": patterns_fixed,
|
|
"path": str(global_path)
|
|
}
|
|
|
|
except SettingsGeneratorError as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Settings merge error: {e}",
|
|
"error": "merge_failed"
|
|
}
|
|
|
|
except PermissionError as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Permission denied: {e}",
|
|
"error": "permission_denied"
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"created": False,
|
|
"message": f"Unexpected error: {e}",
|
|
"error": "unexpected_error"
|
|
}
|
|
|
|
|
|
def ensure_claude_directory(claude_dir: Path) -> bool:
|
|
"""Ensure ~/.claude/ directory exists with correct permissions.
|
|
|
|
Args:
|
|
claude_dir: Path to ~/.claude/ directory
|
|
|
|
Returns:
|
|
True if directory exists/created, False on error
|
|
"""
|
|
try:
|
|
# Create directory if missing (mkdir -p behavior)
|
|
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Set permissions (owner read/write/execute)
|
|
claude_dir.chmod(0o700)
|
|
|
|
return True
|
|
|
|
except PermissionError:
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def main():
|
|
"""Main entry point for CLI."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Configure ~/.claude/settings.json for autonomous-dev plugin",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Fresh install
|
|
%(prog)s --template global_settings_template.json
|
|
|
|
# Upgrade with custom home
|
|
%(prog)s --template global_settings_template.json --home ~/.claude
|
|
|
|
Exit Code:
|
|
Always exits 0 (non-blocking for install.sh)
|
|
Check JSON output "success" field for actual status
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--template",
|
|
type=Path,
|
|
required=True,
|
|
help="Path to global_settings_template.json"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--home",
|
|
type=Path,
|
|
default=Path.home() / ".claude",
|
|
help="Path to .claude directory (default: ~/.claude)"
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--staging",
|
|
type=Path,
|
|
help="Path to staging directory (unused, for compatibility)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Determine global_path
|
|
global_path = args.home / "settings.json"
|
|
|
|
# Check if settings.json already exists
|
|
if global_path.exists():
|
|
# Upgrade scenario: merge with existing settings
|
|
result = upgrade_existing_settings(global_path, args.template)
|
|
else:
|
|
# Fresh install scenario: create from template
|
|
result = create_fresh_settings(args.template, global_path)
|
|
|
|
# Output JSON to stdout
|
|
print(json.dumps(result, indent=2))
|
|
|
|
# Always exit 0 (non-blocking for install.sh)
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|