Skip to main content

#!/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 # Fix specific project python3 check-project-symlinks.py --verbose # Show details

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()