diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 425de8660..96d7c64cd 100644 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -34,7 +34,17 @@ rewrite_paths() { sed -E \ -e 's@(/?)memory/@.specify/memory/@g' \ -e 's@(/?)scripts/@.specify/scripts/@g' \ - -e 's@(/?)templates/@.specify/templates/@g' + -e 's@(/?)templates/@.specify/templates/@g' \ + -e 's@(/?)hooks/@.specify/hooks/@g' +} + +get_hook_extension() { + local script_variant=$1 + if [[ "$script_variant" == "ps" ]]; then + echo ".ps1" + else + echo "" + fi } generate_commands() { @@ -57,8 +67,9 @@ generate_commands() { script_command="(Missing script command for $script_variant)" fi - # Replace {SCRIPT} placeholder with the script command - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") + # Replace {SCRIPT} placeholder with the script command and {HOOK_EXT} with extension + local ext=$(get_hook_extension "$script_variant") + body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g" | sed "s|{HOOK_EXT}|${ext}|g") # Remove the scripts: section from frontmatter while preserving YAML structure body=$(printf '%s\n' "$body" | awk ' @@ -113,6 +124,25 @@ build_variant() { fi [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -exec cp --parents {} "$SPEC_DIR"/ \; ; echo "Copied templates -> .specify/templates"; } + + # Copy hooks selectively based on script variant + if [[ -d hooks ]]; then + mkdir -p "$SPEC_DIR/hooks" + # Always copy README.md + [[ -f hooks/README.md ]] && cp hooks/README.md "$SPEC_DIR/hooks/" + + case $script in + sh) + # Copy bash hook samples (without .ps1) + find hooks -maxdepth 1 -type f -name "*.sample" ! -name "*.ps1.sample" -exec cp {} "$SPEC_DIR/hooks/" \; + ;; + ps) + # Copy PowerShell hook samples (.ps1.sample) + find hooks -maxdepth 1 -type f -name "*.ps1.sample" -exec cp {} "$SPEC_DIR/hooks/" \; + ;; + esac + echo "Copied hooks -> .specify/hooks" + fi # Inject variant into plan-template.md within .specify/templates if present local plan_tpl="$base_dir/.specify/templates/plan-template.md" if [[ -f "$plan_tpl" ]]; then @@ -122,8 +152,9 @@ build_variant() { if [[ -n $script_command ]]; then # Always prefix with .specify/ for plan usage script_command=".specify/$script_command" - # Replace {SCRIPT} placeholder with the script command and __AGENT__ with agent name - substituted=$(sed "s|{SCRIPT}|${script_command}|g" "$plan_tpl" | tr -d '\r' | sed "s|__AGENT__|${agent}|g") + # Replace {SCRIPT} placeholder with the script command, {HOOK_EXT} with extension, and __AGENT__ with agent name + local ext=$(get_hook_extension "$script") + substituted=$(sed "s|{SCRIPT}|${script_command}|g" "$plan_tpl" | tr -d '\r' | sed "s|__AGENT__|${agent}|g" | sed "s|{HOOK_EXT}|${ext}|g") # Strip YAML frontmatter from plan template output (keep body only) stripped=$(printf '%s\n' "$substituted" | awk 'BEGIN{fm=0;dash=0} /^---$/ {dash++; if(dash==1){fm=1; next} else if(dash==2){fm=0; next}} {if(!fm) print}') printf '%s\n' "$stripped" > "$plan_tpl" diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 000000000..e9c920ef7 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,205 @@ +# Specify Hooks + +This directory contains Git-style hook script samples that customize the `/specify` command workflow. All hooks are optional and follow Git's naming conventions for familiar, intuitive usage. + +## Hook Activation + +Hooks are provided as `.sample` files and must be activated by removing the `.sample` extension: + +**Unix/Linux/macOS:** +```bash +# Activate bash hook (example: prepare-feature-num) +cp .specify/hooks/prepare-feature-num.sample .specify/hooks/prepare-feature-num +chmod +x .specify/hooks/prepare-feature-num +``` + +**Windows PowerShell:** +```powershell +# Activate PowerShell hook +Copy-Item .specify/hooks/prepare-feature-num.ps1.sample .specify/hooks/prepare-feature-num.ps1 +``` + +**Cross-platform support:** The system automatically detects and uses the appropriate hook format (`.ps1` for Windows, executable scripts for Unix). + +## Available Hooks (Git-Style Naming) + +### `pre-specify` - Pre-processing Hook +- **When**: Before the entire specify workflow begins +- **Purpose**: Validation, setup, or preprocessing tasks +- **Arguments**: `$1` = feature description +- **Exit codes**: Non-zero exit codes show warnings but don't stop execution + +**Example uses:** +- Validate feature description format and length +- Check prerequisites or dependencies +- Set up external resources or authenticate + +### `prepare-feature-num` - Feature Number Preparation Hook +- **When**: Before auto-incrementing feature number (similar to Git's `prepare-commit-msg`) +- **Purpose**: Provide custom feature numbering from external sources +- **Arguments**: `$1` = feature description +- **Output**: Integer feature number (stdout) +- **Fallback**: If hook fails or outputs nothing, auto-increment is used + +**Example uses:** +- Fetch feature number from external spec server +- Create GitHub issue and use issue number +- Implement custom numbering schemes + +### `post-checkout` - Post-Checkout Hook +- **When**: After branch creation and checkout (matches Git's `post-checkout`) +- **Purpose**: Setup tasks after branch creation but before spec writing +- **Arguments**: + - `$1` = feature description + - `$2` = feature number + - `$3` = branch name + - `$4` = spec file path +- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` also available +- **Exit codes**: Non-zero exit codes show warnings but don't stop execution + +**Example uses:** +- Initialize additional project files +- Set up branch-specific configurations +- Create directory structures +- Send branch creation notifications + +### `post-specify` - Post-Specification Hook +- **When**: After spec file is completely written (true post-specify) +- **Purpose**: Final integration tasks and notifications +- **Arguments**: + - `$1` = feature description + - `$2` = feature number + - `$3` = branch name + - `$4` = spec file path +- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` also available +- **Exit codes**: Non-zero exit codes show warnings but don't stop execution + +**Example uses:** +- Create GitHub issues linking to completed specs +- Send completion notifications +- Trigger CI/CD pipelines for spec review +- Update external tracking systems + +## Hook Examples + +### Custom Feature Numbering from Server + +**Bash version:** +```bash +#!/bin/bash +# .specify/hooks/prepare-feature-num +FEATURE_DESC="$1" +FEATURE_NUMBER=$(curl -s "$SPEC_SERVER/api/next-number") +echo "$FEATURE_NUMBER" +``` + +**PowerShell version:** +```powershell +#!/usr/bin/env pwsh +# .specify/hooks/prepare-feature-num.ps1 +param([string]$FeatureDescription) +$featureNumber = Invoke-RestMethod -Uri "$env:SPEC_SERVER/api/next-number" +Write-Output $featureNumber +``` + +### GitHub Issue for Feature Number + +**Bash version:** +```bash +#!/bin/bash +# .specify/hooks/prepare-feature-num +FEATURE_DESC="$1" +ISSUE_URL=$(gh issue create --title "Spec: $FEATURE_DESC" --body "Specification development") +ISSUE_NUMBER=$(echo "$ISSUE_URL" | grep -o '[0-9]*$') +echo "$ISSUE_NUMBER" +``` + +**PowerShell version:** +```powershell +#!/usr/bin/env pwsh +# .specify/hooks/prepare-feature-num.ps1 +param([string]$FeatureDescription) +$issueUrl = gh issue create --title "Spec: $FeatureDescription" --body "Specification development" +$issueNumber = [regex]::Match($issueUrl, '\d+$').Value +Write-Output $issueNumber +``` + +### Post-Checkout Setup + +**Bash version:** +```bash +#!/bin/bash +# .specify/hooks/post-checkout +FEATURE_DESC="$1" +FEATURE_NUM="$2" +BRANCH_NAME="$3" +SPEC_FILE="$4" +# Create additional project directories +mkdir -p "docs/$BRANCH_NAME" +# Set up branch-specific configuration +echo "Branch $BRANCH_NAME (#$FEATURE_NUM) created for: $FEATURE_DESC" > "docs/$BRANCH_NAME/info.txt" +``` + +### Post-Specification Notification + +**Bash version:** +```bash +#!/bin/bash +# .specify/hooks/post-specify +FEATURE_DESC="$1" +FEATURE_NUM="$2" +BRANCH_NAME="$3" +SPEC_FILE="$4" +# Create completion issue +gh issue create --title "Spec Complete #$FEATURE_NUM: $FEATURE_DESC" --body "Specification ready for review: $SPEC_FILE on branch $BRANCH_NAME" +# Send notification +echo "Specification $FEATURE_NUM completed: $SPEC_FILE" | mail -s "Spec Ready" team@company.com +``` + +## Technical Notes + +### Platform-Specific Behavior +- **Unix/Linux/macOS**: Hooks must be executable (`chmod +x`). System looks for exact hook name. +- **Windows**: PowerShell hooks use `.ps1` extension. No execute permission needed. +- **Cross-platform**: System automatically detects and uses appropriate hook format. + +### Hook Execution +- Hook arguments vary by type: + - `pre-specify` and `prepare-feature-num`: receive only feature description (`$1`) + - `post-checkout` and `post-specify`: receive feature description (`$1`) and have access to environment variables (BRANCH_NAME, SPEC_FILE, FEATURE_NUM) +- The `prepare-feature-num` hook should output only the number to stdout +- Failed hooks generate warnings but don't stop the specification process +- Non-existent or non-executable hook files are safely ignored + +### Available Hook Formats +- `hook-name` - Bash/shell script (Unix/Linux/macOS) +- `hook-name.ps1` - PowerShell script (Windows/cross-platform) + +### Hook Execution Order +1. `pre-specify` - Workflow validation and setup +2. `prepare-feature-num` - Custom feature numbering (optional) +3. **Script execution** - Branch/directory creation +4. `post-checkout` - Post-branch setup tasks +5. **Spec writing** - Template processing and content generation +6. `post-specify` - Completion notifications and final tasks + +## Customization + +**To activate and customize hooks:** + +1. **Copy the sample**: Remove `.sample` from the appropriate hook file +2. **Make executable** (Unix only): `chmod +x .specify/hooks/hook-name` +3. **Edit the hook**: Customize the logic for your needs +4. **Test**: Run the hook manually with test data + +**Example activation:** +```bash +# Unix/Linux/macOS - Activate prepare-feature-num hook +cp .specify/hooks/prepare-feature-num.sample .specify/hooks/prepare-feature-num +chmod +x .specify/hooks/prepare-feature-num + +# Windows PowerShell - Activate prepare-feature-num hook +Copy-Item .specify/hooks/prepare-feature-num.ps1.sample .specify/hooks/prepare-feature-num.ps1 +``` + +The Git-style naming provides familiar patterns for developers already using Git hooks, making the system more intuitive and easier to understand. \ No newline at end of file diff --git a/hooks/post-checkout.ps1.sample b/hooks/post-checkout.ps1.sample new file mode 100644 index 000000000..2e7ec309c --- /dev/null +++ b/hooks/post-checkout.ps1.sample @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh +# Post-checkout hook: Runs after feature branch and directory creation +# Arguments: $args[0] = feature description +# Environment: $env:BRANCH_NAME, $env:SPEC_FILE, $env:FEATURE_NUM available + +param( + [Parameter(Position=0)] + [string]$FeatureDescription +) + +# Example: Create GitHub issue +# if (Get-Command gh -ErrorAction SilentlyContinue) { +# gh issue create --title "Spec: $FeatureDescription" --body "Branch: $env:BRANCH_NAME, Spec: $env:SPEC_FILE" +# } + +# Example: Send notification +# $message = "Feature $env:FEATURE_NUM created: $env:BRANCH_NAME" +# Send-MailMessage -To "team@company.com" -Subject "New Spec" -Body $message -SmtpServer "smtp.company.com" + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/post-checkout.sample b/hooks/post-checkout.sample new file mode 100755 index 000000000..425b22a60 --- /dev/null +++ b/hooks/post-checkout.sample @@ -0,0 +1,15 @@ +#!/bin/bash +# Post-checkout hook: Runs after feature branch and directory creation +# Arguments: $1 = feature description +# Environment: BRANCH_NAME, SPEC_FILE, FEATURE_NUM available + +# Example: Create GitHub issue +# if command -v gh >/dev/null 2>&1; then +# gh issue create --title "Spec: $1" --body "Branch: $BRANCH_NAME, Spec: $SPEC_FILE" +# fi + +# Example: Send notification +# echo "Feature $FEATURE_NUM created: $BRANCH_NAME" | mail -s "New Spec" team@company.com + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/post-specify.ps1.sample b/hooks/post-specify.ps1.sample new file mode 100644 index 000000000..f75c8abad --- /dev/null +++ b/hooks/post-specify.ps1.sample @@ -0,0 +1,29 @@ +#!/usr/bin/env pwsh +# Post-specify hook: Runs after spec file is written and completed +# Arguments: $args[0] = feature description +# Environment: $env:BRANCH_NAME, $env:SPEC_FILE, $env:FEATURE_NUM available + +param( + [Parameter(Position=0)] + [string]$FeatureDescription +) + +# Example: Create GitHub issue linking to completed spec +# if (Get-Command gh -ErrorAction SilentlyContinue) { +# gh issue create --title "Spec Complete: $FeatureDescription" --body "Specification completed: $env:SPEC_FILE on branch $env:BRANCH_NAME" +# } + +# Example: Send completion notification +# $message = "Specification $env:FEATURE_NUM completed: $env:SPEC_FILE" +# Send-MailMessage -To "team@company.com" -Subject "Spec Ready for Review" -Body $message -SmtpServer "smtp.company.com" + +# Example: Trigger CI/CD pipeline +# $body = @{ +# event = "spec_completed" +# branch = $env:BRANCH_NAME +# spec = $env:SPEC_FILE +# } | ConvertTo-Json +# Invoke-RestMethod -Uri $env:CI_WEBHOOK_URL -Method Post -Body $body -ContentType "application/json" + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/post-specify.sample b/hooks/post-specify.sample new file mode 100644 index 000000000..44277fcb8 --- /dev/null +++ b/hooks/post-specify.sample @@ -0,0 +1,18 @@ +#!/bin/bash +# Post-specify hook: Runs after spec file is written and completed +# Arguments: $1 = feature description +# Environment: BRANCH_NAME, SPEC_FILE, FEATURE_NUM available + +# Example: Create GitHub issue linking to completed spec +# if command -v gh >/dev/null 2>&1; then +# gh issue create --title "Spec Complete: $1" --body "Specification completed: $SPEC_FILE on branch $BRANCH_NAME" +# fi + +# Example: Send completion notification +# echo "Specification $FEATURE_NUM completed: $SPEC_FILE" | mail -s "Spec Ready for Review" team@company.com + +# Example: Trigger CI/CD pipeline +# curl -X POST "$CI_WEBHOOK_URL" -d '{"event":"spec_completed","branch":"'$BRANCH_NAME'","spec":"'$SPEC_FILE'"}' + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/pre-specify.ps1.sample b/hooks/pre-specify.ps1.sample new file mode 100644 index 000000000..b06e6c197 --- /dev/null +++ b/hooks/pre-specify.ps1.sample @@ -0,0 +1,18 @@ +#!/usr/bin/env pwsh +# Pre-specify hook: Runs before feature creation +# Arguments: $args[0] = feature description +# Customize this script to add pre-processing logic + +param( + [Parameter(Position=0)] + [string]$FeatureDescription +) + +# Example: Validate feature description +# if ($FeatureDescription.Length -lt 10) { +# Write-Error "Error: Feature description too short" +# exit 1 +# } + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/pre-specify.sample b/hooks/pre-specify.sample new file mode 100755 index 000000000..06988c24d --- /dev/null +++ b/hooks/pre-specify.sample @@ -0,0 +1,13 @@ +#!/bin/bash +# Pre-specify hook: Runs before feature creation +# Arguments: $1 = feature description +# Customize this script to add pre-processing logic + +# Example: Validate feature description +# if [[ ${#1} -lt 10 ]]; then +# echo "Error: Feature description too short" >&2 +# exit 1 +# fi + +# Default: Do nothing +exit 0 \ No newline at end of file diff --git a/hooks/prepare-feature-num.ps1.sample b/hooks/prepare-feature-num.ps1.sample new file mode 100644 index 000000000..6ff2a8356 --- /dev/null +++ b/hooks/prepare-feature-num.ps1.sample @@ -0,0 +1,23 @@ +#!/usr/bin/env pwsh +# Feature-num hook: Override auto-generated feature number +# Arguments: $args[0] = feature description +# Output: Custom feature number (integer) + +param( + [Parameter(Position=0)] + [string]$FeatureDescription +) + +# Example 1: Get from server +# $featureNumber = Invoke-RestMethod -Uri "$env:SPEC_SERVER/api/next-number" +# Write-Output $featureNumber + +# Example 2: Use GitHub issue number +# if (Get-Command gh -ErrorAction SilentlyContinue) { +# $issueUrl = gh issue create --title "Spec: $FeatureDescription" --body "Specification development" +# $issueNumber = [regex]::Match($issueUrl, '\d+$').Value +# Write-Output $issueNumber +# } + +# Default: Let script auto-increment (output nothing) +exit 0 \ No newline at end of file diff --git a/hooks/prepare-feature-num.sample b/hooks/prepare-feature-num.sample new file mode 100755 index 000000000..64b6eb7ea --- /dev/null +++ b/hooks/prepare-feature-num.sample @@ -0,0 +1,16 @@ +#!/bin/bash +# Feature-num hook: Override auto-generated feature number +# Arguments: $1 = feature description +# Output: Custom feature number (integer) + +# Example 1: Get from server +# FEATURE_NUMBER=$(curl -s "$SPEC_SERVER/api/next-number") +# echo "$FEATURE_NUMBER" + +# Example 2: Use GitHub issue number +# ISSUE_URL=$(gh issue create --title "Spec: $1" --body "Specification development") +# ISSUE_NUMBER=$(echo "$ISSUE_URL" | grep -o '[0-9]*$') +# echo "$ISSUE_NUMBER" + +# Default: Let script auto-increment (output nothing) +exit 0 \ No newline at end of file diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh old mode 100644 new mode 100755 index 6670550e4..08e3ead5e --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,18 +3,34 @@ set -e JSON_MODE=false +FEATURE_NUM_OVERRIDE="" ARGS=() -for arg in "$@"; do - case "$arg" in - --json) JSON_MODE=true ;; - --help|-h) echo "Usage: $0 [--json] "; exit 0 ;; - *) ARGS+=("$arg") ;; +while [[ $# -gt 0 ]]; do + case "$1" in + --json) JSON_MODE=true; shift ;; + --feature-num) + if [[ -z "$2" || "$2" == -* ]]; then + echo "Error: --feature-num requires a number (1-999)" >&2 + exit 1 + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + echo "Error: --feature-num must be a positive integer" >&2 + exit 1 + fi + if [[ "$2" -lt 1 || "$2" -gt 999 ]]; then + echo "Error: --feature-num must be between 1 and 999" >&2 + exit 1 + fi + FEATURE_NUM_OVERRIDE="$2" + shift 2 ;; + --help|-h) echo "Usage: $0 [--json] [--feature-num NUMBER(1-999)] "; exit 0 ;; + *) ARGS+=("$1"); shift ;; esac done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] " >&2 + echo "Usage: $0 [--json] [--feature-num NUMBER(1-999)] " >&2 exit 1 fi @@ -53,19 +69,24 @@ cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" mkdir -p "$SPECS_DIR" -HIGHEST=0 -if [ -d "$SPECS_DIR" ]; then - for dir in "$SPECS_DIR"/*; do - [ -d "$dir" ] || continue - dirname=$(basename "$dir") - number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi - done -fi +# Use override if provided, otherwise auto-increment +if [ -n "$FEATURE_NUM_OVERRIDE" ]; then + FEATURE_NUM=$(printf "%03d" "$FEATURE_NUM_OVERRIDE") +else + HIGHEST=0 + if [ -d "$SPECS_DIR" ]; then + for dir in "$SPECS_DIR"/*; do + [ -d "$dir" ] || continue + dirname=$(basename "$dir") + number=$(echo "$dirname" | grep -o '^[0-9]\+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$HIGHEST" ]; then HIGHEST=$number; fi + done + fi -NEXT=$((HIGHEST + 1)) -FEATURE_NUM=$(printf "%03d" "$NEXT") + NEXT=$((HIGHEST + 1)) + FEATURE_NUM=$(printf "%03d" "$NEXT") +fi BRANCH_NAME=$(echo "$FEATURE_DESCRIPTION" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//') WORDS=$(echo "$BRANCH_NAME" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//') diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 old mode 100644 new mode 100755 index f1c8e04e3..f6e551bef --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -3,13 +3,15 @@ [CmdletBinding()] param( [switch]$Json, + [ValidateRange(1, 999)] + [int]$FeatureNum, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription ) $ErrorActionPreference = 'Stop' if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-FeatureNum NUMBER(1-999)] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() @@ -60,17 +62,22 @@ Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -$highest = 0 -if (Test-Path $specsDir) { - Get-ChildItem -Path $specsDir -Directory | ForEach-Object { - if ($_.Name -match '^(\d{3})') { - $num = [int]$matches[1] - if ($num -gt $highest) { $highest = $num } +# Use override if provided, otherwise auto-increment +if ($PSBoundParameters.ContainsKey('FeatureNum')) { + $featureNum = ('{0:000}' -f $FeatureNum) +} else { + $highest = 0 + if (Test-Path $specsDir) { + Get-ChildItem -Path $specsDir -Directory | ForEach-Object { + if ($_.Name -match '^(\d{3})') { + $num = [int]$matches[1] + if ($num -gt $highest) { $highest = $num } + } } } + $next = $highest + 1 + $featureNum = ('{0:000}' -f $next) } -$next = $highest + 1 -$featureNum = ('{0:000}' -f $next) $branchName = $featureDesc.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' $words = ($branchName -split '-') | Where-Object { $_ } | Select-Object -First 3 @@ -91,17 +98,17 @@ New-Item -ItemType Directory -Path $featureDir -Force | Out-Null $template = Join-Path $repoRoot 'templates/spec-template.md' $specFile = Join-Path $featureDir 'spec.md' -if (Test-Path $template) { - Copy-Item $template $specFile -Force -} else { - New-Item -ItemType File -Path $specFile | Out-Null +if (Test-Path $template) { + Copy-Item $template $specFile -Force +} else { + New-Item -ItemType File -Path $specFile | Out-Null } # Set the SPECIFY_FEATURE environment variable for the current session $env:SPECIFY_FEATURE = $branchName if ($Json) { - $obj = [PSCustomObject]@{ + $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8025dae75..304c7bdae 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -700,47 +700,91 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +UTF8_BOM = b'\xef\xbb\xbf' + +def _has_shebang(file_path: Path) -> bool: + """Check if file has shebang, handling UTF-8 BOM.""" + try: + with file_path.open("rb") as f: + head = f.read(5) + # Skip UTF-8 BOM if present + if head.startswith(UTF8_BOM): + head = head[3:] + return head.startswith(b"#!") + except Exception: + return False + +def _should_skip_hook(hook: Path) -> bool: + """Check if hook file should be skipped for permission update.""" + return (hook.is_symlink() or + not hook.is_file() or + hook.name == "README.md" or + hook.name.endswith(".sample")) + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" + """Ensure executable files (with shebangs) under .specify/scripts (for .sh files) and .specify/hooks (for any file with a shebang, except sample files) have execute bits (no-op on Windows).""" if os.name == "nt": return # Windows: skip silently - scripts_root = project_path / ".specify" / "scripts" - if not scripts_root.is_dir(): - return + failures: list[str] = [] updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue + + # Handle scripts + scripts_root = project_path / ".specify" / "scripts" + if scripts_root.is_dir(): + for script in scripts_root.rglob("*.sh"): try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat(); mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: new_mode |= 0o100 - if mode & 0o040: new_mode |= 0o010 - if mode & 0o004: new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") + if script.is_symlink() or not script.is_file(): + continue + if not _has_shebang(script): + continue + st = script.stat(); mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: new_mode |= 0o100 + if mode & 0o040: new_mode |= 0o010 + if mode & 0o004: new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"scripts/{script.relative_to(scripts_root)}: {e}") + + # Handle hooks (skip sample files) + hooks_root = project_path / ".specify" / "hooks" + if hooks_root.is_dir(): + for hook in hooks_root.iterdir(): + try: + if _should_skip_hook(hook): + continue + if not _has_shebang(hook): + continue + st = hook.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: new_mode |= 0o100 + if mode & 0o040: new_mode |= 0o010 + if mode & 0o004: new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(hook, new_mode) + updated += 1 + except Exception as e: + failures.append(f"hooks/{hook.relative_to(hooks_root)}: {e}") + if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") - tracker.add("chmod", "Set script permissions recursively") + tracker.add("chmod", "Set script and hook permissions") (tracker.error if failures else tracker.complete)("chmod", detail) else: if updated: - console.print(f"[cyan]Updated execute permissions on {updated} script(s) recursively[/cyan]") + console.print(f"[cyan]Updated execute permissions on {updated} script(s) and hook(s)[/cyan]") if failures: - console.print("[yellow]Some scripts could not be updated:[/yellow]") + console.print("[yellow]Some scripts/hooks could not be updated:[/yellow]") for f in failures: console.print(f" - {f}") diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 652c86a27..3668383ca 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -15,10 +15,20 @@ The text the user typed after `/specify` in the triggering message **is** the fe Given that feature description, do this: -1. Run the script `{SCRIPT}` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. +1. Run pre-specify hook if available (ignore errors): + - Try `.specify/hooks/pre-specify{HOOK_EXT} "{ARGS}"` +2. Check for prepare-feature-num hook and get custom number: + - Try `.specify/hooks/prepare-feature-num{HOOK_EXT} "{ARGS}"` + - If hook returns a number, use `--feature-num $FEATURE_NUM` with the script +3. Run the script `{SCRIPT}` from repo root (with optional --feature-num parameter) and parse its JSON output for BRANCH_NAME, SPEC_FILE, and FEATURE_NUM. All file paths must be absolute. **IMPORTANT** You must only ever run this script once. The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for. -2. Load `templates/spec-template.md` to understand required sections. -3. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -4. Report completion with branch name, spec file path, and readiness for the next phase. +4. Run post-checkout hook if available (ignore errors): + - Try `.specify/hooks/post-checkout{HOOK_EXT} "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` +5. Load `templates/spec-template.md` to understand required sections. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +7. Run post-specify hook if available (ignore errors): + - Try `.specify/hooks/post-specify{HOOK_EXT} "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` +8. Report completion with branch name, spec file path, and readiness for the next phase. -Note: The script creates and checks out the new branch and initializes the spec file before writing. +Note: The script creates and checks out the new branch and initializes the spec file before writing. +Hooks follow Git-style naming: pre-specify for validation, prepare-feature-num for custom numbering, post-checkout after branch creation, and post-specify after spec completion.