334 lines
8.6 KiB
Python
334 lines
8.6 KiB
Python
"""
|
|
Cross-platform utility functions for Python projects
|
|
Works on Windows, macOS, and Linux
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import platform
|
|
import subprocess
|
|
import shutil
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
import glob as glob_module
|
|
|
|
# Platform detection
|
|
IS_WINDOWS = platform.system() == 'Windows'
|
|
IS_MACOS = platform.system() == 'Darwin'
|
|
IS_LINUX = platform.system() == 'Linux'
|
|
|
|
|
|
def get_home_dir() -> Path:
|
|
"""Get the user's home directory (cross-platform)"""
|
|
return Path.home()
|
|
|
|
|
|
def get_claude_dir() -> Path:
|
|
"""Get the Claude config directory"""
|
|
return get_home_dir() / '.claude'
|
|
|
|
|
|
def get_temp_dir() -> Path:
|
|
"""Get the temp directory (cross-platform)"""
|
|
return Path(os.tmpdir())
|
|
|
|
|
|
def get_plan_dir() -> Path:
|
|
"""Get the plan directory"""
|
|
plan_dir = get_claude_dir() / 'plan'
|
|
plan_dir.mkdir(parents=True, exist_ok=True)
|
|
return plan_dir
|
|
|
|
|
|
def ensure_dir(dir_path: Path) -> Path:
|
|
"""Ensure a directory exists (create if not)"""
|
|
dir_path.mkdir(parents=True, exist_ok=True)
|
|
return dir_path
|
|
|
|
|
|
def get_date_string() -> str:
|
|
"""Get current date in YYYY-MM-DD format"""
|
|
return datetime.now().strftime('%Y-%m-%d')
|
|
|
|
|
|
def get_time_string() -> str:
|
|
"""Get current time in HH:MM format"""
|
|
return datetime.now().strftime('%H:%M')
|
|
|
|
|
|
def get_datetime_string() -> str:
|
|
"""Get current datetime in YYYY-MM-DD HH:MM:SS format"""
|
|
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
def find_files(dir_path: Path, pattern: str, max_age_days: Optional[int] = None, recursive: bool = False) -> List[Dict[str, Any]]:
|
|
"""
|
|
Find files matching a pattern in a directory (cross-platform)
|
|
|
|
Args:
|
|
dir_path: Directory to search
|
|
pattern: File pattern (e.g., "*.tmp", "*.md")
|
|
max_age_days: Only return files modified within this many days
|
|
recursive: Search subdirectories
|
|
|
|
Returns:
|
|
List of dicts with 'path' and 'mtime' keys
|
|
"""
|
|
if not dir_path.exists():
|
|
return []
|
|
|
|
results = []
|
|
|
|
# Use glob for pattern matching
|
|
glob_pattern = str(dir_path / ('**' if recursive else '') / pattern)
|
|
files = glob_module.glob(glob_pattern, recursive=recursive)
|
|
|
|
for file_path in files:
|
|
file_path = Path(file_path)
|
|
try:
|
|
stat = file_path.stat()
|
|
mtime = stat.st_mtime
|
|
|
|
if max_age_days is not None:
|
|
import time
|
|
age_in_days = (time.time() - mtime) / (86400)
|
|
if age_in_days <= max_age_days:
|
|
results.append({'path': str(file_path), 'mtime': mtime})
|
|
else:
|
|
results.append({'path': str(file_path), 'mtime': mtime})
|
|
except (OSError, PermissionError):
|
|
# Ignore permission errors
|
|
pass
|
|
|
|
# Sort by modification time (newest first)
|
|
results.sort(key=lambda x: x['mtime'], reverse=True)
|
|
return results
|
|
|
|
|
|
def command_exists(cmd: str) -> bool:
|
|
"""
|
|
Check if a command exists in PATH (cross-platform)
|
|
Uses shutil.which for safety
|
|
"""
|
|
if not cmd or not isinstance(cmd, str):
|
|
return False
|
|
|
|
# Basic validation - only allow alphanumeric, dash, underscore, dot
|
|
if not cmd.replace('-', '').replace('_', '').replace('.', '').isalnum():
|
|
return False
|
|
|
|
return shutil.which(cmd) is not None
|
|
|
|
|
|
def run_command(cmd: List[str], cwd: Optional[Path] = None, capture: bool = True) -> Dict[str, Any]:
|
|
"""
|
|
Run a command and return output
|
|
|
|
SECURITY NOTE: This function executes shell commands. Only use with
|
|
trusted, hardcoded commands. Never pass user-controlled input directly.
|
|
|
|
Args:
|
|
cmd: Command and arguments as list (safer than string)
|
|
cwd: Working directory
|
|
capture: Whether to capture output
|
|
|
|
Returns:
|
|
Dict with 'success', 'output', 'returncode' keys
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
capture_output=capture,
|
|
text=True,
|
|
check=False
|
|
)
|
|
return {
|
|
'success': result.returncode == 0,
|
|
'output': result.stdout.strip(),
|
|
'stderr': result.stderr.strip(),
|
|
'returncode': result.returncode
|
|
}
|
|
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
|
return {
|
|
'success': False,
|
|
'output': '',
|
|
'stderr': str(e),
|
|
'returncode': -1
|
|
}
|
|
|
|
|
|
def is_git_repo(cwd: Optional[Path] = None) -> bool:
|
|
"""Check if current directory is a git repository"""
|
|
result = run_command(['git', 'rev-parse', '--git-dir'], cwd=cwd)
|
|
return result['success']
|
|
|
|
|
|
def get_git_modified_files(patterns: Optional[List[str]] = None) -> List[str]:
|
|
"""Get git modified files"""
|
|
if not is_git_repo():
|
|
return []
|
|
|
|
result = run_command(['git', 'diff', '--name-only', 'HEAD'])
|
|
if not result['success']:
|
|
return []
|
|
|
|
files = result['output'].split('\n') if result['output'] else []
|
|
files = [f for f in files if f]
|
|
|
|
if patterns:
|
|
import re
|
|
files = [
|
|
f for f in files
|
|
if any(re.search(pattern, f) for pattern in patterns)
|
|
]
|
|
|
|
return files
|
|
|
|
|
|
def read_file(file_path: Path) -> Optional[str]:
|
|
"""Read a text file safely"""
|
|
try:
|
|
return file_path.read_text(encoding='utf-8')
|
|
except (FileNotFoundError, PermissionError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def write_file(file_path: Path, content: str) -> bool:
|
|
"""Write a text file"""
|
|
try:
|
|
ensure_dir(file_path.parent)
|
|
file_path.write_text(content, encoding='utf-8')
|
|
return True
|
|
except (OSError, PermissionError):
|
|
return False
|
|
|
|
|
|
def append_file(file_path: Path, content: str) -> bool:
|
|
"""Append to a text file"""
|
|
try:
|
|
ensure_dir(file_path.parent)
|
|
with open(file_path, 'a', encoding='utf-8') as f:
|
|
f.write(content)
|
|
return True
|
|
except (OSError, PermissionError):
|
|
return False
|
|
|
|
|
|
def replace_in_file(file_path: Path, search: str, replace: str) -> bool:
|
|
"""Replace text in a file (cross-platform sed alternative)"""
|
|
content = read_file(file_path)
|
|
if content is None:
|
|
return False
|
|
|
|
new_content = content.replace(search, replace)
|
|
return write_file(file_path, new_content)
|
|
|
|
|
|
def count_in_file(file_path: Path, pattern: str) -> int:
|
|
"""Count occurrences of a pattern in a file"""
|
|
content = read_file(file_path)
|
|
if content is None:
|
|
return 0
|
|
|
|
import re
|
|
matches = re.findall(pattern, content)
|
|
return len(matches)
|
|
|
|
|
|
def grep_file(file_path: Path, pattern: str) -> List[Dict[str, Any]]:
|
|
"""Search for pattern in file and return matching lines with line numbers"""
|
|
content = read_file(file_path)
|
|
if content is None:
|
|
return []
|
|
|
|
import re
|
|
lines = content.split('\n')
|
|
results = []
|
|
|
|
for idx, line in enumerate(lines, 1):
|
|
if re.search(pattern, line):
|
|
results.append({'line_number': idx, 'content': line})
|
|
|
|
return results
|
|
|
|
|
|
def get_platform_info() -> Dict[str, Any]:
|
|
"""Get platform information"""
|
|
return {
|
|
'system': platform.system(),
|
|
'release': platform.release(),
|
|
'version': platform.version(),
|
|
'machine': platform.machine(),
|
|
'processor': platform.processor(),
|
|
'is_windows': IS_WINDOWS,
|
|
'is_macos': IS_MACOS,
|
|
'is_linux': IS_LINUX,
|
|
'python_version': sys.version_info,
|
|
'home_dir': str(get_home_dir()),
|
|
'temp_dir': str(get_temp_dir()),
|
|
}
|
|
|
|
|
|
def log(message: str) -> None:
|
|
"""Log to stderr (visible to user in Claude Code)"""
|
|
print(message, file=sys.stderr)
|
|
|
|
|
|
def output(data: Any) -> None:
|
|
"""Output to stdout (returned to Claude)"""
|
|
if isinstance(data, (dict, list)):
|
|
import json
|
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
else:
|
|
print(data)
|
|
|
|
|
|
# Module exports
|
|
__all__ = [
|
|
# Platform info
|
|
'IS_WINDOWS',
|
|
'IS_MACOS',
|
|
'IS_LINUX',
|
|
'get_platform_info',
|
|
|
|
# Directories
|
|
'get_home_dir',
|
|
'get_claude_dir',
|
|
'get_temp_dir',
|
|
'get_plan_dir',
|
|
'ensure_dir',
|
|
|
|
# Date/Time
|
|
'get_date_string',
|
|
'get_time_string',
|
|
'get_datetime_string',
|
|
|
|
# File operations
|
|
'find_files',
|
|
'read_file',
|
|
'write_file',
|
|
'append_file',
|
|
'replace_in_file',
|
|
'count_in_file',
|
|
'grep_file',
|
|
|
|
# System
|
|
'command_exists',
|
|
'run_command',
|
|
'is_git_repo',
|
|
'get_git_modified_files',
|
|
|
|
# I/O
|
|
'log',
|
|
'output',
|
|
]
|
|
|
|
|
|
if __name__ == '__main__':
|
|
# Test platform detection
|
|
print("Platform Info:")
|
|
for key, value in get_platform_info().items():
|
|
print(f" {key}: {value}")
|