mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
* Initial plan * feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * test(templates): add comprehensive unit tests for template pack system Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat(presets): pluggable preset system with template/command overrides, catalog, and resolver - Rename 'template packs' to 'presets' to avoid naming collision with core templates - PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py - Extract CommandRegistrar to agents.py as shared infrastructure - CLI: specify preset list/add/remove/search/resolve/info - CLI: specify preset catalog list/add/remove - --preset option on specify init - Priority-based preset stacking (--priority, lower = higher precedence) - Command overrides registered into all detected agent directories (17+ agents) - Extension command safety: skip registration if target extension not installed - Multi-catalog support: env var, project config, user config, built-in defaults - resolve_template() / Resolve-Template in bash/PowerShell scripts - Self-test preset: overrides all 6 core templates + 1 command - Scaffold with 4 examples: core/extension template and command overrides - Preset catalog (catalog.json, catalog.community.json) - Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md - 110 preset tests, 253 total tests passing * feat(presets): propagate command overrides to skills via init-options - Add save_init_options() / load_init_options() helpers that persist CLI flags from 'specify init' to .specify/init-options.json - PresetManager._register_skills() overwrites SKILL.md files when --ai-skills was used during init and corresponding skill dirs exist - PresetManager._unregister_skills() restores core template content on preset removal - registered_skills stored in preset registry metadata - 8 new tests covering skill override, skip conditions, and restore * fix: address PR check failures (ruff F541, CodeQL URL substring) - Remove extraneous f-prefix from two f-strings without placeholders - Replace substring URL check in test with startswith/endswith assertions to satisfy CodeQL incomplete URL substring sanitization rule * fix: address Copilot PR review comments - Move save_init_options() before preset install so skills propagation works during 'specify init --preset --ai-skills' - Clean up downloaded ZIP after successful preset install during init - Validate --from URL scheme (require HTTPS, HTTP only for localhost) - Expose unregister_commands() on extensions.py CommandRegistrar wrapper instead of reaching into private _registrar field - Use _get_merged_packs() for search() and get_pack_info() so all active catalogs are searched, not just the highest-priority one - Fix fetch_catalog() cache to verify cached URL matches current URL - Fix PresetResolver: script resolution uses .sh extension, consistent file extensions throughout resolve(), and resolve_with_source() delegates to resolve() to honor template_type parameter - Fix bash common.sh: fall through to directory scan when python3 returns empty preset list - Fix PowerShell Resolve-Template: filter out dot-folders and sort extensions deterministically * fix: narrow empty except blocks and add explanatory comments * fix: address Copilot PR review comments (round 2) - Fix init --preset error masking: distinguish "not found" from real errors - Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS) - Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp - Fix self-test catalog entry: add note that it's local-only (no download_url) - Fix Windows path issue in resolve_with_source: use Path.relative_to() - Fix skill restore path: use project's .specify/templates/commands/ not source tree - Add encoding="utf-8" to all file read/write in agents.py - Update test to set up core command templates for skill restoration * fix: remove self-test from catalog.json (local-only preset) * fix: address Copilot PR review comments (round 3) - Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache) - Rename _catalog to _catalog_name for consistency with extension system - Enforce install_allowed policy in CLI preset add and download_pack() - Fix shell injection: pass registry path via env var instead of string interpolation * fix: correct PresetError docstring from template to preset * Removed CHANGELOG requirement * Applying review recommendations * Applying review recommendations * Applying review recommendations * Applying review recommendations --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
334 lines
11 KiB
Bash
334 lines
11 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
set -e
|
|
|
|
JSON_MODE=false
|
|
SHORT_NAME=""
|
|
BRANCH_NUMBER=""
|
|
ARGS=()
|
|
i=1
|
|
while [ $i -le $# ]; do
|
|
arg="${!i}"
|
|
case "$arg" in
|
|
--json)
|
|
JSON_MODE=true
|
|
;;
|
|
--short-name)
|
|
if [ $((i + 1)) -gt $# ]; then
|
|
echo 'Error: --short-name requires a value' >&2
|
|
exit 1
|
|
fi
|
|
i=$((i + 1))
|
|
next_arg="${!i}"
|
|
# Check if the next argument is another option (starts with --)
|
|
if [[ "$next_arg" == --* ]]; then
|
|
echo 'Error: --short-name requires a value' >&2
|
|
exit 1
|
|
fi
|
|
SHORT_NAME="$next_arg"
|
|
;;
|
|
--number)
|
|
if [ $((i + 1)) -gt $# ]; then
|
|
echo 'Error: --number requires a value' >&2
|
|
exit 1
|
|
fi
|
|
i=$((i + 1))
|
|
next_arg="${!i}"
|
|
if [[ "$next_arg" == --* ]]; then
|
|
echo 'Error: --number requires a value' >&2
|
|
exit 1
|
|
fi
|
|
BRANCH_NUMBER="$next_arg"
|
|
;;
|
|
--help|-h)
|
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --json Output in JSON format"
|
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
|
echo " --help, -h Show this help message"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
|
exit 0
|
|
;;
|
|
*)
|
|
ARGS+=("$arg")
|
|
;;
|
|
esac
|
|
i=$((i + 1))
|
|
done
|
|
|
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
echo "Usage: $0 [--json] [--short-name <name>] [--number N] <feature_description>" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Function to find the repository root by searching for existing project markers
|
|
find_repo_root() {
|
|
local dir="$1"
|
|
while [ "$dir" != "/" ]; do
|
|
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
|
|
echo "$dir"
|
|
return 0
|
|
fi
|
|
dir="$(dirname "$dir")"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Function to get highest number from specs directory
|
|
get_highest_from_specs() {
|
|
local specs_dir="$1"
|
|
local 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
|
|
|
|
echo "$highest"
|
|
}
|
|
|
|
# Function to get highest number from git branches
|
|
get_highest_from_branches() {
|
|
local highest=0
|
|
|
|
# Get all branches (local and remote)
|
|
branches=$(git branch -a 2>/dev/null || echo "")
|
|
|
|
if [ -n "$branches" ]; then
|
|
while IFS= read -r branch; do
|
|
# Clean branch name: remove leading markers and remote prefixes
|
|
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
|
|
|
# Extract feature number if branch matches pattern ###-*
|
|
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
|
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
|
number=$((10#$number))
|
|
if [ "$number" -gt "$highest" ]; then
|
|
highest=$number
|
|
fi
|
|
fi
|
|
done <<< "$branches"
|
|
fi
|
|
|
|
echo "$highest"
|
|
}
|
|
|
|
# Function to check existing branches (local and remote) and return next available number
|
|
check_existing_branches() {
|
|
local specs_dir="$1"
|
|
|
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
|
git fetch --all --prune 2>/dev/null || true
|
|
|
|
# Get highest number from ALL branches (not just matching short name)
|
|
local highest_branch=$(get_highest_from_branches)
|
|
|
|
# Get highest number from ALL specs (not just matching short name)
|
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
|
|
|
# Take the maximum of both
|
|
local max_num=$highest_branch
|
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
|
max_num=$highest_spec
|
|
fi
|
|
|
|
# Return next number
|
|
echo $((max_num + 1))
|
|
}
|
|
|
|
# Function to clean and format a branch name
|
|
clean_branch_name() {
|
|
local name="$1"
|
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
|
}
|
|
|
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
s="${s//$'\n'/\\n}"
|
|
s="${s//$'\t'/\\t}"
|
|
s="${s//$'\r'/\\r}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
# Resolve repository root. Prefer git information when available, but fall back
|
|
# to searching for repository markers so the workflow still functions in repositories that
|
|
# were initialised with --no-git.
|
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/common.sh"
|
|
|
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
HAS_GIT=true
|
|
else
|
|
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
|
|
exit 1
|
|
fi
|
|
HAS_GIT=false
|
|
fi
|
|
|
|
cd "$REPO_ROOT"
|
|
|
|
SPECS_DIR="$REPO_ROOT/specs"
|
|
mkdir -p "$SPECS_DIR"
|
|
|
|
# Function to generate branch name with stop word filtering and length filtering
|
|
generate_branch_name() {
|
|
local description="$1"
|
|
|
|
# Common stop words to filter out
|
|
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
|
|
|
# Convert to lowercase and split into words
|
|
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
|
|
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
|
local meaningful_words=()
|
|
for word in $clean_name; do
|
|
# Skip empty words
|
|
[ -z "$word" ] && continue
|
|
|
|
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
|
if ! echo "$word" | grep -qiE "$stop_words"; then
|
|
if [ ${#word} -ge 3 ]; then
|
|
meaningful_words+=("$word")
|
|
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
|
meaningful_words+=("$word")
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# If we have meaningful words, use first 3-4 of them
|
|
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
|
local max_words=3
|
|
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
|
|
|
local result=""
|
|
local count=0
|
|
for word in "${meaningful_words[@]}"; do
|
|
if [ $count -ge $max_words ]; then break; fi
|
|
if [ -n "$result" ]; then result="$result-"; fi
|
|
result="$result$word"
|
|
count=$((count + 1))
|
|
done
|
|
echo "$result"
|
|
else
|
|
# Fallback to original logic if no meaningful words found
|
|
local cleaned=$(clean_branch_name "$description")
|
|
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
|
fi
|
|
}
|
|
|
|
# Generate branch name
|
|
if [ -n "$SHORT_NAME" ]; then
|
|
# Use provided short name, just clean it up
|
|
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
|
else
|
|
# Generate from description with smart filtering
|
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
|
fi
|
|
|
|
# Determine branch number
|
|
if [ -z "$BRANCH_NUMBER" ]; then
|
|
if [ "$HAS_GIT" = true ]; then
|
|
# Check existing branches on remotes
|
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
|
else
|
|
# Fall back to local directory check
|
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
fi
|
|
fi
|
|
|
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
|
|
# GitHub enforces a 244-byte limit on branch names
|
|
# Validate and truncate if necessary
|
|
MAX_BRANCH_LENGTH=244
|
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
|
# Calculate how much we need to trim from suffix
|
|
# Account for: feature number (3) + hyphen (1) = 4 chars
|
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - 4))
|
|
|
|
# Truncate suffix at word boundary if possible
|
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
|
# Remove trailing hyphen if truncation created one
|
|
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
|
|
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
|
|
|
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
|
fi
|
|
|
|
if [ "$HAS_GIT" = true ]; then
|
|
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
|
|
# Check if branch already exists
|
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
|
exit 1
|
|
else
|
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
|
exit 1
|
|
fi
|
|
fi
|
|
else
|
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
|
fi
|
|
|
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
|
mkdir -p "$FEATURE_DIR"
|
|
|
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
|
|
|
# Inform the user how to persist the feature variable in their own shell
|
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
|
|
|
if $JSON_MODE; then
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -cn \
|
|
--arg branch_name "$BRANCH_NAME" \
|
|
--arg spec_file "$SPEC_FILE" \
|
|
--arg feature_num "$FEATURE_NUM" \
|
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
|
else
|
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
|
fi
|
|
else
|
|
echo "BRANCH_NAME: $BRANCH_NAME"
|
|
echo "SPEC_FILE: $SPEC_FILE"
|
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
|
fi
|