#!/usr/bin/env python3
# Copyright 2024 Heinrich Krupp
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Utilities for installing and managing Claude Code commands for MCP Memory Service.
"""
import os
import sys
import shutil
import subprocess
from pathlib import Path
from datetime import datetime
from typing import Optional, List, Dict, Tuple
def print_info(text: str) -> None:
"""Print formatted info text."""
print(f" -> {text}")
def print_error(text: str) -> None:
"""Print formatted error text."""
print(f" [ERROR] {text}")
def print_success(text: str) -> None:
"""Print formatted success text."""
print(f" [OK] {text}")
def print_warning(text: str) -> None:
"""Print formatted warning text."""
print(f" [WARNING] {text}")
def check_claude_code_cli() -> Tuple[bool, Optional[str]]:
"""
Check if Claude Code CLI is installed and available.
Returns:
Tuple of (is_available, version_or_error)
"""
try:
# Try to run claude --version
result = subprocess.run(
['claude', '--version'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
version = result.stdout.strip()
return True, version
else:
return False, f"claude command failed: {result.stderr.strip()}"
except subprocess.TimeoutExpired:
return False, "claude command timed out"
except FileNotFoundError:
return False, "claude command not found in PATH"
except Exception as e:
return False, f"Error checking claude CLI: {str(e)}"
def get_claude_commands_directory() -> Path:
"""
Get the Claude Code commands directory path.
Returns:
Path to ~/.claude/commands/
"""
return Path.home() / ".claude" / "commands"
def get_claude_hooks_directory() -> Path:
"""
Get the Claude Code hooks directory path.
Returns:
Path to ~/.claude/hooks/
"""
return Path.home() / ".claude" / "hooks"
def check_for_legacy_claude_paths() -> Tuple[bool, List[str]]:
"""
Check for legacy .claude-code directory structure and provide migration guidance.
Returns:
Tuple of (legacy_found, list_of_issues_and_recommendations)
"""
issues = []
legacy_found = False
# Check for legacy .claude-code directories
legacy_paths = [
Path.home() / ".claude-code",
Path.home() / ".claude-code" / "hooks",
Path.home() / ".claude-code" / "commands"
]
for legacy_path in legacy_paths:
if legacy_path.exists():
legacy_found = True
issues.append(f"⚠ Found legacy directory: {legacy_path}")
# Check what's in the legacy directory
if legacy_path.name == "hooks" and any(legacy_path.glob("*.js")):
issues.append(f" → Contains hook files that should be moved to ~/.claude/hooks/")
elif legacy_path.name == "commands" and any(legacy_path.glob("*.md")):
issues.append(f" → Contains command files that should be moved to ~/.claude/commands/")
if legacy_found:
issues.append("")
issues.append("Migration steps:")
issues.append("1. Create new directory: ~/.claude/")
issues.append("2. Move hooks: ~/.claude-code/hooks/* → ~/.claude/hooks/")
issues.append("3. Move commands: ~/.claude-code/commands/* → ~/.claude/commands/")
issues.append("4. Update settings.json to reference new paths")
issues.append("5. Remove old ~/.claude-code/ directory when satisfied")
return legacy_found, issues
def validate_claude_settings_paths() -> Tuple[bool, List[str]]:
"""
Validate paths in Claude settings files and detect common Windows path issues.
Returns:
Tuple of (all_valid, list_of_issues_and_recommendations)
"""
import json
import platform
issues = []
all_valid = True
# Common Claude settings locations
settings_paths = [
Path.home() / ".claude" / "settings.json",
Path.home() / ".claude" / "settings.local.json"
]
for settings_path in settings_paths:
if not settings_path.exists():
continue
try:
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
# Check hooks configuration
if 'hooks' in settings:
for hook in settings.get('hooks', []):
if 'command' in hook:
command = hook['command']
# Check for Windows path issues
if platform.system() == "Windows":
if '\\' in command and not command.startswith('"'):
all_valid = False
issues.append(f"⚠ Windows path with backslashes in {settings_path.name}:")
issues.append(f" → {command}")
issues.append(f" → Consider using forward slashes: {command.replace(chr(92), '/')}")
# Check for legacy .claude-code references
if '.claude-code' in command:
all_valid = False
issues.append(f"⚠ Legacy path reference in {settings_path.name}:")
issues.append(f" → {command}")
issues.append(f" → Update to use .claude instead of .claude-code")
# Check for missing session-start-wrapper.bat
if 'session-start-wrapper.bat' in command:
all_valid = False
issues.append(f"⚠ Reference to non-existent wrapper file in {settings_path.name}:")
issues.append(f" → {command}")
issues.append(f" → Use Node.js script directly: node path/to/session-start.js")
# Check if referenced files exist
if command.startswith('node '):
script_path_str = command.replace('node ', '').strip()
# Handle quoted paths
if script_path_str.startswith('"') and script_path_str.endswith('"'):
script_path_str = script_path_str[1:-1]
script_path = Path(script_path_str)
if not script_path.exists() and not script_path.is_absolute():
# Try to resolve relative to home directory
script_path = Path.home() / script_path_str
if not script_path.exists():
all_valid = False
issues.append(f"⚠ Hook script not found: {script_path_str}")
issues.append(f" → Check if hooks are properly installed")
except json.JSONDecodeError as e:
all_valid = False
issues.append(f"⚠ JSON parsing error in {settings_path.name}: {str(e)}")
except Exception as e:
all_valid = False
issues.append(f"⚠ Error reading {settings_path.name}: {str(e)}")
return all_valid, issues
def normalize_windows_path_for_json(path_str: str) -> str:
"""
Normalize a Windows path for use in JSON configuration files.
Args:
path_str: Path string that may contain backslashes
Returns:
Path string with forward slashes suitable for JSON
"""
import platform
if platform.system() == "Windows":
# Convert backslashes to forward slashes
normalized = path_str.replace('\\', '/')
# Handle double backslashes from escaped strings
normalized = normalized.replace('//', '/')
return normalized
return path_str
def check_commands_directory_access() -> Tuple[bool, str]:
"""
Check if we can access and write to the Claude commands directory.
Returns:
Tuple of (can_access, status_message)
"""
commands_dir = get_claude_commands_directory()
try:
# Check if directory exists
if not commands_dir.exists():
# Try to create it
commands_dir.mkdir(parents=True, exist_ok=True)
return True, f"Created commands directory: {commands_dir}"
# Check if we can write to it
test_file = commands_dir / ".test_write_access"
try:
test_file.write_text("test")
test_file.unlink()
return True, f"Commands directory accessible: {commands_dir}"
except PermissionError:
return False, f"No write permission to commands directory: {commands_dir}"
except Exception as e:
return False, f"Cannot access commands directory: {str(e)}"
def get_source_commands_directory() -> Path:
"""
Get the source directory containing the command markdown files.
Returns:
Path to the claude_commands directory in the project
"""
# Get the directory containing this script
script_dir = Path(__file__).parent
# Go up one level to the project root and find claude_commands
project_root = script_dir.parent
return project_root / "claude_commands"
def list_available_commands() -> List[Dict[str, str]]:
"""
List all available command files in the source directory.
Returns:
List of command info dictionaries
"""
source_dir = get_source_commands_directory()
commands = []
if not source_dir.exists():
return commands
for md_file in source_dir.glob("*.md"):
# Extract command name from filename
command_name = md_file.stem
# Read the first line to get the description
try:
with open(md_file, 'r', encoding='utf-8') as f:
first_line = f.readline().strip()
# Remove markdown header formatting
description = first_line.lstrip('# ').strip()
except Exception:
description = "Command description unavailable"
commands.append({
'name': command_name,
'file': md_file.name,
'description': description,
'path': str(md_file)
})
return commands
def backup_existing_commands() -> Optional[str]:
"""
Create a backup of existing command files before installation.
Returns:
Path to backup directory if backup was created, None otherwise
"""
commands_dir = get_claude_commands_directory()
if not commands_dir.exists():
return None
# Check if there are any existing .md files
existing_commands = list(commands_dir.glob("*.md"))
if not existing_commands:
return None
# Create backup directory with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = commands_dir / f"backup_{timestamp}"
try:
backup_dir.mkdir(exist_ok=True)
for cmd_file in existing_commands:
shutil.copy2(cmd_file, backup_dir / cmd_file.name)
print_info(f"Backed up {len(existing_commands)} existing commands to: {backup_dir}")
return str(backup_dir)
except Exception as e:
print_error(f"Failed to create backup: {str(e)}")
return None
def install_command_files() -> Tuple[bool, List[str]]:
"""
Install command markdown files to the Claude commands directory.
Returns:
Tuple of (success, list_of_installed_files)
"""
source_dir = get_source_commands_directory()
commands_dir = get_claude_commands_directory()
installed_files = []
if not source_dir.exists():
print_error(f"Source commands directory not found: {source_dir}")
return False, []
try:
# Ensure destination directory exists
commands_dir.mkdir(parents=True, exist_ok=True)
# Copy all .md files
for md_file in source_dir.glob("*.md"):
dest_file = commands_dir / md_file.name
shutil.copy2(md_file, dest_file)
installed_files.append(md_file.name)
print_info(f"Installed: {md_file.name}")
if installed_files:
print_success(f"Successfully installed {len(installed_files)} Claude Code commands")
return True, installed_files
else:
print_warning("No command files found to install")
return False, []
except Exception as e:
print_error(f"Failed to install command files: {str(e)}")
return False, []
def verify_mcp_service_connectivity() -> Tuple[bool, str]:
"""
Verify that the MCP Memory Service is accessible.
Returns:
Tuple of (is_accessible, status_message)
"""
try:
# Try to import the MCP service modules
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
# Test basic connectivity
from mcp_memory_service import config
# Check if we can detect a running service
# This is a basic check - in a real scenario, we'd try to connect
return True, "MCP Memory Service modules available"
except ImportError as e:
return False, f"MCP Memory Service not properly installed: {str(e)}"
except Exception as e:
return False, f"Error checking MCP service: {str(e)}"
def test_command_functionality() -> Tuple[bool, List[str]]:
"""
Test that installed commands are accessible via Claude Code CLI.
Returns:
Tuple of (all_tests_passed, list_of_test_results)
"""
commands_dir = get_claude_commands_directory()
test_results = []
all_passed = True
# Check if command files exist and are readable
for md_file in commands_dir.glob("memory-*.md"):
try:
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
if len(content) > 0:
test_results.append(f"✓ {md_file.name} - readable and non-empty")
else:
test_results.append(f"✗ {md_file.name} - file is empty")
all_passed = False
except Exception as e:
test_results.append(f"✗ {md_file.name} - error reading: {str(e)}")
all_passed = False
# Try to run claude commands (if Claude CLI is available)
claude_available, _ = check_claude_code_cli()
if claude_available:
try:
# Test that claude can see our commands
result = subprocess.run(
['claude', '--help'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
test_results.append("✓ Claude Code CLI is responsive")
else:
test_results.append("✗ Claude Code CLI returned error")
all_passed = False
except Exception as e:
test_results.append(f"✗ Error testing Claude CLI: {str(e)}")
all_passed = False
return all_passed, test_results
def uninstall_commands() -> Tuple[bool, List[str]]:
"""
Uninstall MCP Memory Service commands from Claude Code.
Returns:
Tuple of (success, list_of_removed_files)
"""
commands_dir = get_claude_commands_directory()
removed_files = []
if not commands_dir.exists():
return True, [] # Nothing to remove
try:
# Remove all memory-*.md files
for md_file in commands_dir.glob("memory-*.md"):
md_file.unlink()
removed_files.append(md_file.name)
print_info(f"Removed: {md_file.name}")
if removed_files:
print_success(f"Successfully removed {len(removed_files)} commands")
else:
print_info("No MCP Memory Service commands found to remove")
return True, removed_files
except Exception as e:
print_error(f"Failed to uninstall commands: {str(e)}")
return False, []
def install_claude_commands(verbose: bool = True) -> bool:
"""
Main function to install Claude Code commands for MCP Memory Service.
Args:
verbose: Whether to print detailed progress information
Returns:
True if installation was successful, False otherwise
"""
if verbose:
print_info("Installing Claude Code commands for MCP Memory Service...")
# Check for legacy paths and provide migration guidance
legacy_found, legacy_issues = check_for_legacy_claude_paths()
if legacy_found:
print_warning("Legacy Claude Code directory structure detected:")
for issue in legacy_issues:
print_info(issue)
print_info("")
# Validate existing settings paths
settings_valid, settings_issues = validate_claude_settings_paths()
if not settings_valid:
print_warning("Claude settings path issues detected:")
for issue in settings_issues:
print_info(issue)
print_info("")
# Check Claude Code CLI availability
claude_available, claude_status = check_claude_code_cli()
if not claude_available:
print_error(f"Claude Code CLI not available: {claude_status}")
print_info("Please install Claude Code CLI first: https://claude.ai/code")
return False
if verbose:
print_success(f"Claude Code CLI detected: {claude_status}")
# Check commands directory access
can_access, access_status = check_commands_directory_access()
if not can_access:
print_error(access_status)
return False
if verbose:
print_success(access_status)
# Create backup of existing commands
backup_path = backup_existing_commands()
# Install command files
install_success, installed_files = install_command_files()
if not install_success:
return False
# Verify MCP service connectivity (optional - warn but don't fail)
mcp_available, mcp_status = verify_mcp_service_connectivity()
if mcp_available:
if verbose:
print_success(mcp_status)
else:
if verbose:
print_warning(f"MCP service check: {mcp_status}")
print_info("Commands installed but MCP service may need to be started")
# Test command functionality
if verbose:
print_info("Testing installed commands...")
tests_passed, test_results = test_command_functionality()
for result in test_results:
print_info(result)
if tests_passed:
print_success("All command tests passed")
else:
print_warning("Some command tests failed - commands may still work")
# Show usage instructions
if verbose:
print_info("\nClaude Code commands installed successfully!")
print_info("Available commands:")
for cmd_file in installed_files:
cmd_name = cmd_file.replace('.md', '')
print_info(f" claude /{cmd_name}")
print_info("\nExample usage:")
print_info(' claude /memory-store "Important decision about architecture"')
print_info(' claude /memory-recall "what did we decide last week?"')
print_info(' claude /memory-search --tags "architecture,database"')
print_info(' claude /memory-health')
return True
if __name__ == "__main__":
# Allow running this script directly for testing
import argparse
parser = argparse.ArgumentParser(description="Install Claude Code commands for MCP Memory Service")
parser.add_argument('--test', action='store_true', help='Test installation without installing')
parser.add_argument('--uninstall', action='store_true', help='Uninstall commands')
parser.add_argument('--validate', action='store_true', help='Validate Claude configuration paths')
parser.add_argument('--quiet', action='store_true', help='Minimal output')
args = parser.parse_args()
if args.uninstall:
success, removed = uninstall_commands()
if success:
sys.exit(0)
else:
sys.exit(1)
elif args.validate:
# Path validation mode
print("Claude Code Configuration Validation")
print("=" * 40)
# Check for legacy paths
legacy_found, legacy_issues = check_for_legacy_claude_paths()
if legacy_found:
print("❌ Legacy paths detected:")
for issue in legacy_issues:
print(f" {issue}")
else:
print("✅ No legacy paths found")
print()
# Validate settings
settings_valid, settings_issues = validate_claude_settings_paths()
if settings_valid:
print("✅ Claude settings paths are valid")
else:
print("❌ Settings path issues detected:")
for issue in settings_issues:
print(f" {issue}")
sys.exit(0 if settings_valid and not legacy_found else 1)
elif args.test:
# Test mode - check prerequisites but don't install
claude_ok, claude_msg = check_claude_code_cli()
access_ok, access_msg = check_commands_directory_access()
mcp_ok, mcp_msg = verify_mcp_service_connectivity()
print("Claude Code commands installation test:")
print(f" Claude CLI: {'✓' if claude_ok else '✗'} {claude_msg}")
print(f" Directory access: {'✓' if access_ok else '✗'} {access_msg}")
print(f" MCP service: {'✓' if mcp_ok else '⚠'} {mcp_msg}")
if claude_ok and access_ok:
print("✓ Ready to install Claude Code commands")
sys.exit(0)
else:
print("✗ Prerequisites not met")
sys.exit(1)
else:
# Normal installation
success = install_claude_commands(verbose=not args.quiet)
sys.exit(0 if success else 1)