From 7fdfc506c6158c62bf18a3e166c7749e7f73eb29 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Sep 2025 17:02:25 +0000 Subject: [PATCH 01/18] feat(hooks): add support for custom hooks --- .../scripts/create-release-packages.sh | 4 +- hooks/README.md | 190 ++++++++++++++++++ hooks/post-checkout.ps1.sample | 21 ++ hooks/post-checkout.sample | 15 ++ hooks/post-specify.ps1.sample | 29 +++ hooks/post-specify.sample | 18 ++ hooks/pre-specify.ps1.sample | 18 ++ hooks/pre-specify.sample | 13 ++ hooks/prepare-feature-num.ps1.sample | 23 +++ hooks/prepare-feature-num.sample | 16 ++ scripts/bash/create-new-feature.sh | 43 ++-- scripts/powershell/create-new-feature.ps1 | 34 ++-- src/specify_cli/__init__.py | 89 +++++--- templates/commands/specify.md | 24 ++- 14 files changed, 470 insertions(+), 67 deletions(-) create mode 100644 hooks/README.md create mode 100644 hooks/post-checkout.ps1.sample create mode 100755 hooks/post-checkout.sample create mode 100644 hooks/post-specify.ps1.sample create mode 100644 hooks/post-specify.sample create mode 100644 hooks/pre-specify.ps1.sample create mode 100755 hooks/pre-specify.sample create mode 100644 hooks/prepare-feature-num.ps1.sample create mode 100755 hooks/prepare-feature-num.sample mode change 100644 => 100755 scripts/bash/create-new-feature.sh mode change 100644 => 100755 scripts/powershell/create-new-feature.ps1 diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 425de86600..e1542fcd38 100644 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -34,7 +34,8 @@ 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' } generate_commands() { @@ -113,6 +114,7 @@ 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"; } + [[ -d hooks ]] && { cp -r hooks "$SPEC_DIR/"; echo "Copied hooks -> .specify"; } # 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 diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000000..d69fafd915 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,190 @@ +# 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 +- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` are 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 +- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` are 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" +# Create additional project directories +mkdir -p "docs/$BRANCH_NAME" +# Set up branch-specific configuration +echo "Branch $BRANCH_NAME created for: $FEATURE_DESC" > "docs/$BRANCH_NAME/info.txt" +``` + +### Post-Specification Notification + +**Bash version:** +```bash +#!/bin/bash +# .specify/hooks/post-specify +FEATURE_DESC="$1" +# Create completion issue +gh issue create --title "Spec Complete: $FEATURE_DESC" --body "Specification ready for review: $SPEC_FILE" +# 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 +- Hooks are called with the feature description as the first argument +- The `feature-num` hook should output only the number to stdout +- The `post-specify` hook has access to environment variables set by the create script +- 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 0000000000..1ca9debdfd --- /dev/null +++ b/hooks/post-checkout.ps1.sample @@ -0,0 +1,21 @@ +#!/usr/bin/env pwsh +# Post-specify 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 0000000000..29444bad83 --- /dev/null +++ b/hooks/post-checkout.sample @@ -0,0 +1,15 @@ +#!/bin/bash +# Post-specify 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 0000000000..f75c8abad0 --- /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 0000000000..44277fcb88 --- /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 0000000000..b06e6c1971 --- /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 0000000000..06988c24d6 --- /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 0000000000..6ff2a83564 --- /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 0000000000..64b6eb7eaa --- /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 6670550e4c..5aba552bbf --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,18 +3,20 @@ 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) FEATURE_NUM_OVERRIDE="$2"; shift 2 ;; + --help|-h) echo "Usage: $0 [--json] [--feature-num NUMBER] "; 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] " >&2 exit 1 fi @@ -53,19 +55,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 f1c8e04e37..caf1065a58 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -3,13 +3,14 @@ [CmdletBinding()] param( [switch]$Json, + [int]$FeatureNum = 0, [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] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() @@ -60,17 +61,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 ($FeatureNum -gt 0) { + $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 +97,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 8025dae75a..57b39c09bb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -701,46 +701,77 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ 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 POSIX .sh scripts under .specify/scripts and hooks under .specify/hooks 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 + 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"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 (hook.is_symlink() or not hook.is_file() or + hook.name == "README.md" or hook.name.endswith(".sample")): + continue + try: + with hook.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + 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.name}: {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 652c86a279..62a159c161 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -15,10 +15,24 @@ 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): + - Windows: Try `.specify/hooks/pre-specify.ps1 "{ARGS}"` then `.specify/hooks/pre-specify "{ARGS}"` + - Unix/Linux: Try `.specify/hooks/pre-specify "{ARGS}"` +2. Check for prepare-feature-num hook and get custom number: + - Windows: Try `.specify/hooks/prepare-feature-num.ps1 "{ARGS}"` then `.specify/hooks/prepare-feature-num "{ARGS}"` + - Unix/Linux: Try `.specify/hooks/prepare-feature-num "{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. Export environment variables: `export BRANCH_NAME SPEC_FILE FEATURE_NUM` (Unix) or `$env:BRANCH_NAME = ...; $env:SPEC_FILE = ...; $env:FEATURE_NUM = ...` (Windows) +5. Run post-checkout hook if available (ignore errors): + - Windows: Try `.specify/hooks/post-checkout.ps1 "{ARGS}"` then `.specify/hooks/post-checkout "{ARGS}"` + - Unix/Linux: Try `.specify/hooks/post-checkout "{ARGS}"` +6. Load `templates/spec-template.md` to understand required sections. +7. 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. +8. Run post-specify hook if available (ignore errors): + - Windows: Try `.specify/hooks/post-specify.ps1 "{ARGS}"` then `.specify/hooks/post-specify "{ARGS}"` + - Unix/Linux: Try `.specify/hooks/post-specify "{ARGS}"` +9. 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. From 96d9e1596dc479063678016e8352b046144bf39f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 02:09:27 +0900 Subject: [PATCH 02/18] Update hooks/post-checkout.ps1.sample Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- hooks/post-checkout.ps1.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/post-checkout.ps1.sample b/hooks/post-checkout.ps1.sample index 1ca9debdfd..2e7ec309c5 100644 --- a/hooks/post-checkout.ps1.sample +++ b/hooks/post-checkout.ps1.sample @@ -1,5 +1,5 @@ #!/usr/bin/env pwsh -# Post-specify hook: Runs after feature branch and directory creation +# 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 From c4c1027dda94eea64f1b19cf4b78378b93d6f9df Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 02:09:42 +0900 Subject: [PATCH 03/18] Update hooks/post-checkout.sample Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- hooks/post-checkout.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/post-checkout.sample b/hooks/post-checkout.sample index 29444bad83..425b22a60a 100755 --- a/hooks/post-checkout.sample +++ b/hooks/post-checkout.sample @@ -1,5 +1,5 @@ #!/bin/bash -# Post-specify hook: Runs after feature branch and directory creation +# Post-checkout hook: Runs after feature branch and directory creation # Arguments: $1 = feature description # Environment: BRANCH_NAME, SPEC_FILE, FEATURE_NUM available From c9080da574c5d845093dc26ea49427c82d2cec26 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 02:16:16 +0900 Subject: [PATCH 04/18] Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 57b39c09bb..e846a8e06f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -749,7 +749,8 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = continue except Exception: continue - st = hook.stat(); mode = st.st_mode + st = hook.stat() + mode = st.st_mode if mode & 0o111: continue new_mode = mode From 1ffd2ca937bd577afb57bfc899ded187baf382c2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Sep 2025 17:27:36 +0000 Subject: [PATCH 05/18] feat: add validation for feature number in create-new-feature scripts --- scripts/bash/create-new-feature.sh | 20 +++++++++++++++++--- scripts/powershell/create-new-feature.ps1 | 3 ++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 5aba552bbf..f393ac30e0 100755 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -8,15 +8,29 @@ ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_MODE=true; shift ;; - --feature-num) FEATURE_NUM_OVERRIDE="$2"; shift 2 ;; - --help|-h) echo "Usage: $0 [--json] [--feature-num NUMBER] "; exit 0 ;; + --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] [--feature-num NUMBER] " >&2 + echo "Usage: $0 [--json] [--feature-num NUMBER(1-999)] " >&2 exit 1 fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index caf1065a58..615dc959ac 100755 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -3,6 +3,7 @@ [CmdletBinding()] param( [switch]$Json, + [ValidateRange(1, 999)] [int]$FeatureNum = 0, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -10,7 +11,7 @@ param( $ErrorActionPreference = 'Stop' if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-FeatureNum NUMBER] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-FeatureNum NUMBER(1-999)] " exit 1 } $featureDesc = ($FeatureDescription -join ' ').Trim() From b0b8a913b2db8455448e2afc5baeafd388a0a981 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Thu, 18 Sep 2025 18:35:37 +0000 Subject: [PATCH 06/18] feat(hooks): update post-checkout and post-specify hooks to accept additional arguments --- hooks/README.md | 32 +++++++++++++++++++++++--------- templates/commands/specify.md | 8 ++++---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index d69fafd915..8d9dcaa184 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -49,8 +49,12 @@ Copy-Item .specify/hooks/prepare-feature-num.ps1.sample .specify/hooks/prepare-f ### `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 -- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` are available +- **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:** @@ -62,8 +66,12 @@ Copy-Item .specify/hooks/prepare-feature-num.ps1.sample .specify/hooks/prepare-f ### `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 -- **Environment**: `BRANCH_NAME`, `SPEC_FILE`, `FEATURE_NUM` are available +- **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:** @@ -123,10 +131,13 @@ Write-Output $issueNumber #!/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 created for: $FEATURE_DESC" > "docs/$BRANCH_NAME/info.txt" +echo "Branch $BRANCH_NAME (#$FEATURE_NUM) created for: $FEATURE_DESC" > "docs/$BRANCH_NAME/info.txt" ``` ### Post-Specification Notification @@ -136,8 +147,11 @@ echo "Branch $BRANCH_NAME created for: $FEATURE_DESC" > "docs/$BRANCH_NAME/info. #!/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_DESC" --body "Specification ready for review: $SPEC_FILE" +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 ``` @@ -150,9 +164,9 @@ echo "Specification $FEATURE_NUM completed: $SPEC_FILE" | mail -s "Spec Ready" t - **Cross-platform**: System automatically detects and uses appropriate hook format. ### Hook Execution -- Hooks are called with the feature description as the first argument -- The `feature-num` hook should output only the number to stdout -- The `post-specify` hook has access to environment variables set by the create script +- Hooks are called with multiple arguments: feature description, feature number, branch name, spec file path +- The `prepare-feature-num` hook should output only the number to stdout +- All hooks have access to environment variables (BRANCH_NAME, SPEC_FILE, FEATURE_NUM) in addition to explicit arguments - Failed hooks generate warnings but don't stop the specification process - Non-existent or non-executable hook files are safely ignored diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 62a159c161..bf0351aca8 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -26,13 +26,13 @@ Given that feature description, do this: **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. 4. Export environment variables: `export BRANCH_NAME SPEC_FILE FEATURE_NUM` (Unix) or `$env:BRANCH_NAME = ...; $env:SPEC_FILE = ...; $env:FEATURE_NUM = ...` (Windows) 5. Run post-checkout hook if available (ignore errors): - - Windows: Try `.specify/hooks/post-checkout.ps1 "{ARGS}"` then `.specify/hooks/post-checkout "{ARGS}"` - - Unix/Linux: Try `.specify/hooks/post-checkout "{ARGS}"` + - Windows: Try `.specify/hooks/post-checkout.ps1 "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` then `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` + - Unix/Linux: Try `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` 6. Load `templates/spec-template.md` to understand required sections. 7. 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. 8. Run post-specify hook if available (ignore errors): - - Windows: Try `.specify/hooks/post-specify.ps1 "{ARGS}"` then `.specify/hooks/post-specify "{ARGS}"` - - Unix/Linux: Try `.specify/hooks/post-specify "{ARGS}"` + - Windows: Try `.specify/hooks/post-specify.ps1 "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` then `.specify/hooks/post-specify "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` + - Unix/Linux: Try `.specify/hooks/post-specify "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` 9. 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. 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. From 02d09150b7029bd492ccc6a0851c84522f17ab7c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 03:38:23 +0900 Subject: [PATCH 07/18] Update templates/commands/specify.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- templates/commands/specify.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index bf0351aca8..f9d339630c 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -24,7 +24,7 @@ Given that feature description, do this: - 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. -4. Export environment variables: `export BRANCH_NAME SPEC_FILE FEATURE_NUM` (Unix) or `$env:BRANCH_NAME = ...; $env:SPEC_FILE = ...; $env:FEATURE_NUM = ...` (Windows) +4. Export environment variables: `export BRANCH_NAME SPEC_FILE FEATURE_NUM` (Unix) or `$env:BRANCH_NAME = $BRANCH_NAME; $env:SPEC_FILE = $SPEC_FILE; $env:FEATURE_NUM = $FEATURE_NUM` (Windows) 5. Run post-checkout hook if available (ignore errors): - Windows: Try `.specify/hooks/post-checkout.ps1 "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` then `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` - Unix/Linux: Try `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` From b469525bfdcb53be7b9dbaef73d3767df6e0323d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 17:48:07 +0000 Subject: [PATCH 08/18] feat(hooks): enhance script handling with dynamic extensions and selective hook copying --- .../scripts/create-release-packages.sh | 36 ++++++++++++++++--- templates/commands/specify.md | 23 +++++------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index e1542fcd38..900b1ba6b9 100644 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -58,8 +58,12 @@ 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 {EXT} with extension + local ext="" + if [[ "$script_variant" == "ps" ]]; then + ext=".ps1" + fi + 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 ' @@ -114,7 +118,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"; } - [[ -d hooks ]] && { cp -r hooks "$SPEC_DIR/"; echo "Copied hooks -> .specify"; } + + # 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 @@ -124,8 +146,12 @@ 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, {EXT} with extension, and __AGENT__ with agent name + local ext="" + if [[ "$script" == "ps" ]]; then + ext=".ps1" + fi + 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/templates/commands/specify.md b/templates/commands/specify.md index f9d339630c..2f264729a1 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -16,23 +16,18 @@ The text the user typed after `/specify` in the triggering message **is** the fe Given that feature description, do this: 1. Run pre-specify hook if available (ignore errors): - - Windows: Try `.specify/hooks/pre-specify.ps1 "{ARGS}"` then `.specify/hooks/pre-specify "{ARGS}"` - - Unix/Linux: Try `.specify/hooks/pre-specify "{ARGS}"` + - Try `.specify/hooks/pre-specify{HOOK_EXT} "{ARGS}"` 2. Check for prepare-feature-num hook and get custom number: - - Windows: Try `.specify/hooks/prepare-feature-num.ps1 "{ARGS}"` then `.specify/hooks/prepare-feature-num "{ARGS}"` - - Unix/Linux: Try `.specify/hooks/prepare-feature-num "{ARGS}"` + - 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. -4. Export environment variables: `export BRANCH_NAME SPEC_FILE FEATURE_NUM` (Unix) or `$env:BRANCH_NAME = $BRANCH_NAME; $env:SPEC_FILE = $SPEC_FILE; $env:FEATURE_NUM = $FEATURE_NUM` (Windows) -5. Run post-checkout hook if available (ignore errors): - - Windows: Try `.specify/hooks/post-checkout.ps1 "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` then `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` - - Unix/Linux: Try `.specify/hooks/post-checkout "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` -6. Load `templates/spec-template.md` to understand required sections. -7. 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. -8. Run post-specify hook if available (ignore errors): - - Windows: Try `.specify/hooks/post-specify.ps1 "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` then `.specify/hooks/post-specify "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` - - Unix/Linux: Try `.specify/hooks/post-specify "{ARGS}" "$FEATURE_NUM" "$BRANCH_NAME" "$SPEC_FILE"` -9. 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. 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. From 795f0e2df2c3f9f7477f8a7ebc6a787e41835252 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 17:55:28 +0000 Subject: [PATCH 09/18] refactor: require feature number as a parameter in create-new-feature script https://github.com/github/spec-kit/pull/345#discussion_r2363924172 --- scripts/powershell/create-new-feature.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 615dc959ac..f6e551bef4 100755 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,7 +4,7 @@ param( [switch]$Json, [ValidateRange(1, 999)] - [int]$FeatureNum = 0, + [int]$FeatureNum, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription ) @@ -63,7 +63,7 @@ $specsDir = Join-Path $repoRoot 'specs' New-Item -ItemType Directory -Path $specsDir -Force | Out-Null # Use override if provided, otherwise auto-increment -if ($FeatureNum -gt 0) { +if ($PSBoundParameters.ContainsKey('FeatureNum')) { $featureNum = ('{0:000}' -f $FeatureNum) } else { $highest = 0 From 09331751dfe268d9aa4eaa2bdcd1d381a59fc16c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 17:55:53 +0000 Subject: [PATCH 10/18] refactor: add shebang check for scripts and hooks in ensure_executable_scripts https://github.com/github/spec-kit/pull/345#discussion_r2363924207 --- src/specify_cli/__init__.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e846a8e06f..afe31a9517 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -700,6 +700,18 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path +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(b'\xef\xbb\xbf'): + head = head[3:] + return head.startswith(b"#!") + except Exception: + return False + def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: """Ensure POSIX .sh scripts under .specify/scripts and hooks under .specify/hooks have execute bits (no-op on Windows).""" if os.name == "nt": @@ -715,11 +727,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = try: if script.is_symlink() or not script.is_file(): continue - try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: + if not _has_shebang(script): continue st = script.stat(); mode = st.st_mode if mode & 0o111: @@ -743,11 +751,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = if (hook.is_symlink() or not hook.is_file() or hook.name == "README.md" or hook.name.endswith(".sample")): continue - try: - with hook.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: + if not _has_shebang(hook): continue st = hook.stat() mode = st.st_mode From a9643c0aedf35038a692d6a5904c19b225424ce8 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 20 Sep 2025 03:01:11 +0900 Subject: [PATCH 11/18] Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index afe31a9517..0eff997573 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -700,13 +700,15 @@ 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(b'\xef\xbb\xbf'): + if head.startswith(UTF8_BOM): head = head[3:] return head.startswith(b"#!") except Exception: From 43c4d706d777e0a547623a05e727e42616e0d454 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 20 Sep 2025 03:01:24 +0900 Subject: [PATCH 12/18] Update templates/commands/specify.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- templates/commands/specify.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 2f264729a1..3668383cad 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -30,4 +30,5 @@ Given that feature description, do this: - 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. 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. +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. From 8b8788aae19fcbe42e7a254fc0552ad7ce4e1d63 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 18:03:08 +0000 Subject: [PATCH 13/18] refactor(hooks): add hook skipping logic for permission updates https://github.com/github/spec-kit/pull/345#discussion_r2360359015 --- src/specify_cli/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0eff997573..703d268e90 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -714,6 +714,13 @@ def _has_shebang(file_path: Path) -> bool: 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 and hooks under .specify/hooks have execute bits (no-op on Windows).""" if os.name == "nt": @@ -750,8 +757,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = if hooks_root.is_dir(): for hook in hooks_root.iterdir(): try: - if (hook.is_symlink() or not hook.is_file() or - hook.name == "README.md" or hook.name.endswith(".sample")): + if _should_skip_hook(hook): continue if not _has_shebang(hook): continue From ffaed31d4fcd53192473f01a892417de54c6f1ee Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 19 Sep 2025 18:06:44 +0000 Subject: [PATCH 14/18] refactor(hooks): extract hook extension logic into a separate function https://github.com/github/spec-kit/pull/345#discussion_r2363965931 --- .../scripts/create-release-packages.sh | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh index 900b1ba6b9..96d7c64cdb 100644 --- a/.github/workflows/scripts/create-release-packages.sh +++ b/.github/workflows/scripts/create-release-packages.sh @@ -38,6 +38,15 @@ rewrite_paths() { -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() { local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5 mkdir -p "$output_dir" @@ -58,11 +67,8 @@ generate_commands() { script_command="(Missing script command for $script_variant)" fi - # Replace {SCRIPT} placeholder with the script command and {EXT} with extension - local ext="" - if [[ "$script_variant" == "ps" ]]; then - ext=".ps1" - fi + # 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 @@ -146,11 +152,8 @@ 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, {EXT} with extension, and __AGENT__ with agent name - local ext="" - if [[ "$script" == "ps" ]]; then - ext=".ps1" - fi + # 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}') From 25b69a74c8ab22ab9f14fe442c7cbc83cfbd3330 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 20 Sep 2025 03:07:07 +0900 Subject: [PATCH 15/18] Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 703d268e90..4a8a504983 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -722,7 +722,7 @@ def _should_skip_hook(hook: Path) -> bool: hook.name.endswith(".sample")) def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts and hooks under .specify/hooks 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 From d53641e4917d4eef6bdf3616630389735dadf82d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 20 Sep 2025 03:10:11 +0900 Subject: [PATCH 16/18] Update scripts/bash/create-new-feature.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- scripts/bash/create-new-feature.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f393ac30e0..08e3ead5ec 100755 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -9,7 +9,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --json) JSON_MODE=true; shift ;; --feature-num) - if [[ -z "$2" || "$2" =~ ^- ]]; then + if [[ -z "$2" || "$2" == -* ]]; then echo "Error: --feature-num requires a number (1-999)" >&2 exit 1 fi From 63082e7fdffaf87e1589a5db7d8f245b2c446f10 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 22 Sep 2025 12:20:12 +0900 Subject: [PATCH 17/18] Update src/specify_cli/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4a8a504983..304c7bdae8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -774,7 +774,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = os.chmod(hook, new_mode) updated += 1 except Exception as e: - failures.append(f"hooks/{hook.name}: {e}") + failures.append(f"hooks/{hook.relative_to(hooks_root)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") From b729f9cad6511e401fdf11f4924475d340b566f3 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 22 Sep 2025 05:14:59 +0000 Subject: [PATCH 18/18] docs: update README with hook argument details --- hooks/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hooks/README.md b/hooks/README.md index 8d9dcaa184..e9c920ef79 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -164,9 +164,10 @@ echo "Specification $FEATURE_NUM completed: $SPEC_FILE" | mail -s "Spec Ready" t - **Cross-platform**: System automatically detects and uses appropriate hook format. ### Hook Execution -- Hooks are called with multiple arguments: feature description, feature number, branch name, spec file path +- 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 -- All hooks have access to environment variables (BRANCH_NAME, SPEC_FILE, FEATURE_NUM) in addition to explicit arguments - Failed hooks generate warnings but don't stop the specification process - Non-existent or non-executable hook files are safely ignored