scripts/win/agent/bagent.ps1

<#
 bagent.ps1 — Borg Agent (controller + runtime)
 PowerShell 7+ (.NET 8)
 - start : launches hidden background agent (tray + SQL clipboard detection)
 - stop : stops the agent if running
 - status: prints Running/Stopped
 - serve : internal; runs the agent (don’t call directly)
 
 Storage: %APPDATA%\borg\data\queries
 PID file: %APPDATA%\borg\agent\bagent.pid
#>


param(
    [ValidateSet('start', 'stop', 'status', 'serve')]
    [string]$Command = 'start'
)

# --------- Common paths ---------
$AppData = [Environment]::GetFolderPath('ApplicationData')
$AgentHome = Join-Path $AppData 'borg\agent'
$PidFile = Join-Path $AgentHome 'bagent.pid'
$ScriptPath = $MyInvocation.MyCommand.Path

# --------- Helpers (controller) ---------
function Get-AgentProcess {
    $seen = @{}
    $result = @()

    # PID file lookup
    if (Test-Path $PidFile) {
        $savedPid = Get-Content $PidFile -ErrorAction SilentlyContinue
        if ($savedPid) {
            $proc = Get-Process -Id $savedPid -ErrorAction SilentlyContinue
            if ($proc) { $result += $proc; $seen[$proc.Id] = $true }
        }
    }

    # Fallback search
    try {
        $escaped = [Regex]::Escape($ScriptPath)
        $foundProcs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
        Where-Object {
            $_.Name -match 'pwsh.exe' -and
            $_.CommandLine -match "$escaped.+serve"
        }
        foreach ($m in $foundProcs) {
            if (-not $seen[$m.ProcessId]) {
                $proc = Get-Process -Id $m.ProcessId -ErrorAction SilentlyContinue
                if ($proc) { $result += $proc; $seen[$proc.Id] = $true }
            }
        }
    }
    catch {}

    return $result
}

function Start-Agent {
    if (-not (Test-Path $ScriptPath)) { Write-Error "Script not found: $ScriptPath"; return }
    New-Item -ItemType Directory -Path $AgentHome -Force | Out-Null

    $existing = Get-AgentProcess
    if ($existing.Count -gt 0) {
        Write-Host "Borg Agent already running (PID(s): $($existing.Id -join ', '))."
        return
    }

    $agentCliArgs = @('-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, 'serve')
    $p = Start-Process -FilePath 'pwsh' -WindowStyle Hidden -ArgumentList $agentCliArgs -PassThru
    $p.Id | Set-Content -Path $PidFile -Encoding ASCII
    Write-Host "Borg Agent started (PID $($p.Id))."
}

function Stop-Agent {
    $procs = Get-AgentProcess
    if (-not $procs -or $procs.Count -eq 0) {
        Write-Host "Borg Agent is not running."
        if (Test-Path $PidFile) { Remove-Item $PidFile -Force -ErrorAction SilentlyContinue }
        return
    }

    # Remove PID file first
    if (Test-Path $PidFile) { Remove-Item $PidFile -Force -ErrorAction SilentlyContinue }

    foreach ($p in $procs) {
        Write-Host "Stopping Borg Agent (PID $($p.Id))..."
        try {
            Stop-Process -Id $p.Id -Force -ErrorAction Stop
            Write-Host "Stopped PID $($p.Id)"
        }
        catch {
            #Write-Warning "Could not stop PID $($p.Id): $_"
        }
    }
}


function Get-AgentStatus {
    $procs = Get-AgentProcess
    if ($procs -and $procs.Count -gt 0) {
        Write-Host "Running (PID(s): $($procs.Id -join ', '))"
    }
    else {
        Write-Host "Stopped"
    }
}

# --------- Agent runtime (serve) ---------
function Invoke-AgentRuntime {
    # Runs inside the hidden child pwsh process
    $agentScript = @'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName PresentationFramework
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName Microsoft.VisualBasic # for Interaction::InputBox
 
# ---------- Tray UI ----------
$tray = New-Object System.Windows.Forms.NotifyIcon
$tray.Icon = [System.Drawing.SystemIcons]::Information
$tray.Text = "Borg Agent — SQL Clipboard Watcher"
$tray.Visible = $true
 
$menu = New-Object System.Windows.Forms.ContextMenuStrip
 
$pauseItem = New-Object System.Windows.Forms.ToolStripMenuItem "Pause detection"
$pauseItem.CheckOnClick = $true
$menu.Items.Add($pauseItem) | Out-Null
 
$openFolderItem = New-Object System.Windows.Forms.ToolStripMenuItem "Open queries folder"
$menu.Items.Add($openFolderItem) | Out-Null
 
$exitItem = New-Object System.Windows.Forms.ToolStripMenuItem "Exit"
$exitItem.add_Click({
    try {
        $pidPath = [System.IO.Path]::Combine(
            [System.Environment]::GetFolderPath('ApplicationData'),
            'borg','agent','bagent.pid'
        )
        if (Test-Path $pidPath) { Remove-Item $pidPath -Force -ErrorAction SilentlyContinue }
    } catch {}
    [System.Windows.Threading.Dispatcher]::ExitAllFrames()
})
$menu.Items.Add($exitItem) | Out-Null
 
$tray.ContextMenuStrip = $menu
 
# ---------- Settings ----------
[int] $PollMs = 400
[int] $ToastMs = 3000
[double]$MinLen = 24
[bool] $RequirePunct = $false
 
# Storage root = roaming AppData\borg\data\queries
$storeDir = [System.IO.Path]::Combine([System.Environment]::GetFolderPath('ApplicationData'), 'borg', 'data', 'queries')
[System.IO.Directory]::CreateDirectory($storeDir) | Out-Null
 
$openFolderItem.add_Click({ Start-Process explorer.exe $storeDir })
 
# One toast per NEW clipboard content
$script:lastClipboardHash = $null
$script:lastClipboardText = $null
 
# ---------- Helpers ----------
function Test-IsSqlLike([string]$t) {
    if ([string]::IsNullOrWhiteSpace($t)) { return $false }
    $t = $t.Trim()
 
    # Ignore obvious non-SQL/code and self text
    if ($t.StartsWith("#") -or $t.StartsWith("//") -or $t -match '^\s*(using|class|public|private|function)\b') { return $false }
    if ($t -match 'PowerShell 7|bagent\.ps1|Create STA runspace') { return $false }
 
    if ($t.Length -lt $MinLen) { return $false }
    if ($RequirePunct -and ($t -notmatch '[;`\n]')) { return $false }
 
    $U = $t.ToUpperInvariant()
    $core = @('SELECT','INSERT','UPDATE','DELETE','MERGE','CREATE','ALTER','DROP','TRUNCATE','WITH')
    $struct = @('FROM','INTO','WHERE','JOIN','VALUES','SET','ON','GROUP BY','ORDER BY','TOP','DECLARE','BEGIN','END','EXEC','TABLE','VIEW','PROC')
    $coreHits = ($core | Where-Object { $U.Contains($_) }).Count
    $structHits = ($struct | Where-Object { $U.Contains($_) }).Count
    return ($coreHits -ge 1 -and $structHits -ge 1)
}
 
function Get-ClipboardTextSafe {
    try {
        if ([System.Windows.Clipboard]::ContainsText()) { return [System.Windows.Clipboard]::GetText() }
        return $null
    } catch { return $null }
}
 
function Get-Hash([string]$text) {
    if ([string]::IsNullOrEmpty($text)) { return $null }
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try { return [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))) }
    finally { $sha.Dispose() }
}
 
function Show-SqlToast([string]$text) {
    $first = ($text -split "`r?`n",2)[0]
    if ($first.Length -gt 120) { $first = $first.Substring(0,120) + "…" }
    $tray.BalloonTipTitle = "SQL detected in clipboard"
    $tray.BalloonTipText = $first + "`n(click to save)"
    $tray.ShowBalloonTip($ToastMs)
}
 
function Sanitize-FileName([string]$name) {
    if ([string]::IsNullOrWhiteSpace($name)) { return $null }
    $bad = [System.IO.Path]::GetInvalidFileNameChars()
    $clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { '_' } else { $_ } })
    return $clean.Trim()
}
 
function Save-Sql([string]$keyword, [string]$sqlText) {
    if ([string]::IsNullOrWhiteSpace($keyword)) { return $false }
    $base = (Sanitize-FileName $keyword)
    if ([string]::IsNullOrWhiteSpace($base)) { return $false }
 
    $path = [System.IO.Path]::Combine($storeDir, "$base.sql")
    $i = 1
    while (Test-Path $path) {
        $path = [System.IO.Path]::Combine($storeDir, "$base.$i.sql")
        $i++
    }
 
    [System.IO.File]::WriteAllText($path, $sqlText, [System.Text.Encoding]::UTF8)
    return $path
}
 
# Click toast -> prompt for keyword -> save
$tray.add_BalloonTipClicked({
    if ([string]::IsNullOrWhiteSpace($script:lastClipboardText)) { return }
 
    $defaultKey = ($script:lastClipboardText -split '\s+')[0]
    try {
        $keyword = [Microsoft.VisualBasic.Interaction]::InputBox(
            "Enter a keyword to save this query as (will become the filename).",
            "Save SQL to Borg",
            $defaultKey
        )
    } catch { $keyword = $null }
 
    if (-not [string]::IsNullOrWhiteSpace($keyword)) {
        $saved = Save-Sql $keyword $script:lastClipboardText
        if ($saved) {
            $tray.BalloonTipTitle = "Saved"
            $tray.BalloonTipText = "Stored as: " + [System.IO.Path]::GetFileName($saved)
            $tray.ShowBalloonTip(2000)
        } else {
            $tray.BalloonTipTitle = "Not saved"
            $tray.BalloonTipText = "Invalid name or write error."
            $tray.ShowBalloonTip(2000)
        }
    }
})
 
# Poll the clipboard (one toast per new clipboard content)
$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.Interval = [TimeSpan]::FromMilliseconds($PollMs)
$timer.Add_Tick({
    if ($pauseItem.Checked) { return }
    $txt = Get-ClipboardTextSafe
    $hash = Get-Hash $txt
 
    if ($hash -and $hash -ne $script:lastClipboardHash) {
        $script:lastClipboardHash = $hash
        $script:lastClipboardText = $txt
        if (Test-IsSqlLike $txt) { Show-SqlToast $txt }
    }
})
$timer.Start()
 
[System.Windows.Threading.Dispatcher]::Run()
 
# Cleanup
$timer.Stop()
$tray.Visible = $false
$tray.Dispose()
'@


    # Host the agent in an STA runspace (PS7-safe)
    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
    $iss.ApartmentState = [System.Threading.ApartmentState]::STA
    $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss)
    $runspace.Open()

    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $runspace
    $null = $ps.AddScript($agentScript).Invoke()

    $ps.Dispose()
    $runspace.Close()
    $runspace.Dispose()
}

# --------- Command switch ---------
switch ($Command) {
    'start' { Start-Agent }
    'stop' { Stop-Agent }
    'status' { Get-AgentStatus }
    'serve' { Invoke-AgentRuntime }
}