hosts/_registry.ps1
|
# Host Package Registry # # THIS IS THE ONLY FILE HOST-NEUTRAL CORE CODE CALLS. # # Discovers per-host packages under hosts/<kind>/host.psd1, loads their manifests, # and dispatches to per-host handler functions via the contract defined in hosts/_contract.md. # Phases A-D + Slice 9 are shipped: manifest discovery, validation, dispatch, registry-driven # launch path, and per-host Crew-runtime install (5th contract function). Set-StrictMode -Version Latest $script:SpecrewHostsRoot = $PSScriptRoot # hosts/ directory $script:HostManifestCache = $null # ordered dictionary, Kind => manifest hashtable # Dot-source the canonical team-location helpers (Proposal 108 Slice 9) $_teamCanonicalPath = Join-Path $script:SpecrewHostsRoot '_team-canonical.ps1' if (Test-Path -LiteralPath $_teamCanonicalPath -PathType Leaf) { . $_teamCanonicalPath } function Get-SpecrewHostsRoot { return $script:SpecrewHostsRoot } function Reset-HostManifestCache { $script:HostManifestCache = $null } function Get-RegisteredHostKinds { <# .SYNOPSIS Returns the canonical list of host kinds discovered under hosts/. .DESCRIPTION Enumerates hosts/*/host.psd1 files. The Kind field in each manifest must match the folder name (lowercase). Returns a sorted string[] for deterministic order. Caches results within the session. .OUTPUTS string[] #> if ($null -ne $script:HostManifestCache) { return @($script:HostManifestCache.Keys) } # iter-011: load manifests first, then sort by MenuPriority (then Kind for # stable ordering when priorities tie or are missing). Hosts without a # MenuPriority field sort last (default 999) — keeps new hosts visible but # not surprising users with reordering. $hostDirs = Get-ChildItem -Path $script:SpecrewHostsRoot -Directory -ErrorAction SilentlyContinue | Where-Object { -not $_.Name.StartsWith('_') } $loaded = [System.Collections.Generic.List[object]]::new() foreach ($dir in $hostDirs) { $manifestPath = Join-Path $dir.FullName 'host.psd1' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { continue } try { $manifest = Import-PowerShellDataFile -LiteralPath $manifestPath } catch { Write-Warning "Failed to load host manifest '$manifestPath': $($_.Exception.Message)" continue } # Folder-name vs manifest-Kind parity check if (-not $manifest.ContainsKey('Kind')) { Write-Warning "Host manifest '$manifestPath' is missing required field 'Kind'." continue } if ($manifest.Kind -ne $dir.Name) { Write-Warning "Host manifest at '$manifestPath' has Kind='$($manifest.Kind)' which does not match folder name '$($dir.Name)'." continue } $priority = if ($manifest.ContainsKey('MenuPriority')) { [int]$manifest.MenuPriority } else { 999 } $loaded.Add([pscustomobject]@{ Kind = $manifest.Kind Priority = $priority Manifest = $manifest }) } $sorted = $loaded | Sort-Object Priority, Kind $cache = [ordered]@{} foreach ($entry in $sorted) { $cache[$entry.Kind] = $entry.Manifest } $script:HostManifestCache = $cache return @($cache.Keys) } function Get-HostManifest { <# .SYNOPSIS Returns the manifest hashtable for a given host kind. .OUTPUTS hashtable #> param( [Parameter(Mandatory = $true)] [string]$Kind ) $kindLower = $Kind.ToLowerInvariant() if ($null -eq $script:HostManifestCache) { $null = Get-RegisteredHostKinds } if (-not $script:HostManifestCache.Contains($kindLower)) { throw "Unknown host kind '$Kind'. Registered: $((Get-RegisteredHostKinds) -join ', ')." } return $script:HostManifestCache[$kindLower] } function Test-HostManifestValid { <# .SYNOPSIS Validates a manifest hashtable against the contract. .OUTPUTS pscustomobject with IsValid (bool) + Errors (string[]) #> param( [Parameter(Mandatory = $true)] [hashtable]$Manifest ) $errors = New-Object System.Collections.Generic.List[string] $requiredFields = @('Kind', 'DisplayName', 'Status', 'SchemaVersion', 'Binary', 'InstallUrl', 'SkillRoot', 'HasUserSlashCommandSurface') foreach ($field in $requiredFields) { if (-not $Manifest.ContainsKey($field) -or $null -eq $Manifest[$field]) { $errors.Add("Missing required field: $field") | Out-Null continue } if ($field -in @('Kind', 'DisplayName', 'Status', 'Binary', 'InstallUrl', 'SkillRoot') -and [string]::IsNullOrWhiteSpace([string]$Manifest[$field])) { $errors.Add("Required field '$field' is empty") | Out-Null } } if ($Manifest.ContainsKey('Status')) { $allowedStatuses = @('supported', 'deferred', 'experimental') if ($Manifest.Status -notin $allowedStatuses) { $errors.Add("Status '$($Manifest.Status)' is not one of: $($allowedStatuses -join ', ')") | Out-Null } if ($Manifest.Status -eq 'deferred') { foreach ($deferredField in @('DeferredReason', 'DeferredGuidance')) { if (-not $Manifest.ContainsKey($deferredField) -or [string]::IsNullOrWhiteSpace([string]$Manifest[$deferredField])) { $errors.Add("Status='deferred' requires '$deferredField' to be set") | Out-Null } } } if ($Manifest.Status -eq 'supported') { # Install-<Kind>CrewRuntime resolves the agent root via Get-SpecrewHostAgentRoot, # which reads AgentDir. A supported host without AgentDir cannot deploy its Crew runtime. if (-not $Manifest.ContainsKey('AgentDir') -or [string]::IsNullOrWhiteSpace([string]$Manifest['AgentDir'])) { $errors.Add("Status='supported' requires 'AgentDir' to be set (consumed by Install-<Kind>CrewRuntime)") | Out-Null } } } if ($Manifest.ContainsKey('Kind') -and $Manifest.Kind -is [string] -and $Manifest.Kind -cne $Manifest.Kind.ToLowerInvariant()) { $errors.Add("Field 'Kind' must be lowercase (got '$($Manifest.Kind)')") | Out-Null } return [pscustomobject]@{ IsValid = ($errors.Count -eq 0) Errors = $errors.ToArray() } } function Get-SpecrewHostsByStatus { <# .SYNOPSIS Returns host kinds filtered by Status field. .EXAMPLE Get-SpecrewHostsByStatus -Status supported Get-SpecrewHostsByStatus -Status deferred #> param( [Parameter(Mandatory = $true)] [ValidateSet('supported', 'deferred', 'experimental')] [string]$Status ) if ($null -eq $script:HostManifestCache) { $null = Get-RegisteredHostKinds } return @( foreach ($kind in $script:HostManifestCache.Keys) { if ($script:HostManifestCache[$kind].Status -eq $Status) { $kind } } ) } # Phase B: handler dispatch # Contract function => actual per-host PowerShell function-name template. # To add a new contract function, add an entry here AND export it from each hosts/<kind>/handlers.ps1. $script:HostContractFunctionMap = @{ 'NewLaunchInvocation' = 'New-{0}LaunchInvocation' 'ConvertFlag' = 'ConvertTo-{0}Flag' 'TestRuntimeInstalled' = 'Test-{0}RuntimeInstalled' 'GetSignals' = 'Get-{0}Signals' # Proposal 108 Slice 9: per-host Crew runtime install (5-agent baseline deployment). # Each host's Install-<Kind>CrewRuntime writes the agent charters in that host's # native format (Copilot: .squad/agents/*/charter.md, Claude: .claude/agents/*.md, # Codex: .codex/agents/*.toml, Antigravity: .agents/agents/*.md). 'InstallCrewRuntime' = 'Install-{0}CrewRuntime' } $script:HostHandlersDotSourced = @{} function Resolve-HostHandler { <# .SYNOPSIS Returns the per-host function name for a given contract slot. Does NOT verify the function exists — that's Invoke-HostHandler's job. .EXAMPLE Resolve-HostHandler -Kind claude -ContractFunction NewLaunchInvocation # Returns 'New-ClaudeLaunchInvocation' #> param( [Parameter(Mandatory = $true)][string]$Kind, [Parameter(Mandatory = $true)][string]$ContractFunction ) if (-not $script:HostContractFunctionMap.ContainsKey($ContractFunction)) { throw "Unknown contract function '$ContractFunction'. Registered: $($script:HostContractFunctionMap.Keys -join ', ')." } # Verify Kind exists (will throw if not) $null = Get-HostManifest -Kind $Kind # Pascal-case the Kind for the function name $kindLower = $Kind.ToLowerInvariant() $pascalKind = $kindLower.Substring(0, 1).ToUpperInvariant() + $kindLower.Substring(1) return [string]::Format($script:HostContractFunctionMap[$ContractFunction], $pascalKind) } function Invoke-HostHandler { <# .SYNOPSIS Dispatch a contract function for a given host with the supplied arguments. Requires the host's handlers.ps1 to be dot-sourced (done eagerly at the end of _registry.ps1). .EXAMPLE Invoke-HostHandler -Kind claude -ContractFunction NewLaunchInvocation -Arguments @{ ProjectPath = 'C:\proj'; Prompt = 'BOOT'; Agent = 'Squad'; AllowAll = $true } .OUTPUTS Whatever the per-host contract function returns #> param( [Parameter(Mandatory = $true)][string]$Kind, [Parameter(Mandatory = $true)][string]$ContractFunction, [hashtable]$Arguments = @{} ) $functionName = Resolve-HostHandler -Kind $Kind -ContractFunction $ContractFunction $cmd = Get-Command $functionName -ErrorAction SilentlyContinue if ($null -eq $cmd) { throw "Handler '$functionName' is not defined. Ensure hosts/_registry.ps1 was dot-sourced (which eagerly loads all hosts/<kind>/handlers.ps1)." } return & $functionName @Arguments } # Eagerly dot-source all hosts' handlers.ps1 at the script level so the functions # they define land in the SAME scope that's dot-sourcing _registry.ps1 (typically # the caller's script scope). Lazy loading inside a function dot-sources into the # function's scope only, which doesn't help dispatch. # # Performance: loading 4 small files (~100-150 lines each) is cheap. The alternative — # in-memory modules via New-Module — adds complexity for no measurable benefit at this scale. foreach ($_hostDir in (Get-ChildItem -Path $script:SpecrewHostsRoot -Directory -ErrorAction SilentlyContinue | Where-Object { -not $_.Name.StartsWith('_') })) { $_handlersPath = Join-Path $_hostDir.FullName 'handlers.ps1' if (Test-Path -LiteralPath $_handlersPath -PathType Leaf) { . $_handlersPath $script:HostHandlersDotSourced[$_hostDir.Name.ToLowerInvariant()] = $_handlersPath } } Remove-Variable -Name _hostDir, _handlersPath -ErrorAction SilentlyContinue |