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

@@ -90,6 +90,19 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
exit 1
fi
MAX_FEATURE_NUMBER=9223372036854775807
is_feature_number_in_range() {
local value="$1"
local normalized="${value#"${value%%[!0]*}"}"
[ -n "$normalized" ] || normalized=0
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
# Equal-length digit strings must be compared without arithmetic overflow.
# shellcheck disable=SC2071
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
}
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
@@ -102,9 +115,11 @@ get_highest_from_specs() {
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
if is_feature_number_in_range "$number"; then
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
fi
done
@@ -119,6 +134,19 @@ clean_branch_name() {
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
shell_quote() {
local value="$1" LC_ALL=C
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
printf '%s' "$value"
else
local q="'\"'\"'"
printf "'%s'" "${value//\'/$q}"
fi
}
# Resolve repository root using common.sh functions which prioritize .specify
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
@@ -202,9 +230,24 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
exit 1
fi
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
exit 1
fi
# Determine branch number from existing feature directories
if [ -z "$BRANCH_NUMBER" ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((HIGHEST + 1))
fi
@@ -264,8 +307,8 @@ if [ "$DRY_RUN" != true ]; then
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
fi
if $JSON_MODE; then
@@ -295,7 +338,7 @@ else
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
fi
fi

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

View File

@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
except (OSError, UnicodeError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
@@ -95,16 +95,17 @@ def _json_dump(data: dict[str, str]) -> str:
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
# Strip the repo root prefix lexically (no resolve()) to mirror the
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
# escape the repo and persist a machine-specific absolute path instead of
# the relative "specs/NNN-name" the other variants store.
value = feature_dir_value
try:
relative = Path(value)
if relative.is_absolute():
try:
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
except ValueError:
value = str(relative)
except OSError:
pass
relative = Path(value)
if relative.is_absolute():
try:
value = relative.relative_to(repo_root).as_posix()
except ValueError:
value = str(relative)
current = read_feature_json_feature_directory(repo_root)
if current == value:
@@ -112,9 +113,8 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
_json_dump({"feature_directory": value}),
encoding="utf-8",
(specify_dir / "feature.json").write_bytes(
_json_dump({"feature_directory": value}).encode("utf-8")
)
@@ -182,6 +182,78 @@ def get_feature_paths(
)
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
registry = presets_dir / ".registry"
if registry.is_file():
# Mirrors bash: any failure while reading or sorting the registry
# (invalid JSON, non-dict shapes, unorderable priority values) falls
# back to the directory scan below.
try:
data = json.loads(registry.read_text(encoding="utf-8"))
presets = data.get("presets", {})
return [
pid
for pid, meta in sorted(
presets.items(),
key=lambda kv: kv[1].get("priority", 10)
if isinstance(kv[1], dict)
else 10,
)
if isinstance(meta, dict) and meta.get("enabled", True) is not False
]
except Exception:
pass
try:
return sorted(
p.name
for p in presets_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
except OSError:
return []
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
"""Resolve a template name to a file path using the priority stack.
Order (mirrors resolve_template in scripts/bash/common.sh):
1. .specify/templates/overrides/
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
4. .specify/templates/ (core)
"""
base = repo_root / ".specify" / "templates"
override = base / "overrides" / f"{template_name}.md"
if override.is_file():
return override
presets_dir = repo_root / ".specify" / "presets"
if presets_dir.is_dir():
for preset_id in _sorted_preset_ids(presets_dir):
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
if candidate.is_file():
return candidate
ext_dir = repo_root / ".specify" / "extensions"
if ext_dir.is_dir():
try:
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
except OSError:
extensions = []
for ext in extensions:
if ext.name.startswith("."):
continue
candidate = ext / "templates" / f"{template_name}.md"
if candidate.is_file():
return candidate
core = base / f"{template_name}.md"
if core.is_file():
return core
return None
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():

View File

@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""Create a new feature directory and spec file."""
from __future__ import annotations
import datetime
import json
import re
import shlex
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from common import get_repo_root, persist_feature_json, resolve_template
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_repo_root, persist_feature_json, resolve_template
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
_STOP_WORDS = frozenset(
"""
i a an the to for of in on at by with from is are was were be been being
have has had do does did will would should could can may might must shall
this that these those my your our their want need add get set
""".split()
)
_MAX_BRANCH_LENGTH = 244
_MAX_FEATURE_NUMBER = 2**63 - 1
def _int64_from_digits(value: str) -> int | None:
normalized = value.lstrip("0") or "0"
maximum = str(_MAX_FEATURE_NUMBER)
if len(normalized) > len(maximum) or (
len(normalized) == len(maximum) and normalized > maximum
):
return None
return int(normalized, 10)
def _persistence_assignments(
branch_name: str, feature_dir: str, *, powershell: bool
) -> tuple[str, str]:
if powershell:
quoted_branch = "'" + branch_name.replace("'", "''") + "'"
quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
return (
f"$env:SPECIFY_FEATURE = {quoted_branch}",
f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
)
return (
f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
)
def _usage(argv0: str) -> str:
return (
f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
"[--short-name <name>] [--number N] [--timestamp] <feature_description>"
)
def _help_text(argv0: str) -> str:
return f"""{_usage(argv0)}
Options:
--json Output in JSON format
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
Examples:
{argv0} 'Add user authentication system' --short-name 'user-auth'
{argv0} 'Implement OAuth2 integration for API' --number 5
{argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
"""
@dataclass(frozen=True)
class Args:
json_mode: bool = False
dry_run: bool = False
allow_existing: bool = False
short_name: str = ""
branch_number: str = ""
use_timestamp: bool = False
description: str = ""
def _parse_args(argv: list[str], argv0: str) -> Args:
json_mode = False
dry_run = False
allow_existing = False
short_name = ""
branch_number = ""
use_timestamp = False
rest: list[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--json":
json_mode = True
elif arg == "--dry-run":
dry_run = True
elif arg == "--allow-existing-branch":
allow_existing = True
elif arg in {"--short-name", "--number"}:
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
print(f"Error: {arg} requires a value", file=sys.stderr)
raise SystemExit(1)
i += 1
if arg == "--short-name":
short_name = argv[i]
else:
branch_number = argv[i]
elif arg == "--timestamp":
use_timestamp = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(argv0))
raise SystemExit(0)
else:
rest.append(arg)
i += 1
description = " ".join(rest).strip()
if not description:
if rest:
print(
"Error: Feature description cannot be empty or contain only whitespace",
file=sys.stderr,
)
else:
print(_usage(argv0), file=sys.stderr)
raise SystemExit(1)
return Args(
json_mode=json_mode,
dry_run=dry_run,
allow_existing=allow_existing,
short_name=short_name,
branch_number=branch_number,
use_timestamp=use_timestamp,
description=description,
)
def _clean_branch_name(name: str) -> str:
cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
cleaned = re.sub(r"-+", "-", cleaned)
return cleaned.strip("-")
def _generate_branch_name(description: str) -> str:
clean = re.sub(r"[^a-z0-9]", " ", description.lower())
meaningful: list[str] = []
for word in clean.split():
if word in _STOP_WORDS:
continue
if len(word) >= 3:
meaningful.append(word)
# Keep short words that appear as an uppercase acronym in the original,
# mirroring the bash twin's case-sensitive `grep -qw` check.
elif re.search(
rf"(?<![0-9A-Za-z_]){re.escape(word.upper())}(?![0-9A-Za-z_])",
description,
):
meaningful.append(word)
if meaningful:
max_words = 4 if len(meaningful) == 4 else 3
return "-".join(meaningful[:max_words])
cleaned = _clean_branch_name(description)
return "-".join([part for part in cleaned.split("-") if part][:3])
def _get_highest_from_specs(specs_dir: Path) -> int:
highest = 0
if not specs_dir.is_dir():
return highest
for entry in specs_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if re.match(r"^[0-9]{3,}-", name) and not re.match(
r"^[0-9]{8}-[0-9]{6}-", name
):
number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
if number is not None:
highest = max(highest, number)
return highest
def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
repo_root = get_repo_root(Path(__file__))
specs_dir = repo_root / "specs"
if not args.dry_run:
specs_dir.mkdir(parents=True, exist_ok=True)
if args.short_name:
branch_suffix = _clean_branch_name(args.short_name)
else:
branch_suffix = _generate_branch_name(args.description)
branch_number = args.branch_number
if args.use_timestamp and branch_number:
print(
"[specify] Warning: --number is ignored when --timestamp is used",
file=sys.stderr,
)
branch_number = ""
if args.use_timestamp:
feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
else:
if branch_number:
# Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
# decimal digits, rejecting signs, whitespace, and other
# characters that int() would otherwise tolerate.
if not re.fullmatch(r"[0-9]+", branch_number):
print(
"Error: --number must be an unsigned integer, "
f"got '{branch_number}'",
file=sys.stderr,
)
return 1
number = _int64_from_digits(branch_number)
if number is None:
print(
"Error: --number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
file=sys.stderr,
)
return 1
else:
number = _get_highest_from_specs(specs_dir) + 1
if number > _MAX_FEATURE_NUMBER:
rejected_number = branch_number or str(number)
number_label = "--number" if branch_number else "feature number"
print(
f"Error: {number_label} must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1
branch_name = f"{feature_num}-{branch_suffix}"
# GitHub enforces a 244-byte limit on branch names.
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,
)
print(
f"[specify] Original: {original_branch_name} "
f"({len(original_branch_name)} bytes)",
file=sys.stderr,
)
print(
f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
file=sys.stderr,
)
feature_dir = specs_dir / branch_name
spec_file = feature_dir / "spec.md"
if not args.dry_run:
if feature_dir.is_dir() and not args.allow_existing:
if args.use_timestamp:
print(
f"Error: Feature directory '{feature_dir}' already exists. "
"Rerun to get a new timestamp or use a different --short-name.",
file=sys.stderr,
)
else:
print(
f"Error: Feature directory '{feature_dir}' already exists. "
"Please use a different feature name or specify a different "
"number with --number.",
file=sys.stderr,
)
return 1
feature_dir.mkdir(parents=True, exist_ok=True)
if not spec_file.is_file():
template = resolve_template("spec-template", repo_root)
if template is not None and template.is_file():
shutil.copy(template, spec_file)
else:
print(
"Warning: Spec template not found; created empty spec file",
file=sys.stderr,
)
spec_file.touch()
# Persist to .specify/feature.json so downstream commands can find the feature.
persist_feature_json(repo_root, f"specs/{branch_name}")
# Inform the user how to set feature state in their own shell.
feature_assignment, directory_assignment = _persistence_assignments(
branch_name,
str(feature_dir),
powershell=sys.platform == "win32",
)
print(f"# To persist: {feature_assignment}", file=sys.stderr)
print(f"# {directory_assignment}", file=sys.stderr)
if args.json_mode:
payload: dict[str, object] = {
"BRANCH_NAME": branch_name,
"SPEC_FILE": str(spec_file),
"FEATURE_NUM": feature_num,
}
if args.dry_run:
payload["DRY_RUN"] = True
sys.stdout.write(_json_line(payload))
else:
print(f"BRANCH_NAME: {branch_name}")
print(f"SPEC_FILE: {spec_file}")
print(f"FEATURE_NUM: {feature_num}")
if not args.dry_run:
print(f"# To persist in your shell: {feature_assignment}")
print(f"# {directory_assignment}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Setup implementation plan for a feature."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
try:
from common import get_feature_paths, resolve_template
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import get_feature_paths, resolve_template
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
def _help_text(argv0: str) -> str:
return f"""Usage: {argv0} [--json]
--json Output results in JSON format
--help Show this help message
"""
def main(argv: list[str] | None = None) -> int:
args = list(argv if argv is not None else sys.argv[1:])
json_mode = False
for arg in args:
if arg == "--json":
json_mode = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(sys.argv[0]))
return 0
# Other arguments are accepted and silently ignored, matching setup-plan.sh.
try:
paths = get_feature_paths(script_file=Path(__file__))
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
paths.feature_dir.mkdir(parents=True, exist_ok=True)
# Status messages go to stderr in JSON mode so stdout stays pure JSON.
status_stream = sys.stderr if json_mode else sys.stdout
if paths.impl_plan.is_file():
print(
f"Plan already exists at {paths.impl_plan}, skipping template copy",
file=status_stream,
)
else:
template = resolve_template("plan-template", paths.repo_root)
if template is not None and template.is_file():
shutil.copy(template, paths.impl_plan)
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
else:
print("Warning: Plan template not found", file=status_stream)
paths.impl_plan.touch()
if json_mode:
sys.stdout.write(
_json_line(
{
"FEATURE_SPEC": str(paths.feature_spec),
"IMPL_PLAN": str(paths.impl_plan),
"SPECS_DIR": str(paths.feature_dir),
"BRANCH": paths.current_branch,
}
)
)
else:
print(f"FEATURE_SPEC: {paths.feature_spec}")
print(f"IMPL_PLAN: {paths.impl_plan}")
print(f"SPECS_DIR: {paths.feature_dir}")
print(f"BRANCH: {paths.current_branch}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Check tasks prerequisites and resolve the tasks template."""
from __future__ import annotations
import json
import sys
from pathlib import Path
try:
from common import (
FeaturePaths,
format_speckit_command,
get_feature_paths,
resolve_template,
)
except ImportError: # pragma: no cover - direct execution from unusual cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import (
FeaturePaths,
format_speckit_command,
get_feature_paths,
resolve_template,
)
def _json_line(payload: object) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
def _help_text(argv0: str) -> str:
return f"""Usage: {argv0} [--json]
--json Output results in JSON format
--help Show this help message
"""
def _dir_has_entries(path: Path) -> bool:
try:
return path.is_dir() and any(path.iterdir())
except OSError:
return False
def _available_docs(paths: FeaturePaths) -> list[str]:
docs: list[str] = []
if paths.research.is_file():
docs.append("research.md")
if paths.data_model.is_file():
docs.append("data-model.md")
if _dir_has_entries(paths.contracts_dir):
docs.append("contracts/")
if paths.quickstart.is_file():
docs.append("quickstart.md")
return docs
def _check_file(path: Path, description: str) -> None:
marker = "" if path.is_file() else ""
print(f" {marker} {description}")
def _check_dir(path: Path, description: str) -> None:
marker = "" if _dir_has_entries(path) else ""
print(f" {marker} {description}")
def main(argv: list[str] | None = None) -> int:
json_mode = False
for arg in list(argv if argv is not None else sys.argv[1:]):
if arg == "--json":
json_mode = True
elif arg in {"--help", "-h"}:
sys.stdout.write(_help_text(sys.argv[0]))
return 0
else:
print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
return 1
try:
paths = get_feature_paths(script_file=Path(__file__))
except SystemExit as exc:
if exc.code == 0:
return 0
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
return int(exc.code) if isinstance(exc.code, int) else 1
if not paths.impl_plan.is_file():
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
file=sys.stderr,
)
return 1
if not paths.feature_spec.is_file():
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
print(
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
file=sys.stderr,
)
return 1
docs = _available_docs(paths)
tasks_template = resolve_template("tasks-template", paths.repo_root)
if tasks_template is None or not tasks_template.is_file():
print(
"ERROR: Could not resolve required tasks-template from the template "
f"override stack for {paths.repo_root}",
file=sys.stderr,
)
print(
"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.",
file=sys.stderr,
)
return 1
if json_mode:
sys.stdout.write(
_json_line(
{
"FEATURE_DIR": str(paths.feature_dir),
"AVAILABLE_DOCS": docs,
"TASKS_TEMPLATE": str(tasks_template),
}
)
)
else:
print(f"FEATURE_DIR: {paths.feature_dir}")
print(f"TASKS_TEMPLATE: {tasks_template}")
print("AVAILABLE_DOCS:")
_check_file(paths.research, "research.md")
_check_file(paths.data_model, "data-model.md")
_check_dir(paths.contracts_dir, "contracts/")
_check_file(paths.quickstart, "quickstart.md")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -140,10 +140,9 @@ def _install_shared_infra(
"""Install shared infrastructure files into *project_path*.
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` is
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
``speckit.manifest.json``.
the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
installs PowerShell, and ``py`` installs Python plus the platform shell
fallback. Tracks all installed files in ``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*

View File

@@ -7,7 +7,6 @@ command files into agent-specific directories in the correct format.
"""
import os
import platform
import re
from copy import deepcopy
from pathlib import Path
@@ -486,26 +485,19 @@ class CommandRegistrar:
init_opts = {}
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
if scripts:
from specify_cli.integrations.base import IntegrationBase
script_variant = IntegrationBase.select_script_variant(
script_variant, scripts
)
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts:
fallback_order.append(default_variant)
if secondary_variant in scripts:
fallback_order.append(secondary_variant)
for key in scripts:
if key not in fallback_order:
fallback_order.append(key)
script_variant = fallback_order[0] if fallback_order else None
script_command = scripts.get(script_variant) if script_variant else None
if script_command:
if script_variant == "py":
script_command = IntegrationBase.build_python_invocation(
script_command, project_root
)
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)

View File

@@ -86,7 +86,7 @@ def register(app: typer.Typer) -> None:
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
),
script_type: str = typer.Option(
None, "--script", help="Script type to use: sh or ps"
None, "--script", help="Script type to use: sh, ps, or py"
),
ignore_agent_tools: bool = typer.Option(
False,

View File

@@ -38,7 +38,7 @@ from ._helpers import (
@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):

View File

@@ -131,7 +131,7 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
@@ -425,7 +425,7 @@ def integration_switch(
def integration_upgrade(
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
):
"""Upgrade an integration by reinstalling with diff-aware file handling.

View File

@@ -14,6 +14,7 @@ Provides:
from __future__ import annotations
import os
import platform
import re
import shlex
import shutil
@@ -668,6 +669,46 @@ class IntegrationBase(ABC):
return name
return sys.executable or "python3"
@staticmethod
def build_python_invocation(
script_command: str, project_root: Path | None = None
) -> str:
"""Build a Python script command for the current platform shell."""
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
quoted_interpreter = interpreter.replace("'", "''")
interpreter = f"& '{quoted_interpreter}'"
elif os.name != "nt":
interpreter = shlex.quote(interpreter)
return f"{interpreter} {script_command}"
@staticmethod
def select_script_variant(
requested: object, script_commands: dict[str, str]
) -> str:
"""Select the requested variant or a runnable platform fallback."""
if isinstance(requested, str) and requested in script_commands:
return requested
platform_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
secondary_variant = "sh" if platform_variant == "ps" else "ps"
fallbacks = (
(platform_variant, "py")
if requested == "py"
else (platform_variant, secondary_variant, "py")
)
for candidate in fallbacks:
if candidate in script_commands:
return candidate
available = ", ".join(sorted(script_commands)) or "none"
raise ValueError(
"No runnable script variant for this platform: "
f"requested {requested!r}; available: {available}"
)
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
@@ -702,7 +743,8 @@ class IntegrationBase(ABC):
"""Process a raw command template into agent-ready content.
Performs the same transformations as the release script:
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
back to a runnable platform shell or Python variant when unavailable
2. Replace ``{SCRIPT}`` with the extracted script command
3. Strip ``scripts:`` section from frontmatter
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
@@ -711,37 +753,46 @@ class IntegrationBase(ABC):
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
script_pattern = re.compile(
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
)
script_commands: dict[str, str] = {}
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
# Find the scripts: block
in_frontmatter = False
in_scripts = False
for line in content.splitlines():
if line.strip() == "scripts:":
if line == "---":
if in_frontmatter:
break
in_frontmatter = True
continue
if not in_frontmatter:
continue
if line == "scripts:":
in_scripts = True
continue
if in_scripts and line and not line[0].isspace():
in_scripts = False
break
if in_scripts:
m = script_pattern.match(line)
if m:
script_command = m.group(1).strip()
break
script_commands[m.group(1)] = m.group(2).strip()
selected_script_type = (
IntegrationBase.select_script_variant(script_type, script_commands)
if script_commands
else ""
)
script_command = script_commands.get(selected_script_type, "")
# 2. Replace {SCRIPT}
if script_command:
# For the Python script type, prefix the resolved interpreter so
# the command is portable (``.py`` files are not directly
# executable on Windows).
if script_type == "py":
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
# Quote the interpreter if it contains whitespace (e.g. an
# absolute ``sys.executable`` path under Windows
# ``Program Files``) so it isn't split into multiple args.
if any(ch.isspace() for ch in interpreter):
interpreter = f'"{interpreter}"'
script_command = f"{interpreter} {script_command}"
if selected_script_type == "py":
script_command = IntegrationBase.build_python_invocation(
script_command, project_root
)
content = content.replace("{SCRIPT}", script_command)
# 3. Strip scripts: section from frontmatter

View File

@@ -402,8 +402,13 @@ def install_shared_infra(
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
scanned_variant_dirs: set[str] = set()
shell_variant = "powershell" if os.name == "nt" else "bash"
variant_dirs = (
("python", shell_variant)
if script_type == "py"
else ("bash" if script_type == "sh" else "powershell",)
)
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -458,69 +463,69 @@ def install_shared_infra(
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
for variant_dir in variant_dirs:
variant_src = scripts_src / variant_dir
if not variant_src.is_dir():
continue
dest_variant = dest_scripts / variant_dir
if _ensure_or_bucket_dir(dest_variant):
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
# tracked script as obsolete and delete it. (The safety
# hinge is this flag, not ``seen_rels``, which also holds
# template paths populated later.)
scripts_scanned = True
if not _ensure_or_bucket_dir(dest_variant):
continue
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Python bytecode caches are local artifacts, not
# workflow scripts — never install them.
if "__pycache__" in src_path.parts:
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant stays untracked, so
# stale-cleanup cannot treat its managed scripts as obsolete.
scanned_variant_dirs.add(variant_dir)
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk file in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken
# at entry, so this membership check is O(1) and avoids
# the repeated ``dict(self._files)`` copy that
# ``manifest.files`` performs on every access.
if dst_path.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
if not write:
if bucket == "preserved":
preserved_user_files.append(rel)
else:
skipped_files.append(rel)
# Record the existing-on-disk file in the manifest so a
# fresh manifest run against an already-populated
# ``.specify/`` tree does not silently drop it (#2107).
# ``prior_hashes`` is the function-scope snapshot taken
# at entry, so this membership check is O(1) and avoids
# the repeated ``dict(self._files)`` copy that
# ``manifest.files`` performs on every access.
if dst_path.is_file() and rel not in prior_hashes:
try:
manifest.record_existing(rel, recovered=True)
except (OSError, ValueError) as exc:
# Tolerate races / permission issues / non-file
# collisions so one weird path does not abort
# the whole install.
console.print(
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
)
continue
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
planned_copies.append(
(
dst_path,
rel,
content.encode("utf-8"),
src_path.stat().st_mode & 0o777,
)
if not _ensure_or_bucket_dir(dst_path.parent):
continue
content = src_path.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
content = _resolve_dynamic_command_refs(content, invoke_separator)
planned_copies.append(
(
dst_path,
rel,
content.encode("utf-8"),
src_path.stat().st_mode & 0o777,
)
)
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
@@ -618,14 +623,16 @@ def install_shared_infra(
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
# deletion), scoped to the active variant, and only for *managed* copies —
# deletion), scoped to the selected variants, and only for *managed* copies —
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
if scripts_scanned:
if scanned_variant_dirs:
stale_removed: list[str] = []
script_prefix = f".specify/scripts/{variant_dir}/"
script_prefixes = tuple(
f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs
)
for rel in list(prior_hashes):
if rel in seen_rels or not rel.startswith(script_prefix):
if rel in seen_rels or not rel.startswith(script_prefixes):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such

View File

@@ -11,6 +11,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
py: scripts/python/setup_plan.py --json
---
## User Input

View File

@@ -12,6 +12,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-tasks.sh --json
ps: scripts/powershell/setup-tasks.ps1 -Json
py: scripts/python/setup_tasks.py --json
---
## User Input

View File

@@ -1,6 +1,8 @@
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
import shlex
import sys
from types import SimpleNamespace
import pytest
@@ -495,19 +497,41 @@ class TestProcessTemplatePyScriptType:
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
assert "python" not in result
def test_body_scripts_example_does_not_override_frontmatter(self):
content = (
"---\n"
"scripts:\n"
" sh: scripts/bash/real.sh --json\n"
"---\n"
"Run {SCRIPT} now.\n"
"```yaml\n"
"scripts:\n"
" sh: examples/not-the-command.sh\n"
"```\n"
)
result = IntegrationBase.process_template(content, "agent", "sh")
assert ".specify/scripts/bash/real.sh --json" in result
assert "examples/not-the-command.sh" in result
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
# An interpreter path containing whitespace (e.g. Windows
# ``Program Files``) must be quoted so it isn't split into args.
interpreter = r"C:\Program Files\Python\python.exe"
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
r"C:\Program Files\Python\python.exe",
interpreter,
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert (
'"C:\\Program Files\\Python\\python.exe" '
f"{shlex.quote(interpreter)} "
".specify/scripts/python/check-prerequisites.py --json"
) in result
@@ -529,6 +553,39 @@ class TestProcessTemplatePyScriptType:
)
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
def test_setup_py_falls_back_to_platform_shell(
self, monkeypatch, tmp_path
):
template = tmp_path / "fallback.md"
template.write_text(
"---\n"
"scripts:\n"
" sh: scripts/bash/check-prerequisites.sh --json\n"
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
"---\n"
"Run {SCRIPT} now.\n",
encoding="utf-8",
)
integration = StubIntegration()
monkeypatch.setattr(
integration, "list_command_templates", lambda: [template]
)
created = integration.setup(
tmp_path,
IntegrationManifest("stub", tmp_path),
script_type="py",
)
rendered = created[0].read_text(encoding="utf-8")
expected = (
".specify/scripts/powershell/check-prerequisites.ps1"
if sys.platform == "win32"
else ".specify/scripts/bash/check-prerequisites.sh"
)
assert "{SCRIPT}" not in rendered
assert expected in rendered
class TestInstallScriptsPython:
def _make_integration_with_scripts(self, monkeypatch, tmp_path):

View File

@@ -15,6 +15,22 @@ from tests.conftest import strip_ansi
runner = CliRunner()
@pytest.mark.parametrize(
"args",
[
["init", "--help"],
["integration", "install", "--help"],
["integration", "switch", "--help"],
["integration", "upgrade", "--help"],
],
)
def test_script_help_includes_python_variant(args):
result = runner.invoke(app, args)
assert result.exit_code == 0
assert "sh, ps, or py" in " ".join(strip_ansi(result.output).split())
def _init_project(tmp_path, integration="copilot", integration_options=None):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"

133
tests/parity_helpers.py Normal file
View File

@@ -0,0 +1,133 @@
"""Shared helpers for the core-script Python parity tests."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
BASH_DIR = PROJECT_ROOT / "scripts" / "bash"
PS_DIR = PROJECT_ROOT / "scripts" / "powershell"
PY_DIR = PROJECT_ROOT / "scripts" / "python"
HAS_PWSH = shutil.which("pwsh") is not None
WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell"))
if os.name == "nt"
else None
)
POWERSHELL_EXE = "pwsh" if HAS_PWSH else WINDOWS_POWERSHELL
HAS_POWERSHELL = POWERSHELL_EXE is not None
def make_repo(tmp_path: Path, name: str = "proj") -> Path:
repo = tmp_path / name
(repo / ".specify").mkdir(parents=True)
return repo
def install_scripts(repo: Path, script: str) -> None:
"""Install the bash/powershell/python twins of a kebab-case script name."""
py_name = script.replace("-", "_")
bash_dir = repo / ".specify" / "scripts" / "bash"
bash_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(BASH_DIR / "common.sh", bash_dir / "common.sh")
shutil.copy(BASH_DIR / f"{script}.sh", bash_dir / f"{script}.sh")
ps_dir = repo / ".specify" / "scripts" / "powershell"
ps_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(PS_DIR / "common.ps1", ps_dir / "common.ps1")
shutil.copy(PS_DIR / f"{script}.ps1", ps_dir / f"{script}.ps1")
py_dir = repo / ".specify" / "scripts" / "python"
py_dir.mkdir(parents=True, exist_ok=True)
shutil.copy(PY_DIR / "common.py", py_dir / "common.py")
shutil.copy(PY_DIR / f"{py_name}.py", py_dir / f"{py_name}.py")
def bash_cmd(repo: Path, script: str, *args: str) -> list[str]:
return ["bash", str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh"), *args]
def py_cmd(repo: Path, script: str, *args: str) -> list[str]:
py_name = script.replace("-", "_")
return [
sys.executable,
str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py"),
*args,
]
def ps_cmd(repo: Path, script: str, *args: str) -> list[str]:
assert POWERSHELL_EXE, "no PowerShell available; guard the test with HAS_POWERSHELL"
return [
POWERSHELL_EXE,
"-NoProfile",
"-File",
str(repo / ".specify" / "scripts" / "powershell" / f"{script}.ps1"),
*args,
]
def clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def run(
cmd: list[str], repo: Path, env: dict[str, str] | None = None
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=repo,
capture_output=True,
text=True,
check=False,
env=env if env is not None else clean_env(),
)
def json_stdout(result: subprocess.CompletedProcess[str]) -> object:
return json.loads(result.stdout)
def write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
def normalize_repo_paths(text: str, repo: Path) -> str:
"""Replace the repo path with a placeholder so two-repo runs compare equal."""
repo_paths = sorted({str(repo), str(repo.resolve())}, key=len, reverse=True)
for repo_path in repo_paths:
text = text.replace(repo_path, "<REPO>")
return text.replace("\r\n", "\n")
def normalize_script_names(text: str, repo: Path, script: str) -> str:
"""Replace per-runtime script paths (argv[0] in usage/help output)."""
py_name = script.replace("-", "_")
bash_script = str(repo / ".specify" / "scripts" / "bash" / f"{script}.sh")
py_script = str(repo / ".specify" / "scripts" / "python" / f"{py_name}.py")
return text.replace(bash_script, "<SCRIPT>").replace(py_script, "<SCRIPT>")
def normalize_status_text(text: str) -> str:
return (
text.replace("", " [OK] ")
.replace("", " [FAIL] ")
.replace("\r\n", "\n")
)

View File

@@ -296,6 +296,39 @@ def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
assert data["feature_directory"] == "specs/002-other"
@requires_bash
def test_persisted_feature_json_is_lexical_when_specs_is_symlink(
prereq_repo: Path, tmp_path: Path
) -> None:
"""A symlinked specs/ dir must persist "specs/NNN" like Bash does with its
lexical prefix strip — resolve() would escape the repo and store a
machine-specific absolute path."""
real_specs = tmp_path / "real-specs"
feat = real_specs / "002-other"
feat.mkdir(parents=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
repo = prereq_repo.resolve()
try:
(repo / "specs").symlink_to(real_specs, target_is_directory=True)
except OSError:
pytest.skip("symlinks not supported on this platform")
env = _clean_env()
env["SPECIFY_FEATURE_DIRECTORY"] = str(repo / "specs" / "002-other")
feature_json = repo / ".specify" / "feature.json"
bash = _run(_bash_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert bash.returncode == 0, bash.stderr
bash_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
feature_json.unlink()
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
assert py.returncode == 0, py.stderr
py_persisted = json.loads(feature_json.read_text(encoding="utf-8"))
assert py_persisted == bash_persisted
assert py_persisted["feature_directory"] == "specs/002-other"
@pytest.mark.parametrize(
("args", "expected"),
[

View File

@@ -10,12 +10,18 @@ and ``process_template`` turns them into a valid Python invocation
existence check below enforces that ordering.
"""
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from specify_cli.integrations.base import IntegrationBase
from tests.parity_helpers import HAS_POWERSHELL, POWERSHELL_EXE
REPO_ROOT = Path(__file__).parent.parent
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
@@ -77,6 +83,122 @@ def test_template_renders_python_invocation(name: str):
), f"{name} did not render a Python invocation"
def test_py_missing_variant_rejects_opposite_shell_only():
opposite_variant = "sh" if os.name == "nt" else "ps"
opposite_command = (
"scripts/bash/setup-plan.sh --json"
if opposite_variant == "sh"
else "scripts/powershell/setup-plan.ps1 -Json"
)
content = """---
scripts:
{variant}: {command}
---
Run {{SCRIPT}} now.
""".format(variant=opposite_variant, command=opposite_command)
with pytest.raises(ValueError, match="No runnable script variant"):
IntegrationBase.process_template(content, "agent", "py")
def test_missing_script_preference_keeps_available_shell(monkeypatch):
monkeypatch.setattr(
"specify_cli.integrations.base.platform.system", lambda: "Windows"
)
selected = IntegrationBase.select_script_variant(
None, {"sh": "scripts/bash/setup-plan.sh --json"}
)
assert selected == "sh"
def test_spaced_python_interpreter_uses_powershell_call_operator(monkeypatch):
interpreter = r"C:\Program Files\Py$thon's\python.exe"
quoted_interpreter = interpreter.replace("'", "''")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
interpreter,
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
)
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
result = IntegrationBase.process_template(content, "agent", "py")
assert (
f"& '{quoted_interpreter}' "
".specify/scripts/python/setup_plan.py --json"
) in result
def test_spaced_python_interpreter_uses_posix_shell_quoting(monkeypatch):
interpreter = "/opt/Python $HOME's/bin/python"
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
interpreter,
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="posix")
)
content = "---\nscripts:\n py: scripts/python/setup_plan.py --json\n---\n{SCRIPT}\n"
result = IntegrationBase.process_template(content, "agent", "py")
assert (
f"{shlex.quote(interpreter)} "
".specify/scripts/python/setup_plan.py --json"
) in result
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_spaced_python_interpreter_invocation_runs_in_powershell(
tmp_path, monkeypatch
):
interpreter_dir = tmp_path / "Python With Spaces"
interpreter_dir.mkdir()
if os.name == "nt":
interpreter = interpreter_dir / "python.cmd"
interpreter.write_text(f'@"{sys.executable}" %*\n', encoding="utf-8")
else:
interpreter = interpreter_dir / "python"
interpreter.write_text(
f'#!/bin/sh\nexec "{sys.executable}" "$@"\n', encoding="utf-8"
)
interpreter.chmod(0o755)
(tmp_path / "probe.py").write_text("print('ok')\n", encoding="utf-8")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable", str(interpreter)
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
)
content = "---\nscripts:\n py: probe.py\n---\n{SCRIPT}\n"
command = IntegrationBase.process_template(content, "agent", "py").splitlines()[-1]
result = subprocess.run(
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "ok"
@pytest.mark.parametrize("name", PY_TEMPLATES)
def test_sh_rendering_unchanged(name: str):
# Negative: adding py: lines must not leak into sh rendering.
@@ -102,6 +224,9 @@ def test_install_shared_infra_copies_python_scripts(tmp_path):
console=Console(quiet=True),
force=False,
)
dest = tmp_path / ".specify" / "scripts" / "python"
assert (dest / "check_prerequisites.py").is_file()
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
scripts_dir = tmp_path / ".specify" / "scripts"
assert (scripts_dir / "python" / "check_prerequisites.py").is_file()
shell_variant = "powershell" if os.name == "nt" else "bash"
other_variant = "bash" if os.name == "nt" else "powershell"
assert (scripts_dir / shell_variant).is_dir()
assert not (scripts_dir / other_variant).exists()

View File

@@ -0,0 +1,837 @@
"""Parity tests for the Python create-new-feature port."""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from scripts.python import create_new_feature
from scripts.python.common import persist_feature_json
from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
bash_cmd,
install_scripts,
json_stdout,
make_repo,
normalize_repo_paths,
normalize_script_names,
ps_cmd,
py_cmd,
run,
)
SCRIPT = "create-new-feature"
TEMPLATE_BODY = "# Spec Template\n\nBody.\n"
def _setup_repo(tmp_path: Path, name: str = "proj") -> Path:
repo = make_repo(tmp_path, name)
install_scripts(repo, SCRIPT)
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True)
(templates / "spec-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
return repo
def _normalized_error_text(stderr: str, repo: Path) -> str:
stderr = re.sub(r"\x1b\[[0-9;]*m", "", stderr)
stderr = re.sub(r"(?m)^\s*\|\s?", "", stderr)
stderr = normalize_repo_paths(stderr, repo).replace("-Number", "--number")
return " ".join(stderr.split())
@pytest.fixture
def repo(tmp_path: Path) -> Path:
return _setup_repo(tmp_path)
@pytest.fixture
def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
@requires_bash
@pytest.mark.parametrize(
"description",
[
"Add user authentication system",
"I want to add the new API rate limiting feature for users",
"Fix UI for DB sync",
"a to the of",
],
ids=["plain", "stop_words", "acronyms", "all_stop_words_fallback"],
)
def test_python_branch_name_generation_matches_bash(
repo: Path, description: str
) -> None:
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", description), repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert json_stdout(py) == json_stdout(bash)
@requires_bash
@pytest.mark.parametrize(
"args",
[
("--json", "--dry-run", "--number", "7", "add rate limiting"),
("--json", "--dry-run", "--number", "010", "add rate limiting"),
],
ids=["explicit_number", "leading_zero_number"],
)
def test_python_number_flag_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert py.returncode == bash.returncode == 0
assert json_stdout(py) == json_stdout(bash)
@requires_bash
def test_python_sequential_numbering_matches_bash(repo: Path) -> None:
for name in ("001-first", "0005-fourdigit", "20260101-120000-stamp", "12-short"):
(repo / "specs" / name).mkdir(parents=True)
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "add rate limiting"), repo)
assert py.returncode == bash.returncode == 0
assert json_stdout(py) == json_stdout(bash)
assert json_stdout(py)["FEATURE_NUM"] == "006"
@requires_bash
def test_all_variants_timestamp_mode_match_shape(repo: Path) -> None:
args = ("--json", "--dry-run", "--timestamp", "--short-name", "user-auth", "x")
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
py = run(py_cmd(repo, SCRIPT, *args), repo)
results = [bash, py]
if HAS_POWERSHELL:
results.append(
run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Timestamp",
"-ShortName",
"user-auth",
"x",
),
repo,
)
)
assert all(result.returncode == 0 for result in results)
# Timestamps may straddle a second boundary, so compare shape and suffix.
for result in results:
data = json_stdout(result)
assert re.fullmatch(r"\d{8}-\d{6}-user-auth", data["BRANCH_NAME"])
assert data["BRANCH_NAME"].startswith(data["FEATURE_NUM"])
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_timestamp_number_warning_matches(repo: Path) -> None:
args = (
"--json",
"--dry-run",
"--timestamp",
"--number",
"5",
"--short-name",
"ua",
"x",
)
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Timestamp",
"-Number",
"5",
"-ShortName",
"ua",
"x",
),
repo,
)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(ps)
assert (
py.stderr
== bash.stderr
== ps.stderr.replace("-Number", "--number").replace(
"-Timestamp", "--timestamp"
)
== "[specify] Warning: --number is ignored when --timestamp is used\n"
)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_invalid_number_fails_cleanly(repo: Path) -> None:
args = ("--json", "--dry-run", "--number", "abc", "add rate limiting")
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Number",
"abc",
"add rate limiting",
),
repo,
)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
expected = "Error: --number must be an unsigned integer, got 'abc'"
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_negative_number_fails_cleanly(repo: Path) -> None:
args = ("--json", "--dry-run", "--number", "-1", "add rate limiting")
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Number",
"-1",
"add rate limiting",
),
repo,
)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
expected = "Error: --number must be an unsigned integer, got '-1'"
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize("digit_count", [244, 5000])
def test_all_variants_oversized_number_fails_cleanly(
repo: Path, digit_count: int
) -> None:
number = "9" * digit_count
bash = run(
bash_cmd(
repo,
SCRIPT,
"--json",
"--dry-run",
"--number",
number,
"add rate limiting",
),
repo,
)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Number",
number,
"add rate limiting",
),
repo,
)
py = run(
py_cmd(
repo,
SCRIPT,
"--json",
"--dry-run",
"--number",
number,
"add rate limiting",
),
repo,
)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
expected = (
f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
)
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_branch_truncation_match(repo: Path) -> None:
args = ("--json", "--dry-run", "--short-name", "a" * 300, "x")
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-ShortName",
"a" * 300,
"x",
),
repo,
)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert bash.stderr == ps.stderr == py.stderr
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert len(json_stdout(py)["BRANCH_NAME"]) == 244
@requires_bash
def test_python_full_run_matches_bash(repo_pair: tuple[Path, Path]) -> None:
repo_a, repo_b = repo_pair
description = "Add user authentication system"
bash = run(bash_cmd(repo_a, SCRIPT, "--json", description), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json", description), repo_b)
assert py.returncode == bash.returncode == 0
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
bash.stdout, repo_a
)
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
branch = json_stdout(py)["BRANCH_NAME"]
for repo in repo_pair:
spec = repo / "specs" / branch / "spec.md"
assert spec.read_text(encoding="utf-8") == TEMPLATE_BODY
assert (repo_b / ".specify" / "feature.json").read_bytes() == (
repo_a / ".specify" / "feature.json"
).read_bytes()
@requires_bash
def test_python_missing_template_warning_matches_bash(
repo_pair: tuple[Path, Path],
) -> None:
repo_a, repo_b = repo_pair
for repo in repo_pair:
(repo / ".specify" / "templates" / "spec-template.md").unlink()
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "add rate limiting"), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json", "add rate limiting"), repo_b)
assert py.returncode == bash.returncode == 0
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
branch = json_stdout(py)["BRANCH_NAME"]
for repo in repo_pair:
assert (repo / "specs" / branch / "spec.md").read_text(encoding="utf-8") == ""
@requires_bash
def test_python_existing_directory_error_matches_bash(
repo_pair: tuple[Path, Path],
) -> None:
repo_a, repo_b = repo_pair
description = "add rate limiting"
assert (
run(
bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a
).returncode
== 0
)
assert (
run(
py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b
).returncode
== 0
)
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
bash_retry = run(
bash_cmd(
repo_a,
SCRIPT,
"--json",
"--number",
"1",
"--allow-existing-branch",
description,
),
repo_a,
)
py_retry = run(
py_cmd(
repo_b,
SCRIPT,
"--json",
"--number",
"1",
"--allow-existing-branch",
description,
),
repo_b,
)
assert py_retry.returncode == bash_retry.returncode == 0
assert normalize_repo_paths(py_retry.stdout, repo_b) == normalize_repo_paths(
bash_retry.stdout, repo_a
)
@requires_bash
@pytest.mark.parametrize(
"args",
[
(),
(" ",),
("--short-name",),
("--number",),
],
ids=["missing_description", "whitespace_description", "short_name_no_value", "number_no_value"],
)
def test_python_argument_errors_match_bash(repo: Path, args: tuple[str, ...]) -> None:
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert normalize_script_names(py.stderr, repo, SCRIPT) == normalize_script_names(
bash.stderr, repo, SCRIPT
)
@requires_bash
def test_python_help_matches_bash(repo: Path) -> None:
bash = run(bash_cmd(repo, SCRIPT, "--help"), repo)
py = run(py_cmd(repo, SCRIPT, "--help"), repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert normalize_script_names(py.stdout, repo, SCRIPT) == normalize_script_names(
bash.stdout, repo, SCRIPT
)
@requires_bash
def test_python_persists_relative_feature_json(repo: Path) -> None:
py = run(py_cmd(repo, SCRIPT, "--json", "add rate limiting"), repo)
assert py.returncode == 0, py.stderr
branch = json_stdout(py)["BRANCH_NAME"]
feature_json = (repo / ".specify" / "feature.json").read_text(encoding="utf-8")
assert feature_json == f'{{"feature_directory":"specs/{branch}"}}\n'
def test_persist_feature_json_avoids_platform_newline_translation(
tmp_path: Path, monkeypatch
) -> None:
def windows_write_text(path: Path, data: str, **kwargs) -> int:
encoding = kwargs.get("encoding") or "utf-8"
return path.write_bytes(data.replace("\n", "\r\n").encode(encoding))
monkeypatch.setattr(Path, "write_text", windows_write_text)
persist_feature_json(tmp_path, "specs/001-test")
assert (tmp_path / ".specify" / "feature.json").read_bytes() == (
b'{"feature_directory":"specs/001-test"}\n'
)
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
("py_args", "ps_args"),
[
(
("--json", "--dry-run", "Add user authentication system"),
("-Json", "-DryRun", "Add user authentication system"),
),
(
("--json", "--dry-run", "--short-name", "My Fancy Name", "x"),
("-Json", "-DryRun", "-ShortName", "My Fancy Name", "x"),
),
(
("--json", "--dry-run", "--number", "7", "add rate limiting"),
("-Json", "-DryRun", "-Number", "7", "add rate limiting"),
),
],
ids=["plain", "short_name", "number"],
)
def test_python_json_output_matches_powershell(
repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
) -> None:
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
assert py.returncode == ps.returncode == 0
assert json_stdout(py) == json_stdout(ps)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize("number", ["-1", "+1"], ids=["negative", "positive_sign"])
def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
bash = run(
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
repo,
)
ps = run(
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
repo,
)
py = run(
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
repo,
)
assert bash.returncode == ps.returncode == py.returncode == 1
expected = f"Error: --number must be an unsigned integer, got '{number}'"
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize("timestamp", [False, True], ids=["numbered", "timestamp"])
def test_all_variants_treat_empty_number_as_omitted(
repo: Path, timestamp: bool
) -> None:
bash_args = ["--json", "--dry-run", "--number", ""]
ps_args = ["-Json", "-DryRun", "-Number", ""]
py_args = ["--json", "--dry-run", "--number", ""]
if timestamp:
bash_args.append("--timestamp")
ps_args.append("-Timestamp")
py_args.append("--timestamp")
bash_args.append("x")
ps_args.append("x")
py_args.append("x")
bash = run(bash_cmd(repo, SCRIPT, *bash_args), repo)
ps = run(ps_cmd(repo, SCRIPT, *ps_args), repo)
py = run(py_cmd(repo, SCRIPT, *py_args), repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert bash.stderr == ps.stderr == py.stderr == ""
if not timestamp:
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
("number", "returncode"),
[
(str(2**63 - 1), 0),
(str(2**63), 1),
],
ids=["int64_max", "int64_overflow"],
)
def test_all_variants_share_int64_number_range(
repo: Path, number: str, returncode: int
) -> None:
bash = run(
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
repo,
)
ps = run(
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", number, "x"),
repo,
)
py = run(
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", number, "x"),
repo,
)
assert bash.returncode == ps.returncode == py.returncode == returncode
if returncode == 0:
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
else:
assert bash.stdout == ps.stdout == py.stdout == ""
expected = f"Error: --number must be between 0 and {2**63 - 1}, got '{number}'"
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_reject_exhausted_auto_number_range(repo: Path) -> None:
(repo / "specs" / f"{2**63 - 1}-existing").mkdir(parents=True)
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
expected = f"Error: feature number must be between 0 and {2**63 - 1}, got '{2**63}'"
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize("prefix", [2**63, 2**64 + 5])
def test_all_variants_ignore_out_of_range_existing_prefix(
repo: Path, prefix: int
) -> None:
(repo / "specs" / f"{prefix}-existing").mkdir(parents=True)
bash = run(bash_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "x"), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--dry-run", "x"), repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert json_stdout(py)["FEATURE_NUM"] == "001"
def test_python_ignores_unconvertibly_large_existing_prefix() -> None:
class Entry:
name = f"{'9' * 5000}-existing"
@staticmethod
def is_dir() -> bool:
return True
class SpecsDir:
@staticmethod
def is_dir() -> bool:
return True
@staticmethod
def iterdir() -> list[Entry]:
return [Entry()]
assert create_new_feature._get_highest_from_specs(SpecsDir()) == 0
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_text_mode_match(repo: Path) -> None:
bash = run(bash_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-DryRun", "-Number", "7", "x"), repo)
py = run(py_cmd(repo, SCRIPT, "--dry-run", "--number", "7", "x"), repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert bash.stderr == ps.stderr == py.stderr == ""
assert (
normalize_repo_paths(bash.stdout, repo)
== normalize_repo_paths(ps.stdout, repo)
== normalize_repo_paths(py.stdout, repo)
)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_non_dry_text_mode_match(tmp_path: Path) -> None:
bash_repo = _setup_repo(tmp_path, "bash")
ps_repo = _setup_repo(tmp_path, "powershell")
py_repo = _setup_repo(tmp_path, "python")
bash = run(
bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo
)
ps = run(
ps_cmd(ps_repo, SCRIPT, "-Number", "7", "x"), ps_repo
)
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
assert bash.returncode == ps.returncode == py.returncode == 0
assert (
normalize_repo_paths(bash.stdout, bash_repo)
== normalize_repo_paths(py.stdout, py_repo)
)
assert (
normalize_repo_paths(bash.stderr, bash_repo)
== normalize_repo_paths(py.stderr, py_repo)
)
ps_stdout = normalize_repo_paths(ps.stdout, ps_repo)
ps_stderr = normalize_repo_paths(ps.stderr, ps_repo)
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stdout
assert (
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stdout
)
assert "$env:SPECIFY_FEATURE = '007-x'" in ps_stderr
assert (
"$env:SPECIFY_FEATURE_DIRECTORY = '<REPO>/specs/007-x'" in ps_stderr
)
@requires_bash
def test_python_persist_hints_match_bash_for_spaced_repo_path(
tmp_path: Path,
) -> None:
"""Paths with spaces must be quoted identically (shlex.quote format) so
the side-by-side text/stderr comparison holds."""
bash_repo = _setup_repo(tmp_path, "my proj a")
py_repo = _setup_repo(tmp_path, "my proj b")
bash = run(bash_cmd(bash_repo, SCRIPT, "--number", "7", "x"), bash_repo)
py = run(py_cmd(py_repo, SCRIPT, "--number", "7", "x"), py_repo)
assert bash.returncode == py.returncode == 0, bash.stderr + py.stderr
assert normalize_repo_paths(bash.stdout, bash_repo) == normalize_repo_paths(
py.stdout, py_repo
)
assert normalize_repo_paths(bash.stderr, bash_repo) == normalize_repo_paths(
py.stderr, py_repo
)
assert "export SPECIFY_FEATURE_DIRECTORY='<REPO>/specs/007-x'" in (
normalize_repo_paths(py.stderr, py_repo)
)
def test_python_powershell_persistence_assignments_escape_quotes() -> None:
assert create_new_feature._persistence_assignments(
"007-x", r"C:\repo\O'Brien", powershell=True
) == (
"$env:SPECIFY_FEATURE = '007-x'",
"$env:SPECIFY_FEATURE_DIRECTORY = 'C:\\repo\\O''Brien'",
)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_persist_symlinked_specs_path_lexically(
tmp_path: Path,
) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
for current in repos:
specs_target = tmp_path / f"{current.name}-specs"
specs_target.mkdir()
try:
(current / "specs").symlink_to(
specs_target, target_is_directory=True
)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
bash = run(
bash_cmd(repos[0], SCRIPT, "--json", "--number", "7", "x"),
repos[0],
)
ps = run(
ps_cmd(repos[1], SCRIPT, "-Json", "-Number", "7", "x"),
repos[1],
)
py = run(
py_cmd(repos[2], SCRIPT, "--json", "--number", "7", "x"),
repos[2],
)
assert bash.returncode == ps.returncode == py.returncode == 0
expected = '{"feature_directory":"specs/007-x"}'
for current in repos:
assert (
current / ".specify" / "feature.json"
).read_text(encoding="utf-8").strip() == expected
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_allow_existing_branch(repo: Path) -> None:
feature_dir = repo / "specs" / "001-x"
feature_dir.mkdir(parents=True)
spec_file = feature_dir / "spec.md"
spec_file.write_text("existing\n", encoding="utf-8")
bash = run(
bash_cmd(
repo,
SCRIPT,
"--json",
"--number",
"1",
"--allow-existing-branch",
"x",
),
repo,
)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-Number",
"1",
"-AllowExistingBranch",
"x",
),
repo,
)
py = run(
py_cmd(
repo,
SCRIPT,
"--json",
"--number",
"1",
"--allow-existing-branch",
"x",
),
repo,
)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert spec_file.read_text(encoding="utf-8") == "existing\n"
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
(repo / "specs" / "001-x").mkdir(parents=True)
expected = (
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
"Please use a different feature name or specify a different number "
"with --number."
)
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)

View File

@@ -3118,7 +3118,9 @@ Run {SCRIPT}
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
import yaml
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
monkeypatch.setattr(
"specify_cli.integrations.base.platform.system", lambda: "Windows"
)
ext_dir = temp_dir / "ext-script-windows-fallback"
ext_dir.mkdir()

View File

@@ -0,0 +1,316 @@
"""Parity tests for the Python setup-plan port."""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
POWERSHELL_EXE,
bash_cmd,
clean_env,
install_scripts,
json_stdout,
make_repo,
normalize_repo_paths,
ps_cmd,
py_cmd,
run,
write_feature_json,
)
SCRIPT = "setup-plan"
TEMPLATE_BODY = "# Plan Template\n\nBody.\n"
def _setup_repo(tmp_path: Path, name: str = "proj", template: bool = True) -> Path:
repo = make_repo(tmp_path, name)
install_scripts(repo, SCRIPT)
write_feature_json(repo)
(repo / "specs" / "001-my-feature").mkdir(parents=True)
if template:
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True)
(templates / "plan-template.md").write_text(TEMPLATE_BODY, encoding="utf-8")
return repo
@pytest.fixture
def repo(tmp_path: Path) -> Path:
return _setup_repo(tmp_path)
@requires_bash
def test_python_fresh_copy_matches_bash(tmp_path: Path) -> None:
repo_a = _setup_repo(tmp_path, "proj-a")
repo_b = _setup_repo(tmp_path, "proj-b")
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
assert py.returncode == bash.returncode == 0
assert normalize_repo_paths(py.stdout, repo_b) == normalize_repo_paths(
bash.stdout, repo_a
)
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
for repo in (repo_a, repo_b):
plan = repo / "specs" / "001-my-feature" / "plan.md"
assert plan.read_text(encoding="utf-8") == TEMPLATE_BODY
@requires_bash
@pytest.mark.parametrize("args", [("--json",), ()], ids=["json", "text"])
def test_python_existing_plan_matches_bash(repo: Path, args: tuple[str, ...]) -> None:
plan = repo / "specs" / "001-my-feature" / "plan.md"
plan.write_text("# existing\n", encoding="utf-8")
bash = run(bash_cmd(repo, SCRIPT, *args), repo)
py = run(py_cmd(repo, SCRIPT, *args), repo)
assert py.returncode == bash.returncode == 0
assert py.stdout == bash.stdout
assert py.stderr == bash.stderr
assert plan.read_text(encoding="utf-8") == "# existing\n"
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_ignore_extra_arguments(tmp_path: Path) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
bash = run(bash_cmd(repos[0], SCRIPT, "--json", "--bogus"), repos[0])
ps = run(ps_cmd(repos[1], SCRIPT, "-Json", "--bogus"), repos[1])
py = run(py_cmd(repos[2], SCRIPT, "--json", "--bogus"), repos[2])
assert bash.returncode == ps.returncode == py.returncode == 0
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
ps.stdout, repos[1]
) == normalize_repo_paths(py.stdout, repos[2])
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
ps.stderr, repos[1]
) == normalize_repo_paths(py.stderr, repos[2])
@requires_bash
def test_python_missing_template_matches_bash(tmp_path: Path) -> None:
repo_a = _setup_repo(tmp_path, "proj-a", template=False)
repo_b = _setup_repo(tmp_path, "proj-b", template=False)
bash = run(bash_cmd(repo_a, SCRIPT, "--json"), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json"), repo_b)
assert py.returncode == bash.returncode == 0
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
for repo in (repo_a, repo_b):
plan = repo / "specs" / "001-my-feature" / "plan.md"
assert plan.read_text(encoding="utf-8") == ""
@requires_bash
@pytest.mark.parametrize(
"registry",
[
'{"presets": {"alpha": {"priority": "high"}, "beta": {"priority": 1}}}',
'{"presets": {"alpha": {"priority": 2}, "beta": {"priority": 1}, "gamma": {"priority": null}}}',
"[]",
'{"presets":[]}',
'{"presets":null}',
],
ids=[
"mixed_priorities",
"null_priority",
"list_root",
"list_presets",
"null_presets",
],
)
def test_all_variants_broken_registry_falls_back_to_dir_scan(
tmp_path: Path, registry: str
) -> None:
"""Malformed registries fall back to the alphabetical directory scan."""
repos = [
_setup_repo(tmp_path, "bash", template=False),
_setup_repo(tmp_path, "powershell", template=False),
_setup_repo(tmp_path, "python", template=False),
]
for repo in repos:
presets = repo / ".specify" / "presets"
for name, body in (
(".hidden", "# hidden\n"),
("beta", "# beta plan\n"),
("alpha", "# alpha plan\n"),
):
(presets / name / "templates").mkdir(parents=True)
(presets / name / "templates" / "plan-template.md").write_text(
body, encoding="utf-8"
)
(presets / ".registry").write_text(
registry, encoding="utf-8"
)
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
results = [(bash, repos[0]), (py, repos[2])]
if HAS_POWERSHELL:
results.insert(
1,
(run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1]), repos[1]),
)
assert all(result.returncode == 0 for result, _ in results)
assert len(
{
normalize_repo_paths(result.stdout, repo)
for result, repo in results
}
) == 1
assert len(
{
normalize_repo_paths(result.stderr, repo)
for result, repo in results
}
) == 1
for _, repo in results:
plan = repo / "specs" / "001-my-feature" / "plan.md"
assert plan.read_text(encoding="utf-8") == "# alpha plan\n"
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_powershell_broken_registry_fallback_sorts_directories(
tmp_path: Path,
) -> None:
repo = _setup_repo(tmp_path, "powershell", template=False)
presets = repo / ".specify" / "presets"
for name in ("alpha", "beta"):
templates = presets / name / "templates"
templates.mkdir(parents=True)
(templates / "plan-template.md").write_text(
f"# {name} plan\n", encoding="utf-8"
)
(presets / ".registry").write_text("{broken", encoding="utf-8")
common = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
common_ps = str(common).replace("'", "''")
alpha_ps = str(presets / "alpha").replace("'", "''")
beta_ps = str(presets / "beta").replace("'", "''")
repo_ps = str(repo).replace("'", "''")
command = f"""
. '{common_ps}'
function Get-ChildItem {{
@(
[PSCustomObject]@{{ Name = 'beta'; FullName = '{beta_ps}' }}
[PSCustomObject]@{{ Name = 'alpha'; FullName = '{alpha_ps}' }}
)
}}
Resolve-Template -TemplateName 'plan-template' -RepoRoot '{repo_ps}'
"""
result = run(
[POWERSHELL_EXE, "-NoProfile", "-Command", command],
repo,
)
assert result.returncode == 0
assert result.stderr == ""
assert Path(result.stdout.strip()).read_text(encoding="utf-8") == (
"# alpha plan\n"
)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
)
def test_all_variants_feature_context_error_matches(
tmp_path: Path, context: str
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
env = None
if context == "invalid_json":
(repo / ".specify" / "feature.json").write_text(
"{not json", encoding="utf-8"
)
elif context == "invalid_utf8":
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
elif context == "invalid_init_dir":
env = clean_env()
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
assert bash.stderr == ps.stderr == py.stderr
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
"registry",
[
'{"presets":{"alpha":{"enabled":false,"priority":1}}}',
'{"presets":{"alpha":"invalid"}}',
],
ids=["disabled", "invalid_metadata"],
)
def test_all_variants_ignore_inactive_preset_template(
tmp_path: Path, registry: str
) -> None:
repos = [
_setup_repo(tmp_path, "bash"),
_setup_repo(tmp_path, "powershell"),
_setup_repo(tmp_path, "python"),
]
for current in repos:
preset_templates = (
current / ".specify" / "presets" / "alpha" / "templates"
)
preset_templates.mkdir(parents=True)
(preset_templates / "plan-template.md").write_text(
"# Disabled preset\n", encoding="utf-8"
)
(current / ".specify" / "presets" / ".registry").write_text(
registry, encoding="utf-8"
)
bash = run(bash_cmd(repos[0], SCRIPT, "--json"), repos[0])
ps = run(ps_cmd(repos[1], SCRIPT, "-Json"), repos[1])
py = run(py_cmd(repos[2], SCRIPT, "--json"), repos[2])
assert bash.returncode == ps.returncode == py.returncode == 0
assert normalize_repo_paths(bash.stdout, repos[0]) == normalize_repo_paths(
ps.stdout, repos[1]
) == normalize_repo_paths(py.stdout, repos[2])
assert normalize_repo_paths(bash.stderr, repos[0]) == normalize_repo_paths(
ps.stderr, repos[1]
) == normalize_repo_paths(py.stderr, repos[2])
for current in repos:
assert (
current / "specs" / "001-my-feature" / "plan.md"
).read_text(encoding="utf-8") == TEMPLATE_BODY
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_python_json_output_matches_powershell(repo: Path) -> None:
plan = repo / "specs" / "001-my-feature" / "plan.md"
plan.write_text("# existing\n", encoding="utf-8")
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert py.returncode == ps.returncode == 0
assert json_stdout(py) == json_stdout(ps)

View File

@@ -0,0 +1,207 @@
"""Parity tests for the Python setup-tasks port."""
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.parity_helpers import (
HAS_POWERSHELL,
bash_cmd,
clean_env,
install_scripts,
json_stdout,
make_repo,
normalize_status_text,
ps_cmd,
py_cmd,
run,
write_feature_json,
)
SCRIPT = "setup-tasks"
def _setup_repo(tmp_path: Path) -> Path:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
write_feature_json(repo)
feature = repo / "specs" / "001-my-feature"
feature.mkdir(parents=True)
(feature / "plan.md").write_text("# plan\n", encoding="utf-8")
(feature / "spec.md").write_text("# spec\n", encoding="utf-8")
templates = repo / ".specify" / "templates"
templates.mkdir(parents=True)
(templates / "tasks-template.md").write_text("# Tasks Template\n", encoding="utf-8")
return repo
@pytest.fixture
def repo(tmp_path: Path) -> Path:
return _setup_repo(tmp_path)
@requires_bash
def test_python_json_output_matches_bash(repo: Path) -> None:
feature = repo / "specs" / "001-my-feature"
(feature / "research.md").write_text("# research\n", encoding="utf-8")
(feature / "data-model.md").write_text("# model\n", encoding="utf-8")
(feature / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
(feature / "contracts" / "v1").mkdir(parents=True)
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert json_stdout(py) == json_stdout(bash)
@requires_bash
def test_python_text_output_matches_bash(repo: Path) -> None:
feature = repo / "specs" / "001-my-feature"
(feature / "research.md").write_text("# research\n", encoding="utf-8")
(feature / "contracts").mkdir() # present but empty -> reported missing
bash = run(bash_cmd(repo, SCRIPT), repo)
py = run(py_cmd(repo, SCRIPT), repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert normalize_status_text(py.stdout) == normalize_status_text(bash.stdout)
@requires_bash
def test_python_override_template_wins_matches_bash(repo: Path) -> None:
overrides = repo / ".specify" / "templates" / "overrides"
overrides.mkdir(parents=True)
(overrides / "tasks-template.md").write_text("# Override\n", encoding="utf-8")
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert py.returncode == bash.returncode == 0
assert json_stdout(py) == json_stdout(bash)
assert json_stdout(py)["TASKS_TEMPLATE"].endswith("overrides/tasks-template.md")
@requires_bash
@pytest.mark.parametrize(
"missing",
["plan.md", "spec.md", "tasks-template"],
ids=["missing_plan", "missing_spec", "missing_tasks_template"],
)
def test_python_error_output_matches_bash(repo: Path, missing: str) -> None:
if missing == "tasks-template":
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
else:
(repo / "specs" / "001-my-feature" / missing).unlink()
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@requires_bash
def test_python_unknown_option_matches_bash(repo: Path) -> None:
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert py.stderr == bash.stderr
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_powershell_unknown_option_matches_siblings(repo: Path) -> None:
bash = run(bash_cmd(repo, SCRIPT, "--bogus"), repo)
ps = run(ps_cmd(repo, SCRIPT, "--bogus"), repo)
py = run(py_cmd(repo, SCRIPT, "--bogus"), repo)
assert ps.returncode == bash.returncode == py.returncode == 1
assert ps.stdout == bash.stdout == py.stdout == ""
assert ps.stderr == bash.stderr == py.stderr
@requires_bash
def test_help_beats_unknown_option_matches_bash(repo: Path) -> None:
"""--help must win over a later unknown option and exit 0."""
bash = run(bash_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
py = run(py_cmd(repo, SCRIPT, "--help", "--bogus"), repo)
assert py.returncode == bash.returncode == 0
assert py.stderr == bash.stderr == ""
assert "Usage" in py.stdout and "Usage" in bash.stdout
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_powershell_help_beats_unknown_option(repo: Path) -> None:
"""-Help must win over unknown-argument validation like the siblings."""
ps = run(ps_cmd(repo, SCRIPT, "-Help", "--bogus"), repo)
assert ps.returncode == 0, ps.stderr
assert ps.stderr == ""
assert "Usage" in ps.stdout
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
@pytest.mark.parametrize(
"context", ["missing", "invalid_json", "invalid_utf8", "invalid_init_dir"]
)
def test_all_variants_feature_context_error_matches(
tmp_path: Path, context: str
) -> None:
repo = make_repo(tmp_path)
install_scripts(repo, SCRIPT)
env = None
if context == "invalid_json":
(repo / ".specify" / "feature.json").write_text(
"{not json", encoding="utf-8"
)
elif context == "invalid_utf8":
(repo / ".specify" / "feature.json").write_bytes(b"\xff")
elif context == "invalid_init_dir":
env = clean_env()
env["SPECIFY_INIT_DIR"] = str(tmp_path / "missing")
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo, env)
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo, env)
py = run(py_cmd(repo, SCRIPT, "--json"), repo, env)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
assert bash.stderr == ps.stderr == py.stderr
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_python_json_output_matches_powershell(repo: Path) -> None:
feature = repo / "specs" / "001-my-feature"
(feature / "research.md").write_text("# research\n", encoding="utf-8")
(feature / "contracts" / "v1").mkdir(parents=True)
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert py.returncode == ps.returncode == 0
assert json_stdout(py) == json_stdout(ps)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_missing_template_error_matches_all_variants(repo: Path) -> None:
(repo / ".specify" / "templates" / "tasks-template.md").unlink()
bash = run(bash_cmd(repo, SCRIPT, "--json"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-Json"), repo)
py = run(py_cmd(repo, SCRIPT, "--json"), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
assert bash.stderr == ps.stderr == py.stderr

View File

@@ -0,0 +1,152 @@
"""resolve_skill_placeholders must support the py script variant (#3280)."""
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
from specify_cli._init_options import save_init_options
from specify_cli.agents import CommandRegistrar
FRONTMATTER = {
"scripts": {
"sh": "scripts/bash/setup-plan.sh --json",
"ps": "scripts/powershell/setup-plan.ps1 -Json",
"py": "scripts/python/setup_plan.py --json",
}
}
def _resolve(tmp_path: Path, script: str | None, monkeypatch) -> str:
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
monkeypatch.setattr(
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
staticmethod(lambda path: True),
)
if script:
save_init_options(tmp_path, {"script": script})
return CommandRegistrar.resolve_skill_placeholders(
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
)
def test_py_variant_prefixes_interpreter(tmp_path, monkeypatch):
body = _resolve(tmp_path, "py", monkeypatch)
assert "python3 .specify/scripts/python/setup_plan.py --json" in body
assert "{SCRIPT}" not in body
def test_sh_variant_is_not_prefixed(tmp_path, monkeypatch):
body = _resolve(tmp_path, "sh", monkeypatch)
assert ".specify/scripts/bash/setup-plan.sh --json" in body
assert "python3" not in body
def test_py_interpreter_with_spaces_uses_powershell_call_operator(
tmp_path, monkeypatch
):
interpreter = r"C:\Program Files\Py$thon's\python.exe"
quoted_interpreter = interpreter.replace("'", "''")
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
interpreter,
)
monkeypatch.setattr(
"specify_cli.integrations.base.os", SimpleNamespace(name="nt")
)
save_init_options(tmp_path, {"script": "py"})
body = CommandRegistrar.resolve_skill_placeholders(
"codex", FRONTMATTER, "Run {SCRIPT} now.", tmp_path
)
assert f"& '{quoted_interpreter}' " in body
def test_missing_py_variant_falls_back_to_available_script(tmp_path, monkeypatch):
"""script=py with a template that only ships sh/ps must not leave {SCRIPT} unresolved."""
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
save_init_options(tmp_path, {"script": "py"})
frontmatter = {
"scripts": {
"sh": "scripts/bash/setup-plan.sh --json",
"ps": "scripts/powershell/setup-plan.ps1 -Json",
}
}
body = CommandRegistrar.resolve_skill_placeholders(
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
)
assert "{SCRIPT}" not in body
assert "setup-plan" in body
def test_py_install_includes_python_and_fallback_scripts(tmp_path, monkeypatch):
from specify_cli import _install_shared_infra
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which",
lambda name: "/usr/bin/python3" if name == "python3" else None,
)
_install_shared_infra(tmp_path, "py", force=True)
assert (tmp_path / ".specify/scripts/python/setup_plan.py").is_file()
assert (tmp_path / ".specify/scripts/python/setup_tasks.py").is_file()
save_init_options(tmp_path, {"script": "py"})
frontmatter = {
"scripts": {
"sh": "scripts/bash/check-prerequisites.sh --json",
"ps": "scripts/powershell/check-prerequisites.ps1 -Json",
}
}
body = CommandRegistrar.resolve_skill_placeholders(
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
)
fallback = (
".specify/scripts/bash/check-prerequisites.sh"
if "scripts/bash/" in body
else ".specify/scripts/powershell/check-prerequisites.ps1"
)
assert (tmp_path / fallback).is_file()
def test_py_rejects_one_sided_opposite_platform_fallback(
tmp_path, monkeypatch
):
from specify_cli import _install_shared_infra
from specify_cli import shared_infra
class WindowsOs:
name = "nt"
def __getattr__(self, attr):
return getattr(os, attr)
monkeypatch.setattr(shared_infra, "os", WindowsOs())
monkeypatch.setattr(
"specify_cli.integrations.base.platform.system", lambda: "Windows"
)
_install_shared_infra(tmp_path, "py", force=True)
save_init_options(tmp_path, {"script": "py"})
frontmatter = {
"scripts": {
"sh": "scripts/bash/check-prerequisites.sh --json",
}
}
with pytest.raises(ValueError, match="No runnable script variant"):
CommandRegistrar.resolve_skill_placeholders(
"codex", frontmatter, "Run {SCRIPT} now.", tmp_path
)
assert not (
tmp_path / ".specify/scripts/bash/check-prerequisites.sh"
).exists()