- Stop tracking settings.local.json; commit base settings.json instead - Scope file permissions to workspace/ and .claude/ as safer default - Add .gitignore entries for .claude runtime files (scheduled_tasks, keybindings) - Annotate _LINK_MAP in init.py as user-customizable - Strip personal references from workspace CLAUDE.md template
189 lines
5.5 KiB
Python
189 lines
5.5 KiB
Python
"""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.
|
|
#
|
|
# Customize this dict for your own projects. Each entry creates a symlink
|
|
# from workspace/<name> to the target path resolved from one of the env vars.
|
|
# Users define the corresponding env vars in their shell config (e.g. ~/.zshrc).
|
|
_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
|