scripts/internal/detect-hosts.ps1

# Host detection helpers for Specrew multi-host launch path (F-040)
#
# Provides:
# - Get-SpecrewSupportedHostKinds : canonical host kind list (copilot, claude, codex)
# - Get-SpecrewDeferredHostKinds : reserved-but-deferred kinds (antigravity, auto)
# - Test-SpecrewHostAvailable : PATH probe for a single host
# - Get-SpecrewAvailableHosts : parallel probe across all supported hosts
# - Get-SpecrewHostBinary : binary name for a given host kind
# - Get-SpecrewHostInstallGuidance : actionable install URL/instructions per host
# - Test-HostSkillRoot : verify per-host skill directory presence + frontmatter
#
# Per F-040 research.md Task 3 (host validation flow) + Task 5 (capability matrix).

Set-StrictMode -Version Latest

# Phase D (Open-Closed cleanup, 2026-05-24): per-host lookups now derive from the
# hosts/<kind>/host.psd1 manifests via hosts/_registry.ps1. The previous switch arms
# duplicated manifest data and broke the architecture's "zero hardcoded host enums"
# promise. Function signatures preserved for backwards-compat — bodies are now
# manifest-driven so adding hosts/<new-kind>/ extends behavior with no edits here.

$script:RegistryPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'hosts\_registry.ps1'
if (-not (Test-Path -LiteralPath $script:RegistryPath -PathType Leaf)) {
    # Module-mode lookup
    $script:RegistryPath = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'hosts\_registry.ps1'
}
if (-not (Test-Path -LiteralPath $script:RegistryPath -PathType Leaf)) {
    throw "Host registry not found. Searched: $script:RegistryPath"
}
. $script:RegistryPath

function Get-SpecrewSupportedHostKinds {
    <#
    .SYNOPSIS
    Returns the canonical list of supported host kinds — registry-driven.
    Adding hosts/<new-kind>/host.psd1 with Status='supported' extends this set.
    #>

    return @(Get-SpecrewHostsByStatus -Status supported)
}

function Get-SpecrewDeferredHostKinds {
    <#
    .SYNOPSIS
    Returns the canonical list of deferred-but-reserved host kinds.
    Includes both Status='deferred' entries from manifests AND 'auto' (synthetic,
    no manifest — represents the future auto-selection mode of Proposal 104).
    #>

    $deferredFromManifests = @(Get-SpecrewHostsByStatus -Status deferred)
    return @($deferredFromManifests + @('auto'))
}

function Get-SpecrewHostBinary {
    <#
    .SYNOPSIS
    Returns the binary name for a host kind, read from the manifest.
    #>

    param([Parameter(Mandatory = $true)][string]$HostKind)
    $manifest = Get-HostManifest -Kind $HostKind
    return [string]$manifest.Binary
}

function Get-SpecrewHostSkillRoot {
    <#
    .SYNOPSIS
    Returns the absolute per-host skill root path. Manifest declares the relative path;
    this function joins with the project root and normalizes separators.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$HostKind,
        [Parameter(Mandatory = $true)][string]$ProjectPath
    )
    $manifest = Get-HostManifest -Kind $HostKind
    $relative = ([string]$manifest.SkillRoot) -replace '/', [System.IO.Path]::DirectorySeparatorChar
    return (Join-Path $ProjectPath $relative)
}

function Get-SpecrewHostInstallGuidance {
    <#
    .SYNOPSIS
    Returns user-facing install guidance for a host. Prefers manifest InstallGuidance
    (richer prose, may include install scripts); falls back to a generic format using
    Binary + InstallUrl from the manifest.
    #>

    param([Parameter(Mandatory = $true)][string]$HostKind)
    $manifest = Get-HostManifest -Kind $HostKind
    if ($manifest.PSObject -and $manifest.ContainsKey('InstallGuidance') -and -not [string]::IsNullOrWhiteSpace([string]$manifest.InstallGuidance)) {
        return [string]$manifest.InstallGuidance
    }
    # Fallback construction
    return ("{0} not found on PATH. Install: {1}" -f $manifest.DisplayName, $manifest.InstallUrl)
}

function Get-SpecrewDeferredHostGuidance {
    param(
        [Parameter(Mandatory = $true)]
        [string]$HostKind
    )

    switch ($HostKind.ToLowerInvariant()) {
        'auto' {
            return @(
                'Auto-selection is deferred to Proposal 104 (Multi-Host Onboarding + Selection Flow).',
                'Use --host copilot|claude|codex|antigravity explicitly until F-043 ships.',
                'See file:///C:/Dev/Specrew/proposals/104-multi-host-onboarding-and-selection-flow.md.'
            ) -join ' '
        }
        default {
            throw "Unsupported deferred-host kind '$HostKind'."
        }
    }
}

function Test-AntigravityGeminiDeadlineWarning {
    <#
    .SYNOPSIS
    Determine whether to emit a 2026-06-18 Gemini free-tier deadline warning.
 
    .DESCRIPTION
    Per Antigravity follow-up slice FR-009: warning fires when --host antigravity
    is invoked AND current date is on or after 2026-06-01 (two weeks before
    deadline) AND no Google AI Pro/Ultra subscription evidence is configured.
 
    .OUTPUTS
    PSCustomObject with ShouldWarn (bool) + Message (string or null).
    #>

    param(
        [Parameter(Mandatory = $true)][string]$ProjectPath,
        [DateTime]$CurrentDate = [DateTime]::UtcNow
    )

    $deadlineDate = [DateTime]::Parse('2026-06-18', [System.Globalization.CultureInfo]::InvariantCulture)
    $warningStartDate = $deadlineDate.AddDays(-17)   # two weeks + 3-day buffer

    if ($CurrentDate -lt $warningStartDate) {
        return [pscustomobject]@{ ShouldWarn = $false; Message = $null }
    }

    # Check if user has configured Google AI Pro/Ultra subscription evidence
    # in .specrew/config.yml. Field name TBD; for now look for an env var.
    if (-not [string]::IsNullOrWhiteSpace($env:GOOGLE_AI_SUBSCRIPTION_TIER) -or
        -not [string]::IsNullOrWhiteSpace($env:ANTIGRAVITY_API_KEY)) {
        return [pscustomobject]@{ ShouldWarn = $false; Message = $null }
    }

    $daysUntil = ($deadlineDate - $CurrentDate.Date).Days
    $msg = if ($daysUntil -gt 0) {
        "Antigravity uses Google's Gemini infrastructure. The Gemini CLI free tier stops on 2026-06-18 ($daysUntil day$(if ($daysUntil -ne 1) { 's' }) from now). Configure Google AI Pro/Ultra subscription or set ANTIGRAVITY_API_KEY environment variable to continue using Antigravity after that date."
    } else {
        "Antigravity uses Google's Gemini infrastructure. The Gemini CLI free tier ended on 2026-06-18 ($([Math]::Abs($daysUntil)) day$(if ([Math]::Abs($daysUntil) -ne 1) { 's' }) ago). Configure Google AI Pro/Ultra subscription or set ANTIGRAVITY_API_KEY environment variable; you may have already hit usage limits."
    }

    return [pscustomobject]@{ ShouldWarn = $true; Message = $msg }
}

function Test-SpecrewHostAvailable {
    param(
        [Parameter(Mandatory = $true)]
        [string]$HostKind
    )

    # Probe primary Binary + BinaryAliases per manifest contract.
    # Pre-iter-004 implementation only checked Binary, ignoring BinaryAliases.
    $manifest = Get-HostManifest -Kind $HostKind
    $candidates = @([string]$manifest.Binary)
    if ($manifest.ContainsKey('BinaryAliases') -and $null -ne $manifest.BinaryAliases) {
        foreach ($alias in @($manifest.BinaryAliases)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$alias)) {
                $candidates += [string]$alias
            }
        }
    }
    foreach ($binary in $candidates) {
        if ($null -ne (Get-Command $binary -ErrorAction SilentlyContinue)) {
            return $true
        }
    }
    return $false
}

function Get-SpecrewAvailableHosts {
    # Probe all supported hosts. Sequential is fine — Get-Command is cheap.
    # ForEach-Object -Parallel adds runspace overhead that dominates for 3 cheap probes.
    $result = [ordered]@{}
    foreach ($kind in (Get-SpecrewSupportedHostKinds)) {
        $result[$kind] = Test-SpecrewHostAvailable -HostKind $kind
    }
    return $result
}

function Test-HostSkillRoot {
    <#
    .SYNOPSIS
    Verifies per-host skill directory presence and parses each SKILL.md frontmatter.
 
    .DESCRIPTION
    Returns a result object with:
      - HostKind
      - SkillRoot (absolute path)
      - Exists (bool — directory present)
      - SkillFiles (array of detected SKILL.md / *.md skill files)
      - Warnings (array of human-readable strings naming missing/malformed surfaces)
      - HasUserSlashCommandSurface (bool — false for codex per FR-013)
 
    Codex skill verification is intentionally shallow: Codex has no user-defined
    slash-command surface per 2026-05-23 research, so the .agents/skills/*
    files are deployed for future-proof but NOT invokable. The function returns
    an informational warning rather than treating missing files as a problem.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$HostKind,

        [Parameter(Mandatory = $true)]
        [string]$ProjectPath
    )

    $skillRoot = Get-SpecrewHostSkillRoot -HostKind $HostKind -ProjectPath $ProjectPath
    $exists = Test-Path -LiteralPath $skillRoot -PathType Container
    $warnings = New-Object System.Collections.Generic.List[string]
    $skillFiles = @()
    # Manifest declares HasUserSlashCommandSurface — no hardcoded host-name check
    $manifest = Get-HostManifest -Kind $HostKind
    $hasUserSlashCommandSurface = [bool]$manifest.HasUserSlashCommandSurface
    $displayName = [string]$manifest.DisplayName

    if (-not $exists) {
        if ($hasUserSlashCommandSurface) {
            $warnings.Add("Skill directory missing: $skillRoot. Run 'specrew init' to redeploy skill catalog.") | Out-Null
        }
        else {
            $warnings.Add("Skill directory missing: $skillRoot. Note: $displayName does not invoke skills as slash commands; skill files are deployed for future-proof but NOT invokable.") | Out-Null
        }
    }
    else {
        # Copilot convention is *.md flat in .github/skills/; others (Claude/Codex/Antigravity)
        # use SKILL.md nested. Could be manifest-declared but heuristic-by-skill-root works today;
        # capture via manifest field SkillFilePattern in a follow-up if more hosts diverge.
        if (([string]$manifest.Kind) -eq 'copilot') {
            $skillFiles = Get-ChildItem -Path $skillRoot -Filter '*.md' -ErrorAction SilentlyContinue
        }
        else {
            $skillFiles = Get-ChildItem -Path $skillRoot -Filter 'SKILL.md' -Recurse -ErrorAction SilentlyContinue
        }

        if ((-not $skillFiles -or $skillFiles.Count -eq 0) -and $hasUserSlashCommandSurface) {
            $warnings.Add("Skill directory exists but contains no skill files: $skillRoot") | Out-Null
        }

        # Validate frontmatter on each skill file (minimal: file is non-empty + has --- delimiters)
        foreach ($file in $skillFiles) {
            $content = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue
            if ([string]::IsNullOrWhiteSpace($content)) {
                $warnings.Add("Skill file is empty: $($file.FullName)") | Out-Null
                continue
            }
            if (-not ($content -match '(?m)^---\s*$')) {
                $warnings.Add("Skill file missing YAML frontmatter delimiters: $($file.FullName)") | Out-Null
            }
        }
    }

    if (-not $hasUserSlashCommandSurface) {
        # FR-013: informational note for any host without a user-defined slash-command surface
        $warnings.Add(("INFO: {0} has no user-defined slash-command surface. Skill files are deployed for future-proof but not invokable as /specrew-* on this host." -f $displayName)) | Out-Null
    }

    return [pscustomobject]@{
        HostKind                     = $HostKind.ToLowerInvariant()
        SkillRoot                    = $skillRoot
        Exists                       = $exists
        SkillFiles                   = @($skillFiles)
        Warnings                     = $warnings.ToArray()
        HasUserSlashCommandSurface   = $hasUserSlashCommandSurface
    }
}