Created
November 22, 2025 19:52
-
-
Save TheSethRose/0c150dcb2359bf7968c92cd7ac7625fe to your computer and use it in GitHub Desktop.
Interactive Homebrew TUI (powered by `rich`) to audit versions, discover beta candidates, and interactively automate clean upgrades.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import subprocess | |
| import json | |
| import re | |
| import sys | |
| import argparse | |
| from rich.console import Console | |
| from rich.table import Table | |
| from rich.text import Text | |
| from rich import box | |
| from rich.prompt import Confirm, Prompt | |
| # --- Configuration --- | |
| MAX_VER_WIDTH = 25 # Prevents massive version strings from breaking layout | |
| console = Console() | |
| def run_command(command, stream=False): | |
| if stream: | |
| return subprocess.call(command, shell=True) | |
| try: | |
| return subprocess.check_output(command, shell=True, text=True).strip() | |
| except subprocess.CalledProcessError: | |
| return "" | |
| def clean_version(v): | |
| if not v: return "" | |
| return re.sub(r'[_\-]\d+$', '', v) | |
| def truncate(text, limit): | |
| text = str(text) | |
| if len(text) > limit: | |
| return text[:limit-1] + "…" | |
| return text | |
| def get_data(): | |
| with console.status("[bold green]Scanning Homebrew system..."): | |
| # 1. Get Installed | |
| raw_inst = run_command("brew info --json=v2 --installed") | |
| try: installed_data = json.loads(raw_inst) | |
| except: installed_data = {} | |
| # 2. Get Betas | |
| regex = "/(@|-)(beta|alpha|nightly|insider|preview|dev|next|canary|edge)/" | |
| raw_search = run_command(f"brew search '{regex}'") | |
| all_betas = set([x.strip() for x in raw_search.split('\n') if x.strip() and "==>" not in x]) | |
| return installed_data, all_betas | |
| def get_beta_metadata(beta_names): | |
| if not beta_names: return {} | |
| cmd = f"brew info --json=v2 {' '.join(beta_names)}" | |
| raw = run_command(cmd) | |
| try: | |
| data = json.loads(raw) | |
| lookup = {} | |
| for cat in ['formulae', 'casks']: | |
| for item in data.get(cat, []): | |
| if item.get('token'): lookup[item.get('token')] = item | |
| if item.get('name'): lookup[item.get('name')] = item | |
| return lookup | |
| except: return {} | |
| def parse_item(item, item_type): | |
| name = item.get('token') or item.get('name') | |
| if item_type == 'cask': | |
| local = item.get('installed', "N/A") | |
| latest = item.get('version', "Unknown") | |
| else: | |
| inst = item.get('installed', []) | |
| local = inst[0]['version'] if inst else "N/A" | |
| latest = item['versions']['stable'] | |
| return name, str(local), str(latest) | |
| def build_rows(): | |
| installed_data, all_betas = get_data() | |
| rows = [] | |
| betas_to_lookup = [] | |
| for cat, type_label in [('casks', 'cask'), ('formulae', 'formula')]: | |
| for item in installed_data.get(cat, []): | |
| name, local, latest = parse_item(item, type_label) | |
| beta_match = None | |
| for b in all_betas: | |
| if b.startswith(name): | |
| rem = b[len(name):] | |
| if re.match(r"^(@|-)(beta|alpha|nightly|insider|preview|dev|next|canary|edge)", rem): | |
| beta_match = b | |
| betas_to_lookup.append(b) | |
| break | |
| c_local = clean_version(local) | |
| c_latest = clean_version(latest) | |
| needs_update = c_local != c_latest and local != "N/A" | |
| priority = 3 | |
| if needs_update: priority = 1 | |
| elif beta_match: priority = 2 | |
| rows.append({ | |
| "name": name, | |
| "type": type_label, | |
| "local": local, | |
| "latest": latest, | |
| "beta_name": beta_match if beta_match else "-", | |
| "beta_ver": "-", | |
| "priority": priority, | |
| "needs_update": needs_update | |
| }) | |
| beta_details = get_beta_metadata(betas_to_lookup) | |
| for row in rows: | |
| if row['beta_name'] != "-": | |
| b_data = beta_details.get(row['beta_name']) | |
| if b_data: | |
| if 'version' in b_data: row['beta_ver'] = b_data['version'] | |
| elif 'versions' in b_data: row['beta_ver'] = b_data['versions'].get('stable', 'Unknown') | |
| rows.sort(key=lambda x: (x['priority'], x['name'])) | |
| return rows | |
| def print_table(rows): | |
| table = Table(box=box.SIMPLE, expand=True) | |
| table.add_column("App Name", style="cyan", no_wrap=True) | |
| table.add_column("Type", style="magenta", width=8) | |
| table.add_column("Your Ver", max_width=MAX_VER_WIDTH) | |
| table.add_column("Stable", max_width=MAX_VER_WIDTH) | |
| table.add_column("Avail Beta", style="green") | |
| table.add_column("Beta Ver", max_width=MAX_VER_WIDTH) | |
| for r in rows: | |
| row_style = "" | |
| name_style = "cyan" | |
| if r['needs_update']: | |
| row_style = "bold yellow" | |
| name_style = "bold yellow" | |
| elif r['beta_name'] == "-": | |
| row_style = "dim" | |
| local_ver = Text(truncate(r['local'], MAX_VER_WIDTH)) | |
| stable_ver = Text(truncate(r['latest'], MAX_VER_WIDTH)) | |
| beta_ver = Text(truncate(r['beta_ver'], MAX_VER_WIDTH)) | |
| if r['needs_update']: | |
| local_ver.stylize("red strike") | |
| stable_ver.stylize("green") | |
| table.add_row( | |
| Text(r['name'], style=name_style), | |
| Text(r['type']), | |
| local_ver, | |
| stable_ver, | |
| r['beta_name'] if r['beta_name'] != "-" else "", | |
| beta_ver if r['beta_ver'] != "-" else "", | |
| style=row_style | |
| ) | |
| console.print(table) | |
| console.print(f"[yellow]Yellow[/] = Update Available | [green]Green[/] = Beta Alternative Available", style="italic dim") | |
| # --- AUTO PILOT LOGIC --- | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--dry-run", action="store_true", help="Print command but do not run") | |
| args = parser.parse_args() | |
| # 1. Show the View | |
| rows = build_rows() | |
| print_table(rows) | |
| print("\n") | |
| # 2. Analyze | |
| outdated_count = len([r for r in rows if r['needs_update']]) | |
| potential_swaps = [r for r in rows if r['beta_name'] != "-"] | |
| # 3. Construct Command Chain | |
| chain = ["brew update"] # Always start with fresh definitions | |
| # -- Step A: Updates -- | |
| if outdated_count > 0: | |
| if Confirm.ask(f"[bold yellow]Included upgrades for {outdated_count} outdated apps?[/]"): | |
| chain.append("brew upgrade --greedy") | |
| else: | |
| console.print("[dim]System is up to date.[/]") | |
| # -- Step B: Swaps -- | |
| if potential_swaps: | |
| console.print(f"\n[bold green]Found {len(potential_swaps)} possible beta swaps.[/]") | |
| console.print("[dim]Type names to swap (space separated), 'all', or 'no'[/]") | |
| choice = Prompt.ask("Selection", default="no") | |
| swaps_to_do = [] | |
| if choice.lower() == 'all': | |
| swaps_to_do = potential_swaps | |
| elif choice.lower() not in ['no', 'none', 'n']: | |
| targets = choice.split() | |
| swaps_to_do = [x for x in potential_swaps if x['name'] in targets or x['beta_name'] in targets] | |
| if swaps_to_do: | |
| remove_list = [x['name'] for x in swaps_to_do] | |
| install_list = [x['beta_name'] for x in swaps_to_do] | |
| # Use --force on uninstall to prevent "Directory not empty" errors | |
| chain.append(f"brew uninstall --force {' '.join(remove_list)}") | |
| chain.append(f"brew install --cask {' '.join(install_list)}") | |
| # -- Step C: Cleanup -- | |
| chain.append("brew cleanup -s") | |
| chain.append("brew doctor") | |
| # 4. Execute | |
| full_cmd = " && ".join(chain) | |
| console.print("\n[bold white on blue] Final Command to Run: [/]") | |
| console.print(f"[cyan]{full_cmd}[/]\n") | |
| if args.dry_run: | |
| console.print("[yellow][Dry Run] Exiting.[/]") | |
| else: | |
| if Confirm.ask("Run this now?"): | |
| console.print("\n[green]🚀 Executing...[/]") | |
| run_command(full_cmd, stream=True) | |
| else: | |
| console.print("[red]Aborted.[/]") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment