diff --git a/README.md b/README.md index 76149512f6..b807ce5c21 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [📽️ Video Overview](#️-video-overview) - [🤖 Supported AI Agents](#-supported-ai-agents) - [🔧 Specify CLI Reference](#-specify-cli-reference) +- [🔀 Source Management Modes](#-source-management-modes) - [📚 Core Philosophy](#-core-philosophy) - [🌟 Development Phases](#-development-phases) - [🎯 Experimental Goals](#-experimental-goals) @@ -274,6 +275,133 @@ Additional commands for enhanced quality and validation: | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.
\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | +## 🔀 Source Management Modes + +Spec Kit supports three Git workflow modes to match your development style: + +### Branch Mode (Default) + +Traditional branch-based workflow where each feature is developed on its own branch. + +```bash +specify init my-project --ai claude +# Detects standard Git repo → recommends "branch" mode (you can choose during init) +``` + +**Best for:** +- Standard Git repositories +- Small to medium teams +- Projects with simple branching strategies + +**How it works:** +- `create-new-feature.sh` creates a new Git branch (e.g., `001-feature-name`) +- Specs stored in `specs/001-feature-name/` +- Switch between features with `git checkout` + +### Worktree Mode + +Isolated working directories per feature using [Git worktrees](https://git-scm.com/docs/git-worktree). + +```bash +specify init my-project --ai claude +# In a bare repository → recommends "worktree" mode (you can choose during init) +``` + +**Best for:** +- Bare repositories (highly recommended) +- Working on multiple features simultaneously +- Large teams with many parallel features +- Projects requiring quick context switching without stashing + +**How it works:** +- `create-new-feature.sh` creates a Git worktree in `./worktrees/001-feature-name/` +- Each feature has its own isolated directory +- No need to switch branches or stash changes +- All worktrees share the same Git history + +**Configuration:** +During initialization, you'll be prompted for the worktree folder location (default: `./worktrees`). + +**Example workflow:** +```bash +# Initialize project in worktree mode +specify init --here --ai claude +# Select "worktree" mode when prompted + +# Create first feature (creates worktree) +.specify/scripts/bash/create-new-feature.sh "Add user authentication" +# Creates: ./worktrees/001-user-authentication/ + +# Work in first feature +cd ./worktrees/001-user-authentication/ +# Edit code, run tests... + +# Create second feature without leaving first +cd ../.. +.specify/scripts/bash/create-new-feature.sh "Add payment processing" +# Creates: ./worktrees/002-payment-processing/ + +# Both features are independent and isolated! +``` + +### None Mode + +Skip Git operations entirely for non-Git projects or manual Git control. + +```bash +specify init my-project --ai claude --no-git +# Recommends "none" mode (you can choose during init) +``` + +**Best for:** +- Non-Git version control systems +- Projects where you want manual Git control +- Prototyping or exploration without version control + +**How it works:** +- No Git branches or worktrees created +- Specs stored in `specs/001-feature-name/` as normal +- Feature numbers still auto-increment + +### Mode Selection + +During `specify init`, you'll be prompted to select your preferred source management mode: + +1. **Environment Detection**: The CLI detects your Git environment (bare repo, worktree, standard repo, or no Git) +2. **Recommendation**: Based on the detection, a recommended mode is shown +3. **Interactive Choice**: You select your preferred mode using arrow keys: + - `branch` - Traditional Git branches (recommended for most projects) + - `worktree` - Git worktrees (advanced, for parallel feature development) + - `none` - No Git integration (manual source management) +4. **Configuration**: Your selected mode is saved to `.specify/memory/config.json` + +**Example selection screen:** +``` +Detected environment: Standard Git repository +Recommended mode: branch + +Select source management mode: +▶ branch (Traditional Git branches - recommended for most projects) + worktree (Git worktrees - advanced, for parallel feature development) + none (No Git integration - manual source management) +``` + +### Mode Configuration + +Your selected mode is saved in `.specify/memory/config.json`: + +```json +{ + "version": "1.0", + "source_management_flow": "worktree", + "worktree_folder": "./worktrees" +} +``` + +**Mode detection:** Run `specify check` to see your current mode. + +**Important:** Source management mode cannot be changed after initialization. To switch modes, delete `.specify/memory/config.json` and run `specify init` again. + ## 📚 Core Philosophy Spec-Driven Development is a structured process that emphasizes: diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh old mode 100644 new mode 100755 index 2c3165e41d..6b20dfbdce --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -78,6 +78,30 @@ check_feature_branch() { return 1 fi + # Feature 001: Worktree/branch alignment validation + # Check if we're in a worktree and validate directory/branch name alignment + if [[ -f .git ]]; then + # We're in a worktree (.git is a file, not a directory) + local worktree_dir=$(basename "$(pwd)") + + if [[ "$worktree_dir" != "$branch" ]]; then + echo "ERROR: Worktree directory name doesn't match branch name" >&2 + echo " Directory: $worktree_dir" >&2 + echo " Branch: $branch" >&2 + echo "" >&2 + echo "Spec Kit expects worktree directory names to match branch names." >&2 + echo "This mismatch may have occurred due to manual worktree creation or directory renaming." >&2 + echo "" >&2 + echo "To fix this issue:" >&2 + echo " 1. Return to the main repository" >&2 + echo " 2. Remove this worktree: git worktree remove $worktree_dir" >&2 + echo " 3. Recreate using Spec Kit: ./scripts/bash/create-new-feature.sh '$branch'" >&2 + return 1 + else + echo "[specify] ✓ Worktree/branch alignment validated: $branch" >&2 + fi + fi + return 0 } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh old mode 100644 new mode 100755 index c40cfd77f0..4002c85e7e --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -160,10 +160,20 @@ clean_branch_name() { # were initialised with --no-git. SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if git rev-parse --show-toplevel >/dev/null 2>&1; then - REPO_ROOT=$(git rev-parse --show-toplevel) +# Check if we're in any Git repository (works for both bare and non-bare repos) +if git rev-parse --git-dir >/dev/null 2>&1; then HAS_GIT=true + # Check if this is a bare repository + if [ "$(git rev-parse --is-bare-repository 2>/dev/null)" = "true" ]; then + # Bare repository - use git-dir as root + REPO_ROOT=$(git rev-parse --git-dir) + REPO_ROOT=$(cd "$REPO_ROOT" && pwd) # Resolve to absolute path + else + # Non-bare repository - use show-toplevel + REPO_ROOT=$(git rev-parse --show-toplevel) + fi else + # Not a Git repository - fall back to marker search REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")" if [ -z "$REPO_ROOT" ]; then echo "Error: Could not determine repository root. Please run this script from within the repository." >&2 @@ -174,9 +184,28 @@ fi cd "$REPO_ROOT" +# Save original directory for error recovery +ORIGINAL_DIR="$PWD" + SPECS_DIR="$REPO_ROOT/specs" mkdir -p "$SPECS_DIR" +# Feature 001: Read source management mode from config +CONFIG_FILE="$REPO_ROOT/.specify/memory/config.json" +SOURCE_MODE="branch" # Default to branch mode for backward compatibility +WORKTREE_FOLDER="" + +if [ -f "$CONFIG_FILE" ]; then + # Parse JSON config using grep/sed (no jq dependency needed) + SOURCE_MODE=$(grep -o '"source_management_flow"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "branch") + WORKTREE_FOLDER=$(grep -o '"worktree_folder"[[:space:]]*:[[:space:]]*"[^"]*"' "$CONFIG_FILE" | sed 's/.*"\([^"]*\)".*/\1/' || echo "") +fi + +# Fallback to branch mode if config is invalid +if [ -z "$SOURCE_MODE" ]; then + SOURCE_MODE="branch" +fi + # Function to generate branch name with stop word filtering and length filtering generate_branch_name() { local description="$1" @@ -272,24 +301,133 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then fi if [ "$HAS_GIT" = true ]; then - git checkout -b "$BRANCH_NAME" + # Feature 001: Create worktree or branch based on mode + if [ "$SOURCE_MODE" = "worktree" ]; then + # Worktree mode: create worktree instead of branch + if [ -z "$WORKTREE_FOLDER" ]; then + WORKTREE_FOLDER="./worktrees" + fi + + # Resolve to absolute path + if [[ "$WORKTREE_FOLDER" != /* ]]; then + WORKTREE_FOLDER="$REPO_ROOT/$WORKTREE_FOLDER" + fi + + # Check if worktree folder exists (FR-015) + if [ ! -d "$WORKTREE_FOLDER" ]; then + >&2 echo "[specify] The configured worktree folder does not exist: $WORKTREE_FOLDER" + read -p "Create this directory? (y/N): " -n 1 -r CREATE_FOLDER + echo + if [[ $CREATE_FOLDER =~ ^[Yy]$ ]]; then + mkdir -p "$WORKTREE_FOLDER" || { + >&2 echo "[specify] Error: Failed to create worktree folder (permission denied or invalid path)" + exit 1 + } + >&2 echo "[specify] Created worktree folder: $WORKTREE_FOLDER" + else + >&2 echo "[specify] Error: Cannot create worktree without folder" + exit 1 + fi + fi + + # Check write permissions (FR-015b) + if [ ! -w "$WORKTREE_FOLDER" ]; then + >&2 echo "[specify] Error: Worktree folder is not writable: $WORKTREE_FOLDER" + >&2 echo "[specify] Check folder permissions and try again" + exit 1 + fi + + WORKTREE_PATH="$WORKTREE_FOLDER/$BRANCH_NAME" + + # Check if worktree path already exists (FR-016) + if [ -e "$WORKTREE_PATH" ]; then + >&2 echo "[specify] Error: Worktree path already exists: $WORKTREE_PATH" + >&2 echo "[specify] " + >&2 echo "[specify] Resolution options:" + >&2 echo "[specify] 1. Remove existing worktree: git worktree remove $BRANCH_NAME" + >&2 echo "[specify] 2. Use a different branch name with --short-name" + >&2 echo "[specify] 3. Manually delete the directory: rm -rf '$WORKTREE_PATH'" + >&2 echo "[specify] " + >&2 echo "[specify] To list all worktrees: git worktree list" + exit 1 + fi + + # Create worktree with new branch + git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" || { + >&2 echo "[specify] Error: Failed to create worktree" + exit 1 + } + + >&2 echo "[specify] Created worktree: $WORKTREE_PATH" + >&2 echo "[specify] Branch: $BRANCH_NAME" + + # Change to worktree directory for spec creation + cd "$WORKTREE_PATH" || { + >&2 echo "[specify] Error: Failed to change to worktree directory" + cd "$ORIGINAL_DIR" + exit 1 + } + # Update REPO_ROOT to worktree location + REPO_ROOT="$WORKTREE_PATH" + + elif [ "$SOURCE_MODE" = "none" ]; then + # None mode: skip Git operations + >&2 echo "[specify] Git operations disabled (mode: none)" + else + # Branch mode: traditional branch creation + git checkout -b "$BRANCH_NAME" + fi else - >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + # Warn user if source mode expects Git but Git is unavailable + if [ "$SOURCE_MODE" = "worktree" ] || [ "$SOURCE_MODE" = "branch" ]; then + >&2 echo "[specify] Warning: Git repository not detected but source mode is set to '$SOURCE_MODE'" + >&2 echo "[specify] Creating spec in main repository instead of using Git-based workflow" + >&2 echo "[specify] To use $SOURCE_MODE mode, initialize Git with: git init" + else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + fi +fi + +# Update SPECS_DIR if we're in a worktree +if [ "$SOURCE_MODE" = "worktree" ] && [ "$HAS_GIT" = true ]; then + SPECS_DIR="$REPO_ROOT/specs" fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" +mkdir -p "$FEATURE_DIR" || { + >&2 echo "[specify] Error: Failed to create feature directory: $FEATURE_DIR" + cd "$ORIGINAL_DIR" + exit 1 +} TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md" SPEC_FILE="$FEATURE_DIR/spec.md" -if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi +if [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" || { + >&2 echo "[specify] Error: Failed to copy template file" + cd "$ORIGINAL_DIR" + exit 1 + } +else + touch "$SPEC_FILE" || { + >&2 echo "[specify] Error: Failed to create spec file" + cd "$ORIGINAL_DIR" + exit 1 + } +fi # Set the SPECIFY_FEATURE environment variable for the current session export SPECIFY_FEATURE="$BRANCH_NAME" if $JSON_MODE; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","SOURCE_MODE":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM" "$SOURCE_MODE" else + # Feature 001: Display source management mode + case "$SOURCE_MODE" in + "worktree") echo "MODE: Worktree mode active" ;; + "none") echo "MODE: No Git mode" ;; + *) echo "MODE: Branch mode active" ;; + esac echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f0172e35d..f58ef995d5 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -135,23 +135,66 @@ if (-not $fallbackRoot) { exit 1 } +# Check if we're in any Git repository (works for both bare and non-bare repos) try { - $repoRoot = git rev-parse --show-toplevel 2>$null + $gitDir = git rev-parse --git-dir 2>$null if ($LASTEXITCODE -eq 0) { $hasGit = $true + # Check if this is a bare repository + $isBare = git rev-parse --is-bare-repository 2>$null + if ($LASTEXITCODE -eq 0 -and $isBare -eq "true") { + # Bare repository - use git-dir as root + $repoRoot = git rev-parse --git-dir 2>$null + $repoRoot = Resolve-Path $repoRoot + } else { + # Non-bare repository - use show-toplevel + $repoRoot = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Git show-toplevel failed" + } + } } else { throw "Git not available" } } catch { + # Not a Git repository - fall back to marker search $repoRoot = $fallbackRoot $hasGit = $false } Set-Location $repoRoot +# Save original directory for error recovery +$originalDir = Get-Location + $specsDir = Join-Path $repoRoot 'specs' New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +# Feature 001: Read source management mode from config +$configFile = Join-Path $repoRoot '.specify/memory/config.json' +$sourceMode = 'branch' # Default to branch mode for backward compatibility +$worktreeFolder = '' + +if (Test-Path $configFile) { + try { + $config = Get-Content $configFile | ConvertFrom-Json + if ($config.source_management_flow) { + $sourceMode = $config.source_management_flow + } + if ($config.worktree_folder) { + $worktreeFolder = $config.worktree_folder + } + } catch { + Write-Verbose "Could not parse config file, using default branch mode" + $sourceMode = 'branch' + } +} + +# Fallback to branch mode if config is invalid +if (-not $sourceMode) { + $sourceMode = 'branch' +} + # Function to generate branch name with stop word filtering and length filtering function Get-BranchName { param([string]$Description) @@ -242,24 +285,132 @@ if ($branchName.Length -gt $maxBranchLength) { } if ($hasGit) { - try { - git checkout -b $branchName | Out-Null - } catch { - Write-Warning "Failed to create git branch: $branchName" + # Feature 001: Create worktree or branch based on mode + if ($sourceMode -eq 'worktree') { + # Worktree mode: create worktree instead of branch + if (-not $worktreeFolder) { + $worktreeFolder = './worktrees' + } + + # Resolve to absolute path + if (-not [System.IO.Path]::IsPathRooted($worktreeFolder)) { + $worktreeFolder = Join-Path $repoRoot $worktreeFolder + } + + # Check if worktree folder exists (FR-015) + if (-not (Test-Path $worktreeFolder)) { + Write-Warning "[specify] The configured worktree folder does not exist: $worktreeFolder" + $createFolder = Read-Host "Create this directory? (y/N)" + if ($createFolder -match '^[Yy]') { + try { + New-Item -ItemType Directory -Path $worktreeFolder -Force | Out-Null + Write-Host "[specify] Created worktree folder: $worktreeFolder" -ForegroundColor Green + } catch { + Write-Error "[specify] Error: Failed to create worktree folder (permission denied or invalid path)" + exit 1 + } + } else { + Write-Error "[specify] Error: Cannot create worktree without folder" + exit 1 + } + } + + # Check write permissions (FR-015b) + try { + $testFile = Join-Path $worktreeFolder ".write-test-$([guid]::NewGuid().ToString())" + New-Item -ItemType File -Path $testFile -Force | Out-Null + Remove-Item $testFile -Force + } catch { + Write-Error "[specify] Error: Worktree folder is not writable: $worktreeFolder" + Write-Error "[specify] Check folder permissions and try again" + exit 1 + } + + $worktreePath = Join-Path $worktreeFolder $branchName + + # Check if worktree path already exists (FR-016) + if (Test-Path $worktreePath) { + Write-Error "[specify] Error: Worktree path already exists: $worktreePath" + Write-Error "[specify] " + Write-Error "[specify] Resolution options:" + Write-Error "[specify] 1. Remove existing worktree: git worktree remove $branchName" + Write-Error "[specify] 2. Use a different branch name with --short-name" + Write-Error "[specify] 3. Manually delete the directory: Remove-Item -Recurse -Force '$worktreePath'" + Write-Error "[specify] " + Write-Error "[specify] To list all worktrees: git worktree list" + exit 1 + } + + # Create worktree with new branch + try { + git worktree add -b $branchName $worktreePath | Out-Null + Write-Host "[specify] Created worktree: $worktreePath" -ForegroundColor Green + Write-Host "[specify] Branch: $branchName" -ForegroundColor Green + + # Change to worktree directory for spec creation + try { + Set-Location $worktreePath + } catch { + Write-Error "[specify] Error: Failed to change to worktree directory" + Set-Location $originalDir + exit 1 + } + # Update repoRoot to worktree location + $repoRoot = $worktreePath + } catch { + Write-Error "[specify] Error: Failed to create worktree - $_" + Set-Location $originalDir + exit 1 + } + + } elseif ($sourceMode -eq 'none') { + # None mode: skip Git operations + Write-Warning "[specify] Git operations disabled (mode: none)" + } else { + # Branch mode: traditional branch creation + try { + git checkout -b $branchName | Out-Null + } catch { + Write-Warning "Failed to create git branch: $branchName" + } } } else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + # Warn user if source mode expects Git but Git is unavailable + if ($sourceMode -eq 'worktree' -or $sourceMode -eq 'branch') { + Write-Warning "[specify] Warning: Git repository not detected but source mode is set to '$sourceMode'" + Write-Warning "[specify] Creating spec in main repository instead of using Git-based workflow" + Write-Warning "[specify] To use $sourceMode mode, initialize Git with: git init" + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } +} + +# Update specsDir if we're in a worktree +if ($sourceMode -eq 'worktree' -and $hasGit) { + $specsDir = Join-Path $repoRoot 'specs' } $featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null +try { + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null +} catch { + Write-Error "[specify] Error: Failed to create feature directory: $featureDir" + Set-Location $originalDir + exit 1 +} $template = Join-Path $repoRoot '.specify/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 +try { + if (Test-Path $template) { + Copy-Item $template $specFile -Force + } else { + New-Item -ItemType File -Path $specFile | Out-Null + } +} catch { + Write-Error "[specify] Error: Failed to create spec file: $_" + Set-Location $originalDir + exit 1 } # Set the SPECIFY_FEATURE environment variable for the current session @@ -271,9 +422,16 @@ if ($Json) { SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit + SOURCE_MODE = $sourceMode } $obj | ConvertTo-Json -Compress } else { + # Feature 001: Display source management mode + switch ($sourceMode) { + 'worktree' { Write-Output "MODE: Worktree mode active" } + 'none' { Write-Output "MODE: No Git mode" } + default { Write-Output "MODE: Branch mode active" } + } Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1dedb31949..d0e30b9329 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -34,6 +34,7 @@ import json from pathlib import Path from typing import Optional, Tuple +from dataclasses import dataclass import typer import httpx @@ -56,19 +57,24 @@ ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) + def _github_token(cli_token: str | None = None) -> str | None: """Return sanitized GitHub token (cli arg takes precedence) or None.""" - return ((cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip()) or None + return ( + (cli_token or os.getenv("GH_TOKEN") or os.getenv("GITHUB_TOKEN") or "").strip() + ) or None + def _github_auth_headers(cli_token: str | None = None) -> dict: """Return Authorization header dict only when a non-empty token exists.""" token = _github_token(cli_token) return {"Authorization": f"Bearer {token}"} if token else {} + def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: """Extract and parse GitHub rate-limit headers.""" info = {} - + # Standard GitHub rate-limit headers if "X-RateLimit-Limit" in headers: info["limit"] = headers.get("X-RateLimit-Limit") @@ -81,7 +87,7 @@ def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: info["reset_epoch"] = reset_epoch info["reset_time"] = reset_time info["reset_local"] = reset_time.astimezone() - + # Retry-After header (seconds or HTTP-date) if "Retry-After" in headers: retry_after = headers.get("Retry-After") @@ -90,16 +96,17 @@ def _parse_rate_limit_headers(headers: httpx.Headers) -> dict: except ValueError: # HTTP-date format - not implemented, just store as string info["retry_after"] = retry_after - + return info + def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) -> str: """Format a user-friendly error message with rate-limit information.""" rate_info = _parse_rate_limit_headers(headers) - + lines = [f"GitHub API returned status {status_code} for {url}"] lines.append("") - + if rate_info: lines.append("[bold]Rate Limit Information:[/bold]") if "limit" in rate_info: @@ -112,16 +119,23 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) if "retry_after_seconds" in rate_info: lines.append(f" • Retry after: {rate_info['retry_after_seconds']} seconds") lines.append("") - + # Add troubleshooting guidance lines.append("[bold]Troubleshooting Tips:[/bold]") - lines.append(" • If you're on a shared CI or corporate environment, you may be rate-limited.") - lines.append(" • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN") + lines.append( + " • If you're on a shared CI or corporate environment, you may be rate-limited." + ) + lines.append( + " • Consider using a GitHub token via --github-token or the GH_TOKEN/GITHUB_TOKEN" + ) lines.append(" environment variable to increase rate limits.") - lines.append(" • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated.") - + lines.append( + " • Authenticated requests have a limit of 5,000/hour vs 60/hour for unauthenticated." + ) + return "\n".join(lines) + # Agent configuration with name, folder, install URL, and CLI tool requirement AGENT_CONFIG = { "copilot": { @@ -242,14 +256,23 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) """ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" + + class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. """ + def __init__(self, title: str): self.title = title self.steps = [] # list of dicts: {key, label, status, detail} - self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} + self.status_order = { + "pending": 0, + "running": 1, + "done": 2, + "error": 3, + "skipped": 4, + } self._refresh_cb = None # callable to trigger UI refresh def attach_refresh(self, cb): @@ -257,7 +280,9 @@ def attach_refresh(self, cb): def add(self, key: str, label: str): if key not in [s["key"] for s in self.steps]: - self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) + self.steps.append( + {"key": key, "label": label, "status": "pending", "detail": ""} + ) self._maybe_refresh() def start(self, key: str, detail: str = ""): @@ -281,7 +306,9 @@ def _update(self, key: str, status: str, detail: str): self._maybe_refresh() return - self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) + self.steps.append( + {"key": key, "label": key, "status": status, "detail": detail} + ) self._maybe_refresh() def _maybe_refresh(self): @@ -314,7 +341,9 @@ def render(self): if status == "pending": # Entire line light gray (pending) if detail_text: - line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + line = ( + f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" + ) else: line = f"{symbol} [bright_black]{label}[/bright_black]" else: @@ -327,35 +356,39 @@ def render(self): tree.add(line) return tree + def get_key(): """Get a single keypress in a cross-platform way using readchar.""" key = readchar.readkey() if key == readchar.key.UP or key == readchar.key.CTRL_P: - return 'up' + return "up" if key == readchar.key.DOWN or key == readchar.key.CTRL_N: - return 'down' + return "down" if key == readchar.key.ENTER: - return 'enter' + return "enter" if key == readchar.key.ESC: - return 'escape' + return "escape" if key == readchar.key.CTRL_C: raise KeyboardInterrupt return key -def select_with_arrows(options: dict, prompt_text: str = "Select an option", default_key: str = None) -> str: + +def select_with_arrows( + options: dict, prompt_text: str = "Select an option", default_key: str = None +) -> str: """ Interactive selection using arrow keys with Rich Live display. - + Args: options: Dict with keys as option keys and values as descriptions prompt_text: Text to show above the options default_key: Default option key to start with - + Returns: Selected option key """ @@ -380,31 +413,38 @@ def create_selection_panel(): table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") table.add_row("", "") - table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") + table.add_row( + "", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]" + ) return Panel( table, title=f"[bold]{prompt_text}[/bold]", border_style="cyan", - padding=(1, 2) + padding=(1, 2), ) console.print() def run_selection_loop(): nonlocal selected_key, selected_index - with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: + with Live( + create_selection_panel(), + console=console, + transient=True, + auto_refresh=False, + ) as live: while True: try: key = get_key() - if key == 'up': + if key == "up": selected_index = (selected_index - 1) % len(option_keys) - elif key == 'down': + elif key == "down": selected_index = (selected_index + 1) % len(option_keys) - elif key == 'enter': + elif key == "enter": selected_key = option_keys[selected_index] break - elif key == 'escape': + elif key == "escape": console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(1) @@ -422,8 +462,10 @@ def run_selection_loop(): return selected_key + console = Console() + class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -441,9 +483,10 @@ def format_help(self, ctx, formatter): cls=BannerGroup, ) + def show_banner(): """Display the ASCII art banner.""" - banner_lines = BANNER.strip().split('\n') + banner_lines = BANNER.strip().split("\n") colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] styled_banner = Text() @@ -455,19 +498,34 @@ def show_banner(): console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) console.print() + @app.callback() def callback(ctx: typer.Context): """Show banner when no subcommand is provided.""" - if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: + if ( + ctx.invoked_subcommand is None + and "--help" not in sys.argv + and "-h" not in sys.argv + ): show_banner() - console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) + console.print( + Align.center("[dim]Run 'specify --help' for usage information[/dim]") + ) console.print() -def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]: + +def run_command( + cmd: list[str], + check_return: bool = True, + capture: bool = False, + shell: bool = False, +) -> Optional[str]: """Run a shell command and optionally capture output.""" try: if capture: - result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell) + result = subprocess.run( + cmd, check=check_return, capture_output=True, text=True, shell=shell + ) return result.stdout.strip() else: subprocess.run(cmd, check=check_return, shell=shell) @@ -476,18 +534,19 @@ def run_command(cmd: list[str], check_return: bool = True, capture: bool = False if check_return: console.print(f"[red]Error running command:[/red] {' '.join(cmd)}") console.print(f"[red]Exit code:[/red] {e.returncode}") - if hasattr(e, 'stderr') and e.stderr: + if hasattr(e, "stderr") and e.stderr: console.print(f"[red]Error output:[/red] {e.stderr}") raise return None + def check_tool(tool: str, tracker: StepTracker = None) -> bool: """Check if a tool is installed. Optionally update tracker. - + Args: tool: Name of the tool to check tracker: Optional StepTracker to update with results - + Returns: True if tool is found, False otherwise """ @@ -501,22 +560,23 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: if tracker: tracker.complete(tool, "available") return True - + found = shutil.which(tool) is not None - + if tracker: if found: tracker.complete(tool, "available") else: tracker.error(tool, "not found") - + return found + def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: path = Path.cwd() - + if not path.is_dir(): return False @@ -532,13 +592,274 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False -def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: + +@dataclass +class GitEnvironment: + """Represents the detected Git environment state.""" + + has_git: bool + is_bare: bool + is_worktree: bool + + def suggest_mode(self) -> str: + """Returns suggested source_management_flow based on environment. + + Decision tree: + 1. No Git → "none" + 2. Bare repo → "worktree" (bare repos are designed for worktrees) + 3. Currently in worktree → "worktree" (preserve existing setup) + 4. Standard Git repo → "branch" (default for normal repos) + """ + if not self.has_git: + return "none" + if self.is_bare or self.is_worktree: + return "worktree" + return "branch" + + +def is_git_worktree() -> bool: + """Check if current directory is a registered Git worktree. + + A worktree (not the main repo) has .git as a file (not a directory). + The main repository has .git as a directory. + + Returns: + True if current directory is a worktree (not the main repo) + False otherwise (main repo, not in git, or Git error) + """ + try: + git_path = Path.cwd() / ".git" + + # If .git is a file (not a directory), it's a worktree + if git_path.exists() and git_path.is_file(): + return True + + return False + except (OSError, PermissionError): + return False + + +def is_bare_repo() -> bool: + """Check if the current Git repository is bare (has no working tree). + + Returns: + True if `git rev-parse --is-bare-repository` outputs "true" + False otherwise (not bare, Git error, or not in repo) + """ + try: + result = subprocess.run( + ["git", "rev-parse", "--is-bare-repository"], + check=True, + capture_output=True, + text=True, + cwd=Path.cwd(), + ) + return result.stdout.strip() == "true" + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def detect_git_environment() -> GitEnvironment: + """Detect current Git environment and classify it. + + Detection order: + 1. Check if Git is installed and in a repo + 2. Check if repository is bare + 3. Check if current directory is a worktree (only if not bare) + + Returns: + GitEnvironment object with detection results + """ + # Check 1: Git installed and in a repo? + try: + subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + check=True, + capture_output=True, + cwd=Path.cwd(), + ) + has_git = True + except (subprocess.CalledProcessError, FileNotFoundError): + return GitEnvironment(has_git=False, is_bare=False, is_worktree=False) + + # Check 2: Is it a bare repository? + is_bare = is_bare_repo() + + # Check 3: Is current directory a worktree? (skip if bare) + is_worktree_val = is_git_worktree() if not is_bare else False + + return GitEnvironment(has_git=has_git, is_bare=is_bare, is_worktree=is_worktree_val) + + +def get_config_path(override: Optional[Path] = None) -> Path: + """Returns absolute path to config file with optional override. + + Args: + override: Custom path to config file (optional) + + Returns: + Absolute Path object to config file + """ + if override: + return override.resolve() + else: + return (Path.cwd() / ".specify" / "memory" / "config.json").resolve() + + +def validate_config(config: dict) -> None: + """Validate config dictionary against schema. + + Args: + config: Configuration dictionary to validate + + Raises: + ValueError: If config doesn't match schema + """ + # Check required fields + required_keys = {"source_management_flow", "version"} + if not required_keys.issubset(config.keys()): + missing = required_keys - config.keys() + raise ValueError(f"Config missing required fields: {', '.join(missing)}") + + # Validate version + if config["version"] != "1.0": + raise ValueError( + f"Invalid config version: {config['version']} (expected '1.0')" + ) + + # Validate source_management_flow + valid_modes = {"branch", "worktree", "none"} + mode = config["source_management_flow"] + if mode not in valid_modes: + raise ValueError( + f"Invalid source_management_flow '{mode}' (must be branch/worktree/none)" + ) + + # Conditional field validation + if mode == "worktree": + if "worktree_folder" not in config: + raise ValueError( + "worktree_folder required when source_management_flow=worktree" + ) + else: + # For branch/none modes, worktree_folder should not be present + if "worktree_folder" in config: + raise ValueError( + f"worktree_folder not allowed when source_management_flow={mode} " + "(only valid for worktree mode)" + ) + + # Check for additional properties (strict schema) + allowed_keys = {"version", "source_management_flow", "worktree_folder"} + extra_keys = set(config.keys()) - allowed_keys + if extra_keys: + raise ValueError(f"Unknown config fields: {', '.join(extra_keys)}") + + +def load_config(path: Optional[Path] = None) -> Optional[dict]: + """Loads and validates the source management configuration file. + + Args: + path: Override config file location (defaults to .specify/memory/config.json) + + Returns: + Parsed and validated configuration dict if file exists and is valid + None if config file doesn't exist (project may not be initialized) + + Raises: + ValueError: If config file exists but is invalid (malformed JSON, schema violation) + """ + config_path = get_config_path(path) + + if not config_path.exists(): + return None + + try: + with open(config_path) as f: + config = json.load(f) + except json.JSONDecodeError as e: + raise ValueError( + f"Invalid JSON syntax in {config_path} at line {e.lineno}: {e.msg}" + ) + + # Validate schema + validate_config(config) + + return config + + +def save_config(config: dict, path: Optional[Path] = None) -> None: + """Validates and writes configuration to disk. + + Args: + config: Configuration dictionary matching schema + path: Override config file location (defaults to .specify/memory/config.json) + + Raises: + ValueError: If config doesn't match schema + OSError: If directory doesn't exist or lacks write permissions + """ + # Validate before writing + validate_config(config) + + config_path = get_config_path(path) + + # Create directory if it doesn't exist + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Write with pretty formatting + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + +def get_current_mode(path: Optional[Path] = None, warn_if_missing: bool = True) -> str: + """Get current source management mode from config. + + Args: + path: Override config file location (defaults to .specify/memory/config.json) + warn_if_missing: Whether to print warning if config doesn't exist + + Returns: + Current mode: "branch", "worktree", or "none" + Defaults to "branch" if config doesn't exist (backward compatibility) + """ + try: + config = load_config(path) + + if config is None: + # Backward compatibility: treat missing config as branch mode + if warn_if_missing: + console.print( + "[yellow]Note:[/yellow] No source management config found. " + "Using default 'branch' mode." + ) + console.print( + "[dim]Run 'specify init' in a new project to configure mode.[/dim]" + ) + return "branch" + + return config["source_management_flow"] + + except ValueError as e: + # Config exists but is invalid + console.print(f"[red]Error:[/red] Invalid config file: {e}") + console.print(f"[yellow]Config location:[/yellow] {get_config_path(path)}") + console.print( + "[dim]Delete the config file and run 'specify init' to recreate it.[/dim]" + ) + # Return safe default + return "branch" + + +def init_git_repo( + project_path: Path, quiet: bool = False +) -> Tuple[bool, Optional[str]]: """Initialize a git repository in the specified path. - + Args: project_path: Path to initialize git repository in quiet: if True suppress console output (tracker handles status) - + Returns: Tuple of (success: bool, error_message: Optional[str]) """ @@ -549,7 +870,12 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option console.print("[cyan]Initializing git repository...[/cyan]") subprocess.run(["git", "init"], check=True, capture_output=True, text=True) subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True) - subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit from Specify template"], + check=True, + capture_output=True, + text=True, + ) if not quiet: console.print("[green]✓[/green] Git repository initialized") return True, None @@ -560,28 +886,34 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option error_msg += f"\nError: {e.stderr.strip()}" elif e.stdout: error_msg += f"\nOutput: {e.stdout.strip()}" - + if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") return False, error_msg finally: os.chdir(original_cwd) -def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: + +def handle_vscode_settings( + sub_item, dest_file, rel_path, verbose=False, tracker=None +) -> None: """Handle merging or copying of .vscode/settings.json files.""" + def log(message, color="green"): if verbose and not tracker: console.print(f"[{color}]{message}[/] {rel_path}") try: - with open(sub_item, 'r', encoding='utf-8') as f: + with open(sub_item, "r", encoding="utf-8") as f: new_settings = json.load(f) if dest_file.exists(): - merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) - with open(dest_file, 'w', encoding='utf-8') as f: + merged = merge_json_files( + dest_file, new_settings, verbose=verbose and not tracker + ) + with open(dest_file, "w", encoding="utf-8") as f: json.dump(merged, f, indent=4) - f.write('\n') + f.write("\n") log("Merged:", "green") else: shutil.copy2(sub_item, dest_file) @@ -591,7 +923,10 @@ def log(message, color="green"): log(f"Warning: Could not merge, copying instead: {e}", "yellow") shutil.copy2(sub_item, dest_file) -def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict: + +def merge_json_files( + existing_path: Path, new_content: dict, verbose: bool = False +) -> dict: """Merge new JSON content into existing JSON file. Performs a deep merge where: @@ -609,7 +944,7 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal Merged JSON content as dict """ try: - with open(existing_path, 'r', encoding='utf-8') as f: + with open(existing_path, "r", encoding="utf-8") as f: existing_content = json.load(f) except (FileNotFoundError, json.JSONDecodeError): # If file doesn't exist or is invalid, just use new content @@ -619,7 +954,11 @@ def deep_merge(base: dict, update: dict) -> dict: """Recursively merge update dict into base dict.""" result = base.copy() for key, value in update.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): # Recursively merge nested dictionaries result[key] = deep_merge(result[key], value) else: @@ -634,7 +973,18 @@ def deep_merge(base: dict, update: dict) -> dict: return merged -def download_template_from_github(ai_assistant: str, download_dir: Path, *, script_type: str = "sh", verbose: bool = True, show_progress: bool = True, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Tuple[Path, dict]: + +def download_template_from_github( + ai_assistant: str, + download_dir: Path, + *, + script_type: str = "sh", + verbose: bool = True, + show_progress: bool = True, + client: httpx.Client = None, + debug: bool = False, + github_token: str = None, +) -> Tuple[Path, dict]: repo_owner = "github" repo_name = "spec-kit" if client is None: @@ -661,7 +1011,9 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri try: release_data = response.json() except ValueError as je: - raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") + raise RuntimeError( + f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}" + ) except Exception as e: console.print(f"[red]Error fetching release information[/red]") console.print(Panel(str(e), title="Fetch Error", border_style="red")) @@ -670,16 +1022,25 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri assets = release_data.get("assets", []) pattern = f"spec-kit-template-{ai_assistant}-{script_type}" matching_assets = [ - asset for asset in assets + asset + for asset in assets if pattern in asset["name"] and asset["name"].endswith(".zip") ] asset = matching_assets[0] if matching_assets else None if asset is None: - console.print(f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])") - asset_names = [a.get('name', '?') for a in assets] - console.print(Panel("\n".join(asset_names) or "(no assets)", title="Available Assets", border_style="yellow")) + console.print( + f"[red]No matching release asset found[/red] for [bold]{ai_assistant}[/bold] (expected pattern: [bold]{pattern}[/bold])" + ) + asset_names = [a.get("name", "?") for a in assets] + console.print( + Panel( + "\n".join(asset_names) or "(no assets)", + title="Available Assets", + border_style="yellow", + ) + ) raise typer.Exit(1) download_url = asset["browser_download_url"] @@ -705,12 +1066,14 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri ) as response: if response.status_code != 200: # Handle rate-limiting on download as well - error_msg = _format_rate_limit_error(response.status_code, response.headers, download_url) + error_msg = _format_rate_limit_error( + response.status_code, response.headers, download_url + ) if debug: error_msg += f"\n\n[dim]Response body (truncated 400):[/dim]\n{response.text[:400]}" raise RuntimeError(error_msg) - total_size = int(response.headers.get('content-length', 0)) - with open(zip_path, 'wb') as f: + total_size = int(response.headers.get("content-length", 0)) + with open(zip_path, "wb") as f: if total_size == 0: for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) @@ -744,11 +1107,23 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri "filename": filename, "size": file_size, "release": release_data["tag_name"], - "asset_url": download_url + "asset_url": download_url, } return zip_path, metadata -def download_and_extract_template(project_path: Path, ai_assistant: str, script_type: str, is_current_dir: bool = False, *, verbose: bool = True, tracker: StepTracker | None = None, client: httpx.Client = None, debug: bool = False, github_token: str = None) -> Path: + +def download_and_extract_template( + project_path: Path, + ai_assistant: str, + script_type: str, + is_current_dir: bool = False, + *, + verbose: bool = True, + tracker: StepTracker | None = None, + client: httpx.Client = None, + debug: bool = False, + github_token: str = None, +) -> Path: """Download the latest release and extract it to create a new project. Returns project_path. Uses tracker if provided (with keys: fetch, download, extract, cleanup) """ @@ -765,12 +1140,14 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ show_progress=(tracker is None), client=client, debug=debug, - github_token=github_token + github_token=github_token, ) if tracker: - tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") + tracker.complete( + "fetch", f"release {meta['release']} ({meta['size']:,} bytes)" + ) tracker.add("download", "Download template") - tracker.complete("download", meta['filename']) + tracker.complete("download", meta["filename"]) except Exception as e: if tracker: tracker.error("fetch", str(e)) @@ -789,7 +1166,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if not is_current_dir: project_path.mkdir(parents=True) - with zipfile.ZipFile(zip_path, 'r') as zip_ref: + with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_contents = zip_ref.namelist() if tracker: tracker.start("zip-list") @@ -805,9 +1182,13 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ extracted_items = list(temp_path.iterdir()) if tracker: tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"temp {len(extracted_items)} items") + tracker.complete( + "extracted-summary", f"temp {len(extracted_items)} items" + ) elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]") + console.print( + f"[cyan]Extracted {len(extracted_items)} items to temp location[/cyan]" + ) source_dir = temp_path if len(extracted_items) == 1 and extracted_items[0].is_dir(): @@ -816,43 +1197,68 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Found nested directory structure[/cyan]") + console.print( + f"[cyan]Found nested directory structure[/cyan]" + ) for item in source_dir.iterdir(): dest_path = project_path / item.name if item.is_dir(): if dest_path.exists(): if verbose and not tracker: - console.print(f"[yellow]Merging directory:[/yellow] {item.name}") - for sub_item in item.rglob('*'): + console.print( + f"[yellow]Merging directory:[/yellow] {item.name}" + ) + for sub_item in item.rglob("*"): if sub_item.is_file(): rel_path = sub_item.relative_to(item) dest_file = dest_path / rel_path - dest_file.parent.mkdir(parents=True, exist_ok=True) + dest_file.parent.mkdir( + parents=True, exist_ok=True + ) # Special handling for .vscode/settings.json - merge instead of overwrite - if dest_file.name == "settings.json" and dest_file.parent.name == ".vscode": - handle_vscode_settings(sub_item, dest_file, rel_path, verbose, tracker) + if ( + dest_file.name == "settings.json" + and dest_file.parent.name == ".vscode" + ): + handle_vscode_settings( + sub_item, + dest_file, + rel_path, + verbose, + tracker, + ) else: shutil.copy2(sub_item, dest_file) else: shutil.copytree(item, dest_path) else: if dest_path.exists() and verbose and not tracker: - console.print(f"[yellow]Overwriting file:[/yellow] {item.name}") + console.print( + f"[yellow]Overwriting file:[/yellow] {item.name}" + ) shutil.copy2(item, dest_path) if verbose and not tracker: - console.print(f"[cyan]Template files merged into current directory[/cyan]") + console.print( + f"[cyan]Template files merged into current directory[/cyan]" + ) else: zip_ref.extractall(project_path) extracted_items = list(project_path.iterdir()) if tracker: tracker.start("extracted-summary") - tracker.complete("extracted-summary", f"{len(extracted_items)} top-level items") + tracker.complete( + "extracted-summary", f"{len(extracted_items)} top-level items" + ) elif verbose: - console.print(f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]") + console.print( + f"[cyan]Extracted {len(extracted_items)} items to {project_path}:[/cyan]" + ) for item in extracted_items: - console.print(f" - {item.name} ({'dir' if item.is_dir() else 'file'})") + console.print( + f" - {item.name} ({'dir' if item.is_dir() else 'file'})" + ) if len(extracted_items) == 1 and extracted_items[0].is_dir(): nested_dir = extracted_items[0] @@ -867,7 +1273,9 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ tracker.add("flatten", "Flatten nested directory") tracker.complete("flatten") elif verbose: - console.print(f"[cyan]Flattened nested directory structure[/cyan]") + console.print( + f"[cyan]Flattened nested directory structure[/cyan]" + ) except Exception as e: if tracker: @@ -876,7 +1284,9 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ if verbose: console.print(f"[red]Error extracting template:[/red] {e}") if debug: - console.print(Panel(str(e), title="Extraction Error", border_style="red")) + console.print( + Panel(str(e), title="Extraction Error", border_style="red") + ) if not is_current_dir and project_path.exists(): shutil.rmtree(project_path) @@ -898,7 +1308,9 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_ return project_path -def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: +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).""" if os.name == "nt": return # Windows: skip silently @@ -917,13 +1329,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = continue except Exception: continue - st = script.stat(); mode = st.st_mode + 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 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) @@ -931,33 +1347,71 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = except Exception as e: failures.append(f"{script.relative_to(scripts_root)}: {e}") if tracker: - detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") + detail = f"{updated} updated" + ( + f", {len(failures)} failed" if failures else "" + ) tracker.add("chmod", "Set script permissions recursively") (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) recursively[/cyan]" + ) if failures: console.print("[yellow]Some scripts could not be updated:[/yellow]") for f in failures: console.print(f" - {f}") + @app.command() def init( - project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), - ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder "), - script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), - ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), - no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), - here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), - force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), - skip_tls: bool = typer.Option(False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)"), - debug: bool = typer.Option(False, "--debug", help="Show verbose diagnostic output for network and extraction failures"), - github_token: str = typer.Option(None, "--github-token", help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)"), + project_name: str = typer.Argument( + None, + help="Name for your new project directory (optional if using --here, or use '.' for current directory)", + ), + ai_assistant: str = typer.Option( + None, + "--ai", + help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, amp, shai, q, bob, or qoder ", + ), + script_type: str = typer.Option( + None, "--script", help="Script type to use: sh or ps" + ), + ignore_agent_tools: bool = typer.Option( + False, + "--ignore-agent-tools", + help="Skip checks for AI agent tools like Claude Code", + ), + no_git: bool = typer.Option( + False, "--no-git", help="Skip git repository initialization" + ), + here: bool = typer.Option( + False, + "--here", + help="Initialize project in the current directory instead of creating a new one", + ), + force: bool = typer.Option( + False, + "--force", + help="Force merge/overwrite when using --here (skip confirmation)", + ), + skip_tls: bool = typer.Option( + False, "--skip-tls", help="Skip SSL/TLS verification (not recommended)" + ), + debug: bool = typer.Option( + False, + "--debug", + help="Show verbose diagnostic output for network and extraction failures", + ), + github_token: str = typer.Option( + None, + "--github-token", + help="GitHub token to use for API requests (or set GH_TOKEN or GITHUB_TOKEN environment variable)", + ), ): """ Initialize a new Specify project from the latest template. - + This command will: 1. Check that required tools are installed (git is optional) 2. Let you choose your AI assistant @@ -965,7 +1419,7 @@ def init( 4. Extract the template to a new project directory or current directory 5. Initialize a fresh git repository (if not --no-git and no existing repo) 6. Optionally set up AI assistant commands - + Examples: specify init my-project specify init my-project --ai claude @@ -987,11 +1441,15 @@ def init( project_name = None # Clear project_name to use existing validation logic if here and project_name: - console.print("[red]Error:[/red] Cannot specify both project name and --here flag") + console.print( + "[red]Error:[/red] Cannot specify both project name and --here flag" + ) raise typer.Exit(1) if not here and not project_name: - console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") + console.print( + "[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag" + ) raise typer.Exit(1) if here: @@ -1000,10 +1458,16 @@ def init( existing_items = list(project_path.iterdir()) if existing_items: - console.print(f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)") - console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print( + f"[yellow]Warning:[/yellow] Current directory is not empty ({len(existing_items)} items)" + ) + console.print( + "[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]" + ) if force: - console.print("[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]") + console.print( + "[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]" + ) else: response = typer.confirm("Do you want to continue?") if not response: @@ -1017,7 +1481,7 @@ def init( "Please choose a different project name or remove the existing directory.", title="[red]Directory Conflict[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -1041,20 +1505,22 @@ def init( if not no_git: should_init_git = check_tool("git") if not should_init_git: - console.print("[yellow]Git not found - will skip repository initialization[/yellow]") + console.print( + "[yellow]Git not found - will skip repository initialization[/yellow]" + ) if ai_assistant: if ai_assistant not in AGENT_CONFIG: - console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") + console.print( + f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}" + ) raise typer.Exit(1) selected_ai = ai_assistant else: # Create options dict for selection (agent_key: display_name) ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( - ai_choices, - "Choose your AI assistant:", - "copilot" + ai_choices, "Choose your AI assistant:", "copilot" ) if not ignore_agent_tools: @@ -1069,7 +1535,7 @@ def init( "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", title="[red]Agent Detection Error[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(error_panel) @@ -1077,14 +1543,20 @@ def init( if script_type: if script_type not in SCRIPT_TYPE_CHOICES: - console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") + console.print( + f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}" + ) raise typer.Exit(1) selected_script = script_type else: default_script = "ps" if os.name == "nt" else "sh" if sys.stdin.isatty(): - selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) + selected_script = select_with_arrows( + SCRIPT_TYPE_CHOICES, + "Choose script type (or press Enter)", + default_script, + ) else: selected_script = default_script @@ -1110,24 +1582,95 @@ def init( ("chmod", "Ensure scripts executable"), ("cleanup", "Cleanup"), ("git", "Initialize git repository"), - ("final", "Finalize") + ("final", "Finalize"), ]: tracker.add(key, label) # Track git error message outside Live context so it persists git_error_message = None - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: + with Live( + tracker.render(), console=console, refresh_per_second=8, transient=True + ) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: verify = not skip_tls local_ssl_context = ssl_context if verify else False local_client = httpx.Client(verify=local_ssl_context) - download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug, github_token=github_token) + download_and_extract_template( + project_path, + selected_ai, + selected_script, + here, + verbose=False, + tracker=tracker, + client=local_client, + debug=debug, + github_token=github_token, + ) ensure_executable_scripts(project_path, tracker=tracker) + # Detect Git environment and create config (Feature 001: Worktree Detection) + # Always create config, even with --no-git (will detect "none" mode) + original_cwd = Path.cwd() + os.chdir(project_path) + try: + git_env = detect_git_environment() + suggested_mode = git_env.suggest_mode() + + # Pause live updates to show interactive prompt + live.stop() + + # Prompt user to select or confirm source management mode + mode_choices = { + "branch": "Traditional Git branches (recommended for most projects)", + "worktree": "Git worktrees (advanced, for parallel feature development)", + "none": "No Git integration (manual source management)", + } + + # Show recommendation based on environment + console.print() + console.print(f"[cyan]Detected environment:[/cyan] ", end="") + if not git_env.has_git: + console.print("[yellow]No Git repository detected[/yellow]") + elif git_env.is_bare: + console.print( + "[cyan]Bare repository (designed for worktrees)[/cyan]" + ) + elif git_env.is_worktree: + console.print("[cyan]Already in a Git worktree[/cyan]") + else: + console.print("[cyan]Standard Git repository[/cyan]") + + console.print( + f"[cyan]Recommended mode:[/cyan] [green]{suggested_mode}[/green]" + ) + console.print() + + # Let user choose mode interactively + selected_mode = select_with_arrows( + mode_choices, "Select source management mode:", suggested_mode + ) + + # Resume live updates + live.start() + + # Create config with selected mode + config_data = { + "version": "1.0", + "source_management_flow": selected_mode, + } + + # Add worktree_folder if in worktree mode + if selected_mode == "worktree": + config_data["worktree_folder"] = "./worktrees" + + save_config(config_data) + finally: + os.chdir(original_cwd) + if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -1147,7 +1690,11 @@ def init( tracker.complete("final", "project ready") except Exception as e: tracker.error("final", str(e)) - console.print(Panel(f"Initialization failed: {e}", title="Failure", border_style="red")) + console.print( + Panel( + f"Initialization failed: {e}", title="Failure", border_style="red" + ) + ) if debug: _env_pairs = [ ("Python", sys.version.split()[0]), @@ -1155,8 +1702,17 @@ def init( ("CWD", str(Path.cwd())), ] _label_width = max(len(k) for k, _ in _env_pairs) - env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] - console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) + env_lines = [ + f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" + for k, v in _env_pairs + ] + console.print( + Panel( + "\n".join(env_lines), + title="Debug Environment", + border_style="magenta", + ) + ) if not here and project_path.exists(): shutil.rmtree(project_path) raise typer.Exit(1) @@ -1165,7 +1721,7 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - + # Show git error details if initialization failed if git_error_message: console.print() @@ -1176,10 +1732,10 @@ def init( f"[cyan]cd {project_path if not here else '.'}[/cyan]\n" f"[cyan]git init[/cyan]\n" f"[cyan]git add .[/cyan]\n" - f"[cyan]git commit -m \"Initial commit\"[/cyan]", + f'[cyan]git commit -m "Initial commit"[/cyan]', title="[red]Git Initialization Failed[/red]", border_style="red", - padding=(1, 2) + padding=(1, 2), ) console.print(git_error_panel) @@ -1192,14 +1748,16 @@ def init( f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.", title="[yellow]Agent Folder Security[/yellow]", border_style="yellow", - padding=(1, 2) + padding=(1, 2), ) console.print() console.print(security_notice) steps_lines = [] if not here: - steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") + steps_lines.append( + f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]" + ) step_num = 2 else: steps_lines.append("1. You're already in the project directory!") @@ -1213,19 +1771,27 @@ def init( cmd = f"setx CODEX_HOME {quoted_path}" else: # Unix-like systems cmd = f"export CODEX_HOME={quoted_path}" - - steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]") + + steps_lines.append( + f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]" + ) step_num += 1 steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:") - steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles") - steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification") + steps_lines.append( + " 2.1 [cyan]/speckit.constitution[/] - Establish project principles" + ) + steps_lines.append( + " 2.2 [cyan]/speckit.specify[/] - Create baseline specification" + ) steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan") steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks") steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation") - steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) + steps_panel = Panel( + "\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1, 2) + ) console.print() console.print(steps_panel) @@ -1234,16 +1800,32 @@ def init( "", f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", - f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" + f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])", ] - enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) + enhancements_panel = Panel( + "\n".join(enhancement_lines), + title="Enhancement Commands", + border_style="cyan", + padding=(1, 2), + ) console.print() console.print(enhancements_panel) + @app.command() def check(): """Check that all required tools are installed.""" show_banner() + + # Display current mode if in a Specify project + current_mode = get_current_mode(warn_if_missing=False) + mode_display = { + "branch": "[cyan]Branch mode active[/cyan]", + "worktree": "[cyan]Worktree mode active[/cyan]", + "none": "[yellow]No Git mode[/yellow]", + } + console.print(mode_display.get(current_mode, "[dim]Unknown mode[/dim]")) + console.print("[bold]Checking for installed tools...[/bold]\n") tracker = StepTracker("Check Available Tools") @@ -1282,14 +1864,15 @@ def check(): if not any(agent_results.values()): console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]") + @app.command() def version(): """Display version and system information.""" import platform import importlib.metadata - + show_banner() - + # Get CLI version from package metadata cli_version = "unknown" try: @@ -1298,6 +1881,7 @@ def version(): # Fallback: try reading from pyproject.toml if running from source try: import tomllib + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "rb") as f: @@ -1305,15 +1889,15 @@ def version(): cli_version = data.get("project", {}).get("version", "unknown") except Exception: pass - + # Fetch latest template release version repo_owner = "github" repo_name = "spec-kit" api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - + template_version = "unknown" release_date = "unknown" - + try: response = client.get( api_url, @@ -1331,7 +1915,7 @@ def version(): if release_date != "unknown": # Format the date nicely try: - dt = datetime.fromisoformat(release_date.replace('Z', '+00:00')) + dt = datetime.fromisoformat(release_date.replace("Z", "+00:00")) release_date = dt.strftime("%Y-%m-%d") except Exception: pass @@ -1355,15 +1939,16 @@ def version(): info_table, title="[bold cyan]Specify CLI Information[/bold cyan]", border_style="cyan", - padding=(1, 2) + padding=(1, 2), ) console.print(panel) console.print() + def main(): app() + if __name__ == "__main__": main() -