feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)

* feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python

Ports the three core workflow scripts to Python as part of #3280,
following the check-prerequisites PoC pattern from #3302. Adds
resolve_template() to the shared common.py module and parity tests
that run bash and Python side by side.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tests): treat only None env as unset in parity run helper

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): fall back to directory scan on any registry error, skip hidden preset dirs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(templates): add py: lines for setup_plan and setup_tasks

Ships with the scripts they reference; the remaining templates got
their py: lines in #3403.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: support py variant in skills placeholder resolver

resolve_skill_placeholders only accepted sh/ps, so a py init option
fell into the fallback path and {SCRIPT} rendered without an
interpreter prefix. Accept py and prefix the resolved interpreter,
matching process_template. Also guard ps_cmd against a missing
PowerShell with a clear assert.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: pin clean-error behavior for invalid --number

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(scripts): reword unused-arg comment to match implementation

The loop accepts and silently ignores extra positional args (it doesn't
build a collected list); match the wording to what the code and
setup-plan.sh actually do.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: fall back when configured script variant is missing from frontmatter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): reject signed/whitespace --number values to match bash 10# parity

The bash twin uses $((10#$BRANCH_NUMBER)), which rejects signed and
whitespace-padded values. Python's int() accepted them (e.g. -1),
producing a malformed -01-... prefix that sequential scans ignore.
Restrict --number to unsigned decimal digits before conversion, and
pin the parity with a bash-comparison test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): complete Python port installation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(integrations): fall back for missing script variants

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: make Python script checks platform-aware

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix Windows Python command invocation parity

Use PowerShell's call operator for spaced Python interpreter paths and align setup-tasks missing-template errors across script variants.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): preserve cross-platform Python parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: reject signed PowerShell feature numbers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): align feature number range

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): reject exhausted feature numbers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): complete create feature parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): align create feature outputs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): harden cross-platform parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): keep truncation JSON clean

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): align setup failure parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): close parity edge cases

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): propagate PowerShell setup errors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): harden fallback resolution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): stabilize PowerShell fallbacks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): complete setup-plan parity

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): require runnable script fallbacks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(cli): preserve shell fallback without preference

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): restore help and symlink parity

- setup-tasks.ps1: check -Help before unknown-argument validation so
  '-Help --bogus' exits 0 like the Bash/Python variants
- common.py: strip the repo root prefix lexically in persist_feature_json
  instead of resolve(), so a symlinked specs/ still persists the relative
  'specs/NNN-name' path the Bash/PowerShell helpers store

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): align persist-hint quoting with shlex.quote

- create-new-feature.sh: replace printf %q with a shell_quote helper that
  emits shlex.quote-identical output, so the persistence hints stay
  byte-identical between the Bash and Python variants (printf %q output
  also varies between bash versions)
- promote the negative --number test to an all-variants parity test now
  that Bash and PowerShell reject signed values consistently
- add a spaced-repo-path parity test for the persistence hints

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marsel Safin
2026-07-21 18:40:15 +02:00
committed by GitHub
parent 7873c447bd
commit 2a0ada9a6a
28 changed files with 2883 additions and 167 deletions

View File

@@ -29,13 +29,16 @@ function Find-SpecifyRoot {
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
# or writes an error and exits 1. Strict by design: the path must exist and
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
# design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
param([switch]$ReturnNullOnError)
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
@@ -47,6 +50,7 @@ function Resolve-SpecifyInitDir {
# "not a Spec Kit project" error below.
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
if ($ReturnNullOnError) { return $null }
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
@@ -56,6 +60,7 @@ function Resolve-SpecifyInitDir {
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }
exit 1
}
return $initRoot
@@ -64,9 +69,11 @@ function Resolve-SpecifyInitDir {
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
param([switch]$ReturnNullOnError)
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
return (Resolve-SpecifyInitDir)
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
}
# First, look for .specify directory (spec-kit's own marker)
@@ -147,10 +154,12 @@ function Get-FeaturePathsEnv {
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
[switch]$NoPersist
[switch]$NoPersist,
[switch]$ReturnNullOnError
)
$repoRoot = Get-RepoRoot
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
# Resolve feature directory. Priority:
@@ -174,7 +183,8 @@ function Get-FeaturePathsEnv {
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
if ($featureConfig.feature_directory) {
@@ -185,10 +195,12 @@ function Get-FeaturePathsEnv {
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
if ($ReturnNullOnError) { return $null }
exit 1
}
@@ -334,30 +346,64 @@ function Resolve-Template {
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) { return $priorityProperty.Value }
}
return 10
}
if ($presetEntries.Count -gt 1) {
$allNumeric = $true
$allStrings = $true
foreach ($entry in $presetEntries) {
$priority = & $priorityFor $entry
if ($null -eq $priority -or $priority -isnot [ValueType]) {
$allNumeric = $false
}
if ($null -eq $priority -or $priority -isnot [string]) {
$allStrings = $false
}
}
if (-not $allNumeric -and -not $allStrings) {
throw 'Registry priorities are not mutually orderable'
}
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
Sort-Object { & $priorityFor $_ } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
# Fallback: alphabetical directory order
$sortedPresets = @()
$registryParsed = $false
}
}
if ($sortedPresets.Count -gt 0) {
if ($registryParsed) {
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 '.*' }) {
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}

View File

@@ -7,7 +7,7 @@ param(
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[string]$Number = '',
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
@@ -142,12 +142,13 @@ if ($ShortName) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
# `[ -n "$BRANCH_NUMBER" ]` check.
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
# Treat an explicit empty string as omitted, matching the bash and Python twins.
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
# Warn if -Number and -Timestamp are both specified.
if ($Timestamp -and $hasNumber) {
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
$Number = ''
}
# Determine branch prefix
@@ -158,11 +159,23 @@ if ($Timestamp) {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
if (-not $PSBoundParameters.ContainsKey('Number')) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
[long]$resolvedNumber = 0
if (-not $hasNumber) {
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
if ($highestNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber = $highestNumber + 1
} elseif ($Number -notmatch '^[0-9]+$') {
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
exit 1
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
exit 1
}
$featureNum = ('{0:000}' -f $Number)
$featureNum = ('{0:000}' -f $resolvedNumber)
$branchName = "$featureNum-$branchSuffix"
}
@@ -183,9 +196,9 @@ if ($branchName.Length -gt $maxBranchLength) {
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
}
$featureDir = Join-Path $specsDir $branchName
@@ -225,6 +238,13 @@ if (-not $DryRun) {
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
[Console]::Error.WriteLine("# To persist: $featureAssignment")
[Console]::Error.WriteLine("# $directoryAssignment")
}
if ($Json) {
@@ -242,7 +262,7 @@ if ($Json) {
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE set to: $branchName"
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
Write-Output "# To persist in your shell: $featureAssignment"
Write-Output "# $directoryAssignment"
}
}

View File

@@ -4,7 +4,10 @@
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
[switch]$Help,
# Capture extra positional arguments to match Bash/Python behavior.
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
@@ -21,7 +24,11 @@ if ($Help) {
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null

View File

@@ -3,21 +3,34 @@
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Help wins over unknown-argument validation to match the Bash/Python
# variants, which stop at --help and exit 0.
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
if ($RemainingArgs.Count -gt 0) {
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
exit 1
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
$paths = Get-FeaturePathsEnv
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
@@ -45,8 +58,8 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path