Skip to content

Instantly share code, notes, and snippets.

@FalconNL93
Last active March 20, 2026 07:47
Show Gist options
  • Select an option

  • Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.

Select an option

Save FalconNL93/b40028ea05ca8638356783c0b800a9e5 to your computer and use it in GitHub Desktop.
Dotnet Helper Script
{
"min_major": 5,
"dotnet_dir": "~/.dotnet",
"tools": [
"dotnet-ef",
"csharpier",
"dotnet-outdated-tool"
],
"workloads": {
"update": true,
"from_previous_sdk": true
},
"unlink_brew": true,
"cleanup_old_versions": true,
"verbose": false
}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
# ------------------------- Defaults (overridable by config.json) --------------
MIN_MAJOR_DEFAULT = 5
DOTNET_DIR_DEFAULT = Path.home() / ".dotnet"
INSTALL_SCRIPT = Path.home() / "dotnet-install.sh"
RELEASES_URL = "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/releases-index.json"
DOTNET_INSTALL_SH_URL = "https://dot.net/v1/dotnet-install.sh"
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "config.json"
BLOCK_BEGIN = "# BEGIN .NET SDK setup"
BLOCK_END = "# END .NET SDK setup"
BLOCK_CONTENT = (
"""# BEGIN .NET SDK setup
export DOTNET_ROOT="$HOME/.dotnet"
case ":$PATH:" in
*":$DOTNET_ROOT:"*) ;;
*) export PATH="$DOTNET_ROOT:$PATH" ;;
esac
case ":$PATH:" in
*":$HOME/.dotnet/tools:"*) ;;
*) export PATH="$HOME/.dotnet/tools:$PATH" ;;
esac
export DOTNET_CLI_TELEMETRY_OPTOUT="true"
export DOTNET_NOLOGO="true"
export DOTNET_SKIP_FIRST_TIME_EXPERIENCE="true"
# END .NET SDK setup
""".rstrip()
+ "\n"
)
TOOL_SPEC_RE = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._-]*(?:@[A-Za-z0-9][A-Za-z0-9._-]*)?$"
)
NON_POSIX_SHELLS = {"fish", "nu", "nushell", "xonsh", "elvish", "powershell", "pwsh"}
# ------------------------- Runtime config (loaded from config.json) -----------
class Config:
def __init__(self) -> None:
self.min_major: int = MIN_MAJOR_DEFAULT
self.dotnet_dir: Path = DOTNET_DIR_DEFAULT
self.channels: Optional[List[str]] = None # None => auto-detect
self.tools: List[str] = []
self.workload_update: bool = True
self.workload_from_previous_sdk: bool = True
self.unlink_brew: bool = True # Auto-unlink Homebrew dotnet on macOS
self.cleanup_old_versions: bool = True # Remove old minor versions after update
self.verbose: bool = False # Show detailed output
@property
def dotnet_path(self) -> Path:
return self.dotnet_dir / "dotnet"
def dotnet_cmd(self) -> str:
if self.dotnet_path.exists() and os.access(self.dotnet_path, os.X_OK):
return str(self.dotnet_path)
return "dotnet"
CFG = Config()
RC_CHANGED = False
FORCE = False
VERBOSE = False
# ------------------------- basic utils ---------------------------------------
def die(msg: str, code: int = 1) -> None:
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def info(msg: str) -> None:
print(msg)
def verbose_info(msg: str) -> None:
"""Print info message only in verbose mode."""
if VERBOSE:
print(msg)
def is_tty() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def run(
cmd: List[str],
*,
check: bool = True,
capture: bool = False,
cwd: Optional[Path] = None,
) -> subprocess.CompletedProcess:
if capture:
return subprocess.run(
cmd,
text=True,
cwd=str(cwd) if cwd else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return subprocess.run(cmd, text=True, cwd=str(cwd) if cwd else None, check=check)
def _as_bool(v: Any, default: bool) -> bool:
return v if isinstance(v, bool) else default
def _as_int(v: Any, default: int) -> int:
if isinstance(v, int):
return v
if isinstance(v, str) and v.strip().isdigit():
return int(v.strip())
return default
def _as_str_list(v: Any) -> Optional[List[str]]:
if not isinstance(v, list):
return None
out: List[str] = []
for item in v:
if isinstance(item, str) and item.strip():
out.append(item.strip())
return out
def _expand_path(p: str) -> Path:
return Path(os.path.expanduser(p)).resolve()
def is_linux() -> bool:
return sys.platform.startswith("linux")
# ------------------------- config.json ---------------------------------------
def load_config() -> None:
if not CONFIG_FILE.exists():
return
try:
cfg = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
except Exception as e:
die(f"Failed to parse {CONFIG_FILE}: {e}")
if not isinstance(cfg, dict):
die(f"{CONFIG_FILE} must contain a JSON object at the root")
CFG.min_major = _as_int(cfg.get("min_major"), CFG.min_major)
dotnet_dir = cfg.get("dotnet_dir")
if isinstance(dotnet_dir, str) and dotnet_dir.strip():
CFG.dotnet_dir = _expand_path(dotnet_dir.strip())
channels = _as_str_list(cfg.get("channels"))
if channels is not None:
CFG.channels = sorted(set(channels), key=natural_channel_sort_key)
tools = _as_str_list(cfg.get("tools"))
if tools is not None:
cleaned: List[str] = []
for t in tools:
if TOOL_SPEC_RE.match(t):
cleaned.append(t)
else:
info(f"WARN: Skipping invalid tool spec in config.json: {t}")
CFG.tools = cleaned
workloads = cfg.get("workloads")
if isinstance(workloads, dict):
CFG.workload_update = _as_bool(workloads.get("update"), CFG.workload_update)
CFG.workload_from_previous_sdk = _as_bool(
workloads.get("from_previous_sdk"), CFG.workload_from_previous_sdk
)
CFG.unlink_brew = _as_bool(cfg.get("unlink_brew"), CFG.unlink_brew)
CFG.cleanup_old_versions = _as_bool(
cfg.get("cleanup_old_versions"), CFG.cleanup_old_versions
)
CFG.verbose = _as_bool(cfg.get("verbose"), CFG.verbose)
# ------------------------- macOS: Homebrew dependencies ----------------------
DOTNET_DEPS_BREW: List[str] = [
"openssl@3",
"icu4c",
]
def macos_brew_pkg_is_installed(pkg: str) -> bool:
brew_cmd = shutil.which("brew")
if not brew_cmd:
return False
r = run([brew_cmd, "list", "--formula", pkg], check=False, capture=True)
return r.returncode == 0
def macos_install_brew_packages(pkgs: List[str]) -> None:
brew_cmd = shutil.which("brew")
if not brew_cmd:
info("WARN: Homebrew not found. Cannot install macOS dependencies.")
return
info("Installing missing dependencies via Homebrew...")
for pkg in pkgs:
r = run([brew_cmd, "install", pkg], check=False)
if r.returncode != 0:
verbose_info(f" Skipping {pkg}: could not be installed.")
# ------------------------- Linux: package manager / dependencies -------------
def linux_detect_package_manager() -> Optional[str]:
"""
Returns: "apt", "dnf", "pacman" or None.
"""
if shutil.which("apt-get") and shutil.which("dpkg"):
return "apt"
if shutil.which("dnf") and shutil.which("rpm"):
return "dnf"
if shutil.which("pacman"):
return "pacman"
return None
def linux_pkg_is_installed(pm: str, pkg: str) -> bool:
if pm == "apt":
r = run(["dpkg", "-s", pkg], check=False, capture=True)
return r.returncode == 0
if pm == "dnf":
r = run(["rpm", "-q", pkg], check=False, capture=True)
return r.returncode == 0
if pm == "pacman":
r = run(["pacman", "-Qi", pkg], check=False, capture=True)
return r.returncode == 0
return False
def linux_install_packages(pm: str, pkgs: List[str]) -> None:
if not pkgs:
return
if pm == "apt":
info("Installing missing dependencies via apt...")
run(["sudo", "apt-get", "update"], check=False)
for pkg in pkgs:
r = run(["sudo", "apt-get", "install", "-y", pkg], check=False)
if r.returncode != 0:
verbose_info(f" Skipping {pkg}: could not be installed.")
return
if pm == "dnf":
info("Installing missing dependencies via dnf...")
for pkg in pkgs:
r = run(["sudo", "dnf", "install", "-y", pkg], check=False)
if r.returncode != 0:
verbose_info(f" Skipping {pkg}: could not be installed.")
return
if pm == "pacman":
info("Installing missing dependencies via pacman...")
for pkg in pkgs:
r = run(["sudo", "pacman", "-Sy", "--needed", pkg], check=False)
if r.returncode != 0:
verbose_info(f" Skipping {pkg}: could not be installed.")
return
die(f"Unsupported package manager: {pm}")
# Dependencies that share the same package name across all supported Linux package managers.
DOTNET_DEPS_GLOBAL: List[str] = [
"ca-certificates",
]
# Per-package-manager dependencies (names differ per distro family).
DOTNET_DEPS_APT: List[str] = [
"zlib1g",
"libstdc++6",
"libicu74",
"libssl3",
"libkrb5-3",
]
DOTNET_DEPS_DNF: List[str] = [
"zlib",
"libstdc++",
"libicu",
"openssl-libs",
"krb5-libs",
]
DOTNET_DEPS_PACMAN: List[str] = [
"zlib",
"gcc-libs",
"icu",
"openssl",
"krb5",
]
def linux_dotnet_deps(pm: str) -> List[str]:
"""Return the full dependency list for the given package manager."""
if pm == "apt":
return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_APT
if pm == "dnf":
return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_DNF
if pm == "pacman":
return DOTNET_DEPS_GLOBAL + DOTNET_DEPS_PACMAN
return list(DOTNET_DEPS_GLOBAL)
def linux_ensure_dependencies() -> None:
"""
Installs missing dependencies on Linux, idempotently.
"""
if not is_linux():
return
pm = linux_detect_package_manager()
if pm is None:
info(
"WARN: Could not detect a supported package manager (apt/dnf/pacman). Skipping dependency install."
)
return
wanted = linux_dotnet_deps(pm)
missing = [p for p in wanted if not linux_pkg_is_installed(pm, p)]
if not missing:
verbose_info("Linux dependencies: already satisfied.")
return
info("Linux dependencies missing:")
for p in missing:
info(f" - {p}")
linux_install_packages(pm, missing)
def ensure_dependencies() -> None:
"""Abstract: install any OS-level dependencies required by .NET."""
linux_ensure_dependencies()
# ------------------------- dotnet install script ------------------------------
def ensure_install_script() -> None:
if INSTALL_SCRIPT.exists() and INSTALL_SCRIPT.stat().st_size > 0:
return
verbose_info("Downloading dotnet-install.sh...")
try:
with urllib.request.urlopen(DOTNET_INSTALL_SH_URL, timeout=60) as r:
data = r.read()
INSTALL_SCRIPT.write_bytes(data)
INSTALL_SCRIPT.chmod(0o755)
except Exception as e:
die(f"Failed downloading dotnet-install.sh: {e}")
# ------------------------- releases / channels --------------------------------
def fetch_releases_index() -> Dict[str, Any]:
try:
with urllib.request.urlopen(RELEASES_URL, timeout=30) as r:
return json.load(r)
except Exception as e:
die(f"Failed fetching releases index: {e}")
raise RuntimeError("unreachable")
def parse_channel_major(channel: str) -> Optional[int]:
m = re.match(r"^(\d+)\.", channel)
return int(m.group(1)) if m else None
def natural_channel_sort_key(ch: str) -> Tuple[int, int, str]:
m = re.match(r"^(\d+)\.(\d+)(.*)$", ch)
if not m:
return (9999, 9999, ch)
return (int(m.group(1)), int(m.group(2)), m.group(3) or "")
def normalize_channel_arg(arg: str) -> str:
a = arg.strip()
if re.fullmatch(r"\d+", a):
return f"{int(a)}.0"
if re.fullmatch(r"\d+\.\d+", a):
maj, minor = a.split(".", 1)
return f"{int(maj)}.{int(minor)}"
die(f"Invalid channel argument: {arg}. Use like: install 8 or install 8.0")
raise RuntimeError("unreachable")
def is_stable_channel(ch: str) -> bool:
return re.fullmatch(r"\d+\.\d+", ch) is not None
def _iter_channels(index: Dict[str, Any]):
for entry in index.get("releases-index", []):
ch = entry.get("channel-version")
if not isinstance(ch, str):
continue
major = parse_channel_major(ch)
if major is not None and major >= CFG.min_major:
yield ch, entry
def get_channels_filtered(index: Dict[str, Any]) -> List[str]:
chans = {ch for ch, _ in _iter_channels(index)}
return sorted(chans, key=natural_channel_sort_key)
def get_latest_release_per_channel(index: Dict[str, Any]) -> List[Tuple[str, str]]:
pairs: List[Tuple[str, str]] = []
for ch, entry in _iter_channels(index):
lr = entry.get("latest-release")
if isinstance(lr, str):
pairs.append((ch, lr))
return sorted(pairs, key=lambda p: natural_channel_sort_key(p[0]))
def latest_stable_channel(index: Dict[str, Any]) -> str:
channels = [c for c in get_channels_filtered(index) if is_stable_channel(c)]
if not channels:
die("Could not determine latest stable channel from releases index.")
return channels[-1]
def installed_channels() -> List[str]:
sdk_dir = CFG.dotnet_dir / "sdk"
if not sdk_dir.exists():
return []
out: Set[str] = set()
for d in sdk_dir.iterdir():
if not d.is_dir():
continue
parts = d.name.split(".")
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
out.add(f"{parts[0]}.{parts[1]}")
return sorted(out, key=natural_channel_sort_key)
# ------------------------- dynamic shell / rc detection -----------------------
def detect_shell_binaries() -> List[str]:
shells: Set[str] = set()
env_shell = os.environ.get("SHELL", "")
if env_shell:
shells.add(Path(env_shell).name)
etc_shells = Path("/etc/shells")
if etc_shells.exists():
try:
for line in etc_shells.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
shells.add(Path(line).name)
except Exception:
pass
for name in ("bash", "zsh", "ksh", "mksh", "dash", "sh", "busybox"):
if shutil.which(name):
shells.add(name)
return sorted(shells)
def is_posixish_shell(shell_name: str) -> bool:
return shell_name.lower() not in NON_POSIX_SHELLS
def canonical_rc_for_current_shell(shell_name: str) -> List[Path]:
home = Path.home()
name = shell_name.lower()
if name.endswith("zsh"):
return [home / ".zshrc"]
if name.endswith("bash"):
return [home / ".bashrc"]
if name in ("ksh", "mksh"):
return [home / f".{name}rc"]
return [home / f".{shell_name}rc"]
def existing_rc_candidates_for_shell(shell_name: str) -> List[Path]:
home = Path.home()
name = shell_name
candidates = [
home / f".{name}rc",
home / f".{name}_rc",
home / f".{name}profile",
home / f".{name}_profile",
home / f".{name}env",
home / f".{name}_env",
home / f".{name}login",
home / f".{name}_login",
home / ".config" / name / "rc",
home / ".config" / name / f"{name}rc",
home / ".config" / name / f"config.{name}",
]
return [p for p in candidates if p.exists()]
def rc_files_to_patch() -> List[Path]:
shells = detect_shell_binaries()
files: List[Path] = []
for sh_name in shells:
if not is_posixish_shell(sh_name):
continue
files.extend(existing_rc_candidates_for_shell(sh_name))
env_shell = os.environ.get("SHELL", "")
if env_shell:
sh = Path(env_shell).name
if is_posixish_shell(sh):
files.extend(canonical_rc_for_current_shell(sh))
seen: Set[Path] = set()
out: List[Path] = []
for p in files:
if p not in seen:
out.append(p)
seen.add(p)
return out
# ------------------------- rc block editing ----------------------------------
def ensure_rc_block(rc: Path) -> bool:
global RC_CHANGED
rc.parent.mkdir(parents=True, exist_ok=True)
if not rc.exists():
rc.write_text("")
text = rc.read_text()
if BLOCK_BEGIN in text and BLOCK_END in text:
start = text.index(BLOCK_BEGIN)
end_idx = text.index(BLOCK_END, start) + len(BLOCK_END)
# Consume the trailing newline that belongs to the block so it isn't
# duplicated when the block is rewritten.
if end_idx < len(text) and text[end_idx] == "\n":
end_idx += 1
current = text[start:end_idx].rstrip("\n")
desired = BLOCK_CONTENT.rstrip("\n")
if current == desired:
return False
rc.write_text(text[:start] + BLOCK_CONTENT + text[end_idx:])
RC_CHANGED = True
return True
new_text = text
if new_text and not new_text.endswith("\n"):
new_text += "\n"
if new_text and not new_text.endswith("\n\n"):
new_text += "\n"
rc.write_text(new_text + BLOCK_CONTENT)
RC_CHANGED = True
return True
def remove_rc_block(rc: Path) -> bool:
global RC_CHANGED
if not rc.exists():
return False
text = rc.read_text()
if BLOCK_BEGIN not in text or BLOCK_END not in text:
return False
start = text.index(BLOCK_BEGIN)
end_idx = text.index(BLOCK_END, start) + len(BLOCK_END)
# Consume the trailing newline that belongs to the block so the surrounding
# content isn't left with a stray leading newline.
if end_idx < len(text) and text[end_idx] == "\n":
end_idx += 1
new_text = text[:start] + text[end_idx:]
new_text = re.sub(r"\n{4,}", "\n\n", new_text)
rc.write_text(new_text)
RC_CHANGED = True
return True
def patch_rc_files() -> None:
for rc in rc_files_to_patch():
ensure_rc_block(rc)
def unpatch_rc_files() -> None:
for rc in rc_files_to_patch():
remove_rc_block(rc)
# ------------------------- CLI checklist (curses) -----------------------------
def select_channels_curses(channels: List[str], prechecked: List[str]) -> List[str]:
if not is_tty():
if prechecked:
return prechecked[:]
die("No TTY available for interactive selection.", 1)
raise RuntimeError("unreachable")
try:
import curses # stdlib
except Exception:
return select_channels_fallback_text(channels, prechecked)
pre = set(prechecked)
checked = [c in pre for c in channels]
cursor_visible = 0
filter_text = ""
def visible_indices() -> List[int]:
if not filter_text:
return list(range(len(channels)))
ft = filter_text.lower()
return [i for i, ch in enumerate(channels) if ft in ch.lower()]
def selected_count() -> int:
return sum(1 for v in checked if v)
def prompt_filter(stdscr) -> None:
nonlocal filter_text, cursor_visible
h, w = stdscr.getmaxyx()
prompt = "Filter: "
stdscr.move(h - 1, 0)
stdscr.clrtoeol()
stdscr.addnstr(h - 1, 0, prompt, max(0, w - 1))
stdscr.refresh()
curses.echo()
try:
raw = stdscr.getstr(
h - 1,
min(len(prompt), max(0, w - 1)),
max(0, w - len(prompt) - 1),
)
try:
s = raw.decode("utf-8", errors="ignore").strip()
except Exception:
s = str(raw).strip()
filter_text = s
finally:
curses.noecho()
cursor_visible = 0
help_line = "↑/↓ j/k Space Enter / a n i q/Esc"
def ui(stdscr):
nonlocal cursor_visible
curses.curs_set(0)
stdscr.keypad(True)
while True:
stdscr.erase()
h, w = stdscr.getmaxyx()
vis = visible_indices()
stdscr.addnstr(0, 0, "Select .NET SDK channels", max(0, w - 1))
stdscr.hline(1, 0, "-", max(0, w - 1))
if not vis:
msg = f'No matches for filter: "{filter_text}" (press / to change; empty clears)'
stdscr.addnstr(3, 0, msg, max(0, w - 1))
stdscr.hline(h - 2, 0, "-", max(0, w - 1))
footer = f"Selected: {selected_count()}/{len(channels)} Filter: {filter_text or '—'} {help_line}"
stdscr.addnstr(h - 1, 0, footer, max(0, w - 1))
stdscr.refresh()
key = stdscr.getch()
if key == ord("/"):
prompt_filter(stdscr)
elif key in (27, ord("q")):
return []
elif key in (curses.KEY_ENTER, 10, 13):
curses.flash()
continue
cursor_visible = max(0, min(cursor_visible, len(vis) - 1))
view_height = max(1, h - 4)
view_top = max(
0, min(cursor_visible - view_height + 1, len(vis) - view_height)
)
view_top = max(0, view_top)
for row in range(view_height):
vpos = view_top + row
if vpos >= len(vis):
break
oi = vis[vpos]
mark = "[x]" if checked[oi] else "[ ]"
line = f"{mark} {channels[oi]}"
y = 2 + row
if vpos == cursor_visible:
stdscr.attron(curses.A_REVERSE)
stdscr.addnstr(y, 0, line, max(0, w - 1))
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addnstr(y, 0, line, max(0, w - 1))
stdscr.hline(h - 2, 0, "-", max(0, w - 1))
footer = f"Selected: {selected_count()}/{len(channels)} Filter: {filter_text or '—'} {help_line}"
stdscr.addnstr(h - 1, 0, footer, max(0, w - 1))
stdscr.refresh()
key = stdscr.getch()
if key in (curses.KEY_UP, ord("k")):
cursor_visible = max(0, cursor_visible - 1)
elif key in (curses.KEY_DOWN, ord("j")):
cursor_visible = min(len(vis) - 1, cursor_visible + 1)
elif key == ord(" "):
oi = vis[cursor_visible]
checked[oi] = not checked[oi]
elif key in (curses.KEY_ENTER, 10, 13):
selected = [ch for ch, on in zip(channels, checked) if on]
if not selected:
curses.flash()
continue
return selected
elif key in (27, ord("q")):
return []
elif key == ord("/"):
prompt_filter(stdscr)
elif key == ord("a"):
for oi in vis:
checked[oi] = True
elif key == ord("n"):
for oi in vis:
checked[oi] = False
elif key == ord("i"):
for oi in vis:
checked[oi] = not checked[oi]
try:
selected = curses.wrapper(ui) # type: ignore
except KeyboardInterrupt:
die("Cancelled.", code=130)
except Exception:
return select_channels_fallback_text(channels, prechecked)
if not selected:
die("No channels selected (cancelled).", code=130)
return selected
def select_channels_fallback_text(
channels: List[str], prechecked: List[str]
) -> List[str]:
pre = set(prechecked)
info("Select .NET SDK channels to install/update:")
info("")
for i, ch in enumerate(channels, start=1):
mark = "*" if ch in pre else " "
info(f" [{i:>2}] {mark} {ch}")
info("")
info(
"Enter numbers separated by commas/spaces (e.g. 1 3 5) or channel versions (e.g. 8.0 10.0)."
)
info(
"Press Enter to use installed channels."
if prechecked
else "Press Enter to cancel."
)
raw = input("> ").strip()
if not raw:
if prechecked:
return list(prechecked)
die("No channels selected.", code=130)
tokens = re.split(r"[,\s]+", raw)
by_index = {str(i): ch for i, ch in enumerate(channels, start=1)}
by_value = set(channels)
selected: List[str] = []
for t in tokens:
if not t:
continue
if t in by_index:
selected.append(by_index[t])
elif t in by_value:
selected.append(t)
else:
die(f"Invalid selection token: {t}")
order = {c: i for i, c in enumerate(channels)}
selected = sorted(set(selected), key=lambda x: order.get(x, 10**9))
if not selected:
die("No channels selected.", code=130)
return selected
# ------------------------- workloads / tools ----------------------------------
def update_workloads() -> None:
if not CFG.workload_update:
return
info("Updating .NET workloads...")
cmd = [CFG.dotnet_cmd(), "workload", "update"]
if CFG.workload_from_previous_sdk:
cmd.append("--from-previous-sdk")
run(cmd, check=False)
def get_installed_global_tools() -> List[str]:
"""Get list of currently installed global tools."""
if not CFG.dotnet_path.exists():
return []
p = run([CFG.dotnet_cmd(), "tool", "list", "-g"], capture=True, check=False)
if p.returncode != 0:
return []
txt = (p.stdout or "").strip()
if not txt:
return []
ids: List[str] = []
for line in txt.splitlines():
line = line.strip()
if not line or line.lower().startswith("package id") or set(line) == {"-"}:
continue
parts = line.split()
if parts:
ids.append(parts[0])
return ids
def update_global_tools() -> None:
info("Updating global .NET tools...")
p = run([CFG.dotnet_cmd(), "tool", "list", "-g"], capture=True)
txt = (p.stdout or "").strip()
if not txt:
info("No global tools found (or dotnet tool list returned no output).")
return
ids: List[str] = []
for line in txt.splitlines():
line = line.strip()
if not line or line.lower().startswith("package id") or set(line) == {"-"}:
continue
parts = line.split()
if parts:
ids.append(parts[0])
if not ids:
info("No global tools found.")
return
dn = CFG.dotnet_cmd()
for tool_id in ids:
info(f"Updating tool: {tool_id}")
run([dn, "tool", "update", "-g", tool_id], check=False)
def ensure_tools_from_config() -> None:
if not CFG.tools:
return
info("Ensuring global tools from config.json...")
dn = CFG.dotnet_cmd()
for spec in CFG.tools:
if "@" in spec:
tool_id, ver = spec.split("@", 1)
info(f"\n=== Tool: {tool_id} (version {ver}) ===")
r = run(
[dn, "tool", "update", "-g", tool_id, "--version", ver], check=False
)
if r.returncode != 0:
run(
[dn, "tool", "install", "-g", tool_id, "--version", ver],
check=False,
)
else:
tool_id = spec
info(f"\n=== Tool: {tool_id} ===")
r = run([dn, "tool", "update", "-g", tool_id], check=False)
if r.returncode != 0:
run([dn, "tool", "install", "-g", tool_id], check=False)
def post_update_maintenance() -> None:
update_workloads()
update_global_tools()
ensure_tools_from_config()
# ------------------------- macOS: code signatures / quarantine ---------------
def macos_fix_code_signatures() -> None:
"""
Fix code signature issues on macOS by clearing all extended attributes and re-signing.
IMPORTANT: This should be called AFTER all dotnet operations (workload/tool updates)
because those operations download/modify files and can re-introduce quarantine flags
or break code signatures. This is why signature errors appear randomly - they occur
when workloads or tools are updated, not during initial SDK installation.
"""
if sys.platform != "darwin":
return
if not CFG.dotnet_dir.exists():
return
verbose_info("Fixing macOS code signatures and quarantine flags...")
# Step 1: Clear ALL extended attributes recursively (more aggressive)
if shutil.which("xattr"):
verbose_info(" Clearing extended attributes...")
# -c clears all attributes, -r is recursive
run(["xattr", "-cr", str(CFG.dotnet_dir)], check=False)
# Step 2: Re-sign critical binaries with ad-hoc signature
if shutil.which("codesign"):
verbose_info(" Re-signing binaries...")
dotnet_exe = CFG.dotnet_dir / "dotnet"
if dotnet_exe.exists():
# Remove existing signature first
run(["codesign", "--remove-signature", str(dotnet_exe)], check=False)
# Re-sign with ad-hoc signature (allows local execution)
run(
["codesign", "--force", "--deep", "--sign", "-", str(dotnet_exe)],
check=False,
)
# Also handle host executable
host_exe = CFG.dotnet_dir / "host" / "fxr"
if host_exe.exists() and host_exe.is_dir():
for version_dir in host_exe.iterdir():
if version_dir.is_dir():
for exe in version_dir.glob("*"):
if exe.is_file() and os.access(exe, os.X_OK):
run(
["codesign", "--remove-signature", str(exe)],
check=False,
)
run(
["codesign", "--force", "--sign", "-", str(exe)],
check=False,
)
def apply_platform_fixes() -> None:
"""Abstract: apply any OS-level fixes needed after SDK or tool operations."""
macos_fix_code_signatures()
def clear_symbol_cache() -> None:
symbol_cache = CFG.dotnet_dir / "symbolcache"
if symbol_cache.exists():
verbose_info("Clearing .NET symbol cache...")
shutil.rmtree(symbol_cache, ignore_errors=True)
def remove_channel_sdks(channel: str) -> None:
"""Remove installed SDK directories for a channel so dotnet-install.sh performs a clean reinstall."""
sdk_dir = CFG.dotnet_dir / "sdk"
if not sdk_dir.exists():
return
prefix = channel + "."
for d in sdk_dir.iterdir():
if d.is_dir() and d.name.startswith(prefix):
info(f" Removing SDK {d.name} for forced reinstall...")
shutil.rmtree(d, ignore_errors=True)
def uninstall_channels(channels: List[str]) -> None:
"""Uninstall specific SDK channels."""
if not channels:
return
sdk_dir = CFG.dotnet_dir / "sdk"
if not sdk_dir.exists():
return
for ch in channels:
info(f"Uninstalling .NET SDK channel: {ch}")
prefix = ch + "."
removed_any = False
for d in sdk_dir.iterdir():
if d.is_dir() and d.name.startswith(prefix):
info(f" Removing SDK {d.name}...")
shutil.rmtree(d, ignore_errors=True)
removed_any = True
if not removed_any:
info(f" No installed SDKs found for channel {ch}.")
def get_channel_versions(channel: str) -> List[str]:
"""Get all installed SDK versions for a specific channel, sorted by version."""
sdk_dir = CFG.dotnet_dir / "sdk"
if not sdk_dir.exists():
return []
prefix = channel + "."
versions = []
for d in sdk_dir.iterdir():
if d.is_dir() and d.name.startswith(prefix):
versions.append(d.name)
# Sort by version number
def version_sort_key(v: str) -> Tuple[int, ...]:
parts = v.split(".")
return tuple(int(p) if p.isdigit() else 0 for p in parts)
return sorted(versions, key=version_sort_key)
def cleanup_old_channel_versions(channel: str) -> None:
"""Remove old minor versions of a channel, keeping only the latest."""
if not CFG.cleanup_old_versions:
return
versions = get_channel_versions(channel)
if len(versions) <= 1:
return # Nothing to clean up
latest = versions[-1] # Last item is the newest
old_versions = versions[:-1] # Everything except the latest
sdk_dir = CFG.dotnet_dir / "sdk"
for old_ver in old_versions:
verbose_info(f" Cleaning up old version: {old_ver}")
old_path = sdk_dir / old_ver
if old_path.exists():
shutil.rmtree(old_path, ignore_errors=True)
# ------------------------- dotnet install/update/uninstall --------------------
def install_or_update_channels(channels: List[str]) -> None:
ensure_install_script()
CFG.dotnet_dir.mkdir(parents=True, exist_ok=True)
patch_rc_files()
for ch in channels:
info(f"Installing/updating .NET SDK channel: {ch}")
if FORCE:
remove_channel_sdks(ch)
cmd = [
str(INSTALL_SCRIPT),
"--channel",
ch,
"--install-dir",
str(CFG.dotnet_dir),
]
run(cmd, check=True)
# Cleanup old versions after successful install/update
cleanup_old_channel_versions(ch)
clear_symbol_cache()
post_update_maintenance()
# Fix signatures AFTER all updates (workloads/tools can break them)
apply_platform_fixes()
# Ensure Homebrew dotnet doesn't interfere (macOS only)
cmd_unlink_brew()
def update_installed() -> None:
installed = installed_channels()
if not installed:
die(f"No installed SDK channels found in {CFG.dotnet_dir}/sdk")
ensure_install_script()
CFG.dotnet_dir.mkdir(parents=True, exist_ok=True)
patch_rc_files()
for ch in installed:
info(f"Updating installed channel: {ch}")
if FORCE:
remove_channel_sdks(ch)
cmd = [
str(INSTALL_SCRIPT),
"--channel",
ch,
"--install-dir",
str(CFG.dotnet_dir),
]
run(cmd, check=True)
# Cleanup old versions after successful update
cleanup_old_channel_versions(ch)
clear_symbol_cache()
post_update_maintenance()
# Fix signatures AFTER all updates (workloads/tools can break them)
apply_platform_fixes()
# Ensure Homebrew dotnet doesn't interfere (macOS only)
cmd_unlink_brew()
def uninstall_dotnet() -> None:
info(f"Removing {CFG.dotnet_dir} and installer...")
if CFG.dotnet_dir.exists():
shutil.rmtree(CFG.dotnet_dir, ignore_errors=True)
if INSTALL_SCRIPT.exists():
try:
INSTALL_SCRIPT.unlink()
except Exception:
pass
unpatch_rc_files()
info("Uninstall complete.")
# ------------------------- commands -------------------------------------------
def cmd_deps() -> None:
"""Check for missing .NET dependencies and install them after prompting."""
if sys.platform == "darwin":
_cmd_deps_macos()
elif is_linux():
_cmd_deps_linux()
else:
info("deps command is only applicable on macOS and Linux.")
def _cmd_deps_macos() -> None:
brew_cmd = shutil.which("brew")
if not brew_cmd:
info("WARN: Homebrew not found. Cannot check macOS dependencies.")
return
missing = [p for p in DOTNET_DEPS_BREW if not macos_brew_pkg_is_installed(p)]
info("Homebrew dependencies:")
for p in DOTNET_DEPS_BREW:
status = "✗ missing" if p in missing else "✓ installed"
info(f" {status}: {p}")
if not missing:
info("\n✓ All macOS .NET dependencies are already installed.")
return
info(f"\nMissing ({len(missing)}):")
for p in missing:
info(f" - {p}")
info("")
try:
answer = input("Install missing packages? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
info("")
info("Cancelled.")
return
if answer not in ("y", "yes"):
info("Skipped.")
return
macos_install_brew_packages(missing)
info("✓ Dependencies installed.")
def _cmd_deps_linux() -> None:
pm = linux_detect_package_manager()
if pm is None:
info(
"WARN: Could not detect a supported package manager (apt/dnf/pacman). Cannot check dependencies."
)
return
wanted = linux_dotnet_deps(pm)
missing = [p for p in wanted if not linux_pkg_is_installed(pm, p)]
info(f"Global dependencies (shared across all distros):")
for p in DOTNET_DEPS_GLOBAL:
status = "✗ missing" if p in missing else "✓ installed"
info(f" {status}: {p}")
info(f"\nPer-distro dependencies ({pm}):")
per_pm = {
"apt": DOTNET_DEPS_APT,
"dnf": DOTNET_DEPS_DNF,
"pacman": DOTNET_DEPS_PACMAN,
}.get(pm, [])
for p in per_pm:
status = "✗ missing" if p in missing else "✓ installed"
info(f" {status}: {p}")
if not missing:
info("\n✓ All .NET dependencies are already installed.")
return
info(f"\nMissing ({len(missing)}):")
for p in missing:
info(f" - {p}")
info("")
try:
answer = input("Install missing packages? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
info("")
info("Cancelled.")
return
if answer not in ("y", "yes"):
info("Skipped.")
return
linux_install_packages(pm, missing)
info("✓ Dependencies installed.")
def cmd_unlink_brew() -> None:
"""
Detects if dotnet is installed via Homebrew and unlinks it to prevent
conflicts with the official .NET SDK in ~/.dotnet. Idempotent.
"""
if not CFG.unlink_brew:
return
if sys.platform != "darwin":
verbose_info("unlink-brew command is only applicable on macOS.")
return
brew_cmd = shutil.which("brew")
if not brew_cmd:
verbose_info("Homebrew not found on this system. Nothing to fix.")
return
verbose_info("Checking for Homebrew dotnet installation...")
# Check if dotnet formula is installed
result = run([brew_cmd, "list", "--formula"], check=False, capture=True)
if result.returncode != 0:
info("Unable to query Homebrew formulas.")
return
installed_formulas = result.stdout.lower().split()
if "dotnet" not in installed_formulas and "dotnet-sdk" not in installed_formulas:
verbose_info("✓ No Homebrew dotnet installation found.")
return
verbose_info("Found Homebrew dotnet installation.")
# Check if dotnet is currently linked
link_result = run(
[brew_cmd, "list", "--formula", "dotnet"], check=False, capture=True
)
is_installed = link_result.returncode == 0
if not is_installed:
# Try dotnet-sdk as alternative name
link_result = run(
[brew_cmd, "list", "--formula", "dotnet-sdk"], check=False, capture=True
)
is_installed = link_result.returncode == 0
dotnet_formula = "dotnet-sdk" if is_installed else "dotnet"
else:
dotnet_formula = "dotnet"
if not is_installed:
verbose_info("✓ Homebrew dotnet detected but formula status unclear.")
return
# Check if it's linked
verbose_info(f"Checking if {dotnet_formula} is linked...")
link_check = run([brew_cmd, "info", dotnet_formula], check=False, capture=True)
if link_check.returncode == 0 and "not linked" in link_check.stdout.lower():
verbose_info(
f"✓ Homebrew {dotnet_formula} is already unlinked. No action needed."
)
return
# Unlink it
info(f"Unlinking Homebrew {dotnet_formula}...")
unlink_result = run([brew_cmd, "unlink", dotnet_formula], check=False, capture=True)
if unlink_result.returncode == 0:
info(f"✓ Successfully unlinked Homebrew {dotnet_formula}.")
info("")
info("Your ~/.dotnet installation will now take precedence.")
info(f"Verify with: which dotnet # Should show {CFG.dotnet_dir}/dotnet")
else:
info(f"Failed to unlink {dotnet_formula}: {unlink_result.stderr.strip()}")
def cmd_list() -> None:
index = fetch_releases_index()
for ch, lr in get_latest_release_per_channel(index):
info(f"{ch}: {lr}")
def cmd_fix_signatures() -> None:
"""Fix macOS code signature and quarantine issues."""
if sys.platform != "darwin":
info("fix-signatures command is only applicable on macOS.")
return
if not CFG.dotnet_dir.exists():
info(f"No .NET installation found at {CFG.dotnet_dir}")
return
info("Fixing macOS code signatures and quarantine issues...")
macos_fix_code_signatures()
info("✓ Complete. Try running: dotnet --version")
def cmd_cleanup() -> None:
"""Cleanup old minor versions for all installed channels."""
installed = installed_channels()
if not installed:
info(f"No installed SDK channels found in {CFG.dotnet_dir}/sdk")
return
info("Cleaning up old SDK versions...")
cleaned_any = False
for ch in installed:
versions = get_channel_versions(ch)
if len(versions) > 1:
info(f"Channel {ch}: {len(versions)} version(s) installed")
cleanup_old_channel_versions(ch)
cleaned_any = True
if not cleaned_any:
info(
"No old versions to clean up. All channels have only one version installed."
)
else:
info("Cleanup complete.")
def cmd_check() -> None:
"""Run diagnostic checks and attempt to fix common issues."""
info("Running .NET installation diagnostics...\n")
issues_found = 0
fixes_applied = 0
# Check 1: Dotnet installation exists
info("[1/6] Checking .NET installation...")
if not CFG.dotnet_dir.exists():
info(" ✗ .NET not installed in {CFG.dotnet_dir}")
info(" → Run: ./dotnet-helper.py install")
issues_found += 1
elif not CFG.dotnet_path.exists():
info(f" ✗ .NET executable not found at {CFG.dotnet_path}")
info(" → Run: ./dotnet-helper.py install")
issues_found += 1
else:
info(f" ✓ .NET installed at {CFG.dotnet_dir}")
# Check 2: Shell RC configuration
info("\n[2/6] Checking shell configuration...")
rc_files = list(rc_files_to_patch())
if not rc_files:
info(" ✗ No shell RC files found to configure")
issues_found += 1
else:
needs_patch = False
for rc in rc_files:
if rc.exists():
text = rc.read_text()
if BLOCK_BEGIN not in text:
needs_patch = True
break
if needs_patch:
info(" ⚠ Shell RC files need configuration")
info(" → Applying fix...")
patch_rc_files()
info(" ✓ Shell RC files configured")
fixes_applied += 1
else:
info(" ✓ Shell RC files properly configured")
# Check 3: PATH configuration
info("\n[3/6] Checking PATH configuration...")
path_env = os.environ.get("PATH", "")
dotnet_in_path = str(CFG.dotnet_dir) in path_env
if dotnet_in_path:
info(f" ✓ {CFG.dotnet_dir} is in PATH")
else:
info(f" ⚠ {CFG.dotnet_dir} not in current PATH")
info(" → Reload shell: source ~/.zshrc (or your RC file)")
issues_found += 1
# Check 4: Homebrew dotnet conflicts (macOS only)
if sys.platform == "darwin":
info("\n[4/6] Checking Homebrew conflicts...")
brew_cmd = shutil.which("brew")
if brew_cmd:
result = run([brew_cmd, "list", "--formula"], check=False, capture=True)
if result.returncode == 0:
installed_formulas = result.stdout.lower().split()
if "dotnet" in installed_formulas or "dotnet-sdk" in installed_formulas:
info(" ⚠ Homebrew dotnet detected")
if CFG.unlink_brew:
info(" → Applying fix...")
cmd_unlink_brew()
fixes_applied += 1
else:
info(" → unlink_brew is disabled in config.json")
issues_found += 1
else:
info(" ✓ No Homebrew dotnet conflicts")
else:
info(" ⚠ Could not check Homebrew formulas")
else:
info(" ✓ Homebrew not installed")
# Additional check: Code signatures and quarantine
info("\n[4.5/6] Checking code signatures...")
if CFG.dotnet_path.exists():
# Try to run dotnet --version to see if it's blocked
test_result = run(
[str(CFG.dotnet_path), "--version"], check=False, capture=True
)
if test_result.returncode != 0 and (
"killed" in test_result.stderr.lower()
or "signature" in test_result.stderr.lower()
):
info(" ⚠ Code signature issues detected")
info(" → Fixing code signatures and quarantine...")
macos_fix_code_signatures()
info(" ✓ Code signatures fixed")
fixes_applied += 1
else:
info(" ✓ No code signature issues detected")
else:
info(" ⊘ Dotnet not installed yet")
else:
info("\n[4/6] Checking Homebrew conflicts...")
info(" ⊘ Not applicable (not macOS)")
# Check 5: Linux dependencies
if is_linux():
info("\n[5/6] Checking Linux dependencies...")
pm = linux_detect_package_manager()
if pm:
wanted = linux_dotnet_deps(pm)
missing = [p for p in wanted if not linux_pkg_is_installed(pm, p)]
if missing:
info(f" ⚠ Missing dependencies: {', '.join(missing)}")
info(" → Installing...")
linux_install_packages(pm, missing)
info(" ✓ Dependencies installed")
fixes_applied += 1
else:
info(" ✓ All dependencies satisfied")
else:
info(" ⚠ Could not detect package manager")
issues_found += 1
else:
info("\n[5/6] Checking Linux dependencies...")
info(" ⊘ Not applicable (not Linux)")
# Check 6: Old SDK versions
info("\n[6/6] Checking for old SDK versions...")
installed = installed_channels()
if installed:
old_versions_exist = False
for ch in installed:
versions = get_channel_versions(ch)
if len(versions) > 1:
old_versions_exist = True
break
if old_versions_exist:
info(" ⚠ Multiple versions of some channels detected")
if CFG.cleanup_old_versions:
info(" → Run cleanup: ./dotnet-helper.py cleanup")
else:
info(" → cleanup_old_versions is disabled in config.json")
issues_found += 1
else:
info(" ✓ No old versions detected")
else:
info(" ⚠ No SDK channels installed")
# Summary
info("\n" + "=" * 50)
if issues_found == 0 and fixes_applied == 0:
info("✓ All checks passed! Your .NET installation is healthy.")
elif fixes_applied > 0:
info(f"✓ Applied {fixes_applied} fix(es). {issues_found} issue(s) remaining.")
if issues_found > 0:
info(" Review the checks above for remaining issues.")
else:
info(f"⚠ Found {issues_found} issue(s). Review the checks above.")
info("=" * 50)
def cmd_reinstall() -> None:
"""Reinstall .NET by removing ~/.dotnet and reinstalling SDKs and tools."""
info("Preparing to reinstall .NET...\n")
# Collect current state
installed = installed_channels()
tools = get_installed_global_tools()
if not installed:
info("No SDK channels currently installed.")
info("Nothing to reinstall. Use 'install' command instead.")
return
info("Current installation:")
info(f" SDK channels: {', '.join(installed)}")
if tools:
info(f" Global tools: {', '.join(tools)}")
else:
info(" Global tools: none")
# Confirm
if is_tty():
info("")
response = input("Proceed with reinstall? This will remove ~/.dotnet [y/N]: ")
if response.lower().strip() not in ("y", "yes"):
info("Reinstall cancelled.")
return
else:
info("\nRunning in non-interactive mode. Proceeding with reinstall...")
info("")
info("Step 1: Removing .NET installation...")
if CFG.dotnet_dir.exists():
shutil.rmtree(CFG.dotnet_dir, ignore_errors=True)
if INSTALL_SCRIPT.exists():
try:
INSTALL_SCRIPT.unlink()
except Exception:
pass
info(" ✓ Removed")
info("")
info("Step 2: Reinstalling SDK channels...")
install_or_update_channels(installed)
if tools:
info("")
info("Step 3: Reinstalling global tools...")
dn = CFG.dotnet_cmd()
for tool_id in tools:
info(f" Installing {tool_id}...")
run([dn, "tool", "install", "-g", tool_id], check=False)
info("")
info("=" * 50)
info("✓ Reinstall complete!")
info("=" * 50)
def resolve_channels_for_interactive() -> List[str]:
if CFG.channels:
return CFG.channels
index = fetch_releases_index()
channels = get_channels_filtered(index)
if not channels:
die("Could not retrieve channels from releases index.")
return channels
def cmd_default() -> None:
channels = resolve_channels_for_interactive()
installed = installed_channels()
selected = select_channels_curses(channels, installed)
# Uninstall channels that were deselected
to_uninstall = [ch for ch in installed if ch not in selected]
if to_uninstall:
uninstall_channels(to_uninstall)
# Install/update selected channels
if selected:
install_or_update_channels(selected)
info("")
info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk")
def cmd_install(args: List[str]) -> None:
index = fetch_releases_index()
available = set(get_channels_filtered(index))
if not args:
latest = latest_stable_channel(index)
info(f"Installing latest stable channel: {latest}")
install_or_update_channels([latest])
info("")
info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk")
return
wanted = [normalize_channel_arg(a) for a in args]
missing = [w for w in wanted if w not in available]
if missing:
die(f"Unknown/unsupported channel(s): {', '.join(missing)}")
wanted_sorted = sorted(set(wanted), key=natural_channel_sort_key)
install_or_update_channels(wanted_sorted)
info("")
info(f"Installed SDKs are in: {CFG.dotnet_dir}/sdk")
def usage() -> None:
exe = Path(sys.argv[0]).name
print(
f"""Usage:
{exe} [-f] [-v] # interactive selection
{exe} list # show latest release per channel
{exe} [-f] [-v] update # update installed channels
{exe} uninstall # uninstall dotnet from ~/.dotnet
{exe} [-f] [-v] install [CH...] # install/update; no args installs latest stable
{exe} unlink-brew # unlink Homebrew dotnet (macOS only)
{exe} cleanup # remove old minor versions, keep latest only
{exe} check # run diagnostics and fix common issues
{exe} reinstall # clean reinstall (preserves SDK/tool list)
{exe} fix-signatures # fix macOS code signatures (if dotnet crashes)
{exe} deps # check and install missing .NET dependencies (Linux only)
Flags:
-f, --force reinstall even when the latest SDK is already present
-v, --verbose show detailed output
"""
)
def main() -> None:
global FORCE, VERBOSE
load_config()
argv = sys.argv[1:]
if argv and argv[0] in ("-h", "--help", "help"):
usage()
return
FORCE = any(a in ("-f", "--force") for a in argv)
VERBOSE = CFG.verbose or any(a in ("-v", "--verbose") for a in argv)
argv = [a for a in argv if a not in ("-f", "--force", "-v", "--verbose")]
if not argv:
cmd_default()
print("")
print("Done. Reload: source ~/.zshrc" if RC_CHANGED else "Done.")
return
cmd = argv[0]
args = argv[1:]
if cmd == "list":
cmd_list()
elif cmd == "update":
update_installed()
elif cmd == "uninstall":
uninstall_dotnet()
elif cmd == "install":
cmd_install(args)
elif cmd == "unlink-brew":
cmd_unlink_brew()
elif cmd == "cleanup":
cmd_cleanup()
elif cmd == "check":
cmd_check()
elif cmd == "reinstall":
cmd_reinstall()
elif cmd == "fix-signatures":
cmd_fix_signatures()
elif cmd == "deps":
cmd_deps()
else:
usage()
sys.exit(1)
print("")
print("Done. Reload: source ~/.zshrc" if RC_CHANGED else "Done.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
die("Cancelled.", code=130)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment