diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 50b2ce08de..247f6e9af2 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -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 @@ -202,9 +217,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 diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index bb9d19062b..afc226ea00 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -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 } } diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 91b36bebdb..5e9aebb291 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -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" } } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 9e0403eba6..6ed0344dd9 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -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 diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 index c7d85fc2a6..ead83da1c6 100644 --- a/scripts/powershell/setup-tasks.ps1 +++ b/scripts/powershell/setup-tasks.ps1 @@ -3,11 +3,18 @@ [CmdletBinding()] param( [switch]$Json, - [switch]$Help + [switch]$Help, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$RemainingArgs ) $ErrorActionPreference = 'Stop' +if ($RemainingArgs.Count -gt 0) { + [Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'") + exit 1 +} + if ($Help) { Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]" exit 0 @@ -17,7 +24,11 @@ if ($Help) { . "$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 +56,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 diff --git a/scripts/python/common.py b/scripts/python/common.py index 77c13eefbb..65c625dcad 100644 --- a/scripts/python/common.py +++ b/scripts/python/common.py @@ -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 "" @@ -112,9 +112,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 +181,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//templates/ (sorted by .registry priority) + 3. .specify/extensions//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(): diff --git a/scripts/python/create_new_feature.py b/scripts/python/create_new_feature.py new file mode 100644 index 0000000000..ea856bf594 --- /dev/null +++ b/scripts/python/create_new_feature.py @@ -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 ] [--number N] [--timestamp] " + ) + + +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 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"(? 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()) diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py new file mode 100644 index 0000000000..7b8e77ce5a --- /dev/null +++ b/scripts/python/setup_plan.py @@ -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()) diff --git a/scripts/python/setup_tasks.py b/scripts/python/setup_tasks.py new file mode 100644 index 0000000000..b3abb6dc1a --- /dev/null +++ b/scripts/python/setup_tasks.py @@ -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()) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 110234a03e..1d5a34073c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -140,10 +140,9 @@ def _install_shared_infra( """Install shared infrastructure files into *project_path*. Copies ``.specify/scripts//`` and ``.specify/templates/`` from - the bundled core_pack or source checkout, where ```` 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___`` placeholders using *invoke_separator* diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index e4d09ffe99..291174881e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,7 +7,6 @@ """ import os -import platform import re from copy import deepcopy from pathlib import Path @@ -475,26 +474,19 @@ def resolve_skill_placeholders( 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" - ) - 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) + if scripts: + from specify_cli.integrations.base import IntegrationBase - script_variant = fallback_order[0] if fallback_order else None + script_variant = IntegrationBase.select_script_variant( + script_variant, scripts + ) 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) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 9eb8302888..5b01ceeb1c 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -77,7 +77,7 @@ def init( 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, diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 66fd2b2d26..8a70b791ba 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -38,7 +38,7 @@ @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")'), ): diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 6568d1af18..6c1589c7b5 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -43,7 +43,7 @@ @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'), @@ -336,7 +336,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. diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index bfbb81b85b..2407341305 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import platform import re import shlex import shutil @@ -619,6 +620,46 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str: 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. @@ -653,7 +694,8 @@ def process_template( """Process a raw command template into agent-ready content. Performs the same transformations as the release script: - 1. Extract ``scripts.`` value from YAML frontmatter + 1. Select ``scripts.`` 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* @@ -662,37 +704,46 @@ def process_template( 7. Replace ``__SPECKIT_COMMAND___`` 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 diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 1b07cc7712..d99cca4e85 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -402,8 +402,13 @@ def _is_managed(rel: str, dst: Path) -> bool: # 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 _ensure_or_bucket_dir(directory: Path) -> bool: 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 - - 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(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 + + 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 _ensure_or_bucket_dir(directory: Path) -> bool: # 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 diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 312c5ab181..664f428114 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -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 diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index ae7192c3d3..00d73354e3 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -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 diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index d03ea0cb25..fd1ae6863f 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -1,6 +1,8 @@ """Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives.""" +import shlex import sys +from types import SimpleNamespace import pytest @@ -477,19 +479,41 @@ def test_sh_does_not_prefix_interpreter(self): 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 @@ -511,6 +535,39 @@ def test_py_uses_project_venv(self, monkeypatch, tmp_path): ) 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): diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 7200572659..8620f4ac5e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -15,6 +15,22 @@ 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" diff --git a/tests/parity_helpers.py b/tests/parity_helpers.py new file mode 100644 index 0000000000..9289471eaf --- /dev/null +++ b/tests/parity_helpers.py @@ -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, "") + 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, "