# Copyright (c) 2026 Progralink. Licensed under the DevCage License -- see LICENSE.
#
# devcage.ps1 -- Windows launcher orchestrator.
# Dot-sources sandbox/lib/ps/ modules for all helper functions.
# See CLAUDE.md for full architecture documentation.

<#
.SYNOPSIS
    DevCage - Windows launcher.

.DESCRIPTION
    DevCage - AI Sandbox.
    Runs a sandboxed Docker container on the current directory.
    Default: drops into a bash shell. Pass "claude", "gemini", "codex", or "aider"
    to launch an AI coding agent (lazy-installed on first use). Use "*-yolo" variants
    for auto-approve mode.

    No admin privileges required - only Docker Desktop access.
    On Windows, your user must be in the "docker-users" group.
    An administrator can add you with:
      net localgroup docker-users <your-username> /add
    Then log out and back in.

    If script execution is disabled, run with:
      powershell -ExecutionPolicy Bypass -File devcage.ps1

    Security model:
      - Only the current working directory is mounted read-write on the host.
      - Agent config dirs are mounted READ-ONLY by default (copied into container).
        Pass --host-agents-config rw to make them read-write so settings persist to host.
        Pass --host-agents-config no to skip mounting agent configs entirely.
      - Container runs as unprivileged user with ALL capabilities dropped.
      - Docker-in-Docker is OFF by default; pass --embed-docker to enable it.
      - no-new-privileges prevents setuid/setgid escalation inside the sandbox.
      - LAN access is blocked; only Docker-internal and Internet traffic allowed.

.EXAMPLE
    .\devcage.ps1                         # bash shell inside sandbox
    .\devcage.ps1 claude                  # launch Claude Code
    .\devcage.ps1 gemini                  # launch Gemini CLI
    .\devcage.ps1 codex                   # launch Codex CLI
    .\devcage.ps1 aider                   # launch Aider
    .\devcage.ps1 claude-yolo             # Claude Code (auto-approve)
    .\devcage.ps1 gemini-yolo             # Gemini CLI (auto-approve)
    .\devcage.ps1 python3 --version       # run arbitrary command
    .\devcage.ps1 --path ~\my-project     # use ~\my-project as workspace
    .\devcage.ps1 --path-browse claude    # browse for workspace, then launch Claude
    .\devcage.ps1 --volume ~\data:/data   # mount host dir into container
    .\devcage.ps1 --host-agents-config rw # mount agent config read-write
    .\devcage.ps1 --embed-docker          # enable Docker-in-Docker sidecar
    .\devcage.ps1 --memory 16g            # override memory limit (default: auto)
    .\devcage.ps1 --cpus 8                # override CPU limit (default: auto)
    .\devcage.ps1 --skip-build            # skip build, use cached image
    .\devcage.ps1 --rebuild               # force full rebuild (no cache)
    .\devcage.ps1 --clean                 # tear down all containers & volumes
    .\devcage.ps1 --help                  # print help and exit
#>

$ErrorActionPreference = "Stop"

# Force UTF-8 output so Unicode status characters render correctly
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# Use BOM-less UTF8 for native command piping -- Windows PowerShell 5.1's
# [Encoding]::UTF8 includes a BOM which corrupts piped script content
# (e.g. dind-lan-block.sh gets a BOM-prefixed shebang that sh can't parse).
$OutputEncoding = New-Object System.Text.UTF8Encoding $false

$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$DevcageVersion = if (Test-Path (Join-Path $ScriptDir "VERSION.txt")) {
    (Get-Content (Join-Path $ScriptDir "VERSION.txt") -Raw).Trim()
} else { "unknown" }
$ComposeFile = Join-Path (Join-Path $ScriptDir "sandbox") "docker-compose.yml"

# ── Detect container engine (docker preferred, podman fallback) ──
if (Get-Command docker -ErrorAction SilentlyContinue) {
    $ContainerEngine = "docker"
} elseif (Get-Command podman -ErrorAction SilentlyContinue) {
    $ContainerEngine = "podman"
} else {
    $ContainerEngine = $null
}

# ── Load helper modules ───────────────────────────────────────────
$_libDir = Join-Path $ScriptDir "sandbox\lib\ps"
. "$_libDir\Constants.ps1"
. "$_libDir\Utils.ps1"
. "$_libDir\Config.ps1"
. "$_libDir\Build.ps1"
. "$_libDir\Logging.ps1"
. "$_libDir\Resources.ps1"
. "$_libDir\Volumes.ps1"
. "$_libDir\Container.ps1"

# ── Usage ─────────────────────────────────────────────────────────
function Show-Usage {
    Write-Host @"
DevCage - Windows launcher

Usage: .\devcage.ps1 [OPTIONS] [COMMAND...]

Options:
  -v, --version   Print version and exit
  --help          Show this help message and exit
  --yolo-config, -yc   Auto-approve project-level config (init.sh, crontab)
  --gpu [TYPE]    Enable GPU passthrough (nvidia or amd). Auto-detected if type omitted
  --no-gpu        Disable GPU passthrough even if a GPU is detected
  --embed-docker, -d  Enable Docker-in-Docker sidecar (off by default)
  --host-agents-config, -hac MODE
                  Control host agent config mounting (MODE: ro, rw, no/disabled)
                  ro = read-only (default), rw = read-write, no = skip entirely
  --memory SIZE   Container memory limit (default: auto = engine maximum)
                  Accepts absolute (e.g. 8g), percentage (e.g. 50%), or auto
  --cpus N        Container CPU limit (default: auto = engine maximum)
                  Accepts absolute (e.g. 4), percentage (e.g. 50%), or auto
  --apt-packages, -apt PKGS
                  Comma/space-separated list of apt packages to install (as root)
  --brew-packages, -brew PKGS
                  Comma/space-separated list of Homebrew packages to install (as devcage user)
  --snap-packages, -snap PKGS
                  Comma/space-separated list of snap packages to install (--devmode)
  --script, -s PATH
                  Additional init script (sourced last, after hierarchy scripts)
  --path DIR      Use DIR as the workspace directory instead of the current directory
  --path-browse   Open a folder browse dialog to select the workspace directory
                  Remembers the last selected directory for convenience
  --no-cache-volumes, -ncv
                  Disable persistent per-workspace package caches
  --cache-dirs LIST
                  Comma-separated directory names cached on Docker volumes for
                  native ext4 performance (default: node_modules). Auto-detects
                  nested locations via package.json. Set to "" to disable.
                  Example: --cache-dirs "node_modules,target"
  -p, --port, --ports  Forward container ports to host (comma-separated)
                       Supports standard Docker syntax:
                         3000              -> same port on host (with --bind address)
                         5080:80           -> host:container mapping
                         0.0.0.0:5080:80   -> bind:host:container (overrides --bind)
                       Examples: 3000,5173 or 5080:80,8443:443
  --bind ADDR     Bind address for forwarded ports (default: 127.0.0.1)
                  Use 0.0.0.0 to expose ports on all interfaces (LAN accessible)
  --git-auth, -git
                  Inherit host git config (~/.gitconfig) and credentials
                  (~/.git-credentials, GH_TOKEN, GITHUB_TOKEN)
  --env, -e KEY=VALUE
                  Inject environment variable into container (repeatable)
  --volume, -V SOURCE:DEST[:MODE]
                  Mount a host path into the container (repeatable)
                  MODE is 'ro' (default) or 'rw' (read-write)
  --verbose       Trace entrypoint execution (set -x) for debugging
  --no-reuse      Disable container reuse (create ephemeral container with --rm)
  --stop          Stop the workspace container without removing volumes
  --recreate      Stop and remove existing container, then create fresh
  --sessions      Show active exec sessions for the current workspace
  --idle-timeout N  Idle timeout in seconds before stopping reusable container (default: 300; 0 = stop immediately when last session exits)
  --skip-build    Skip Docker image build (use existing image)
  --rebuild       Force full image rebuild (ignore cache)
  --clean         Tear down all containers, volumes, and package caches and exit
  --clean-global  Remove ALL DevCage containers, images, volumes, and builder cache
  -P, --profile, --profiles  Activate config profile(s)
                             Single: -P test  or  Multiple: --profiles test,prod
  --skip-sandbox-warning  (Linux-only, ignored on Windows)
  -l, --launch NAME  Load a launch configuration from .devcage\launches\NAME.conf
  -L, --lang, --langs LANGS
                  Comma-separated languages to include (case-insensitive)
                  Valid: java, php, go, rust, cpp, python, javascript, dotnet
                  Aliases: c/c++ (cpp), .net/csharp/vb/f# (dotnet), py (python),
                           ts/typescript (javascript), golang (go)
                  Use "all" to explicitly enable all (overrides config)
                  Use "none" to disable all language toolsets
                  Omit to include all languages (default)
                  Note: python/javascript/cpp control dev tools only -
                        Python3, Node.js, and gcc are always available

AI tools (lazy-installed on first use):
  claude          Claude Code (Anthropic)
  gemini          Gemini CLI (Google)
  codex           Codex CLI (OpenAI)
  aider           Aider
  claude-yolo     Claude Code (auto-approve)
  gemini-yolo     Gemini CLI (auto-approve)
  codex-yolo      Codex CLI (auto-approve)
  aider-yolo      Aider (auto-approve)
  copilot         GitHub Copilot CLI (GitHub)
  goose           Goose
  copilot-yolo    GitHub Copilot CLI (auto-approve)
  goose-yolo      Goose (auto-approve)

Config hierarchy (each level overrides the previous):
  1. Global:  config\*             (next to this script)
  2. User:    ~\.devcage\config\*  (current user, all projects)
  3. Project: <workspace>\.devcage\config\*  (current project only)
  Each level can contain: switches.conf, versions.conf, init.sh, .env
  CLI flags override all config levels.

Profiles (named config variants layered on top of base config):
  Sibling directories named config.{profile} at each scope level.
  -P test or --profile test loads config.test\ at each scope.
  --profiles test,prod loads config.test\, config.prod\, config.test.cluster1\
  Multi-profile dirs (config.test.cluster1\) require ALL named profiles to be active.

Examples:
  .\devcage.ps1                         # bash shell inside sandbox
  .\devcage.ps1 claude                  # launch Claude Code
  .\devcage.ps1 gemini                  # launch Gemini CLI
  .\devcage.ps1 codex                   # launch Codex CLI
  .\devcage.ps1 aider                   # launch Aider
  .\devcage.ps1 claude-yolo             # Claude Code (auto-approve)
  .\devcage.ps1 gemini-yolo             # Gemini CLI (auto-approve)
  .\devcage.ps1 claude -p "explain this"  # pass args to Claude Code
  .\devcage.ps1 python3 --version       # run arbitrary command
  .\devcage.ps1 -p 3000,5173            # forward dev server ports to host
  .\devcage.ps1 -p 5080:80              # map container port 80 to host port 5080
  .\devcage.ps1 -p 3000 --bind 0.0.0.0  # forward port, LAN accessible
  .\devcage.ps1 -p 0.0.0.0:5080:80      # full Docker syntax (overrides --bind)
  .\devcage.ps1 --gpu nvidia             # force NVIDIA GPU passthrough
  .\devcage.ps1 --no-gpu                 # disable GPU even if detected
  .\devcage.ps1 --embed-docker --memory 4g # enable DinD, 4 GB memory limit
  .\devcage.ps1 --memory 50% --cpus 50% # half of host resources
  .\devcage.ps1 --env DATABASE_URL=postgres://localhost/db  # inject env var
  .\devcage.ps1 --verbose              # trace entrypoint for debugging
  .\devcage.ps1 --git-auth               # inherit host git config & credentials
  .\devcage.ps1 --volume ~\datasets:/data      # mount host dir read-only
  .\devcage.ps1 -V ~\models:/models:rw         # mount host dir read-write
  .\devcage.ps1 --host-agents-config rw  # persist config changes to host
  .\devcage.ps1 --apt-packages "vim htop strace"  # pre-install OS packages (root)
  .\devcage.ps1 --brew-packages "gh lazygit"      # pre-install Homebrew packages
  .\devcage.ps1 --snap-packages "lxd node"       # pre-install snap packages (--devmode)
  .\devcage.ps1 --script C:\my-init.sh         # additional init script (sourced last)
  .\devcage.ps1 --skip-build            # skip build, use cached image
  .\devcage.ps1 --rebuild              # force full rebuild (no cache)
  .\devcage.ps1 --path ~\my-project claude  # use ~\my-project as workspace
  .\devcage.ps1 --path-browse claude    # browse for workspace, then launch Claude
  .\devcage.ps1 -P test                 # activate "test" profile
  .\devcage.ps1 --profiles test,prod    # activate multiple profiles
  .\devcage.ps1 --clean                 # remove current workspace resources
  .\devcage.ps1 --clean-global          # remove ALL DevCage resources system-wide
"@
}

# ── Variable defaults ─────────────────────────────────────────────
$HostAgentsConfig = "ro"
$EmbedDocker      = $false
$NoCacheVolumes   = $false
$CacheDirs        = "node_modules"
$SkipBuild        = $false
$Rebuild          = $false
$Verbose          = $false
$YoloConfig       = $false
$MemoryLimit      = "auto"
$CpuLimit         = "auto"
$AptPackages      = ""
$BrewPackages     = ""
$SnapPackages     = ""
$PortList         = ""
$BindAddr         = "127.0.0.1"
$DnsServers       = ""
$ExtraInitScript  = ""
$GpuType          = ""
$NoGpu            = $true
$GitAuth          = $false
$EnvVars          = @()
$CustomVolumes    = @()
$WorkspacePath    = ""
$BrowseForWorkspace = $false
$ProfileList      = @()
$ProfilesFromConf = ""
$Langs            = ""
$LogKeep          = 10
$NoReuse          = $false
$IdleTimeout      = 300
$StopOnly         = $false
$Recreate         = $false
$ShowSessions     = $false
$ActiveProfiles   = @()
$ContainerArgs    = @()
$LaunchName       = ""
$LaunchAgent      = ""

# ── Config setup: initialize dirs and first pass ─────────────────
Initialize-ConfigDirs
Invoke-DiscoverConfig

# ── Pre-scan for --path (needed before --clean which exits early) ─
for ($pi = 0; $pi -lt $args.Count; $pi++) {
    if ($args[$pi] -eq "--path" -and ($pi + 1) -lt $args.Count) {
        $WorkspacePath = $args[$pi + 1]
        break
    }
}

# ── Parse flags (CLI overrides defaults above) ───────────────────
$i = 0
while ($i -lt $args.Count) {
    switch ($args[$i]) {
        { $_ -in "-v", "--version" } {
            Write-Host "DevCage $DevcageVersion"
            exit 0
        }
        "--help" {
            Show-Usage
            exit 0
        }
        "--clean" {
            $script:Cwd = if ($WorkspacePath) { Normalize-WorkspacePath $WorkspacePath } else { (Get-Location).Path }
            Compute-WorkspaceSlug
            Write-Host "Tearing down DevCage containers and volumes..."
            if (-not $ContainerEngine) {
                Write-Host "Error: Neither docker nor podman found in PATH." -ForegroundColor Red
                exit 1
            }
            $reuseCid = & $ContainerEngine ps -aq --filter "label=dev-sandbox.workspace=$($script:Cwd)" 2>$null | Select-Object -First 1
            if ($reuseCid) {
                & $ContainerEngine rm -f $reuseCid *>$null
                Write-Host "Removed reuse-mode container"
            }
            $prevEAP = $ErrorActionPreference
            try {
                $ErrorActionPreference = "SilentlyContinue"
                & $ContainerEngine compose -f $ComposeFile exec -T dind sh -c 'docker ps -aq 2>/dev/null | xargs docker rm -f 2>/dev/null' *>$null
                & $ContainerEngine compose -f $ComposeFile down -v --remove-orphans
            } finally {
                $ErrorActionPreference = $prevEAP
            }
            foreach ($vol in @("devcage_${WorkspaceSlug}_cache", "devcage_${WorkspaceSlug}_local", "devcage-claude-config", "devcage-gemini-config", "devcage-codex-config", "devcage-copilot-config", "devcage-goose-config")) {
                & $ContainerEngine volume rm $vol *>$null
                if ($LASTEXITCODE -eq 0) { Write-Host "Removed cache volume: $vol" }
            }
            $dcVolumes = & $ContainerEngine volume ls --format '{{.Name}}' 2>$null |
                Where-Object { $_ -match "^devcage_${WorkspaceSlug}_dircache_" }
            foreach ($vol in $dcVolumes) {
                & $ContainerEngine volume rm $vol *>$null
                if ($LASTEXITCODE -eq 0) { Write-Host "Removed dircache volume: $vol" }
            }
            exit 0
        }
        "--clean-global" {
            Write-Host "Removing ALL DevCage containers, images, volumes, and builder cache..."
            if (-not $ContainerEngine) {
                Write-Host "Error: Neither docker nor podman found in PATH." -ForegroundColor Red
                exit 1
            }
            $prevEAP = $ErrorActionPreference; $ErrorActionPreference = "SilentlyContinue"
            $containerIds = & $ContainerEngine ps -a --filter "name=devcage" --format '{{.ID}}' 2>$null
            if ($containerIds) { $containerIds | ForEach-Object { & $ContainerEngine rm -f $_ *>$null } }
            $volumes = & $ContainerEngine volume ls --filter "name=devcage-" --format '{{.Name}}' 2>$null
            if ($volumes) {
                $volumes | ForEach-Object {
                    & $ContainerEngine volume rm $_ *>$null
                    if ($LASTEXITCODE -eq 0) { Write-Host "Removed volume: $_" }
                }
            }
            $wsVolumes = & $ContainerEngine volume ls --filter "name=devcage_" --format '{{.Name}}' 2>$null
            if ($wsVolumes) {
                $wsVolumes | ForEach-Object {
                    & $ContainerEngine volume rm $_ *>$null
                    if ($LASTEXITCODE -eq 0) { Write-Host "Removed volume: $_" }
                }
            }
            $images = & $ContainerEngine images --format '{{.Repository}} {{.ID}}' 2>$null
            if ($images) {
                $images | Where-Object { $_ -match '^devcage-' } |
                    ForEach-Object { ($_ -split ' ')[1] } | Sort-Object -Unique |
                    ForEach-Object { & $ContainerEngine rmi -f $_ *>$null }
            }
            $dindImages = & $ContainerEngine images --format '{{.Repository}}:{{.Tag}}' 2>$null |
                Where-Object { $_ -match '^docker:.*-dind$' }
            foreach ($img in $dindImages) { & $ContainerEngine rmi -f $img *>$null }
            & $ContainerEngine builder prune -f *>$null
            $ErrorActionPreference = $prevEAP
            Write-Host "Global cleanup complete."
            exit 0
        }
        { $_ -in "--host-agents-config", "-hac" } {
            $i++
            if ($i -lt $args.Count -and $args[$i] -in "ro", "rw", "no", "disabled") {
                $HostAgentsConfig = $args[$i]; $i++
            } else {
                Write-Host "Error: --host-agents-config requires a mode: ro, rw, no, or disabled" -ForegroundColor Red; exit 1
            }
        }
        "--editable-config" { $HostAgentsConfig = "rw"; $i++ }
        { $_ -match '^--host-agents-config=(.+)$' } {
            $hacMode = $Matches[1]
            if ($hacMode -in "ro", "rw", "no", "disabled") { $HostAgentsConfig = $hacMode }
            else { Write-Host "Error: --host-agents-config value '$hacMode' must be ro, rw, no, or disabled" -ForegroundColor Red; exit 1 }
            $i++
        }
        "--gpu" {
            $NoGpu = $false; $i++
            if ($i -lt $args.Count -and $args[$i] -match '^(nvidia|amd)$') { $GpuType = $args[$i]; $i++ }
            else { $GpuType = "auto" }
        }
        "--no-gpu" { $NoGpu = $true; $GpuType = ""; $i++ }
        { $_ -in "--embed-docker", "-d" } { $EmbedDocker = $true; $i++ }
        { $_ -in "--no-cache-volumes", "-ncv" } { $NoCacheVolumes = $true; $i++ }
        "--cache-dirs" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --cache-dirs requires a value" -ForegroundColor Red; exit 1 }
            $i++; $CacheDirs = $args[$i]; $i++
        }
        { $_ -in "-p", "--port", "--ports" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: -p/--port/--ports requires a value (e.g. 3000,5173 or 5080:80)" -ForegroundColor Red; exit 1 }
            $i++; $PortList = $args[$i]; $i++
        }
        "--bind" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --bind requires an IP address (e.g. 127.0.0.1 or 0.0.0.0)" -ForegroundColor Red; exit 1 }
            $i++
            if (-not (Test-IPv4Address $args[$i])) { Write-Host "Error: --bind value '$($args[$i])' is not a valid IPv4 address" -ForegroundColor Red; exit 1 }
            $BindAddr = $args[$i]
            if ($BindAddr -eq "0.0.0.0") { [Console]::Error.WriteLine("WARNING: --bind 0.0.0.0 will expose forwarded ports to ALL network interfaces (LAN-accessible).") }
            $i++
        }
        "--dns" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --dns requires comma-separated DNS server IPs" -ForegroundColor Red; exit 1 }
            $i++
            foreach ($dip in ($args[$i] -split ',')) {
                $dip = $dip.Trim()
                if (-not (Test-IPv4Address $dip)) { Write-Host "Error: --dns value '$dip' is not a valid IPv4 address" -ForegroundColor Red; exit 1 }
            }
            $DnsServers = $args[$i]; $i++
        }
        { $_ -in "--apt-packages", "-apt" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --apt-packages requires a value" -ForegroundColor Red; exit 1 }
            $i++; $val = $args[$i]
            foreach ($pkg in ($val -split '[,\s]+')) {
                if ($pkg -and $pkg -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.+\-]*(:[a-zA-Z0-9]+)?$') {
                    Write-Host "Error: invalid package name '$pkg' in --apt-packages" -ForegroundColor Red; exit 1
                }
            }
            $AptPackages = $val; $i++
        }
        { $_ -in "--brew-packages", "-brew" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --brew-packages requires a value" -ForegroundColor Red; exit 1 }
            $i++; $val = $args[$i]
            foreach ($pkg in ($val -split '[,\s]+')) {
                if ($pkg -and $pkg -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_./-]*$') {
                    Write-Host "Error: invalid package name '$pkg' in --brew-packages" -ForegroundColor Red; exit 1
                }
            }
            $BrewPackages = $val; $i++
        }
        { $_ -in "--snap-packages", "-snap" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --snap-packages requires a value" -ForegroundColor Red; exit 1 }
            $i++; $val = $args[$i]
            foreach ($pkg in ($val -split '[,\s]+')) {
                if ($pkg -and $pkg -notmatch '^[a-z][a-z0-9\-]*$') {
                    Write-Host "Error: invalid snap name '$pkg' in --snap-packages" -ForegroundColor Red; exit 1
                }
            }
            $SnapPackages = $val; $i++
        }
        { $_ -in "--script", "-s" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --script requires a path" -ForegroundColor Red; exit 1 }
            $i++; $ExtraInitScript = $args[$i]; $i++
        }
        { $_ -in "--env", "-e" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --env requires KEY=VALUE" -ForegroundColor Red; exit 1 }
            $i++
            if ($args[$i] -notmatch '^[a-zA-Z_][a-zA-Z0-9_]*=') {
                Write-Host "Error: --env value must be KEY=VALUE (got '$($args[$i])')" -ForegroundColor Red; exit 1
            }
            if ($args[$i] -match '[\x00-\x1f\x7f]') { Write-Host "Error: --env value contains control characters" -ForegroundColor Red; exit 1 }
            $EnvVars += $args[$i]; $i++
        }
        { $_ -in "--volume", "-V" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --volume requires SOURCE:DEST[:MODE]" -ForegroundColor Red; exit 1 }
            $i++; $CustomVolumes += $args[$i]; $i++
        }
        { $_ -in "--yolo-config", "-yc" } { $YoloConfig = $true; $i++ }
        { $_ -in "--git-auth", "-git" } { $GitAuth = $true; $i++ }
        "--verbose" { $Verbose = $true; $i++ }
        "--skip-sandbox-warning" { $i++ }  # Linux-only; silently ignored on Windows
        "--no-reuse" { $NoReuse = $true; $i++ }
        "--stop" { $StopOnly = $true; $i++ }
        "--recreate" { $Recreate = $true; $i++ }
        "--sessions" { $ShowSessions = $true; $i++ }
        "--idle-timeout" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --idle-timeout requires a value in seconds" -ForegroundColor Red; exit 1 }
            $i++
            if ($args[$i] -notmatch '^\d+$') { Write-Host "Error: --idle-timeout requires a non-negative integer (got '$($args[$i])')" -ForegroundColor Red; exit 1 }
            $IdleTimeout = [int]$args[$i]; $i++
        }
        "--skip-build" { $SkipBuild = $true; $i++ }
        "--rebuild" { $Rebuild = $true; $SkipBuild = $false; $i++ }
        { $_ -in "-L", "--lang", "--langs" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: $($args[$i]) requires comma-separated languages (or 'none')" -ForegroundColor Red; exit 1 }
            $i++; $Langs = $args[$i]; $i++
        }
        "--path" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --path requires a directory path" -ForegroundColor Red; exit 1 }
            $i++; $WorkspacePath = $args[$i]; $i++
        }
        { $_ -in "-P", "--profile", "--profiles" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: $($args[$i]) requires a name or comma-separated names" -ForegroundColor Red; exit 1 }
            $i++
            foreach ($pn in ($args[$i] -split ',')) {
                $pn = $pn.Trim()
                if ($pn -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_-]*$') {
                    Write-Host "Error: invalid profile name '$pn' (must match [a-zA-Z0-9][a-zA-Z0-9_-]*, no dots)" -ForegroundColor Red; exit 1
                }
                $ProfileList += $pn
            }
            $i++
        }
        { $_ -in "--path-browse", "--browse" } { $BrowseForWorkspace = $true; $i++ }
        "--memory" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --memory requires a value" -ForegroundColor Red; exit 1 }
            $i++; $MemoryLimit = $args[$i]; $i++
        }
        "--cpus" {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --cpus requires a value" -ForegroundColor Red; exit 1 }
            $i++; $CpuLimit = $args[$i]; $i++
        }
        { $_ -in "--launch", "-l" } {
            if ($i + 1 -ge $args.Count) { Write-Host "Error: --launch requires a name" -ForegroundColor Red; exit 1 }
            $i++; $LaunchName = $args[$i]
            if ($LaunchName -match '[/\\:*?"''<>|]' -or $LaunchName -eq '.' -or $LaunchName -eq '..') {
                Write-Host "Error: invalid launch name '$LaunchName'" -ForegroundColor Red; exit 1
            }
            $i++
        }
        "--" {
            $i++
            if ($i -lt $args.Count) { $ContainerArgs = @($args[$i..($args.Count - 1)]) }
            $i = $args.Count
        }
        default {
            $ContainerArgs = @($args[$i..($args.Count - 1)])
            $i = $args.Count
        }
    }
}

# ── Folder browse dialog (--path-browse) ──────────────────────────
if ($BrowseForWorkspace -and -not $WorkspacePath) {
    $WorkspacePath = Show-BrowseFolder
    if (-not $WorkspacePath) { exit 0 }
}

# ── Load launch configuration (--launch NAME) ─────────────────────
if ($LaunchName) {
    $Cwd = if ($WorkspacePath) { $WorkspacePath } else { (Get-Location).Path }
    $LaunchFound = $false
    foreach ($ldir in @(
        (Join-Path $Cwd ".devcage\local\launches"),
        (Join-Path $Cwd ".devcage\launches")
    )) {
        $lconf = Join-Path $ldir "$LaunchName.conf"
        if (Test-Path $lconf) {
            $parsed = Parse-Switches -Files @($lconf)
            if ($ldir -like "*\local\launches") {
                Apply-Switches -Parsed $parsed -SourceLabel "launch:$LaunchName"
            } else {
                Apply-Switches -Parsed $parsed -SourceLabel "launch:$LaunchName" -SkipWorkspace
            }
            $LaunchFound = $true
            break
        }
    }
    if (-not $LaunchFound) {
        Write-Host "Error: launch '$LaunchName' not found in .devcage/launches/ or .devcage/local/launches/" -ForegroundColor Red
        exit 1
    }
}

# ── Inject launch agent into positional args if needed ─────────────
if ($ContainerArgs.Count -eq 0 -and $LaunchAgent) {
    $ContainerArgs = @($LaunchAgent)
}

# ── Quiet mode: suppress informational output when a command is given ──
$script:Quiet = $false
if ($ContainerArgs.Count -gt 0 -and -not $Verbose) {
    $script:Quiet = $true
}

Write-Info "Starting DevCage v$DevcageVersion..."

# ── Resolve active profiles ──────────────────────────────────────
Resolve-ActiveProfiles

# ── Workspace resolution ──────────────────────────────────────────
Resolve-Workspace

# ── Workspace slug + container name ───────────────────────────────
Compute-WorkspaceSlug

# ── Handle --stop (early exit) ────────────────────────────────────
if ($StopOnly) {
    if (-not $ContainerEngine) { Write-Host "Error: Neither docker nor podman found in PATH." -ForegroundColor Red; exit 1 }
    $existing = & $ContainerEngine ps -q --filter "label=dev-sandbox.workspace=$Cwd" 2>$null | Select-Object -First 1
    if ($existing) {
        Write-Host "Stopping container ${ContainerName}..."
        & $ContainerEngine stop $existing *>$null
        Write-Host "Container stopped."
    } else {
        Write-Host "No running container found for workspace: $Cwd"
    }
    exit 0
}

# ── Handle --sessions (early exit) ───────────────────────────────
if ($ShowSessions) {
    if (-not $ContainerEngine) { Write-Host "Error: Neither docker nor podman found in PATH." -ForegroundColor Red; exit 1 }
    $existing = & $ContainerEngine ps -q --filter "label=dev-sandbox.workspace=$Cwd" 2>$null | Select-Object -First 1
    if ($existing) {
        Write-Host "Active sessions in container ${ContainerName}:"
        & $ContainerEngine exec $existing find /tmp/.devcage-sessions -type f -printf '%f\n' 2>$null
    } else {
        Write-Host "No running container found for workspace: $Cwd"
    }
    exit 0
}

# ── Config: second pass + versions + image name ───────────────────
Set-ProjectConfigDir
Invoke-FullConfigApply
Load-Versions
Resolve-ImageName

# ── Host env, DinD cleanup, temp directory ────────────────────────
Setup-HostEnv
Invoke-ComposeCleanup
New-SandboxTmpDir

# ── Engine preflight ──────────────────────────────────────────────
Ensure-EngineRunning

# ── Resource limits ───────────────────────────────────────────────
Resolve-Resources
Export-ResourceEnv

# ── Volumes ───────────────────────────────────────────────────────
Build-AllVolumes

# ── Run args core flags (reads SandboxProjectInit/Crontab from volumes) ─
Build-RunArgs

# ── Session log setup ─────────────────────────────────────────────
Initialize-SessionLog
$script:RunArgs += @("-e", "DEVCAGE_SESSION_LOG=/workspace/.devcage/logs/session-${script:SessionTs}.log")

# ── Env files ─────────────────────────────────────────────────────
Load-EnvFiles

# ── Port forwarding args ──────────────────────────────────────────
Build-PortArgs

# ── Interactive command detection, compose files, build state ─────
Detect-InteractiveCmd
Initialize-ComposeFiles
Initialize-BuildState

# ── Build decision (skip/build/rebuild) ──────────────────────────
Invoke-BuildDecision

# ── Common run args, exec env, volume assembly ───────────────────
Build-CommonRunArgs
Build-ExecEnvArgs
Build-AllVolumeArgs

# ── Run container ─────────────────────────────────────────────────
$RunExitCode = 1
try {
    Run-Container
    $RunExitCode = $script:RunExitCode
    Write-SessionFooter $RunExitCode
    Print-ExitDiagnostics $RunExitCode
} finally {
    Invoke-CleanupOnExit
}
exit $RunExitCode
