"""Sync workspace symlinks from zshrc environment variables.""" import os import subprocess from pathlib import Path from rich.console import Console from bin.project_root import get_myclaude_project_root, get_workspace_root console = Console() stderr_console = Console(stderr=True) # Link name -> list of environment variable names to try, in order. _LINK_MAP: dict[str, list[str]] = { "myacademia": ["path_myacademia"], "myslides": ["path_myslides", "MYSLIDES_ROOT"], "metabot": ["METABOT_HOME"], "mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"], "mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"], } def _read_zsh_env(var: str, zshrc: Path) -> str | None: """Source zshrc and print the value of a variable.""" cmd = [ "zsh", "-c", f"source '{zshrc}' >/dev/null 2>&1; echo -n ${var}", ] try: result = subprocess.run( cmd, capture_output=True, text=True, check=False, timeout=10, ) except (OSError, subprocess.TimeoutExpired): return None value = result.stdout.strip() return value if value else None def _resolve_target( env_vars: list[str], zshrc: Path, ) -> Path | None: """Find the first valid target path from environment variables.""" for var in env_vars: raw = _read_zsh_env(var, zshrc) if raw is None: continue expanded = os.path.expandvars(os.path.expanduser(raw)) path = Path(expanded) if path.exists(): return path.resolve() return None def _current_link_target(workspace: Path, name: str) -> Path | None: """Return the target of an existing symlink, or None.""" link = workspace / name if link.is_symlink(): try: return Path(os.readlink(link)) except OSError: return None return None def _sync_link( workspace: Path, name: str, target: Path, dry_run: bool, force: bool, ) -> bool: """Create or update a symlink. Returns True if changed.""" link = workspace / name current = _current_link_target(workspace, name) if current is not None and current.resolve() == target.resolve(): return False if link.exists() and not link.is_symlink(): if not force: stderr_console.print( f"[yellow]{name} exists but is not a symlink. " "Use --force to replace.[/yellow]" ) return False if dry_run: console.print( f"[dry-run] Would remove {name} and symlink -> {target}" ) return True if link.is_dir(): link.rmdir() else: link.unlink() elif current is not None: if dry_run: console.print( f"[dry-run] Would update {name}: {current} -> {target}" ) return True link.unlink() else: if dry_run: console.print(f"[dry-run] Would create {name} -> {target}") return True link.symlink_to(target, target_is_directory=target.is_dir()) return True def sync_workspace_links( zshrc: Path | None = None, dry_run: bool = False, force: bool = False, verbose: bool = True, ) -> bool: """Sync all workspace symlinks. Returns True on success.""" workspace = get_workspace_root() if zshrc is None: zshrc = Path.home() / ".zshrc" if verbose: console.print(f"[bold]Workspace:[/bold] {workspace}") console.print(f"[bold]Zshrc:[/bold] {zshrc}") console.print() changed = 0 skipped = 0 broken = 0 # Handle myclaude separately: use project root detection, not zshrc. myclaude_target = get_myclaude_project_root() if _sync_link(workspace, "myclaude", myclaude_target, dry_run, force): action = "would update" if dry_run else "updated" if verbose: console.print( f"[green]myclaude: {action} -> {myclaude_target}[/green]" ) changed += 1 else: if verbose: console.print("[dim]myclaude: already correct[/dim]") for name, env_vars in _LINK_MAP.items(): target = _resolve_target(env_vars, zshrc) if target is None: link = workspace / name if link.is_symlink() and not link.exists(): if verbose: stderr_console.print( f"[red]{name}: broken symlink (no env var found)[/red]" ) broken += 1 else: if verbose: console.print(f"[dim]{name}: skipped (no env var)[/dim]") skipped += 1 continue if _sync_link(workspace, name, target, dry_run, force): action = "would update" if dry_run else "updated" if verbose: console.print(f"[green]{name}: {action} -> {target}[/green]") changed += 1 else: if verbose: console.print(f"[dim]{name}: already correct[/dim]") if verbose: console.print() label = "Preview" if dry_run else "Done" console.print( f"[bold]{label}:[/bold] {changed} changed, " f"{skipped} skipped, {broken} broken." ) return broken == 0