scripts/specrew.ps1
|
param( [Parameter(Mandatory = $false, Position = 0)] [string]$Command, [Alias('help')] [switch]$HelpRequested, [Alias('info')] [switch]$InfoRequested, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$Arguments ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $Arguments = @($Arguments | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($HelpRequested.IsPresent) { $Arguments = @($Arguments) + '--help' } if ($InfoRequested.IsPresent) { $Arguments = @($Arguments) + '--info' } function Show-Usage { @' specrew - Spec-governed AI crew operating model Usage: specrew init [options] Bootstrap Specrew in the current or target project specrew start [args] Start or resume the Squad-driven Spec Kit lifecycle specrew review [options] Replay the persisted reviewer closeout packet specrew where [options] Show the velocity dashboard ("where am I?") specrew status [options] Alias for specrew where specrew update [options] Refresh Specrew assets or upgrade managed platforms specrew team <command> [args] Manage Squad team members specrew version [options] Show version and slash-command compatibility state Commands: init Initialize Specrew (Spec Kit + Squad + governance) start Start or resume feature delivery through Squad + Spec Kit review Show reviewer summary for a completed iteration where Show the velocity dashboard status Alias for where update Refresh Specrew or upgrade Spec Kit / Squad in an existing project team Manage team members (add, update, remove, list) version Show the installed Specrew version and slash-command compatibility help Show this help message Examples: specrew init --project-path . specrew start specrew start "Build a REST API for user management" specrew review --project-path . specrew where specrew status --compact specrew update specrew update --info specrew update --all specrew team list specrew version specrew team add security-analyst --role "Security Analyst" --charter "Review security" specrew team update security-analyst --charter "Updated charter" specrew team remove security-analyst For detailed command help: specrew init --help specrew start --help specrew review --help specrew where --help specrew update --help specrew version --help specrew team --help (shows usage when no subcommand provided) Slash-command catalog (`/specrew.help` fallback): /specrew.where Current Specrew project dashboard /specrew.status Alias for /specrew.where /specrew.update Refresh Specrew-managed assets and runtime surfaces /specrew.team Manage Squad team members /specrew.review Replay reviewer closeout state without approving a boundary /specrew.help Canonical catalog/help fallback /specrew.version Installed version and compatibility state '@ | Write-Host } function Test-ArgumentPresent { param( [string[]]$ArgumentList, [string[]]$OptionNames ) foreach ($argument in $ArgumentList) { foreach ($optionName in $OptionNames) { if ($argument -eq $optionName -or $argument.StartsWith(('{0}=' -f $optionName), [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } } return $false } function Write-UnsupportedArgumentError { param( [Parameter(Mandatory = $true)][string]$CommandName, [Parameter(Mandatory = $true)][string]$Argument ) Write-Output "WARNING: Unsupported argument '$Argument' for 'specrew $CommandName'." Write-Host ("ERROR: Unsupported argument '{0}'." -f $Argument) -ForegroundColor Red Write-Host ("Run 'specrew {0} --help' for usage or '/specrew.help' for the full Specrew catalog." -f $CommandName) -ForegroundColor Yellow exit 1 } function Write-MissingArgumentValueError { param( [Parameter(Mandatory = $true)][string]$CommandName, [Parameter(Mandatory = $true)][string]$OptionName ) Write-Output "WARNING: Missing value for '$OptionName' in 'specrew $CommandName'." Write-Host ("ERROR: '{0}' requires a value." -f $OptionName) -ForegroundColor Red Write-Host ("Run 'specrew {0} --help' for usage or '/specrew.help' for the full Specrew catalog." -f $CommandName) -ForegroundColor Yellow exit 1 } function Resolve-ProjectPathFromArguments { param([AllowEmptyCollection()][string[]]$ArgumentList) $normalizedArguments = @($ArgumentList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) for ($index = 0; $index -lt $normalizedArguments.Count; $index++) { $argument = $normalizedArguments[$index] if ($argument -match '^--project-path=(.+)$') { return $Matches[1] } if ($argument -ieq '--project-path') { $index++ if ($index -lt $normalizedArguments.Count) { return $normalizedArguments[$index] } return $null } } return (Get-Location).Path } function Assert-OptionArguments { param( [Parameter(Mandatory = $true)][string]$CommandName, [Parameter(Mandatory = $true)][AllowEmptyCollection()][string[]]$ArgumentList, [string[]]$SwitchOptions = @(), [string[]]$ValueOptions = @(), [int]$MaxPositionals = 0 ) $normalizedArguments = @($ArgumentList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $remainingPositionals = $MaxPositionals for ($index = 0; $index -lt $normalizedArguments.Count; $index++) { $argument = $normalizedArguments[$index] if ($SwitchOptions -icontains $argument) { continue } $matchedValueOption = $null foreach ($optionName in $ValueOptions) { if ($argument -ieq $optionName -or $argument.StartsWith(('{0}=' -f $optionName), [System.StringComparison]::OrdinalIgnoreCase)) { $matchedValueOption = $optionName break } } if ($null -ne $matchedValueOption) { if ($argument -ieq $matchedValueOption) { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName $CommandName -OptionName $matchedValueOption } } continue } if (-not $argument.StartsWith('-') -and $remainingPositionals -gt 0) { $remainingPositionals-- continue } Write-UnsupportedArgumentError -CommandName $CommandName -Argument $argument } } function Assert-TeamArguments { param([AllowEmptyCollection()][string[]]$ArgumentList) $normalizedArguments = @($ArgumentList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($normalizedArguments.Count -eq 0) { return } if ($normalizedArguments[0] -in @('--help', '-h')) { return } $subcommand = $normalizedArguments[0] $index = 1 switch ($subcommand) { 'list' { while ($index -lt $normalizedArguments.Count) { $argument = $normalizedArguments[$index] if ($argument -ieq '--project-path') { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName 'team' -OptionName '--project-path' } } elseif (-not $argument.StartsWith('--project-path=', [System.StringComparison]::OrdinalIgnoreCase)) { Write-UnsupportedArgumentError -CommandName 'team' -Argument $argument } $index++ } return } 'add' { if ($index -lt $normalizedArguments.Count -and -not $normalizedArguments[$index].StartsWith('-')) { $index++ } } 'update' { if ($index -lt $normalizedArguments.Count -and -not $normalizedArguments[$index].StartsWith('-')) { $index++ } } 'remove' { if ($index -lt $normalizedArguments.Count -and -not $normalizedArguments[$index].StartsWith('-')) { $index++ } while ($index -lt $normalizedArguments.Count) { $argument = $normalizedArguments[$index] if ($argument -ieq '--project-path') { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName 'team' -OptionName '--project-path' } } elseif (-not $argument.StartsWith('--project-path=', [System.StringComparison]::OrdinalIgnoreCase)) { Write-UnsupportedArgumentError -CommandName 'team' -Argument $argument } $index++ } return } default { Write-UnsupportedArgumentError -CommandName 'team' -Argument $subcommand } } while ($index -lt $normalizedArguments.Count) { $argument = $normalizedArguments[$index] switch -Regex ($argument) { '^--project-path(?:=.+)?$' { if ($argument -ieq '--project-path') { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName 'team' -OptionName '--project-path' } } } '^--role(?:=.+)?$' { if ($argument -ieq '--role') { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName 'team' -OptionName '--role' } } } '^--charter(?:=.+)?$' { if ($argument -ieq '--charter') { $index++ if ($index -ge $normalizedArguments.Count) { Write-MissingArgumentValueError -CommandName 'team' -OptionName '--charter' } } } default { Write-UnsupportedArgumentError -CommandName 'team' -Argument $argument } } $index++ } } function Assert-WhitelistedArguments { param( [Parameter(Mandatory = $true)][string]$CommandName, [AllowEmptyCollection()][string[]]$ArgumentList ) switch ($CommandName) { 'where' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--compact', '--ascii', '--no-color', '--json', '--team', '--worktrees', '--help', '-h') -ValueOptions @('--project-path', '--feature', '--iteration', '--recentcount', '--barwidth') } 'status' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--compact', '--ascii', '--no-color', '--json', '--team', '--worktrees', '--help', '-h') -ValueOptions @('--project-path', '--feature', '--iteration', '--recentcount', '--barwidth') } 'update' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--info', '--all', '--specrew', '--squad', '--spec-kit', '--skip-update-check', '--help', '-h') -ValueOptions @('--project-path') } 'review' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--quiet', '--json', '--open', '--help', '-h') -ValueOptions @('--project-path', '--feature', '--iteration') -MaxPositionals 1 } 'version' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--help', '-h') -ValueOptions @('--project-path') } 'team' { Assert-TeamArguments -ArgumentList $ArgumentList } 'help' { Assert-OptionArguments -CommandName $CommandName -ArgumentList $ArgumentList -SwitchOptions @('--help', '-h') } } } $scriptRoot = Split-Path -Parent $PSCommandPath $versionCheckHelperPath = Join-Path $scriptRoot 'internal\version-check.ps1' if (-not (Test-Path -LiteralPath $versionCheckHelperPath -PathType Leaf)) { throw "Missing version-check helper '$versionCheckHelperPath'." } . $versionCheckHelperPath function Assert-ProjectSetup { param( [Parameter(Mandatory = $true)][string]$CommandName, [AllowEmptyCollection()][string[]]$ArgumentList ) $projectPath = Resolve-ProjectPathFromArguments -ArgumentList $ArgumentList if ([string]::IsNullOrWhiteSpace($projectPath)) { Write-MissingArgumentValueError -CommandName $CommandName -OptionName '--project-path' } $resolvedProjectPath = Resolve-ProjectPath -Path $projectPath $configPath = Join-Path $resolvedProjectPath '.specrew\config.yml' if (Test-Path -LiteralPath $configPath -PathType Leaf) { return } Write-Output "WARNING: Specrew project setup is missing at '$resolvedProjectPath'." Write-Host ("ERROR: 'specrew {0}' requires a Specrew-managed project." -f $CommandName) -ForegroundColor Red Write-Host ("Run 'specrew init --project-path {0}' first, then retry the command." -f $resolvedProjectPath) -ForegroundColor Yellow exit 1 } function Assert-SlashCommandCompatibility { param( [Parameter(Mandatory = $true)][string]$CommandName, [AllowEmptyCollection()][string[]]$ArgumentList ) $projectPath = Resolve-ProjectPathFromArguments -ArgumentList $ArgumentList if ([string]::IsNullOrWhiteSpace($projectPath)) { return } $resolvedProjectPath = Resolve-ProjectPath -Path $projectPath $slashCommandMinVersionText = Get-SpecrewSlashCommandMinVersion $slashCommandMinVersion = ConvertTo-SpecrewSemanticVersion -Value $slashCommandMinVersionText if ($null -eq $slashCommandMinVersion) { return } $reasons = New-Object System.Collections.Generic.List[string] $projectBaselineVersionText = Get-SpecrewVersionConfigValue -ProjectRoot $resolvedProjectPath -Key 'specrew_version' $projectBaselineVersion = ConvertTo-SpecrewSemanticVersion -Value $projectBaselineVersionText if ($null -ne $projectBaselineVersion -and $projectBaselineVersion -lt $slashCommandMinVersion) { $reasons.Add(("project baseline {0}" -f $projectBaselineVersionText)) | Out-Null } $installedVersionText = Get-SpecrewInstalledVersion -ProjectRoot $resolvedProjectPath $installedVersion = ConvertTo-SpecrewSemanticVersion -Value $installedVersionText if ($null -ne $installedVersion -and $installedVersion -lt $slashCommandMinVersion) { $reasons.Add(("installed version {0}" -f $installedVersionText)) | Out-Null } if ($reasons.Count -eq 0) { return } Write-Output "WARNING: Slash-command compatibility check failed for 'specrew $CommandName'." Write-Host ("ERROR: 'specrew {0}' requires Specrew {1} or later." -f $CommandName, $slashCommandMinVersionText) -ForegroundColor Red Write-Host ("Observed: {0}." -f ($reasons -join '; ')) -ForegroundColor Yellow Write-Host "Run 'specrew update' to refresh project assets or 'Update-Module Specrew' to upgrade the installed module." -ForegroundColor Yellow exit 1 } if (-not $Command -or $Command -eq 'help' -or $Command -eq '--help' -or $Command -eq '-h') { Show-Usage exit 0 } switch ($Command) { 'init' { $initScript = Join-Path $scriptRoot 'specrew-init.ps1' if (-not (Test-Path -LiteralPath $initScript)) { Write-Host "ERROR: specrew-init.ps1 not found at $initScript" -ForegroundColor Red exit 1 } & pwsh -NoProfile -ExecutionPolicy Bypass -File $initScript @Arguments exit $LASTEXITCODE } 'team' { Assert-WhitelistedArguments -CommandName 'team' -ArgumentList $Arguments Assert-ProjectSetup -CommandName 'team' -ArgumentList $Arguments Assert-SlashCommandCompatibility -CommandName 'team' -ArgumentList $Arguments $teamScript = Join-Path $scriptRoot 'specrew-team.ps1' if (-not (Test-Path -LiteralPath $teamScript)) { Write-Host "ERROR: specrew-team.ps1 not found at $teamScript" -ForegroundColor Red exit 1 } if (-not $Arguments -or $Arguments.Count -eq 0) { Write-Host "Usage: specrew team <command> [options]" -ForegroundColor Yellow Write-Host "" Write-Host "Commands:" -ForegroundColor Cyan Write-Host " add <member-name> --role <role> --charter <charter-text>" Write-Host " list" Write-Host " update <member-name> [--role <role>] [--charter <charter-text>]" Write-Host " remove <member-name>" Write-Host "" Write-Host "Examples:" -ForegroundColor Cyan Write-Host " specrew team list" Write-Host " specrew team add security-analyst --role 'Security Analyst' --charter 'Review security'" exit 0 } & pwsh -NoProfile -ExecutionPolicy Bypass -File $teamScript @Arguments exit $LASTEXITCODE } 'start' { $startScript = Join-Path $scriptRoot 'specrew-start.ps1' if (-not (Test-Path -LiteralPath $startScript)) { Write-Host "ERROR: specrew-start.ps1 not found at $startScript" -ForegroundColor Red exit 1 } $startArguments = @($Arguments) if (-not (Test-ArgumentPresent -ArgumentList $startArguments -OptionNames @('--project-path', '-ProjectPath', '-project-path'))) { $startArguments = @('--project-path', (Get-Location).Path) + $startArguments } & $startScript -CliArgs $startArguments exit $LASTEXITCODE } 'review' { Assert-WhitelistedArguments -CommandName 'review' -ArgumentList $Arguments Assert-ProjectSetup -CommandName 'review' -ArgumentList $Arguments Assert-SlashCommandCompatibility -CommandName 'review' -ArgumentList $Arguments $reviewScript = Join-Path $scriptRoot 'specrew-review.ps1' if (-not (Test-Path -LiteralPath $reviewScript)) { Write-Host "ERROR: specrew-review.ps1 not found at $reviewScript" -ForegroundColor Red exit 1 } & pwsh -NoProfile -ExecutionPolicy Bypass -File $reviewScript @Arguments exit $LASTEXITCODE } 'where' { Assert-WhitelistedArguments -CommandName 'where' -ArgumentList $Arguments Assert-ProjectSetup -CommandName 'where' -ArgumentList $Arguments Assert-SlashCommandCompatibility -CommandName 'where' -ArgumentList $Arguments $whereScript = Join-Path $scriptRoot 'specrew-where.ps1' if (-not (Test-Path -LiteralPath $whereScript)) { Write-Host "ERROR: specrew-where.ps1 not found at $whereScript" -ForegroundColor Red exit 1 } & $whereScript -CliArgs $Arguments exit $LASTEXITCODE } 'status' { Assert-WhitelistedArguments -CommandName 'status' -ArgumentList $Arguments Assert-ProjectSetup -CommandName 'status' -ArgumentList $Arguments Assert-SlashCommandCompatibility -CommandName 'status' -ArgumentList $Arguments $whereScript = Join-Path $scriptRoot 'specrew-where.ps1' if (-not (Test-Path -LiteralPath $whereScript)) { Write-Host "ERROR: specrew-where.ps1 not found at $whereScript" -ForegroundColor Red exit 1 } # Alias parity safeguard: `status` MUST NOT diverge from `where`. & $whereScript -CliArgs $Arguments exit $LASTEXITCODE } 'update' { Assert-WhitelistedArguments -CommandName 'update' -ArgumentList $Arguments Assert-ProjectSetup -CommandName 'update' -ArgumentList $Arguments $updateScript = Join-Path $scriptRoot 'specrew-update.ps1' if (-not (Test-Path -LiteralPath $updateScript)) { Write-Host "ERROR: specrew-update.ps1 not found at $updateScript" -ForegroundColor Red exit 1 } & pwsh -NoProfile -ExecutionPolicy Bypass -File $updateScript @Arguments exit $LASTEXITCODE } 'version' { Assert-WhitelistedArguments -CommandName 'version' -ArgumentList $Arguments $versionScript = Join-Path $scriptRoot 'specrew-version.ps1' if (-not (Test-Path -LiteralPath $versionScript)) { Write-Host "ERROR: specrew-version.ps1 not found at $versionScript" -ForegroundColor Red exit 1 } & pwsh -NoProfile -ExecutionPolicy Bypass -File $versionScript @Arguments exit $LASTEXITCODE } default { Write-Host "ERROR: Unknown command '$Command'" -ForegroundColor Red Write-Host "Run 'specrew help' or '/specrew.help' to see the supported Specrew command catalog." -ForegroundColor Yellow Write-Host "" Show-Usage exit 1 } } |