Skip to content

Instantly share code, notes, and snippets.

@kfrancis
Forked from davidfowl/cleanrg.cs
Last active June 17, 2025 23:37
Show Gist options
  • Save kfrancis/cd1d559ffe99f207f2cff6147e529070 to your computer and use it in GitHub Desktop.
Save kfrancis/cd1d559ffe99f207f2cff6147e529070 to your computer and use it in GitHub Desktop.

Revisions

  1. kfrancis revised this gist Jun 17, 2025. 1 changed file with 357 additions and 65 deletions.
    422 changes: 357 additions & 65 deletions cleanrg.cs
    Original file line number Diff line number Diff line change
    @@ -1,32 +1,41 @@
    // -----------------------------------------------------------------------------
    // Azure RG Janitor - Spectre Edition
    // Run with: dotnet run cleanup-rg.cs
    // Azure RG Smart Janitor - Enhanced Cleanup Tool
    // Run with: dotnet run smart-cleanup.cs
    // Targets: .NET 10 Preview 4+
    // -----------------------------------------------------------------------------

    #:package Azure.Identity@1.*
    #:package Azure.ResourceManager@1.*
    #:package Azure.ResourceManager.Compute@1.*
    #:package Azure.ResourceManager.Storage@1.*
    #:package Azure.ResourceManager.Sql@1.*
    #:package Spectre.Console@0.50.0

    using Azure.Identity;
    using Azure.ResourceManager;
    using Azure.ResourceManager.Resources;
    using Azure.ResourceManager.Compute;
    using Azure.ResourceManager.Storage;
    using Azure.ResourceManager.Sql;
    using Spectre.Console;
    using System.Text.RegularExpressions;

    // ─────────────── settings ─────────────────────────────────────────────────────
    const int MaxParallel = 10; // limit concurrent deletions
    // ─────────────── Settings ─────────────────────────────────────────────────────
    const int MaxParallel = 5; // Reduced for safety
    const int StaleAgeDays = 30; // Consider RGs older than this as potentially stale
    const int EmptyResourceThreshold = 3; // RGs with <= this many resources are candidates
    // ──────────────────────────────────────────────────────────────────────────────

    // 1️⃣ Select subscription
    // 1️⃣ Select subscription
    var arm = new ArmClient(new DefaultAzureCredential());
    var subs = arm.GetSubscriptions().GetAllAsync();
    var subList = new List<SubscriptionResource>();

    await AnsiConsole.Status()
    .Spinner(Spinner.Known.Dots)
    .SpinnerStyle(Style.Parse("yellow"))
    .StartAsync("Fetching Azure subscriptions...", async ctx =>
    {
    await foreach (var s in subs)
    await foreach (var s in arm.GetSubscriptions())
    subList.Add(s);
    });

    @@ -42,88 +51,342 @@ await AnsiConsole.Status()
    new SelectionPrompt<string>()
    .Title("Select an Azure subscription:")
    .AddChoices(subChoices)
    .UseConverter(x => x) // keep display as is
    .PageSize(10)
    .MoreChoicesText("[grey](Move up and down to reveal more subscriptions)[/]")
    .HighlightStyle("blue")
    .EnableSearch()
    );

    if (selectedSub == "Cancel")
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted by user.[/]");
    return;
    }

    var subIdx = subChoices.IndexOf(selectedSub);
    var sub = subList[subIdx];

    AnsiConsole.MarkupLine($"[bold blue]🔍 Scanning subscription [yellow]{sub.Data.DisplayName}[/]…[/]");
    AnsiConsole.MarkupLine($"[bold blue]🔍 Analyzing subscription [yellow]{sub.Data.DisplayName}[/]…[/]");

    // 2️⃣ Find candidate resource groups
    var matches = new List<ResourceGroupResource>();
    await foreach (var rg in sub.GetResourceGroups().GetAllAsync())
    // Helper function to analyze a resource group
    async Task<RgInfo> AnalyzeResourceGroup(ResourceGroupResource rg)
    {
    matches.Add(rg);
    var resources = new List<string>();
    var resourceTypes = new List<string>();
    var hasRunningVMs = false;
    var hasDatabases = false;
    var hasProductionIndicators = false;
    DateTime? lastModified = null;

    try
    {
    // Get all resources in the RG - Fixed: removed GetAll() as Pageable<T> is already IEnumerable
    var genericResources = rg.GetGenericResources();
    foreach (var resource in genericResources)
    {
    resources.Add(resource.Data.Name);
    var resourceType = resource.Data.ResourceType.Type;
    if (!resourceTypes.Contains(resourceType))
    resourceTypes.Add(resourceType);

    // Check for running VMs
    if (resourceType == "virtualMachines")
    {
    try
    {
    var vmResource = await rg.GetVirtualMachines().GetAsync(resource.Data.Name);
    if (vmResource.Value.Data.InstanceView?.Statuses?.Any(s =>
    s.Code?.Contains("PowerState/running") == true) == true)
    {
    hasRunningVMs = true;
    }
    }
    catch { /* Ignore access errors */ }
    }

    // Check for databases
    if (resourceType == "servers" || resourceType == "databases" ||
    resourceType == "sqlServers" || resourceType == "managedInstances")
    {
    hasDatabases = true;
    }

    // Update last modified
    if (resource.Data.CreatedOn.HasValue &&
    (!lastModified.HasValue || resource.Data.CreatedOn.Value.DateTime > lastModified))
    {
    lastModified = resource.Data.CreatedOn.Value.DateTime;
    }
    }
    }
    catch
    {
    // If we can't enumerate resources, be conservative
    hasProductionIndicators = true;
    }

    // Check for production indicators
    var rgName = rg.Data.Name.ToLowerInvariant();
    var tags = rg.Data.Tags ?? new Dictionary<string, string>();

    hasProductionIndicators = hasProductionIndicators ||
    rgName.Contains("prod") ||
    rgName.Contains("production") ||
    tags.Any(t => t.Key.ToLowerInvariant().Contains("env") &&
    t.Value.ToLowerInvariant().Contains("prod")) ||
    tags.Any(t => t.Value.ToLowerInvariant().Contains("production"));

    // Check if it matches test patterns
    var testPatterns = new[] { "test", "demo", "poc", "temp", "sandbox", "dev", "experiment", "trial", "sample" };
    var matchesTestPattern = testPatterns.Any(pattern => rgName.Contains(pattern)) ||
    tags.Any(t => testPatterns.Any(p => t.Value.ToLowerInvariant().Contains(p)));

    // Determine risk level
    var riskLevel = "HIGH"; // Default to high risk

    if (!hasRunningVMs && !hasDatabases && !hasProductionIndicators)
    {
    if (matchesTestPattern || resources.Count <= EmptyResourceThreshold)
    riskLevel = "LOW";
    else if (lastModified.HasValue && (DateTime.UtcNow - lastModified.Value).Days > StaleAgeDays)
    riskLevel = "MEDIUM";
    else
    riskLevel = "MEDIUM";
    }

    return new RgInfo(
    rg,
    resources.Count,
    hasRunningVMs,
    hasDatabases,
    hasProductionIndicators,
    matchesTestPattern,
    lastModified,
    riskLevel,
    resourceTypes
    );
    }

    if (matches.Count == 0)
    // 2️⃣ Analyze all resource groups intelligently
    var allRgs = new List<RgInfo>();
    var analyzed = 0;

    await AnsiConsole.Progress()
    .AutoClear(false)
    .Columns(
    [
    new TaskDescriptionColumn(),
    new ProgressBarColumn(),
    new SpinnerColumn(),
    new ElapsedTimeColumn(),
    ])
    .StartAsync(async ctx =>
    {
    var rgList = new List<ResourceGroupResource>();
    // Fixed: removed GetAll() as Pageable<T> is already IEnumerable
    foreach (var rg in sub.GetResourceGroups())
    rgList.Add(rg);

    var analysisTask = ctx.AddTask($"[blue]Analyzing resource groups[/]", maxValue: rgList.Count);

    foreach (var rg in rgList)
    {
    try
    {
    analysisTask.Description = $"[blue]Analyzing[/] [yellow]{rg.Data.Name}[/]";
    var rgInfo = await AnalyzeResourceGroup(rg);
    allRgs.Add(rgInfo);
    analyzed++;
    }
    catch (Exception ex)
    {
    AnsiConsole.MarkupLine($"[red]⚠ Error analyzing {rg.Data.Name}: {ex.Message}[/]");
    }
    analysisTask.Increment(1);
    }
    });

    AnsiConsole.MarkupLine($"[green]✅ Analyzed {analyzed} resource groups[/]");

    // 3️⃣ Filter for cleanup candidates
    var cleanupCandidates = allRgs.Where(rg =>
    rg.RiskLevel == "LOW" || rg.RiskLevel == "MEDIUM"
    ).OrderBy(rg => rg.RiskLevel)
    .ThenBy(rg => rg.ResourceGroup.Data.Name, StringComparer.OrdinalIgnoreCase)
    .ToList();

    if (cleanupCandidates.Count == 0)
    {
    AnsiConsole.MarkupLine("[green]✔ No resource groups match.[/]");
    AnsiConsole.MarkupLine("[green]✔ No cleanup candidates found. Your subscription looks clean![/]");

    // Show summary of what was excluded
    var excluded = allRgs.Where(rg => rg.RiskLevel == "HIGH").ToList();
    if (excluded.Count > 0)
    {
    AnsiConsole.MarkupLine($"\n[yellow]ℹ {excluded.Count} resource groups were excluded due to production indicators:[/]");
    foreach (var rg in excluded.Take(5))
    AnsiConsole.MarkupLine($" • [yellow]{rg.ResourceGroup.Data.Name}[/] - {rg.ResourceTypes.Count} resources");
    if (excluded.Count > 5)
    AnsiConsole.MarkupLine($" • [grey]... and {excluded.Count - 5} more[/]");
    }
    return;
    }

    // 3️⃣ Pick list for resource groups
    var rgChoices = matches.Select(rg => $"{rg.Data.Name}").OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList();
    matches = matches.OrderBy(rg => rg.Data.Name, StringComparer.OrdinalIgnoreCase).ToList();
    var prompt = new MultiSelectionPrompt<string>()
    .Title($"Select resource groups to delete ([yellow]{matches.Count}[/] found):")
    .NotRequired()
    .PageSize(10)
    .InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept, type to search)[/]")
    .AddChoices(rgChoices);

    // foreach (var choice in rgChoices)
    // prompt.Select(choice); // pre-select all

    var selectedRgs = AnsiConsole.Prompt(prompt) ?? rgChoices;
    AnsiConsole.MarkupLine($"[grey]Selected resource groups:[/]");
    foreach (var name in selectedRgs)
    AnsiConsole.MarkupLine($" [yellow]{name}[/]");
    if (selectedRgs.Count == 0)
    // 4️⃣ Display cleanup candidates in a detailed table
    var table = new Table()
    .Border(TableBorder.Rounded)
    .AddColumn("Risk")
    .AddColumn("Name")
    .AddColumn("Resources")
    .AddColumn("Age")
    .AddColumn("Patterns")
    .AddColumn("Details");

    foreach (var rg in cleanupCandidates)
    {
    var riskColor = rg.RiskLevel switch
    {
    "LOW" => "green",
    "MEDIUM" => "yellow",
    _ => "red"
    };

    var ageText = rg.LastModified?.ToString("yyyy-MM-dd") ?? "Unknown";
    var ageDays = rg.LastModified.HasValue ? (DateTime.UtcNow - rg.LastModified.Value).Days : 0;
    if (ageDays > StaleAgeDays)
    ageText += $" ([red]{ageDays}d[/])";

    var patterns = new List<string>();
    if (rg.MatchesTestPattern) patterns.Add("Test name");
    if (rg.ResourceCount <= EmptyResourceThreshold) patterns.Add("Few resources");
    if (!rg.HasRunningVMs && !rg.HasDatabases) patterns.Add("No critical services");

    var details = string.Join(", ", rg.ResourceTypes.Take(3));
    if (rg.ResourceTypes.Count > 3)
    details += $", +{rg.ResourceTypes.Count - 3} more";

    table.AddRow(
    $"[{riskColor}]{rg.RiskLevel}[/]",
    rg.ResourceGroup.Data.Name,
    rg.ResourceCount.ToString(),
    ageText,
    string.Join(", ", patterns),
    details
    );
    }

    AnsiConsole.Write(table);

    // 5️⃣ Show totals and options
    AnsiConsole.MarkupLine($"\n[bold]Summary:[/]");
    AnsiConsole.MarkupLine($" • [green]{cleanupCandidates.Count(c => c.RiskLevel == "LOW")}[/] low-risk candidates");
    AnsiConsole.MarkupLine($" • [yellow]{cleanupCandidates.Count(c => c.RiskLevel == "MEDIUM")}[/] medium-risk candidates");
    AnsiConsole.MarkupLine($" • [red]{allRgs.Count(rg => rg.RiskLevel == "HIGH")}[/] high-risk (excluded)");

    // 6️⃣ Dry run option
    var mode = AnsiConsole.Prompt(
    new SelectionPrompt<string>()
    .Title("What would you like to do?")
    .AddChoices([
    "Dry run - Show what would be deleted",
    "Select specific resource groups to delete",
    "Auto-delete only LOW risk items",
    "Cancel"
    ])
    .HighlightStyle("blue")
    );

    if (mode == "Cancel")
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted – nothing selected.[/]");
    AnsiConsole.MarkupLine("[red]❌ Aborted by user.[/]");
    return;
    }

    matches = matches.Where(rg => selectedRgs.Contains(rg.Data.Name)).OrderBy(rg => rg.Data.Name, StringComparer.OrdinalIgnoreCase).ToList();
    List<RgInfo> toDelete = [];

    // 1️⃣ pretty-print the list
    var table = new Table()
    .Border(TableBorder.Rounded)
    .AddColumn("#")
    .AddColumn("Name");
    if (mode == "Dry run - Show what would be deleted")
    {
    toDelete = cleanupCandidates.Where(c => c.RiskLevel == "LOW").ToList();
    AnsiConsole.MarkupLine($"\n[bold blue]🔍 DRY RUN - Would delete {toDelete.Count} low-risk resource groups:[/]");
    foreach (var rg in toDelete)
    {
    AnsiConsole.MarkupLine($" • [yellow]{rg.ResourceGroup.Data.Name}[/]");
    AnsiConsole.MarkupLine($" - {rg.ResourceCount} resources: {string.Join(", ", rg.ResourceTypes)}");
    }
    AnsiConsole.MarkupLine("\n[grey]No actual deletions performed.[/]");
    return;
    }
    else if (mode == "Auto-delete only LOW risk items")
    {
    toDelete = cleanupCandidates.Where(c => c.RiskLevel == "LOW").ToList();
    }
    else if (mode == "Select specific resource groups to delete")
    {
    var rgChoices = cleanupCandidates.Select(rg =>
    $"{rg.ResourceGroup.Data.Name} ({rg.RiskLevel} risk - {rg.ResourceCount} resources)"
    ).ToList();

    if (rgChoices.Count == 0)
    {
    AnsiConsole.MarkupLine("[yellow]No candidates available for selection.[/]");
    return;
    }

    int idx = 1;
    foreach (var rg in matches)
    table.AddRow(idx++.ToString(), rg.Data.Name);
    var selectedRgNames = AnsiConsole.Prompt(
    new MultiSelectionPrompt<string>()
    .Title("Select resource groups to delete:")
    .NotRequired()
    .PageSize(15)
    .InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept)[/]")
    .AddChoices(rgChoices)
    );

    AnsiConsole.Write(table);
    if (selectedRgNames.Count == 0)
    {
    AnsiConsole.MarkupLine("[red]❌ Nothing selected.[/]");
    return;
    }

    toDelete = cleanupCandidates.Where(rg =>
    selectedRgNames.Any(s => s.StartsWith(rg.ResourceGroup.Data.Name + " ("))
    ).ToList();
    }

    if (toDelete.Count == 0)
    {
    AnsiConsole.MarkupLine("[yellow]No resource groups to delete.[/]");
    return;
    }

    // 7️⃣ Final confirmation with detailed breakdown
    AnsiConsole.MarkupLine($"\n[bold red]⚠ DELETION CONFIRMATION[/]");
    AnsiConsole.MarkupLine($"About to delete [yellow]{toDelete.Count}[/] resource groups:");

    foreach (var rg in toDelete)
    {
    AnsiConsole.MarkupLine($" • [yellow]{rg.ResourceGroup.Data.Name}[/] ([{(rg.RiskLevel == "LOW" ? "green" : "yellow")}]{rg.RiskLevel}[/] risk)");
    }

    // 2️⃣ confirmation
    bool proceed = AnsiConsole.Confirm(
    $"Delete [yellow]{matches.Count}[/] resource group(s) shown above?", defaultValue: false);
    var finalConfirm = AnsiConsole.Confirm(
    $"\n[bold]Are you absolutely sure you want to delete these {toDelete.Count} resource groups?[/]",
    defaultValue: false
    );

    if (!proceed)
    if (!finalConfirm)
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted – nothing deleted.[/]");
    AnsiConsole.MarkupLine("[red]❌ Aborted.[/]");
    return;
    }

    // 3️⃣ delete with a progress bar
    AnsiConsole.MarkupLine("[bold]🗑 Deleting[/]");
    // 8️⃣ Execute deletions with enhanced progress tracking
    AnsiConsole.MarkupLine("[bold]🗑 Deleting resource groups...[/]");

    using var gate = new SemaphoreSlim(MaxParallel);
    var successCount = 0;
    var failureCount = 0;

    await AnsiConsole.Progress()
    .AutoClear(true)
    .AutoClear(false)
    .Columns(
    [
    new TaskDescriptionColumn(),
    @@ -133,20 +396,31 @@ await AnsiConsole.Progress()
    ])
    .StartAsync(async ctx =>
    {
    var overallTask = ctx.AddTask("Overall progress", maxValue: matches.Count);
    var rgTasks = matches.ToDictionary(
    rg => rg.Data.Name,
    rg => ctx.AddTask($"[grey]Deleting:[/] [yellow]{rg.Data.Name}[/]", maxValue: 1)
    var overallTask = ctx.AddTask("Overall progress", maxValue: toDelete.Count);
    var rgTasks = toDelete.ToDictionary(
    rg => rg.ResourceGroup.Data.Name,
    rg => ctx.AddTask($"[grey]Deleting:[/] [yellow]{rg.ResourceGroup.Data.Name}[/]", maxValue: 1)
    );

    var deleteTasks = matches.Select(async rg =>
    var deleteTasks = toDelete.Select(async rgInfo =>
    {
    await gate.WaitAsync();
    try
    {
    rgTasks[rg.Data.Name].Increment(0.5); // show started
    await rg.DeleteAsync(Azure.WaitUntil.Completed);
    rgTasks[rg.Data.Name].Value = 1;
    var rgTask = rgTasks[rgInfo.ResourceGroup.Data.Name];
    rgTask.Increment(0.3);

    await rgInfo.ResourceGroup.DeleteAsync(Azure.WaitUntil.Completed);

    rgTask.Value = 1;
    rgTask.Description = $"[green]✅ Deleted:[/] [yellow]{rgInfo.ResourceGroup.Data.Name}[/]";
    Interlocked.Increment(ref successCount);
    }
    catch (Exception ex)
    {
    var rgTask = rgTasks[rgInfo.ResourceGroup.Data.Name];
    rgTask.Description = $"[red]❌ Failed:[/] [yellow]{rgInfo.ResourceGroup.Data.Name}[/] - {ex.Message}";
    Interlocked.Increment(ref failureCount);
    }
    finally
    {
    @@ -158,9 +432,27 @@ await AnsiConsole.Progress()
    await Task.WhenAll(deleteTasks);
    });

    AnsiConsole.MarkupLine("[bold green]✅ Cleanup completed.[/]");
    // 9️⃣ Final results
    AnsiConsole.MarkupLine($"\n[bold]Cleanup Results:[/]");
    AnsiConsole.MarkupLine($" • [green]✅ Successfully deleted: {successCount}[/]");
    if (failureCount > 0)
    AnsiConsole.MarkupLine($" • [red]❌ Failed to delete: {failureCount}[/]");

    AnsiConsole.MarkupLine("[bold green]🎉 Cleanup completed![/]");

    // ═══════════════════════════════════════════════════════════════════════════════
    // Type definitions (must be at the end with top-level statements)
    // ═══════════════════════════════════════════════════════════════════════════════

    // Print what was deleted
    AnsiConsole.MarkupLine("[bold]Deleted resource groups:[/]");
    foreach (var rg in matches)
    AnsiConsole.MarkupLine($" [yellow]{rg.Data.Name}[/]");
    // Enhanced resource group info
    public record RgInfo(
    ResourceGroupResource ResourceGroup,
    int ResourceCount,
    bool HasRunningVMs,
    bool HasDatabases,
    bool HasProductionIndicators,
    bool MatchesTestPattern,
    DateTime? LastModified,
    string RiskLevel,
    List<string> ResourceTypes
    );
  2. @davidfowl davidfowl created this gist Jun 16, 2025.
    166 changes: 166 additions & 0 deletions cleanrg.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,166 @@
    // -----------------------------------------------------------------------------
    // Azure RG Janitor - Spectre Edition
    // Run with: dotnet run cleanup-rg.cs
    // Targets: .NET 10 Preview 4+
    // -----------------------------------------------------------------------------

    #:package Azure.Identity@1.*
    #:package Azure.ResourceManager@1.*
    #:package Spectre.Console@0.50.0

    using Azure.Identity;
    using Azure.ResourceManager;
    using Azure.ResourceManager.Resources;
    using Spectre.Console;

    // ─────────────── settings ─────────────────────────────────────────────────────
    const int MaxParallel = 10; // limit concurrent deletions
    // ──────────────────────────────────────────────────────────────────────────────

    // 1️⃣ Select subscription
    var arm = new ArmClient(new DefaultAzureCredential());
    var subs = arm.GetSubscriptions().GetAllAsync();
    var subList = new List<SubscriptionResource>();
    await AnsiConsole.Status()
    .Spinner(Spinner.Known.Dots)
    .SpinnerStyle(Style.Parse("yellow"))
    .StartAsync("Fetching Azure subscriptions...", async ctx =>
    {
    await foreach (var s in subs)
    subList.Add(s);
    });

    if (subList.Count == 0)
    {
    AnsiConsole.MarkupLine("[red]❌ No subscriptions found.[/]");
    return;
    }

    var subChoices = subList.Select(s => $"{s.Data.DisplayName} ({s.Data.SubscriptionId})").ToList();
    subChoices.Add("Cancel");
    var selectedSub = AnsiConsole.Prompt(
    new SelectionPrompt<string>()
    .Title("Select an Azure subscription:")
    .AddChoices(subChoices)
    .UseConverter(x => x) // keep display as is
    .PageSize(10)
    .MoreChoicesText("[grey](Move up and down to reveal more subscriptions)[/]")
    .HighlightStyle("blue")
    .EnableSearch()
    );
    if (selectedSub == "Cancel")
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted by user.[/]");
    return;
    }
    var subIdx = subChoices.IndexOf(selectedSub);
    var sub = subList[subIdx];

    AnsiConsole.MarkupLine($"[bold blue]🔍 Scanning subscription [yellow]{sub.Data.DisplayName}[/]…[/]");

    // 2️⃣ Find candidate resource groups
    var matches = new List<ResourceGroupResource>();
    await foreach (var rg in sub.GetResourceGroups().GetAllAsync())
    {
    matches.Add(rg);
    }

    if (matches.Count == 0)
    {
    AnsiConsole.MarkupLine("[green]✔ No resource groups match.[/]");
    return;
    }

    // 3️⃣ Pick list for resource groups
    var rgChoices = matches.Select(rg => $"{rg.Data.Name}").OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList();
    matches = matches.OrderBy(rg => rg.Data.Name, StringComparer.OrdinalIgnoreCase).ToList();
    var prompt = new MultiSelectionPrompt<string>()
    .Title($"Select resource groups to delete ([yellow]{matches.Count}[/] found):")
    .NotRequired()
    .PageSize(10)
    .InstructionsText("[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept, type to search)[/]")
    .AddChoices(rgChoices);

    // foreach (var choice in rgChoices)
    // prompt.Select(choice); // pre-select all

    var selectedRgs = AnsiConsole.Prompt(prompt) ?? rgChoices;
    AnsiConsole.MarkupLine($"[grey]Selected resource groups:[/]");
    foreach (var name in selectedRgs)
    AnsiConsole.MarkupLine($" [yellow]{name}[/]");
    if (selectedRgs.Count == 0)
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted – nothing selected.[/]");
    return;
    }

    matches = matches.Where(rg => selectedRgs.Contains(rg.Data.Name)).OrderBy(rg => rg.Data.Name, StringComparer.OrdinalIgnoreCase).ToList();

    // 1️⃣ pretty-print the list
    var table = new Table()
    .Border(TableBorder.Rounded)
    .AddColumn("#")
    .AddColumn("Name");

    int idx = 1;
    foreach (var rg in matches)
    table.AddRow(idx++.ToString(), rg.Data.Name);

    AnsiConsole.Write(table);

    // 2️⃣ confirmation
    bool proceed = AnsiConsole.Confirm(
    $"Delete [yellow]{matches.Count}[/] resource group(s) shown above?", defaultValue: false);

    if (!proceed)
    {
    AnsiConsole.MarkupLine("[red]❌ Aborted – nothing deleted.[/]");
    return;
    }

    // 3️⃣ delete with a progress bar
    AnsiConsole.MarkupLine("[bold]🗑 Deleting…[/]");

    using var gate = new SemaphoreSlim(MaxParallel);
    await AnsiConsole.Progress()
    .AutoClear(true)
    .Columns(
    [
    new TaskDescriptionColumn(),
    new ProgressBarColumn(),
    new SpinnerColumn(),
    new ElapsedTimeColumn(),
    ])
    .StartAsync(async ctx =>
    {
    var overallTask = ctx.AddTask("Overall progress", maxValue: matches.Count);
    var rgTasks = matches.ToDictionary(
    rg => rg.Data.Name,
    rg => ctx.AddTask($"[grey]Deleting:[/] [yellow]{rg.Data.Name}[/]", maxValue: 1)
    );

    var deleteTasks = matches.Select(async rg =>
    {
    await gate.WaitAsync();
    try
    {
    rgTasks[rg.Data.Name].Increment(0.5); // show started
    await rg.DeleteAsync(Azure.WaitUntil.Completed);
    rgTasks[rg.Data.Name].Value = 1;
    }
    finally
    {
    gate.Release();
    overallTask.Increment(1);
    }
    });

    await Task.WhenAll(deleteTasks);
    });

    AnsiConsole.MarkupLine("[bold green]✅ Cleanup completed.[/]");

    // Print what was deleted
    AnsiConsole.MarkupLine("[bold]Deleted resource groups:[/]");
    foreach (var rg in matches)
    AnsiConsole.MarkupLine($" [yellow]{rg.Data.Name}[/]");