2025-09-12 10:27:43 -07:00
#!/usr/bin/env pwsh
2025-09-20 12:14:42 -07:00
# Common PowerShell functions analogous to common.sh
2025-09-12 10:27:43 -07:00
2026-03-24 14:55:21 +01:00
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param ([ string ] $StartDir = ( Get-Location ). Path )
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
2026-03-25 12:54:49 -05:00
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
$current = if ( $resolved ) { $resolved . Path } else { $null }
2026-03-24 14:55:21 +01:00
if ( -not $current ) { return $null }
while ( $true ) {
if ( Test-Path -LiteralPath ( Join-Path $current ".specify" ) -PathType Container ) {
return $current
}
$parent = Split-Path $current -Parent
if ([ string ]:: IsNullOrEmpty ( $parent ) -or $parent -eq $current ) {
return $null
}
$current = $parent
}
}
2026-06-09 06:13:07 -05:00
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
2025-09-12 10:27:43 -07:00
function Get-RepoRoot {
2026-03-24 14:55:21 +01:00
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ( $specifyRoot ) {
return $specifyRoot
}
2026-06-09 06:13:07 -05:00
# Final fallback to script location
2026-03-24 14:55:21 +01:00
# Use -LiteralPath to handle paths with wildcard characters
return ( Resolve-Path -LiteralPath ( Join-Path $PSScriptRoot "../../.." )). Path
2025-09-12 10:27:43 -07:00
}
function Get-CurrentBranch {
2026-06-09 06:13:07 -05:00
# Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
2025-09-20 15:04:25 -07:00
if ( $env:SPECIFY_FEATURE ) {
return $env:SPECIFY_FEATURE
}
2026-03-24 14:55:21 +01:00
2026-06-09 06:13:07 -05:00
# No explicit feature set - return empty to signal "unknown".
return ""
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param (
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot ,
[ Parameter ( Mandatory = $true )][ string ] $FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [ System.IO.Path ]:: DirectorySeparatorChar
if ( $null -ne $IsWindows ) { $onWin = $IsWindows } else { $onWin = $true }
if ( $onWin ) {
$cmp = [ System.StringComparison ]:: OrdinalIgnoreCase
} else {
$cmp = [ System.StringComparison ]:: Ordinal
}
if ( $FeatureDirectory . StartsWith ( $prefix , $cmp )) {
$FeatureDirectory = $FeatureDirectory . Substring ( $prefix . Length )
}
$fjPath = Join-Path ( Join-Path $RepoRoot '.specify' ) 'feature.json'
# Read current value and skip write when unchanged
if ( Test-Path -LiteralPath $fjPath -PathType Leaf ) {
2026-03-24 14:55:21 +01:00
try {
2026-06-09 06:13:07 -05:00
$raw = Get-Content -LiteralPath $fjPath -Raw
$cfg = $raw | ConvertFrom-Json
if ( $cfg . feature_directory -eq $FeatureDirectory ) {
return
2026-03-24 14:55:21 +01:00
}
} catch {
2026-06-09 06:13:07 -05:00
# File is corrupt or unreadable - overwrite it
2026-03-24 14:55:21 +01:00
}
}
2026-06-09 06:13:07 -05:00
# Ensure .specify/ directory exists
$specifyDir = Join-Path $RepoRoot '.specify'
if ( -not ( Test-Path -LiteralPath $specifyDir -PathType Container )) {
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
2025-09-20 12:14:42 -07:00
}
2026-04-13 20:11:17 -03:00
2026-06-09 06:13:07 -05:00
# Write feature.json
$json = @ { feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System . Text . UTF8Encoding ( $false )
[ System.IO.File ]:: WriteAllText ( $fjPath , $json , $utf8NoBom )
2025-09-12 10:27:43 -07:00
}
function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch
2026-04-08 13:48:36 -05:00
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
2026-06-09 06:13:07 -05:00
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error - no feature context available
2026-04-08 13:48:36 -05:00
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ( $env:SPECIFY_FEATURE_DIRECTORY ) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if ( -not [ System.IO.Path ]:: IsPathRooted ( $featureDir )) {
$featureDir = Join-Path $repoRoot $featureDir
}
2026-06-09 06:13:07 -05:00
# Persist to feature.json so future sessions without the env var still work
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
2026-04-08 13:48:36 -05:00
} elseif ( Test-Path $featureJson ) {
2026-04-13 20:11:17 -03:00
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
2026-04-08 13:48:36 -05:00
try {
2026-04-13 20:11:17 -03:00
$featureConfig = $featureJsonRaw | ConvertFrom-Json
2026-04-08 13:48:36 -05:00
} catch {
2026-04-13 20:11:17 -03:00
[ Console ]:: Error . WriteLine ( "ERROR: Failed to parse .specify/feature.json: $_ " )
exit 1
}
if ( $featureConfig . feature_directory ) {
$featureDir = $featureConfig . feature_directory
# Normalize relative paths to absolute under repo root
if ( -not [ System.IO.Path ]:: IsPathRooted ( $featureDir )) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
2026-06-09 06:13:07 -05:00
[ Console ]:: Error . WriteLine ( "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." )
exit 1
2026-04-08 13:48:36 -05:00
}
} else {
2026-06-09 06:13:07 -05:00
[ Console ]:: Error . WriteLine ( "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." )
exit 1
2026-04-08 13:48:36 -05:00
}
2025-09-20 12:14:42 -07:00
2025-09-12 10:27:43 -07:00
[ PSCustomObject ] @ {
2025-09-20 12:14:42 -07:00
REPO_ROOT = $repoRoot
2025-09-12 10:27:43 -07:00
CURRENT_BRANCH = $currentBranch
2025-09-20 12:14:42 -07:00
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
2025-09-12 10:27:43 -07:00
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param ([ string ] $Path , [ string ] $Description )
if ( Test-Path -Path $Path -PathType Leaf ) {
2026-05-26 13:18:34 -05:00
Write-Output " [OK] $Description "
2025-09-12 10:27:43 -07:00
return $true
} else {
2026-05-26 13:18:34 -05:00
Write-Output " [FAIL] $Description "
2025-09-12 10:27:43 -07:00
return $false
}
}
function Test-DirHasFiles {
param ([ string ] $Path , [ string ] $Description )
if (( Test-Path -Path $Path -PathType Container ) -and ( Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_ . PSIsContainer } | Select-Object -First 1 )) {
2026-05-26 13:18:34 -05:00
Write-Output " [OK] $Description "
2025-09-12 10:27:43 -07:00
return $true
} else {
2026-05-26 13:18:34 -05:00
Write-Output " [FAIL] $Description "
2025-09-12 10:27:43 -07:00
return $false
}
}
2025-10-14 11:52:26 -07:00
2026-06-03 23:24:13 +02:00
function Get-InvokeSeparator {
param ([ string ] $RepoRoot = ( Get-RepoRoot ))
if ( $null -eq $script:SpecKitInvokeSeparatorCache ) {
$script:SpecKitInvokeSeparatorCache = @ {}
}
if ( $script:SpecKitInvokeSeparatorCache . ContainsKey ( $RepoRoot )) {
return $script:SpecKitInvokeSeparatorCache [ $RepoRoot ]
}
$separator = '.'
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
if ( Test-Path -LiteralPath $integrationJson -PathType Leaf ) {
try {
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
$key = if ( $state . default_integration ) { [ string ] $state . default_integration } elseif ( $state . integration ) { [ string ] $state . integration } else { '' }
if ( $key -and $state . integration_settings ) {
$settingProperty = $state . integration_settings . PSObject . Properties [ $key ]
if ( $settingProperty ) {
$setting = $settingProperty . Value
if ( $setting -and ( $setting . invoke_separator -eq '.' -or $setting . invoke_separator -eq '-' )) {
$separator = [ string ] $setting . invoke_separator
}
}
}
} catch {
$separator = '.'
}
}
$script:SpecKitInvokeSeparatorCache [ $RepoRoot ] = $separator
return $separator
}
function Format-SpecKitCommand {
param (
[ Parameter ( Mandatory = $true )][ string ] $CommandName ,
[ string ] $RepoRoot = ( Get-RepoRoot )
)
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
$name = $CommandName . TrimStart ( '/' )
if ( $name . StartsWith ( 'speckit.' )) {
$name = $name . Substring ( 8 )
} elseif ( $name . StartsWith ( 'speckit-' )) {
$name = $name . Substring ( 8 )
}
$name = $name -replace '\.' , $separator
return "/speckit $separator$name "
}
2026-04-23 10:07:52 -05:00
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if ( Get-Command python3 -ErrorAction SilentlyContinue ) { return @ ( 'python3' ) }
if ( Get-Command python -ErrorAction SilentlyContinue ) {
$ver = & python - -version 2 >& 1
if ( $ver -match 'Python 3' ) { return @ ( 'python' ) }
}
if ( Get-Command py -ErrorAction SilentlyContinue ) {
$ver = & py -3 - -version 2 >& 1
if ( $ver -match 'Python 3' ) { return @ ( 'py' , '-3' ) }
}
return $null
}
2026-03-13 15:09:14 -05:00
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
param (
[ Parameter ( Mandatory = $true )][ string ] $TemplateName ,
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/ $TemplateName .md"
if ( Test-Path $override ) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if ( Test-Path $presetsDir ) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @ ()
if ( Test-Path $registryFile ) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData . presets
if ( $presets ) {
$sortedPresets = $presets . PSObject . Properties |
2026-04-23 10:07:52 -05:00
Where-Object { $null -eq $_ . Value . enabled -or $_ . Value . enabled -ne $false } |
2026-03-13 15:09:14 -05:00
Sort-Object { if ( $null -ne $_ . Value . priority ) { $_ . Value . priority } else { 10 } } |
ForEach-Object { $_ . Name }
}
} catch {
# Fallback: alphabetical directory order
$sortedPresets = @ ()
}
}
if ( $sortedPresets . Count -gt 0 ) {
foreach ( $presetId in $sortedPresets ) {
$candidate = Join-Path $presetsDir " $presetId /templates/ $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ( $preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_ . Name -notlike '.*' }) {
$candidate = Join-Path $preset . FullName "templates/ $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if ( Test-Path $extDir ) {
foreach ( $ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_ . Name -notlike '.*' } | Sort-Object Name ) {
$candidate = Join-Path $ext . FullName "templates/ $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base " $TemplateName .md"
if ( Test-Path $core ) { return $core }
return $null
}
2026-04-23 10:07:52 -05:00
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
function Resolve-TemplateContent {
param (
[ Parameter ( Mandatory = $true )][ string ] $TemplateName ,
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @ ()
$layerStrategies = @ ()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/ $TemplateName .md"
if ( Test-Path $override ) {
$layerPaths += $override
$layerStrategies += 'replace'
}
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if ( Test-Path $presetsDir ) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @ ()
if ( Test-Path $registryFile ) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData . presets
if ( $presets ) {
$sortedPresets = $presets . PSObject . Properties |
Where-Object { $null -eq $_ . Value . enabled -or $_ . Value . enabled -ne $false } |
Sort-Object { if ( $null -ne $_ . Value . priority ) { $_ . Value . priority } else { 10 } } |
ForEach-Object { $_ . Name }
}
} catch {
$sortedPresets = @ ()
}
}
if ( $sortedPresets . Count -gt 0 ) {
$pyCmd = Get-Python3Command
if ( -not $pyCmd ) {
# Check if any preset has strategy fields that would be ignored
foreach ( $pid in $sortedPresets ) {
$mf = Join-Path $presetsDir " $pid /preset.yml"
if (( Test-Path $mf ) -and ( Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue )) {
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
break
}
}
}
$yamlWarned = $false
foreach ( $presetId in $sortedPresets ) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifest = Join-Path $presetsDir " $presetId /preset.yml"
if (( Test-Path $manifest ) -and $pyCmd ) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ( $pyCmd . Count -gt 1 ) { $pyCmd [ 1 ..( $pyCmd . Count - 1 )] } else { @ () }
$pyStderrFile = [ System.IO.Path ]:: GetTempFileName ()
$stratResult = & $pyCmd [ 0 ] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(sys.argv[1]) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
"@ $manifest $TemplateName 2 > $pyStderrFile
if ( $stratResult ) {
$parts = $stratResult . Trim () -split " `t " , 2
$strategy = $parts [ 0 ]. ToLowerInvariant ()
if ( $parts . Count -gt 1 -and $parts [ 1 ]) { $manifestFilePath = $parts [ 1 ] }
}
if ( -not $yamlWarned -and ( Test-Path $pyStderrFile ) -and ( Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue ) -match 'yaml_missing' ) {
Write-Warning "PyYAML not available; composition strategies may be ignored"
$yamlWarned = $true
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
$strategy = 'replace'
if ( $pyStderrFile ) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ( $manifestFilePath ) {
# Reject absolute paths and parent traversal
if ([ System.IO.Path ]:: IsPathRooted ( $manifestFilePath ) -or $manifestFilePath -match '\.\.[\\/]' ) {
$manifestFilePath = ''
}
}
if ( $manifestFilePath ) {
$mf = Join-Path $presetsDir " $presetId / $manifestFilePath "
if ( Test-Path $mf ) { $candidate = $mf }
}
if ( -not $candidate ) {
$cf = Join-Path $presetsDir " $presetId /templates/ $TemplateName .md"
if ( Test-Path $cf ) { $candidate = $cf }
}
if ( $candidate ) {
$layerPaths += $candidate
$layerStrategies += $strategy
}
}
} else {
# Fallback: alphabetical directory order (no registry or parse failure)
foreach ( $preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_ . Name -notlike '.*' }) {
$candidate = Join-Path $preset . FullName "templates/ $TemplateName .md"
if ( Test-Path $candidate ) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if ( Test-Path $extDir ) {
foreach ( $ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_ . Name -notlike '.*' } | Sort-Object Name ) {
$candidate = Join-Path $ext . FullName "templates/ $TemplateName .md"
if ( Test-Path $candidate ) {
$layerPaths += $candidate
$layerStrategies += 'replace'
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base " $TemplateName .md"
if ( Test-Path $core ) {
$layerPaths += $core
$layerStrategies += 'replace'
}
if ( $layerPaths . Count -eq 0 ) { return $null }
2026-05-26 13:18:34 -05:00
# If the top (highest-priority) layer is replace, it wins entirely --
2026-04-23 10:07:52 -05:00
# lower layers are irrelevant regardless of their strategies.
if ( $layerStrategies [ 0 ] -eq 'replace' ) {
return ( Get-Content $layerPaths [ 0 ] -Raw )
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ( $s in $layerStrategies ) {
if ( $s -ne 'replace' ) { $hasComposition = $true ; break }
}
if ( -not $hasComposition ) {
return ( Get-Content $layerPaths [ 0 ] -Raw )
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ( $i = 0 ; $i -lt $layerPaths . Count ; $i ++) {
if ( $layerStrategies [ $i ] -eq 'replace' ) {
$baseIdx = $i
break
}
}
if ( $baseIdx -lt 0 ) { return $null }
$content = Get-Content $layerPaths [ $baseIdx ] -Raw
for ( $i = $baseIdx - 1 ; $i -ge 0 ; $i --) {
$path = $layerPaths [ $i ]
$strat = $layerStrategies [ $i ]
$layerContent = Get-Content $path -Raw
switch ( $strat ) {
'replace' { $content = $layerContent }
'prepend' { $content = " $layerContent `n`n $content " }
'append' { $content = " $content `n`n $layerContent " }
'wrap' {
if ( -not $layerContent . Contains ( '{CORE_TEMPLATE}' )) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent . Replace ( '{CORE_TEMPLATE}' , $content )
}
default { throw "Unknown strategy: $strat " }
}
}
return $content
2026-05-28 23:02:27 +08:00
}