#!/usr/bin/env python3 """ Project Symlink Manager
Scans, validates, and fixes CODITECT symlinks (.coditect and .claude) across all projects in ~/PROJECTS/.
Usage:
python3 check-project-symlinks.py # Check only
python3 check-project-symlinks.py --fix # Fix all issues
python3 check-project-symlinks.py --fix
Part of /symlinks command implementation.
Created: 2026-01-24 """
import argparse import os import shutil import sys from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple
ANSI colors
Shared Colors module (consolidates 36 duplicate definitions)
_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors
def colorize(text: str, color: str) -> str: return f"{color}{text}{Colors.END}"
Paths
HOME = Path.home() PROJECTS_DIR = HOME / "PROJECTS" BACKUP_DIR = PROJECTS_DIR / "BU"
Platform-specific protected location
if sys.platform == "darwin": PROTECTED_LOC = HOME / "Library" / "Application Support" / "CODITECT" / "core" elif sys.platform == "win32": PROTECTED_LOC = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData" / "Local")) / "CODITECT" / "core" else: PROTECTED_LOC = Path(os.environ.get("XDG_DATA_HOME", HOME / ".local" / "share")) / "coditect" / "core"
Skip these directories when scanning
SKIP_DIRS = {'.git', 'node_modules', 'pycache', '.venv', 'venv', 'BU'}
class SymlinkStatus: VALID = "valid" # Resolves to protected location OUTDATED = "outdated" # Resolves to development copy BROKEN = "broken" # Target doesn't exist DIRECTORY = "directory" # Embedded copy, not symlink MISSING = "missing" # No .coditect at all
def get_status_display(status: str) -> str: """Get colored status display.""" if status == SymlinkStatus.VALID: return colorize("✅ VALID", Colors.GREEN) elif status == SymlinkStatus.OUTDATED: return colorize("⚠️ OUTDATED", Colors.YELLOW) elif status == SymlinkStatus.BROKEN: return colorize("❌ BROKEN", Colors.RED) elif status == SymlinkStatus.DIRECTORY: return colorize("❌ EMBEDDED", Colors.RED) else: return colorize("— NONE", Colors.CYAN)
def check_symlink(path: Path, expected_resolution: Path) -> Tuple[str, Optional[str], Optional[Path]]: """ Check a symlink's status.
Returns: (status, target, resolved_path)
"""
if not path.exists() and not path.is_symlink():
return SymlinkStatus.MISSING, None, None
if path.is_symlink():
target = os.readlink(path)
try:
resolved = path.resolve()
if resolved == expected_resolution:
return SymlinkStatus.VALID, target, resolved
elif "coditect-rollout-master" in str(resolved) or "coditect-core" in str(resolved):
return SymlinkStatus.OUTDATED, target, resolved
else:
return SymlinkStatus.BROKEN, target, resolved
except OSError:
return SymlinkStatus.BROKEN, target, None
elif path.is_dir():
return SymlinkStatus.DIRECTORY, None, None
else:
return SymlinkStatus.BROKEN, None, None
def scan_projects(verbose: bool = False) -> List[Dict]: """Scan all projects in ~/PROJECTS/ for symlink status.""" results = []
if not PROJECTS_DIR.exists():
print(colorize(f"⚠ Projects directory not found: {PROJECTS_DIR}", Colors.YELLOW))
return results
for item in sorted(PROJECTS_DIR.iterdir()):
if not item.is_dir() or item.name in SKIP_DIRS or item.name.startswith('.'):
continue
coditect_path = item / ".coditect"
claude_path = item / ".claude"
coditect_status, coditect_target, coditect_resolved = check_symlink(coditect_path, PROTECTED_LOC)
claude_status, claude_target, claude_resolved = check_symlink(claude_path, coditect_path)
# For .claude, valid means it points to .coditect
if claude_status == SymlinkStatus.VALID or (claude_path.is_symlink() and os.readlink(claude_path) == ".coditect"):
claude_status = SymlinkStatus.VALID
result = {
"name": item.name,
"path": item,
"coditect": {
"status": coditect_status,
"target": coditect_target,
"resolved": coditect_resolved,
},
"claude": {
"status": claude_status,
"target": claude_target,
"resolved": claude_resolved,
}
}
# Only include if has .coditect or .claude (or both missing counts as candidate)
if coditect_status != SymlinkStatus.MISSING or claude_status != SymlinkStatus.MISSING:
results.append(result)
return results
def fix_claude_md_symlink(dry_run: bool = False) -> bool: """Ensure CLAUDE.md -> CODITECT.md in protected installation (ADR-212).""" claude_md = PROTECTED_LOC / "CLAUDE.md" coditect_md = PROTECTED_LOC / "CODITECT.md"
if not coditect_md.exists():
print(colorize(" ⚠ CODITECT.md not found in protected installation", Colors.YELLOW))
return False
if claude_md.is_symlink() and os.readlink(claude_md) == "CODITECT.md":
return True # Already correct
print(f"\nFixing: CLAUDE.md -> CODITECT.md (ADR-212)")
if not dry_run:
if claude_md.exists() or claude_md.is_symlink():
# Need write permission to unlink in protected installation
try:
os.chmod(str(claude_md), 0o644)
except OSError:
pass
claude_md.unlink()
claude_md.symlink_to("CODITECT.md")
print(colorize(f" ✓ Created CLAUDE.md -> CODITECT.md", Colors.GREEN))
return True
def fix_project_symlinks(project_path: Path, dry_run: bool = False) -> bool: """Fix symlinks for a single project.""" project_name = project_path.name coditect_path = project_path / ".coditect" claude_path = project_path / ".claude"
print(f"\nFixing: {project_name}")
# Handle .coditect
if coditect_path.is_symlink():
if not dry_run:
coditect_path.unlink()
print(f" Removed old symlink: .coditect")
elif coditect_path.is_dir():
# Backup embedded directory
backup_path = BACKUP_DIR / f"{project_name}-coditect-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
if not dry_run:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
shutil.move(str(coditect_path), str(backup_path))
print(f" Backed up embedded .coditect to: {backup_path}")
# Create correct .coditect symlink
if not dry_run:
os.symlink("../.coditect", coditect_path)
print(f" Created: .coditect -> ../.coditect")
# Handle .claude
if claude_path.is_symlink():
if not dry_run:
claude_path.unlink()
elif claude_path.is_dir():
backup_path = BACKUP_DIR / f"{project_name}-claude-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
if not dry_run:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
shutil.move(str(claude_path), str(backup_path))
print(f" Backed up embedded .claude to: {backup_path}")
# Create correct .claude symlink
if not dry_run:
os.symlink(".coditect", claude_path)
print(f" Created: .claude -> .coditect")
print(colorize(f" ✓ Fixed: {project_name}", Colors.GREEN))
return True
def print_report(results: List[Dict], verbose: bool = False): """Print symlink status report.""" print() print(colorize("╔" + "═" * 70 + "╗", Colors.CYAN)) print(colorize("║" + "CODITECT Symlink Status".center(70) + "║", Colors.CYAN)) print(colorize("╠" + "═" * 70 + "╣", Colors.CYAN))
# Protected location status
if PROTECTED_LOC.exists():
print(colorize(f"║ Protected Location: {str(PROTECTED_LOC)[:48]:<48} ✅ ║", Colors.CYAN))
else:
print(colorize(f"║ Protected Location: NOT FOUND ❌ ║", Colors.RED))
# Root symlink status
root_coditect = PROJECTS_DIR / ".coditect"
if root_coditect.is_symlink():
target = os.readlink(root_coditect)
print(colorize(f"║ Root Symlink: ~/PROJECTS/.coditect ✅ ║", Colors.CYAN))
else:
print(colorize(f"║ Root Symlink: ~/PROJECTS/.coditect ❌ ║", Colors.RED))
# CLAUDE.md -> CODITECT.md in protected installation (ADR-212)
claude_md = PROTECTED_LOC / "CLAUDE.md"
coditect_md = PROTECTED_LOC / "CODITECT.md"
if coditect_md.exists() and claude_md.is_symlink() and os.readlink(claude_md) == "CODITECT.md":
print(colorize(f"║ CLAUDE.md -> CODITECT.md (ADR-212) ✅ ║", Colors.CYAN))
elif coditect_md.exists():
print(colorize(f"║ CLAUDE.md -> CODITECT.md (ADR-212) ❌ ║", Colors.RED))
else:
print(colorize(f"║ CODITECT.md: not found in protected location ⚠ ║", Colors.YELLOW))
print(colorize("╠" + "═" * 70 + "╣", Colors.CYAN))
print(colorize("║ " + f"{'Project':<24}│ {'.coditect':<18}│ {'.claude':<12}│ {'Status':<10}" + " ║", Colors.CYAN))
print(colorize("╠" + "═" * 70 + "╣", Colors.CYAN))
valid_count = 0
issue_count = 0
for result in results:
name = result["name"][:23]
coditect_status = result["coditect"]["status"]
claude_status = result["claude"]["status"]
# Determine overall status
if coditect_status == SymlinkStatus.VALID and claude_status == SymlinkStatus.VALID:
overall = colorize("✅ VALID", Colors.GREEN)
valid_count += 1
else:
overall = colorize("❌ FIX", Colors.RED)
issue_count += 1
# Format targets
coditect_display = result["coditect"]["target"] or coditect_status
if len(str(coditect_display)) > 16:
coditect_display = str(coditect_display)[:16] + ".."
claude_display = result["claude"]["target"] or claude_status
if len(str(claude_display)) > 10:
claude_display = str(claude_display)[:10] + ".."
print(f"║ {name:<24}│ {str(coditect_display):<18}│ {str(claude_display):<12}│ {overall:<10} ║")
if verbose and (coditect_status != SymlinkStatus.VALID or claude_status != SymlinkStatus.VALID):
if result["coditect"]["resolved"]:
print(f"║ └─ resolves to: {str(result['coditect']['resolved'])[:50]:<50} ║")
print(colorize("╚" + "═" * 70 + "╝", Colors.CYAN))
print()
print(f"Summary: {colorize(str(valid_count), Colors.GREEN)} valid, {colorize(str(issue_count), Colors.RED) if issue_count else '0'} need fixing")
return issue_count
def main(): parser = argparse.ArgumentParser(description="CODITECT Project Symlink Manager") parser.add_argument("--fix", nargs="?", const="all", default=None, help="Fix symlinks (optionally specify project name)") parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes") parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed information")
args = parser.parse_args()
print(colorize("\nCODITECT Project Symlink Manager", Colors.BOLD))
print("=" * 40)
# Verify protected installation
if not PROTECTED_LOC.exists():
print(colorize(f"\n⚠ Protected installation not found: {PROTECTED_LOC}", Colors.YELLOW))
print("Run CODITECT-CORE-INITIAL-SETUP.py first.")
sys.exit(1)
# Scan projects
results = scan_projects(args.verbose)
if not results:
print("\nNo projects with .coditect found in ~/PROJECTS/")
sys.exit(0)
# Print report
issue_count = print_report(results, args.verbose)
# Fix if requested
if args.fix:
# Always check CLAUDE.md -> CODITECT.md in protected installation (ADR-212)
fix_claude_md_symlink(args.dry_run)
if args.fix == "all":
# Fix all projects with issues
to_fix = [r for r in results if r["coditect"]["status"] != SymlinkStatus.VALID
or r["claude"]["status"] != SymlinkStatus.VALID]
if not to_fix:
print(colorize("\n✓ All symlinks are valid, nothing to fix.", Colors.GREEN))
sys.exit(0)
print(f"\nFixing {len(to_fix)} project(s)..." + (" (dry run)" if args.dry_run else ""))
for result in to_fix:
fix_project_symlinks(result["path"], args.dry_run)
else:
# Fix specific project
project_path = PROJECTS_DIR / args.fix
if not project_path.exists():
print(colorize(f"\n⚠ Project not found: {args.fix}", Colors.RED))
sys.exit(1)
fix_project_symlinks(project_path, args.dry_run)
if not args.dry_run:
print(colorize("\n✓ Fixes applied. Run again to verify.", Colors.GREEN))
elif issue_count > 0:
print(f"\nTo fix issues, run: python3 {sys.argv[0]} --fix")
if name == "main": main()