From 8e14ab19354c60572244acd9afc5365998596077 Mon Sep 17 00:00:00 2001 From: "Sakoda, Taro (cub)" <35255268+t-sakoda@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:44:26 +0900 Subject: [PATCH 1/7] fix: support feature branch numbers with 4+ digits (#2040) * fix: support feature branch numbers with 4+ digits in common.sh and common.ps1 The sequential feature number pattern was hardcoded to exactly 3 digits (`{3}`), causing branches like `1234-feature-name` to be rejected. Changed to `{3,}` (3 or more digits) to support growing projects. Also added a guard to exclude malformed timestamp patterns from being accepted as sequential prefixes. Closes #344 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: narrow timestamp guard and use [long] to prevent overflow - Change [int] to [long] in PowerShell Get-CurrentBranch to avoid overflow for large feature numbers (>2,147,483,647) - Narrow malformed-timestamp exclusion from ^[0-9]+-[0-9]{6}- to ^[0-9]{7}-[0-9]{6}- so valid sequential branches like 004-123456-fix-bug are not rejected Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression tests for 4+ digit feature branch support Cover check_feature_branch and find_feature_dir_by_prefix with 4-digit sequential prefixes, as requested in PR review #2040. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reject timestamp-like branches without trailing slug Branches like "20260319-143022" (no "-" suffix) were incorrectly accepted as sequential prefixes. Add explicit rejection for 7-or-8 digit date + 6-digit time patterns with no trailing slug, in both common.sh and common.ps1. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/bash/common.sh | 14 ++++++++++---- scripts/powershell/common.ps1 | 12 ++++++++---- tests/test_timestamp_branches.py | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 416fcadfc..5e45e8708 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -78,7 +78,7 @@ get_current_branch() { latest_timestamp="$ts" latest_feature=$dirname fi - elif [[ "$dirname" =~ ^([0-9]{3})- ]]; then + elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then local number=${BASH_REMATCH[1]} number=$((10#$number)) if [[ "$number" -gt "$highest" ]]; then @@ -124,9 +124,15 @@ check_feature_branch() { return 0 fi - if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi @@ -146,7 +152,7 @@ find_feature_dir_by_prefix() { local prefix="" if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then prefix="${BASH_REMATCH[1]}" - elif [[ "$branch_name" =~ ^([0-9]{3})- ]]; then + elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then prefix="${BASH_REMATCH[1]}" else # If branch doesn't have a recognized prefix, fall back to exact match diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 7a96d3fac..8c8c801ee 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -83,8 +83,8 @@ function Get-CurrentBranch { $latestTimestamp = $ts $latestFeature = $_.Name } - } elseif ($_.Name -match '^(\d{3})-') { - $num = [int]$matches[1] + } elseif ($_.Name -match '^(\d{3,})-') { + $num = [long]$matches[1] if ($num -gt $highest) { $highest = $num # Only update if no timestamp branch found yet @@ -139,9 +139,13 @@ function Test-FeatureBranch { return $true } - if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') { + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') + $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" + Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" return $false } return $true diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 0c9eb07b4..9e36f1756 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -186,11 +186,26 @@ class TestCheckFeatureBranch: result = source_and_call('check_feature_branch "main" "true"') assert result.returncode != 0 + def test_accepts_four_digit_sequential_branch(self): + """check_feature_branch accepts 4+ digit sequential branch.""" + result = source_and_call('check_feature_branch "1234-feat" "true"') + assert result.returncode == 0 + def test_rejects_partial_timestamp(self): """Test 9: check_feature_branch rejects 7-digit date.""" result = source_and_call('check_feature_branch "2026031-143022-feat" "true"') assert result.returncode != 0 + def test_rejects_timestamp_without_slug(self): + """check_feature_branch rejects timestamp-like branch missing trailing slug.""" + result = source_and_call('check_feature_branch "20260319-143022" "true"') + assert result.returncode != 0 + + def test_rejects_7digit_timestamp_without_slug(self): + """check_feature_branch rejects 7-digit date + 6-digit time without slug.""" + result = source_and_call('check_feature_branch "2026031-143022" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── @@ -214,6 +229,15 @@ class TestFindFeatureDirByPrefix: assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat" + def test_four_digit_sequential_prefix(self, tmp_path: Path): + """find_feature_dir_by_prefix resolves 4+ digit sequential prefix.""" + (tmp_path / "specs" / "1000-original-feat").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + # ── get_current_branch Tests ───────────────────────────────────────────────── From b44ffc010183bf77895343bf19bce999fbd88d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Thu, 2 Apr 2026 14:52:21 +0200 Subject: [PATCH 2/7] feat(scripts): add --dry-run flag to create-new-feature (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(scripts): add --dry-run flag to create-new-feature scripts Add a --dry-run / -DryRun flag to both bash and PowerShell create-new-feature scripts that computes the next branch name, spec file path, and feature number without creating any branches, directories, or files. This enables external tools to query the next available name before running the full specify workflow. When combined with --json, the output includes a DRY_RUN field. Without --dry-run, behavior is completely unchanged. Closes #1931 Assisted-By: 🤖 Claude Code * fix(scripts): gate specs/ dir creation behind dry-run check Dry-run was unconditionally creating the root specs/ directory via mkdir -p / New-Item before the dry-run guard. This violated the documented contract of zero side effects. Also adds returncode assertion on git branch --list in tests and adds PowerShell dry-run test coverage (skipped when pwsh unavailable). Addresses review comments on #1998. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Gate `mkdir -p $SPECS_DIR` behind DRY_RUN check (bash + PowerShell) so dry-run creates zero directories - Add returncode assertion on `git branch --list` in test - Strengthen spec dir test to verify root `specs/` is not created - Add PowerShell dry-run test class (5 tests, skipped without pwsh) - Fix run_ps_script to use temp repo copy instead of project root Assisted-By: 🤖 Claude Code * fix: use git ls-remote for remote-aware dry-run numbering Dry-run now queries remote branches via `git ls-remote --heads` (read-only, no fetch) to account for remote-only branches when computing the next sequential number. This prevents dry-run from returning a number that already exists on a remote. Added test verifying dry-run sees remote-only higher-numbered branches and adjusts numbering accordingly. Assisted-By: 🤖 Claude Code * fix(scripts): deduplicate number extraction and branch scanning logic Extract shared _extract_highest_number helper (bash) and Get-HighestNumberFromNames (PowerShell) to eliminate duplicated number extraction patterns between local branch and remote ref scanning. Add SkipFetch/skip_fetch parameter to check_existing_branches / Get-NextBranchNumber so dry-run reuses the same function instead of inlining duplicate max-of-branches-and-specs logic. Assisted-By: 🤖 Claude Code * fix(tests): use isolated paths for remote branch test Move remote.git and second_clone directories under git_repo instead of git_repo.parent to prevent path collisions with parallel test workers. Assisted-By: 🤖 Claude Code * fix: address PR review feedback - Set GIT_TERMINAL_PROMPT=0 for git ls-remote calls to prevent credential prompts from blocking dry-run in automation scenarios - Add returncode assertion to test_dry_run_with_timestamp git branch --list check Assisted-By: 🤖 Claude Code --- scripts/bash/create-new-feature.sh | 189 +++++++----- scripts/powershell/create-new-feature.ps1 | 233 +++++++++------ tests/test_timestamp_branches.py | 338 ++++++++++++++++++++++ 3 files changed, 603 insertions(+), 157 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 54ba1dbf5..36ea53799 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -3,6 +3,7 @@ set -e JSON_MODE=false +DRY_RUN=false ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" @@ -15,6 +16,9 @@ while [ $i -le $# ]; do --json) JSON_MODE=true ;; + --dry-run) + DRY_RUN=true + ;; --allow-existing-branch) ALLOW_EXISTING=true ;; @@ -49,10 +53,11 @@ while [ $i -le $# ]; do USE_TIMESTAMP=true ;; --help|-h) - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" + echo " --dry-run Compute branch name and paths without creating branches, directories, or files" echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" @@ -74,7 +79,7 @@ done FEATURE_DESCRIPTION="${ARGS[*]}" if [ -z "$FEATURE_DESCRIPTION" ]; then - echo "Usage: $0 [--json] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " >&2 exit 1 fi @@ -110,39 +115,59 @@ get_highest_from_specs() { # Function to get highest number from git branches get_highest_from_branches() { + git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number +} + +# Extract the highest sequential feature number from a list of ref names (one per line). +# Shared by get_highest_from_branches and get_highest_from_remote_refs. +_extract_highest_number() { 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 sequential feature number (>=3 digits), skip timestamp branches. - if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then - number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0") - number=$((10#$number)) - if [ "$number" -gt "$highest" ]; then - highest=$number - fi + while IFS= read -r name; do + [ -z "$name" ] && continue + if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0") + number=$((10#$number)) + if [ "$number" -gt "$highest" ]; then + highest=$number fi - done <<< "$branches" - fi - + fi + done echo "$highest" } -# Function to check existing branches (local and remote) and return next available number +# Function to get highest number from remote branches without fetching (side-effect-free) +get_highest_from_remote_refs() { + local highest=0 + + for remote in $(git remote 2>/dev/null); do + local remote_highest + remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number) + if [ "$remote_highest" -gt "$highest" ]; then + highest=$remote_highest + fi + done + + echo "$highest" +} + +# Function to check existing branches (local and remote) and return next available number. +# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching. check_existing_branches() { local specs_dir="$1" + local skip_fetch="${2:-false}" - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - git fetch --all --prune >/dev/null 2>&1 || true - - # Get highest number from ALL branches (not just matching short name) - local highest_branch=$(get_highest_from_branches) + if [ "$skip_fetch" = true ]; then + # Side-effect-free: query remotes via ls-remote + local highest_remote=$(get_highest_from_remote_refs) + local highest_branch=$(get_highest_from_branches) + if [ "$highest_remote" -gt "$highest_branch" ]; then + highest_branch=$highest_remote + fi + else + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + git fetch --all --prune >/dev/null 2>&1 || true + local highest_branch=$(get_highest_from_branches) + fi # Get highest number from ALL specs (not just matching short name) local highest_spec=$(get_highest_from_specs "$specs_dir") @@ -179,7 +204,9 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -mkdir -p "$SPECS_DIR" +if [ "$DRY_RUN" != true ]; then + mkdir -p "$SPECS_DIR" +fi # Function to generate branch name with stop word filtering and length filtering generate_branch_name() { @@ -251,7 +278,14 @@ if [ "$USE_TIMESTAMP" = true ]; then else # Determine branch number if [ -z "$BRANCH_NUMBER" ]; then - if [ "$HAS_GIT" = true ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + # Dry-run without git: local spec dirs only + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then # Check existing branches on remotes BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") else @@ -288,62 +322,79 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&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 - if [ "$ALLOW_EXISTING" = true ]; then - # Switch to the existing branch instead of failing - if ! git checkout "$BRANCH_NAME" 2>/dev/null; then - >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." +FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" +SPEC_FILE="$FEATURE_DIR/spec.md" + +if [ "$DRY_RUN" != true ]; then + 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 + if [ "$ALLOW_EXISTING" = true ]; then + # Switch to the existing branch instead of failing + if ! git checkout "$BRANCH_NAME" 2>/dev/null; then + >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + exit 1 + fi + elif [ "$USE_TIMESTAMP" = true ]; then + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." + exit 1 + else + >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." exit 1 fi - elif [ "$USE_TIMESTAMP" = true ]; then - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name." - exit 1 else - >&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number." + >&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 + + mkdir -p "$FEATURE_DIR" + + if [ ! -f "$SPEC_FILE" ]; then + TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true + if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then + cp "$TEMPLATE" "$SPEC_FILE" else - >&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again." - exit 1 + echo "Warning: Spec template not found; created empty spec file" >&2 + touch "$SPEC_FILE" fi fi -else - >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" + + # Inform the user how to persist the feature variable in their own shell + printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -mkdir -p "$FEATURE_DIR" - -SPEC_FILE="$FEATURE_DIR/spec.md" -if [ ! -f "$SPEC_FILE" ]; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" - fi -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}' + if [ "$DRY_RUN" = true ]; 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,DRY_RUN:true}' + else + 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}' + fi else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + if [ "$DRY_RUN" = true ]; then + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$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 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" + if [ "$DRY_RUN" != true ]; then + printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" + fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 3708ea2db..2cfa35139 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -4,6 +4,7 @@ param( [switch]$Json, [switch]$AllowExistingBranch, + [switch]$DryRun, [string]$ShortName, [Parameter()] [long]$Number = 0, @@ -16,10 +17,11 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" + Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" @@ -35,7 +37,7 @@ if ($Help) { # Check if feature description provided if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { - Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " exit 1 } @@ -49,7 +51,7 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) { function Get-HighestNumberFromSpecs { param([string]$SpecsDir) - + [long]$highest = 0 if (Test-Path $SpecsDir) { Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object { @@ -65,48 +67,87 @@ function Get-HighestNumberFromSpecs { return $highest } -function Get-HighestNumberFromBranches { - param() - +# Extract the highest sequential feature number from a list of branch/ref names. +# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs. +function Get-HighestNumberFromNames { + param([string[]]$Names) + [long]$highest = 0 - try { - $branches = git branch -a 2>$null - if ($LASTEXITCODE -eq 0) { - foreach ($branch in $branches) { - # Clean branch name: remove leading markers and remote prefixes - $cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' - - # Extract sequential feature number (>=3 digits), skip timestamp branches. - if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') { - [long]$num = 0 - if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { - $highest = $num - } - } + foreach ($name in $Names) { + if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') { + [long]$num = 0 + if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) { + $highest = $num } } - } catch { - # If git command fails, return 0 - Write-Verbose "Could not check Git branches: $_" } return $highest } +function Get-HighestNumberFromBranches { + param() + + try { + $branches = git branch -a 2>$null + if ($LASTEXITCODE -eq 0 -and $branches) { + $cleanNames = $branches | ForEach-Object { + $_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', '' + } + return Get-HighestNumberFromNames -Names $cleanNames + } + } catch { + Write-Verbose "Could not check Git branches: $_" + } + return 0 +} + +function Get-HighestNumberFromRemoteRefs { + [long]$highest = 0 + try { + $remotes = git remote 2>$null + if ($remotes) { + foreach ($remote in $remotes) { + $env:GIT_TERMINAL_PROMPT = '0' + $refs = git ls-remote --heads $remote 2>$null + $env:GIT_TERMINAL_PROMPT = $null + if ($LASTEXITCODE -eq 0 -and $refs) { + $refNames = $refs | ForEach-Object { + if ($_ -match 'refs/heads/(.+)$') { $matches[1] } + } | Where-Object { $_ } + $remoteHighest = Get-HighestNumberFromNames -Names $refNames + if ($remoteHighest -gt $highest) { $highest = $remoteHighest } + } + } + } + } catch { + Write-Verbose "Could not query remote refs: $_" + } + return $highest +} + +# Return next available branch number. When SkipFetch is true, queries remotes +# via ls-remote (read-only) instead of fetching. function Get-NextBranchNumber { param( - [string]$SpecsDir + [string]$SpecsDir, + [switch]$SkipFetch ) - # Fetch all remotes to get latest branch info (suppress errors if no remotes) - try { - git fetch --all --prune 2>$null | Out-Null - } catch { - # Ignore fetch errors + if ($SkipFetch) { + # Side-effect-free: query remotes via ls-remote + $highestBranch = Get-HighestNumberFromBranches + $highestRemote = Get-HighestNumberFromRemoteRefs + $highestBranch = [Math]::Max($highestBranch, $highestRemote) + } else { + # Fetch all remotes to get latest branch info (suppress errors if no remotes) + try { + git fetch --all --prune 2>$null | Out-Null + } catch { + # Ignore fetch errors + } + $highestBranch = Get-HighestNumberFromBranches } - # Get highest number from ALL branches (not just matching short name) - $highestBranch = Get-HighestNumberFromBranches - # Get highest number from ALL specs (not just matching short name) $highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir @@ -119,7 +160,7 @@ function Get-NextBranchNumber { function ConvertTo-CleanBranchName { param([string]$Name) - + return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' } # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) @@ -134,12 +175,14 @@ $hasGit = Test-HasGit Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +if (-not $DryRun) { + New-Item -ItemType Directory -Path $specsDir -Force | Out-Null +} # Function to generate branch name with stop word filtering and length filtering function Get-BranchName { param([string]$Description) - + # Common stop words to filter out $stopWords = @( 'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from', @@ -148,17 +191,17 @@ function Get-BranchName { 'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their', 'want', 'need', 'add', 'get', 'set' ) - + # Convert to lowercase and extract words (alphanumeric only) $cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' ' $words = $cleanName -split '\s+' | Where-Object { $_ } - + # Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original) $meaningfulWords = @() foreach ($word in $words) { # Skip stop words if ($stopWords -contains $word) { continue } - + # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word @@ -167,7 +210,7 @@ function Get-BranchName { $meaningfulWords += $word } } - + # If we have meaningful words, use first 3-4 of them if ($meaningfulWords.Count -gt 0) { $maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 } @@ -203,7 +246,13 @@ if ($Timestamp) { } else { # Determine branch number if ($Number -eq 0) { - if ($hasGit) { + if ($DryRun -and $hasGit) { + # Dry-run: query remotes via ls-remote (side-effect-free, no fetch) + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + # Dry-run without git: local spec dirs only + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { # Check existing branches on remotes $Number = Get-NextBranchNumber -SpecsDir $specsDir } else { @@ -224,86 +273,94 @@ if ($branchName.Length -gt $maxBranchLength) { # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4 $prefixLength = $featureNum.Length + 1 $maxSuffixLength = $maxBranchLength - $prefixLength - + # Truncate suffix $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength)) # Remove trailing hyphen if truncation created one $truncatedSuffix = $truncatedSuffix -replace '-$', '' - + $originalBranchName = $branchName $branchName = "$featureNum-$truncatedSuffix" - + Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit" Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)" Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -if ($hasGit) { - $branchCreated = $false - try { - git checkout -q -b $branchName 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - $branchCreated = $true - } - } catch { - # Exception during git command - } +$featureDir = Join-Path $specsDir $branchName +$specFile = Join-Path $featureDir 'spec.md' - if (-not $branchCreated) { - # Check if branch already exists - $existingBranch = git branch --list $branchName 2>$null - if ($existingBranch) { - if ($AllowExistingBranch) { - # Switch to the existing branch instead of failing - git checkout -q $branchName 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." +if (-not $DryRun) { + if ($hasGit) { + $branchCreated = $false + try { + git checkout -q -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $branchCreated = $true + } + } catch { + # Exception during git command + } + + if (-not $branchCreated) { + # Check if branch already exists + $existingBranch = git branch --list $branchName 2>$null + if ($existingBranch) { + if ($AllowExistingBranch) { + # Switch to the existing branch instead of failing + git checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + exit 1 + } + } elseif ($Timestamp) { + Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." + exit 1 + } else { + Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." exit 1 } - } elseif ($Timestamp) { - Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName." - exit 1 } else { - Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number." + Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." exit 1 } + } + } else { + Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + } + + New-Item -ItemType Directory -Path $featureDir -Force | Out-Null + + if (-not (Test-Path -PathType Leaf $specFile)) { + $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot + if ($template -and (Test-Path $template)) { + Copy-Item $template $specFile -Force } else { - Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again." - exit 1 + New-Item -ItemType File -Path $specFile -Force | Out-Null } } -} else { - Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName" + + # Set the SPECIFY_FEATURE environment variable for the current session + $env:SPECIFY_FEATURE = $branchName } -$featureDir = Join-Path $specsDir $branchName -New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - -$specFile = Join-Path $featureDir 'spec.md' -if (-not (Test-Path -PathType Leaf $specFile)) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - if ($template -and (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 HAS_GIT = $hasGit } + if ($DryRun) { + $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true + } $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" - Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + if (-not $DryRun) { + Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" + } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 9e36f1756..edc93fb39 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -436,3 +436,341 @@ class TestAllowExistingBranchPowerShell: assert "-AllowExistingBranch" in contents # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + + +# ── Dry-Run Tests ──────────────────────────────────────────────────────────── + + +class TestDryRun: + def test_dry_run_sequential_outputs_name(self, git_repo: Path): + """T009: Dry-run computes correct branch name with existing specs.""" + (git_repo / "specs" / "001-first-feat").mkdir(parents=True) + (git_repo / "specs" / "002-second-feat").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "new-feat", "New feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}" + + def test_dry_run_no_branch_created(self, git_repo: Path): + """T010: Dry-run does not create a git branch.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature" + ) + assert result.returncode == 0, result.stderr + branches = subprocess.run( + ["git", "branch", "--list", "*no-branch*"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "", "branch should not exist after dry-run" + + def test_dry_run_no_spec_dir_created(self, git_repo: Path): + """T011: Dry-run does not create any directories (including root specs/).""" + specs_root = git_repo / "specs" + if specs_root.exists(): + shutil.rmtree(specs_root) + assert not specs_root.exists(), "specs/ should not exist before dry-run" + + result = run_script( + git_repo, "--dry-run", "--short-name", "no-dir", "No dir feature" + ) + assert result.returncode == 0, result.stderr + assert not specs_root.exists(), "specs/ should not be created during dry-run" + + def test_dry_run_empty_repo(self, git_repo: Path): + """T012: Dry-run returns 001 prefix when no existing specs or branches.""" + result = run_script( + git_repo, "--dry-run", "--short-name", "first", "First feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "001-first", f"expected 001-first, got: {branch}" + + def test_dry_run_with_short_name(self, git_repo: Path): + """T013: Dry-run with --short-name produces expected name.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + (git_repo / "specs" / "002-existing").mkdir(parents=True) + (git_repo / "specs" / "003-existing").mkdir(parents=True) + result = run_script( + git_repo, "--dry-run", "--short-name", "user-auth", "Add user authentication" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "004-user-auth", f"expected 004-user-auth, got: {branch}" + + def test_dry_run_then_real_run_match(self, git_repo: Path): + """T014: Dry-run name matches subsequent real creation.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + # Dry-run first + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "match-test", "Match test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + # Real run + real_result = run_script( + git_repo, "--short-name", "match-test", "Match test" + ) + assert real_result.returncode == 0, real_result.stderr + real_branch = None + for line in real_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + real_branch = line.split(":", 1)[1].strip() + assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}" + + def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): + """Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering.""" + (git_repo / "specs" / "001-existing").mkdir(parents=True) + + # Set up a bare remote and push (use subdirs of git_repo for isolation) + remote_dir = git_repo / "test-remote.git" + subprocess.run( + ["git", "init", "--bare", str(remote_dir)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "remote", "add", "origin", str(remote_dir)], + check=True, cwd=git_repo, capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "HEAD"], + check=True, cwd=git_repo, capture_output=True, + ) + + # Clone into a second copy, create a higher-numbered branch, push it + second_clone = git_repo / "test-second-clone" + subprocess.run( + ["git", "clone", str(remote_dir), str(second_clone)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=second_clone, check=True, capture_output=True, + ) + # Create branch 005 on the remote (higher than local 001) + subprocess.run( + ["git", "checkout", "-b", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "origin", "005-remote-only"], + cwd=second_clone, check=True, capture_output=True, + ) + + # Primary repo: dry-run should see 005 via ls-remote and return 006 + dry_result = run_script( + git_repo, "--dry-run", "--short-name", "remote-test", "Remote test" + ) + assert dry_result.returncode == 0, dry_result.stderr + dry_branch = None + for line in dry_result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + dry_branch = line.split(":", 1)[1].strip() + assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}" + + def test_dry_run_json_includes_field(self, git_repo: Path): + """T015: JSON output includes DRY_RUN field when --dry-run is active.""" + import json + + result = run_script( + git_repo, "--dry-run", "--json", "--short-name", "json-test", "JSON test" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}" + assert data["DRY_RUN"] is True + + def test_dry_run_json_absent_without_flag(self, git_repo: Path): + """T016: Normal JSON output does NOT include DRY_RUN field.""" + import json + + result = run_script( + git_repo, "--json", "--short-name", "no-dry", "No dry run" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + def test_dry_run_with_timestamp(self, git_repo: Path): + """T017: Dry-run works with --timestamp flag.""" + result = run_script( + git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch is not None, "no BRANCH_NAME in output" + assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" + # Verify no side effects + branches = subprocess.run( + ["git", "branch", "--list", f"*ts-feat*"], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "" + + def test_dry_run_with_number(self, git_repo: Path): + """T018: Dry-run works with --number flag.""" + result = run_script( + git_repo, "--dry-run", "--number", "42", "--short-name", "num-feat", "Number feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "042-num-feat", f"expected 042-num-feat, got: {branch}" + + def test_dry_run_no_git(self, no_git_dir: Path): + """T019: Dry-run works in non-git directory.""" + (no_git_dir / "specs" / "001-existing").mkdir(parents=True) + result = run_script( + no_git_dir, "--dry-run", "--short-name", "no-git-dry", "No git dry run" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "002-no-git-dry", f"expected 002-no-git-dry, got: {branch}" + # Verify no spec dir created + spec_dirs = [ + d.name + for d in (no_git_dir / "specs").iterdir() + if d.is_dir() and "no-git-dry" in d.name + ] + assert len(spec_dirs) == 0 + + +# ── PowerShell Dry-Run Tests ───────────────────────────────────────────────── + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + try: + subprocess.run(["pwsh", "--version"], capture_output=True, check=True) + return True + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + +def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run create-new-feature.ps1 from the temp repo's scripts directory.""" + script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" + cmd = ["pwsh", "-NoProfile", "-File", str(script), *args] + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +@pytest.fixture +def ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell scripts and .specify dir.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True + ) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], + cwd=tmp_path, + check=True, + ) + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE_PS, ps_dir / "create-new-feature.ps1") + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + return tmp_path + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available") +class TestPowerShellDryRun: + def test_ps_dry_run_outputs_name(self, ps_git_repo: Path): + """PowerShell -DryRun computes correct branch name.""" + (ps_git_repo / "specs" / "001-first").mkdir(parents=True) + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "ps-feat", "PS feature" + ) + assert result.returncode == 0, result.stderr + branch = None + for line in result.stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + branch = line.split(":", 1)[1].strip() + assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}" + + def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path): + """PowerShell -DryRun does not create a git branch.""" + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch" + ) + assert result.returncode == 0, result.stderr + branches = subprocess.run( + ["git", "branch", "--list", "*no-ps-branch*"], + cwd=ps_git_repo, + capture_output=True, + text=True, + ) + assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" + assert branches.stdout.strip() == "", "branch should not exist after dry-run" + + def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path): + """PowerShell -DryRun does not create specs/ directory.""" + specs_root = ps_git_repo / "specs" + if specs_root.exists(): + shutil.rmtree(specs_root) + assert not specs_root.exists() + + result = run_ps_script( + ps_git_repo, "-DryRun", "-ShortName", "no-ps-dir", "No dir" + ) + assert result.returncode == 0, result.stderr + assert not specs_root.exists(), "specs/ should not be created during dry-run" + + def test_ps_dry_run_json_includes_field(self, ps_git_repo: Path): + """PowerShell -DryRun JSON output includes DRY_RUN field.""" + import json + + result = run_ps_script( + ps_git_repo, "-DryRun", "-Json", "-ShortName", "ps-json", "JSON test" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" in data, f"DRY_RUN missing from JSON: {data}" + assert data["DRY_RUN"] is True + + def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): + """PowerShell normal JSON output does NOT include DRY_RUN field.""" + import json + + result = run_ps_script( + ps_git_repo, "-Json", "-ShortName", "ps-no-dry", "No dry run" + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" From 4f9d966bebc77fde9b5f2ae06eda5eddf3d54272 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:00:12 -0500 Subject: [PATCH 3/7] Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage 5: Skills, Generic & Option-Driven Integrations (#1924) Add SkillsIntegration base class and migrate codex, kimi, agy, and generic to the integration system. Integrations: - SkillsIntegration(IntegrationBase) in base.py — creates speckit-/SKILL.md layout matching release ZIP output byte-for-byte - CodexIntegration — .agents/skills/, --skills default=True - KimiIntegration — .kimi/skills/, --skills + --migrate-legacy options, dotted→hyphenated skill directory migration - AgyIntegration — .agent/skills/, skills-only (commands deprecated v1.20.5) - GenericIntegration — user-specified --commands-dir, MarkdownIntegration - All four have update-context.sh/.ps1 scripts - All four registered in INTEGRATION_REGISTRY CLI changes: - --ai auto-promotes to integration path for all registered agents - Interactive agent selection also auto-promotes (bug fix) - --ai-skills and --ai-commands-dir show deprecation notices on integration path - Next-steps display shows correct skill invocation syntax for skills integrations - agy added to CommandRegistrar.AGENT_CONFIGS Tests: - test_integration_base_skills.py — reusable mixin with setup, frontmatter, directory structure, scripts, CLI auto-promote, and complete file inventory (sh+ps) tests - Per-agent test files: test_integration_{codex,kimi,agy,generic}.py - Kimi legacy migration tests, generic --commands-dir validation - Registry updated with Stage 5 keys - Removed 9 dead-mock tests, moved 4 integration tests to proper locations - Fixed all bare project-name tests to use tmp_path - Fixed 6 pre-existing ANSI escape code test failures in test_extensions.py and test_presets.py 1524 tests pass, 0 failures. * fix: remove unused variable flagged by ruff (F841) * fix: address PR review — integration-type-aware deprecation messages and early generic validation - --ai-skills deprecation message now distinguishes SkillsIntegration ("skills are the default") from command-based integrations ("has no effect") - --ai-commands-dir validation for generic runs even when auto-promoted, giving clear CLI error instead of late ValueError from setup() - Resolves review comments from #2052 * fix: address PR review round 2 - Remove unused SKILL_DESCRIPTIONS dict from base.py (dead code after switching to template descriptions for ZIP parity) - Narrow YAML parse catch from Exception to yaml.YAMLError - Remove unused shutil import from test_integration_kimi.py - Remove unused _REGISTRAR_EXEMPT class attr from test_registry.py - Reword --ai-commands-dir deprecation to be actionable - Update generic validation error to mention both --ai and --integration * fix: address PR review round 3 - Clarify parsed_options forwarding is intentional (all options passed, integrations decide what to use) - Extract _strip_ansi() helper in test_extensions.py and test_presets.py - Remove unused pytest import (test_cli.py), unused locals (test_integration_base_skills.py) - Reword --ai-commands-dir deprecation to be actionable without referencing the not-yet-implemented --integration-options * fix: address PR review round 4 - Reorder kimi migration: run super().setup() first so hyphenated targets exist, then migrate dotted dirs (prevents user content loss) - Move _strip_ansi() to shared tests/conftest.py, import from there in test_extensions.py, test_presets.py, test_ai_skills.py - Remove now-unused re imports from all three test files * fix: address PR review round 5 - Use write_bytes() for LF-only newlines (no CRLF on Windows) - Add --integration-options CLI parameter — raw string passed through to the integration via opts['raw_options']; the integration owns parsing of its own options - GenericIntegration.setup() reads --commands-dir from raw_options when not in parsed_options (supports --integration-options="...") - Skip early --ai-commands-dir validation when --integration-options is provided (integration validates in its own setup()) - Remove parse_integration_options from core — integrations parse their own options * fix: address PR review round 6 - GenericIntegration is now stateless: removed self._commands_dir instance state, overrides setup() directly to compute destination from parsed_options/raw_options on the stack - commands_dest() raises by design (stateless singleton) - _quote() in SkillsIntegration now escapes backslashes and double quotes to produce valid YAML even with special characters * fix: address PR review round 7 - Support --commands-dir=value form in raw_options parsing (not just --commands-dir value with space separator) - Normalize CRLF to LF in write_file_and_record() before encoding - Persist ai_skills=True in init-options.json when using a SkillsIntegration, so extensions/presets emit SKILL.md overrides correctly even without explicit --ai-skills flag --- src/specify_cli/__init__.py | 97 +++- src/specify_cli/agents.py | 6 + src/specify_cli/integrations/__init__.py | 8 + src/specify_cli/integrations/agy/__init__.py | 41 ++ .../agy/scripts/update-context.ps1 | 17 + .../agy/scripts/update-context.sh | 24 + src/specify_cli/integrations/base.py | 162 ++++++- .../integrations/codex/__init__.py | 40 ++ .../codex/scripts/update-context.ps1 | 17 + .../codex/scripts/update-context.sh | 24 + .../integrations/generic/__init__.py | 133 ++++++ .../generic/scripts/update-context.ps1 | 17 + .../generic/scripts/update-context.sh | 24 + src/specify_cli/integrations/kimi/__init__.py | 124 ++++++ .../kimi/scripts/update-context.ps1 | 17 + .../kimi/scripts/update-context.sh | 24 + tests/conftest.py | 10 + tests/integrations/test_cli.py | 10 +- tests/integrations/test_integration_agy.py | 25 ++ .../test_integration_base_skills.py | 402 +++++++++++++++++ tests/integrations/test_integration_codex.py | 25 ++ .../integrations/test_integration_generic.py | 311 +++++++++++++ tests/integrations/test_integration_kimi.py | 149 +++++++ .../integrations/test_integration_kiro_cli.py | 29 ++ tests/integrations/test_registry.py | 17 +- tests/test_ai_skills.py | 414 ++---------------- tests/test_extensions.py | 17 +- tests/test_presets.py | 7 +- 28 files changed, 1777 insertions(+), 414 deletions(-) create mode 100644 src/specify_cli/integrations/agy/__init__.py create mode 100644 src/specify_cli/integrations/agy/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/agy/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/codex/__init__.py create mode 100644 src/specify_cli/integrations/codex/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/codex/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/generic/__init__.py create mode 100644 src/specify_cli/integrations/generic/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/generic/scripts/update-context.sh create mode 100644 src/specify_cli/integrations/kimi/__init__.py create mode 100644 src/specify_cli/integrations/kimi/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/kimi/scripts/update-context.sh create mode 100644 tests/conftest.py create mode 100644 tests/integrations/test_integration_agy.py create mode 100644 tests/integrations/test_integration_base_skills.py create mode 100644 tests/integrations/test_integration_codex.py create mode 100644 tests/integrations/test_integration_generic.py create mode 100644 tests/integrations/test_integration_kimi.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 698b672da..7d1ecbc00 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1907,6 +1907,7 @@ def init( preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), + integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): """ Initialize a new Specify project. @@ -1997,6 +1998,26 @@ def init( f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]" ) + # Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path + if use_integration: + if ai_skills: + from .integrations.base import SkillsIntegration as _SkillsCheck + if isinstance(resolved_integration, _SkillsCheck): + console.print( + "[dim]Note: --ai-skills is not needed with --integration; " + "skills are the default for this integration.[/dim]" + ) + else: + console.print( + "[dim]Note: --ai-skills has no effect with --integration " + f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" + ) + if ai_commands_dir and resolved_integration.key != "generic": + console.print( + "[dim]Note: --ai-commands-dir is deprecated; " + 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' + ) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -2062,8 +2083,18 @@ def init( "copilot" ) + # Auto-promote interactively selected agents to the integration path + # when a matching integration is registered (same behavior as --ai). + if not use_integration: + from .integrations import get_integration as _get_int + _resolved = _get_int(selected_ai) + if _resolved: + use_integration = True + resolved_integration = _resolved + # Agents that have moved from explicit commands/prompts to agent skills. - if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: + # Skip this check when using the integration path — skills are the default. + if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: # If selected interactively (no --ai provided), automatically enable # ai_skills so the agent remains usable without requiring an extra flag. # Preserve fail-fast behavior only for explicit '--ai ' without skills. @@ -2073,14 +2104,20 @@ def init( ai_skills = True console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}") - # Validate --ai-commands-dir usage - if selected_ai == "generic": + # Validate --ai-commands-dir usage. + # Skip validation when --integration-options is provided — the integration + # will validate its own options in setup(). + if selected_ai == "generic" and not integration_options: if not ai_commands_dir: - console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic") - console.print("[dim]Example: specify init my-project --ai generic --ai-commands-dir .myagent/commands/[/dim]") + console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") + console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]") raise typer.Exit(1) - elif ai_commands_dir: - console.print(f"[red]Error:[/red] --ai-commands-dir can only be used with --ai generic (not '{selected_ai}')") + elif ai_commands_dir and not use_integration: + console.print( + f"[red]Error:[/red] --ai-commands-dir can only be used with the " + f"'generic' integration via --ai generic or --integration generic " + f"(not '{selected_ai}')" + ) raise typer.Exit(1) current_dir = Path.cwd() @@ -2210,9 +2247,21 @@ def init( manifest = IntegrationManifest( resolved_integration.key, project_path, version=get_speckit_version() ) + + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True + resolved_integration.setup( project_path, manifest, + parsed_options=integration_parsed_options or None, script_type=selected_script, + raw_options=integration_options, ) manifest.save() @@ -2268,7 +2317,7 @@ def init( shutil.rmtree(project_path) raise typer.Exit(1) # For generic agent, rename placeholder directory to user-specified path - if selected_ai == "generic" and ai_commands_dir: + if not use_integration and selected_ai == "generic" and ai_commands_dir: placeholder_dir = project_path / ".speckit" / "commands" target_dir = project_path / ai_commands_dir if placeholder_dir.is_dir(): @@ -2284,10 +2333,11 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) # Determine skills directory and migrate any legacy Kimi dotted skills. + # (Legacy path only — integration path handles skills in setup().) migrated_legacy_kimi_skills = 0 removed_legacy_kimi_skills = 0 skills_dir: Optional[Path] = None - if selected_ai in NATIVE_SKILLS_AGENTS: + if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS: skills_dir = _get_skills_dir(project_path, selected_ai) if selected_ai == "kimi" and skills_dir.is_dir(): ( @@ -2295,7 +2345,7 @@ def init( removed_legacy_kimi_skills, ) = _migrate_legacy_kimi_dotted_skills(skills_dir) - if ai_skills: + if not use_integration and ai_skills: if selected_ai in NATIVE_SKILLS_AGENTS: bundled_found = _has_bundled_skills(project_path, selected_ai) if bundled_found: @@ -2383,6 +2433,11 @@ def init( } if use_integration: init_opts["integration"] = resolved_integration.key + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist): + init_opts["ai_skills"] = True save_init_options(project_path, init_opts) # Install preset if specified @@ -2484,17 +2539,27 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 - if selected_ai == "codex" and ai_skills: + # Determine skill display mode for the next-steps panel. + # Skills integrations (codex, kimi, agy) should show skill invocation syntax + # regardless of whether --ai-skills was explicitly passed. + _is_skills_integration = False + if use_integration: + from .integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + + codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) + kimi_skill_mode = selected_ai == "kimi" + agy_skill_mode = selected_ai == "agy" and _is_skills_integration + native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode + + if codex_skill_mode and not ai_skills: + # Integration path installed skills; show the helpful notice steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") step_num += 1 - - codex_skill_mode = selected_ai == "codex" and ai_skills - kimi_skill_mode = selected_ai == "kimi" - native_skill_mode = codex_skill_mode or kimi_skill_mode usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: - if codex_skill_mode: + if codex_skill_mode or agy_skill_mode: return f"$speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4a8c2d1b2..8107ae701 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -168,6 +168,12 @@ class CommandRegistrar: "format": "markdown", "args": "$ARGUMENTS", "extension": ".md" + }, + "agy": { + "dir": ".agent/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", } } diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ed131103c..bb87cec99 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -46,17 +46,21 @@ def _register_builtins() -> None: users install and invoke. """ # -- Imports (alphabetical) ------------------------------------------- + from .agy import AgyIntegration from .amp import AmpIntegration from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration + from .codex import CodexIntegration from .codebuddy import CodebuddyIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .gemini import GeminiIntegration + from .generic import GenericIntegration from .iflow import IflowIntegration from .junie import JunieIntegration from .kilocode import KilocodeIntegration + from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration @@ -70,17 +74,21 @@ def _register_builtins() -> None: from .windsurf import WindsurfIntegration # -- Registration (alphabetical) -------------------------------------- + _register(AgyIntegration()) _register(AmpIntegration()) _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) + _register(CodexIntegration()) _register(CodebuddyIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(GeminiIntegration()) + _register(GenericIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) + _register(KimiIntegration()) _register(KiroCliIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py new file mode 100644 index 000000000..9cd522745 --- /dev/null +++ b/src/specify_cli/integrations/agy/__init__.py @@ -0,0 +1,41 @@ +"""Antigravity (agy) integration — skills-based agent. + +Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout. +Explicit command support was deprecated in version 1.20.5; +``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class AgyIntegration(SkillsIntegration): + """Integration for Antigravity IDE.""" + + key = "agy" + config = { + "name": "Antigravity", + "folder": ".agent/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".agent/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Antigravity since v1.20.5)", + ), + ] diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 new file mode 100644 index 000000000..9eeb46165 --- /dev/null +++ b/src/specify_cli/integrations/agy/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh new file mode 100755 index 000000000..d7303f619 --- /dev/null +++ b/src/specify_cli/integrations/agy/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a88039b9a..dac5063f5 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -7,6 +7,8 @@ Provides: integrations (the common case — subclass, set three class attrs, done). - ``TomlIntegration`` — concrete base for TOML-format integrations (Gemini, Tabnine — subclass, set three class attrs, done). +- ``SkillsIntegration`` — concrete base for integrations that install + commands as agent skills (``speckit-/SKILL.md`` layout). """ from __future__ import annotations @@ -200,10 +202,14 @@ class IntegrationBase(ABC): ) -> Path: """Write *content* to *dest*, hash it, and record in *manifest*. - Creates parent directories as needed. Returns *dest*. + Creates parent directories as needed. Writes bytes directly to + avoid platform newline translation (CRLF on Windows). Any + ``\r\n`` sequences in *content* are normalised to ``\n`` before + writing. Returns *dest*. """ dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(content, encoding="utf-8") + normalized = content.replace("\r\n", "\n") + dest.write_bytes(normalized.encode("utf-8")) rel = dest.resolve().relative_to(project_root.resolve()) manifest.record_existing(rel) return dest @@ -633,3 +639,155 @@ class TomlIntegration(IntegrationBase): created.extend(self.install_scripts(project_root, manifest)) return created + + +# --------------------------------------------------------------------------- +# SkillsIntegration — skills-format agents (Codex, Kimi, Agy) +# --------------------------------------------------------------------------- + + +class SkillsIntegration(IntegrationBase): + """Concrete base for integrations that install commands as agent skills. + + Skills use the ``speckit-/SKILL.md`` directory layout following + the `agentskills.io `_ spec. + + Subclasses set ``key``, ``config``, ``registrar_config`` (and + optionally ``context_file``) like any integration. They may also + override ``options()`` to declare additional CLI flags (e.g. + ``--skills``, ``--migrate-legacy``). + + ``setup()`` processes each shared command template into a + ``speckit-/SKILL.md`` file with skills-oriented frontmatter. + """ + + def skills_dest(self, project_root: Path) -> Path: + """Return the absolute path to the skills output directory. + + Derived from ``config["folder"]`` and the configured + ``commands_subdir`` (defaults to ``"skills"``). + + Raises ``ValueError`` when ``config`` or ``folder`` is missing. + """ + if not self.config: + raise ValueError( + f"{type(self).__name__}.config is not set." + ) + folder = self.config.get("folder") + if not folder: + raise ValueError( + f"{type(self).__name__}.config is missing required 'folder' entry." + ) + subdir = self.config.get("commands_subdir", "skills") + return project_root / folder / subdir + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install command templates as agent skills. + + Creates ``speckit-/SKILL.md`` for each shared command + template. Each SKILL.md has normalised frontmatter containing + ``name``, ``description``, ``compatibility``, and ``metadata``. + """ + import yaml + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + skills_dir = self.skills_dest(project_root).resolve() + try: + skills_dir.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Skills destination {skills_dir} escapes " + f"project root {project_root_resolved}" + ) from exc + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + + # Derive the skill name from the template stem + command_name = src_file.stem # e.g. "plan" + skill_name = f"speckit-{command_name.replace('.', '-')}" + + # Parse frontmatter for description + frontmatter: dict[str, Any] = {} + if raw.startswith("---"): + parts = raw.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) + if isinstance(fm, dict): + frontmatter = fm + except yaml.YAMLError: + pass + + # Process body through the standard template pipeline + processed_body = self.process_template( + raw, self.key, script_type, arg_placeholder + ) + # Strip the processed frontmatter — we rebuild it for skills. + # Preserve leading whitespace in the body to match release ZIP + # output byte-for-byte (the template body starts with \n after + # the closing ---). + if processed_body.startswith("---"): + parts = processed_body.split("---", 2) + if len(parts) >= 3: + processed_body = parts[2] + + # Select description — use the original template description + # to stay byte-for-byte identical with release ZIP output. + description = frontmatter.get("description", "") + if not description: + description = f"Spec Kit: {command_name} workflow" + + # Build SKILL.md with manually formatted frontmatter to match + # the release packaging script output exactly (double-quoted + # values, no yaml.safe_dump quoting differences). + def _quote(v: str) -> str: + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + skill_content = ( + f"---\n" + f"name: {_quote(skill_name)}\n" + f"description: {_quote(description)}\n" + f"compatibility: {_quote('Requires spec-kit project structure with .specify/ directory')}\n" + f"metadata:\n" + f" author: {_quote('github-spec-kit')}\n" + f" source: {_quote('templates/commands/' + src_file.name)}\n" + f"---\n" + f"{processed_body}" + ) + + # Write speckit-/SKILL.md + skill_dir = skills_dir / skill_name + skill_file = skill_dir / "SKILL.md" + dst = self.write_file_and_record( + skill_content, skill_file, project_root, manifest + ) + created.append(dst) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py new file mode 100644 index 000000000..f6415f9bb --- /dev/null +++ b/src/specify_cli/integrations/codex/__init__.py @@ -0,0 +1,40 @@ +"""Codex CLI integration — skills-based agent. + +Codex uses the ``.agents/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class CodexIntegration(SkillsIntegration): + """Integration for OpenAI Codex CLI.""" + + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 new file mode 100644 index 000000000..d73a5a4d3 --- /dev/null +++ b/src/specify_cli/integrations/codex/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Codex CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh new file mode 100755 index 000000000..512d6e91d --- /dev/null +++ b/src/specify_cli/integrations/codex/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Codex CLI integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py new file mode 100644 index 000000000..4107c4869 --- /dev/null +++ b/src/specify_cli/integrations/generic/__init__.py @@ -0,0 +1,133 @@ +"""Generic integration — bring your own agent. + +Requires ``--commands-dir`` to specify the output directory for command +files. No longer special-cased in the core CLI — just another +integration with its own required option. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration +from ..manifest import IntegrationManifest + + +class GenericIntegration(MarkdownIntegration): + """Integration for user-specified (generic) agents.""" + + key = "generic" + config = { + "name": "Generic (bring your own agent)", + "folder": None, # Set dynamically from --commands-dir + "commands_subdir": "commands", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": "", # Set dynamically from --commands-dir + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = None + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--commands-dir", + required=True, + help="Directory for command files (e.g. .myagent/commands/)", + ), + ] + + @staticmethod + def _resolve_commands_dir( + parsed_options: dict[str, Any] | None, + opts: dict[str, Any], + ) -> str: + """Extract ``--commands-dir`` from parsed options or raw_options. + + Returns the directory string or raises ``ValueError``. + """ + parsed_options = parsed_options or {} + + commands_dir = parsed_options.get("commands_dir") + if commands_dir: + return commands_dir + + # Fall back to raw_options (--integration-options="--commands-dir ...") + raw = opts.get("raw_options") + if raw: + import shlex + tokens = shlex.split(raw) + for i, token in enumerate(tokens): + if token == "--commands-dir" and i + 1 < len(tokens): + return tokens[i + 1] + if token.startswith("--commands-dir="): + return token.split("=", 1)[1] + + raise ValueError( + "--commands-dir is required for the generic integration" + ) + + def commands_dest(self, project_root: Path) -> Path: + """Not supported for GenericIntegration — use setup() directly. + + GenericIntegration is stateless; the output directory comes from + ``parsed_options`` or ``raw_options`` at call time, not from + instance state. + """ + raise ValueError( + "GenericIntegration.commands_dest() cannot be called directly; " + "the output directory is resolved from parsed_options in setup()" + ) + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install commands to the user-provided commands directory.""" + commands_dir = self._resolve_commands_dir(parsed_options, opts) + + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = (project_root / commands_dir).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = "$ARGUMENTS" + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + processed, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 new file mode 100644 index 000000000..2e9467f80 --- /dev/null +++ b/src/specify_cli/integrations/generic/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Generic integration: create/update context file +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh new file mode 100755 index 000000000..d8ad30a7b --- /dev/null +++ b/src/specify_cli/integrations/generic/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Generic integration: create/update context file +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py new file mode 100644 index 000000000..5421d4801 --- /dev/null +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -0,0 +1,124 @@ +"""Kimi Code integration — skills-based agent (Moonshot AI). + +Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +``/skill:speckit-`` invocation syntax. + +Includes legacy migration logic for projects initialised before Kimi +moved from dotted skill directories (``speckit.xxx``) to hyphenated +(``speckit-xxx``). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, SkillsIntegration +from ..manifest import IntegrationManifest + + +class KimiIntegration(SkillsIntegration): + """Integration for Kimi Code CLI (Moonshot AI).""" + + key = "kimi" + config = { + "name": "Kimi Code", + "folder": ".kimi/", + "commands_subdir": "skills", + "install_url": "https://code.kimi.com/", + "requires_cli": True, + } + registrar_config = { + "dir": ".kimi/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "KIMI.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Kimi)", + ), + IntegrationOption( + "--migrate-legacy", + is_flag=True, + default=False, + help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)", + ), + ] + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install skills with optional legacy dotted-name migration.""" + parsed_options = parsed_options or {} + + # Run base setup first so hyphenated targets (speckit-*) exist, + # then migrate/clean legacy dotted dirs without risking user content loss. + created = super().setup( + project_root, manifest, parsed_options=parsed_options, **opts + ) + + if parsed_options.get("migrate_legacy", False): + skills_dir = self.skills_dest(project_root) + if skills_dir.is_dir(): + _migrate_legacy_kimi_dotted_skills(skills_dir) + + return created + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + + Returns ``(migrated_count, removed_count)``. + """ + if not skills_dir.is_dir(): + return (0, 0) + + migrated_count = 0 + removed_count = 0 + + for legacy_dir in sorted(skills_dir.glob("speckit.*")): + if not legacy_dir.is_dir(): + continue + if not (legacy_dir / "SKILL.md").exists(): + continue + + suffix = legacy_dir.name[len("speckit."):] + if not suffix: + continue + + target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + + if not target_dir.exists(): + shutil.move(str(legacy_dir), str(target_dir)) + migrated_count += 1 + continue + + # Target exists — only remove legacy if SKILL.md is identical + target_skill = target_dir / "SKILL.md" + legacy_skill = legacy_dir / "SKILL.md" + if target_skill.is_file(): + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + has_extra = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + pass + + return (migrated_count, removed_count) diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 new file mode 100644 index 000000000..aa6678d05 --- /dev/null +++ b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 @@ -0,0 +1,17 @@ +# update-context.ps1 — Kimi Code integration: create/update KIMI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh new file mode 100755 index 000000000..2f81bc2a4 --- /dev/null +++ b/src/specify_cli/integrations/kimi/scripts/update-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# update-context.sh — Kimi Code integration: create/update KIMI.md +# +# Thin wrapper that delegates to the shared update-agent-context script. + +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..4387c9ac8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Shared test helpers for the Spec Kit test suite.""" + +import re + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + +def strip_ansi(text: str) -> str: + """Remove ANSI escape codes from Rich-formatted CLI output.""" + return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 03b0e1186..cd0071783 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,26 +3,24 @@ import json import os -import pytest - class TestInitIntegrationFlag: - def test_integration_and_ai_mutually_exclusive(self): + def test_integration_and_ai_mutually_exclusive(self, tmp_path): from typer.testing import CliRunner from specify_cli import app runner = CliRunner() result = runner.invoke(app, [ - "init", "test-project", "--ai", "claude", "--integration", "copilot", + "init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot", ]) assert result.exit_code != 0 assert "mutually exclusive" in result.output - def test_unknown_integration_rejected(self): + def test_unknown_integration_rejected(self, tmp_path): from typer.testing import CliRunner from specify_cli import app runner = CliRunner() result = runner.invoke(app, [ - "init", "test-project", "--integration", "nonexistent", + "init", str(tmp_path / "test-project"), "--integration", "nonexistent", ]) assert result.exit_code != 0 assert "Unknown integration" in result.output diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py new file mode 100644 index 000000000..3efaa9936 --- /dev/null +++ b/tests/integrations/test_integration_agy.py @@ -0,0 +1,25 @@ +"""Tests for AgyIntegration (Antigravity).""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestAgyIntegration(SkillsIntegrationTests): + KEY = "agy" + FOLDER = ".agent/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agent/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestAgyAutoPromote: + """--ai agy auto-promotes to integration path.""" + + def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): + """--ai agy (without --ai-skills) should auto-promote to integration.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"]) + + assert "--integration agy" in result.output diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py new file mode 100644 index 000000000..23505c306 --- /dev/null +++ b/tests/integrations/test_integration_base_skills.py @@ -0,0 +1,402 @@ +"""Reusable test mixin for standard SkillsIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``SkillsIntegrationTests``. + +Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, +adapted for the ``speckit-/SKILL.md`` skills layout. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import SkillsIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class SkillsIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".agents/" + COMMANDS_SUBDIR: str — e.g. "skills" + REGISTRAR_DIR: str — e.g. ".agents/skills" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_skills_integration(self): + assert isinstance(get_integration(self.KEY), SkillsIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "markdown" + assert i.registrar_config["args"] == "$ARGUMENTS" + assert i.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + assert len(created) > 0 + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + assert f.exists() + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.skills_dest(tmp_path) + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0, "No skill files were created" + for f in skill_files: + # Each SKILL.md is in speckit-/ under the skills directory + assert f.resolve().parent.parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_skill_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + expected_commands = { + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + } + + # Derive command names from the skill directory names + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name # e.g. "speckit-plan" + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + + assert actual_commands == expected_commands + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + assert "source" in fm["metadata"] + + def test_skill_uses_template_descriptions(self, tmp_path): + """SKILL.md should use the original template description for ZIP parity.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + # Description must be a non-empty string (from the template) + assert isinstance(fm["description"], str) + assert len(fm["description"]) > 0, f"{f} has empty description" + + def test_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced, not raw templates.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content after the frontmatter.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + content = f.read_text(encoding="utf-8") + # Body is everything after the second --- + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + def test_pre_existing_skills_not_removed(self, tmp_path): + """Pre-existing non-speckit skills should be left untouched.""" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(tmp_path) + foreign_dir = skills_dir / "other-tool" + foreign_dir.mkdir(parents=True) + (foreign_dir / "SKILL.md").write_text("# Foreign skill\n") + + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + + assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + assert f"--integration {self.KEY}" in result.output + + def test_integration_flag_creates_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"int-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + + # -- IntegrationOption ------------------------------------------------ + + def test_options_include_skills_flag(self): + i = get_integration(self.KEY) + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + + # -- Complete file inventory ------------------------------------------ + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the full expected file list for a given script variant.""" + i = get_integration(self.KEY) + skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills") + + files = [] + # Skill files + for cmd in self._SKILL_COMMANDS: + files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") + # Integration metadata + files += [ + ".specify/init-options.json", + ".specify/integration.json", + f".specify/integrations/{self.KEY}.manifest.json", + f".specify/integrations/{self.KEY}/scripts/update-context.ps1", + f".specify/integrations/{self.KEY}/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ] + # Script variant + if script_variant == "sh": + files += [ + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/update-agent-context.sh", + ] + else: + files += [ + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/update-agent-context.ps1", + ] + # Templates + files += [ + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ] + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, + "--script", "ps", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py new file mode 100644 index 000000000..eb633f02b --- /dev/null +++ b/tests/integrations/test_integration_codex.py @@ -0,0 +1,25 @@ +"""Tests for CodexIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestCodexIntegration(SkillsIntegrationTests): + KEY = "codex" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestCodexAutoPromote: + """--ai codex auto-promotes to integration path.""" + + def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path): + """--ai codex (without --ai-skills) should auto-promote to integration.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"]) + + assert "--integration codex" in result.output diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py new file mode 100644 index 000000000..2815456f2 --- /dev/null +++ b/tests/integrations/test_integration_generic.py @@ -0,0 +1,311 @@ +"""Tests for GenericIntegration.""" + +import os + +import pytest + +from specify_cli.integrations import get_integration +from specify_cli.integrations.base import MarkdownIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class TestGenericIntegration: + """Tests for GenericIntegration — requires --commands-dir option.""" + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + from specify_cli.integrations import INTEGRATION_REGISTRY + assert "generic" in INTEGRATION_REGISTRY + + def test_is_markdown_integration(self): + assert isinstance(get_integration("generic"), MarkdownIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder_is_none(self): + i = get_integration("generic") + assert i.config["folder"] is None + + def test_config_requires_cli_false(self): + i = get_integration("generic") + assert i.config["requires_cli"] is False + + def test_context_file_is_none(self): + i = get_integration("generic") + assert i.context_file is None + + # -- Options ---------------------------------------------------------- + + def test_options_include_commands_dir(self): + i = get_integration("generic") + opts = i.options() + assert len(opts) == 1 + assert opts[0].name == "--commands-dir" + assert opts[0].required is True + assert opts[0].is_flag is False + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_requires_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={}) + + def test_setup_requires_nonempty_commands_dir(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + with pytest.raises(ValueError, match="--commands-dir is required"): + i.setup(tmp_path, m, parsed_options={"commands_dir": ""}) + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".myagent/commands"}, + ) + expected_dir = tmp_path / ".myagent" / "commands" + assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_setup_creates_md_files(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + assert f.name.startswith("speckit.") + assert f.name.endswith(".md") + + def test_templates_are_processed(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.setup( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + created = i.install( + tmp_path, m, + parsed_options={"commands_dir": ".custom/cmds"}, + ) + m.save() + modified = created[0] + modified.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified.exists() + assert modified in skipped + + def test_different_commands_dirs(self, tmp_path): + """Generic should work with various user-specified paths.""" + for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]: + project = tmp_path / path.replace("/", "-") + project.mkdir() + i = get_integration("generic") + m = IntegrationManifest("generic", project) + created = i.setup( + project, m, + parsed_options={"commands_dir": path}, + ) + expected = project / path + assert expected.is_dir(), f"Dir {expected} not created for {path}" + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" + assert scripts_dir.is_dir(), "Scripts directory not created for generic" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" + assert os.access(sh, os.X_OK) + + # -- CLI -------------------------------------------------------------- + + def test_cli_generic_without_commands_dir_fails(self, tmp_path): + """--integration generic without --ai-commands-dir should fail.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(tmp_path / "test-generic"), "--integration", "generic", + "--script", "sh", "--no-git", + ]) + # Generic requires --commands-dir / --ai-commands-dir + # The integration path validates via setup() + assert result.exit_code != 0 + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/generic/scripts/update-context.ps1", + ".specify/integrations/generic/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/update-agent-context.sh", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration generic --ai-commands-dir ... --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "inventory-generic-ps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "ps", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() + for p in project.rglob("*") if p.is_file() + ) + expected = sorted([ + ".myagent/commands/speckit.analyze.md", + ".myagent/commands/speckit.checklist.md", + ".myagent/commands/speckit.clarify.md", + ".myagent/commands/speckit.constitution.md", + ".myagent/commands/speckit.implement.md", + ".myagent/commands/speckit.plan.md", + ".myagent/commands/speckit.specify.md", + ".myagent/commands/speckit.tasks.md", + ".myagent/commands/speckit.taskstoissues.md", + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/generic.manifest.json", + ".specify/integrations/generic/scripts/update-context.ps1", + ".specify/integrations/generic/scripts/update-context.sh", + ".specify/integrations/speckit.manifest.json", + ".specify/memory/constitution.md", + ".specify/scripts/powershell/check-prerequisites.ps1", + ".specify/scripts/powershell/common.ps1", + ".specify/scripts/powershell/create-new-feature.ps1", + ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/update-agent-context.ps1", + ".specify/templates/agent-file-template.md", + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py new file mode 100644 index 000000000..25787e612 --- /dev/null +++ b/tests/integrations/test_integration_kimi.py @@ -0,0 +1,149 @@ +"""Tests for KimiIntegration — skills integration with legacy migration.""" + +from specify_cli.integrations import get_integration +from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.manifest import IntegrationManifest + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestKimiIntegration(SkillsIntegrationTests): + KEY = "kimi" + FOLDER = ".kimi/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".kimi/skills" + CONTEXT_FILE = "KIMI.md" + + +class TestKimiOptions: + """Kimi declares --skills and --migrate-legacy options.""" + + def test_migrate_legacy_option(self): + i = get_integration("kimi") + opts = i.options() + migrate_opts = [o for o in opts if o.name == "--migrate-legacy"] + assert len(migrate_opts) == 1 + assert migrate_opts[0].is_flag is True + assert migrate_opts[0].default is False + + +class TestKimiLegacyMigration: + """Test Kimi dotted → hyphenated skill directory migration.""" + + def test_migrate_dotted_to_hyphenated(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Plan Skill\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 1 + assert removed == 0 + assert not legacy.exists() + assert (skills_dir / "speckit-plan" / "SKILL.md").exists() + + def test_skip_when_target_exists_different_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Old\n") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text("# New (different)\n") + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + assert target.exists() + + def test_remove_when_target_exists_same_content(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Identical\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 1 + assert not legacy.exists() + assert target.exists() + + def test_preserve_legacy_with_extra_files(self, tmp_path): + skills_dir = tmp_path / ".kimi" / "skills" + content = "# Same\n" + legacy = skills_dir / "speckit.plan" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text(content) + (legacy / "extra.md").write_text("user file") + + target = skills_dir / "speckit-plan" + target.mkdir(parents=True) + (target / "SKILL.md").write_text(content) + + migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) + + assert migrated == 0 + assert removed == 0 + assert legacy.exists() + + def test_nonexistent_dir_returns_zeros(self, tmp_path): + migrated, removed = _migrate_legacy_kimi_dotted_skills( + tmp_path / ".kimi" / "skills" + ) + assert migrated == 0 + assert removed == 0 + + def test_setup_with_migrate_legacy_option(self, tmp_path): + """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" + i = get_integration("kimi") + + skills_dir = tmp_path / ".kimi" / "skills" + legacy = skills_dir / "speckit.oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiNextSteps: + """CLI output tests for kimi next-steps display.""" + + def test_next_steps_show_skill_invocation(self, tmp_path): + """Kimi next-steps guidance should display /skill:speckit-* usage.""" + import os + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "kimi-next-steps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kimi", "--no-git", + "--ignore-agent-tools", "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "/skill:speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index d6ae7afce..6b2b27b77 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -1,5 +1,7 @@ """Tests for KiroCliIntegration.""" +import os + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +11,30 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" CONTEXT_FILE = "AGENTS.md" + + +class TestKiroAlias: + """--ai kiro alias normalizes to kiro-cli and auto-promotes.""" + + def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): + """--ai kiro should normalize to canonical kiro-cli and auto-promote.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "kiro-alias-proj" + target.mkdir() + + old_cwd = os.getcwd() + try: + os.chdir(target) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "kiro", + "--ignore-agent-tools", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + assert "--integration kiro-cli" in result.output + assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index e70f3006a..8ab142514 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -11,13 +11,17 @@ from specify_cli.integrations.base import MarkdownIntegration from .conftest import StubIntegration -# Every integration key that must be registered (Stage 2 + Stage 3). +# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5). ALL_INTEGRATION_KEYS = [ "copilot", # Stage 3 — standard markdown integrations "claude", "qwen", "opencode", "junie", "kilocode", "auggie", "roo", "codebuddy", "qodercli", "amp", "shai", "bob", "trae", "pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", + # Stage 4 — TOML integrations + "gemini", "tabnine", + # Stage 5 — skills, generic & option-driven integrations + "codex", "kimi", "agy", "generic", ] @@ -61,9 +65,16 @@ class TestRegistryCompleteness: class TestRegistrarKeyAlignment: - """Every integration key must have a matching AGENT_CONFIGS entry.""" + """Every integration key must have a matching AGENT_CONFIGS entry. - @pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS) + ``generic`` is excluded because it has no fixed directory — its + output path comes from ``--commands-dir`` at runtime. + """ + + @pytest.mark.parametrize( + "key", + [k for k in ALL_INTEGRATION_KEYS if k != "generic"], + ) def test_integration_key_in_registrar(self, key): from specify_cli.agents import CommandRegistrar assert key in CommandRegistrar.AGENT_CONFIGS, ( diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index 7f9ecf66a..e4ee41828 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -10,7 +10,6 @@ Tests cover: - CLI validation: --ai-skills requires --ai """ -import re import zipfile import pytest import tempfile @@ -21,6 +20,7 @@ from pathlib import Path from unittest.mock import patch import specify_cli +from tests.conftest import strip_ansi from specify_cli import ( _get_skills_dir, @@ -684,207 +684,15 @@ class TestCommandCoexistence: assert result is True -# ===== New-Project Command Skip Tests ===== +# ===== Legacy Download Path Tests ===== -class TestNewProjectCommandSkip: - """Test that init() removes extracted commands for new projects only. +class TestLegacyDownloadPath: + """Tests for download_and_extract_template() called directly. - These tests run init() end-to-end via CliRunner with - download_and_extract_template patched to create local fixtures. + These test the legacy download/extract code that still exists in + __init__.py. They do NOT go through CLI auto-promote. """ - def _fake_extract(self, agent, project_path, **_kwargs): - """Simulate template extraction: create agent commands dir.""" - agent_cfg = AGENT_CONFIG.get(agent, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True, exist_ok=True) - (cmds_dir / "speckit.specify.md").write_text("# spec") - - def test_new_project_commands_removed_after_skills_succeed(self, tmp_path): - """For new projects, commands should be removed when skills succeed.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("claude", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - # Skills should have been called - mock_skills.assert_called_once() - - # Commands dir should have been removed after skills succeeded - cmds_dir = target / ".claude" / "commands" - assert not cmds_dir.exists() - - def test_new_project_nonstandard_commands_subdir_removed_after_skills_succeed(self, tmp_path): - """For non-standard agents, configured commands_subdir should be removed on success.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-kiro-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("kiro-cli", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "kiro-cli", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - mock_skills.assert_called_once() - - prompts_dir = target / ".kiro" / "prompts" - assert not prompts_dir.exists() - - def test_codex_native_skills_preserved_without_conversion(self, tmp_path): - """Codex should keep bundled .agents/skills and skip install_ai_skills conversion.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "new-codex-proj" - - def fake_download(project_path, *args, **kwargs): - skill_dir = project_path / ".agents" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills") as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - mock_skills.assert_not_called() - assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists() - - def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path): - """Codex should attempt fallback conversion when bundled skills are missing.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "missing-codex-skills" - - with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 1 - mock_skills.assert_called_once() - assert mock_skills.call_args.kwargs.get("overwrite_existing") is True - assert "Expected bundled agent skills" in result.output - assert "fallback conversion failed" in result.output - - def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path): - """Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "foreign-codex-skills" - - def fake_download(project_path, *args, **kwargs): - skill_dir = project_path / ".agents" / "skills" / "other-tool" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True) as mock_skills, \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - mock_skills.assert_called_once() - assert mock_skills.call_args.kwargs.get("overwrite_existing") is True - - def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path): - """Kimi init should migrate dotted legacy skills even when --ai-skills is not set.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "kimi-legacy-no-ai-skills" - - def fake_download(project_path, *args, **kwargs): - legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan" - legacy_dir.mkdir(parents=True, exist_ok=True) - (legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n") - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"], - ) - - assert result.exit_code == 0 - assert not (target / ".kimi" / "skills" / "speckit.plan").exists() - assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists() - - def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch): - """Codex --here skills init should not delete a pre-existing .codex directory.""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "codex-preserve-here" - target.mkdir() - existing_prompts = target / ".codex" / "prompts" - existing_prompts.mkdir(parents=True) - (existing_prompts / "custom.md").write_text("custom") - monkeypatch.chdir(target) - - with patch("specify_cli.download_and_extract_template", return_value=target), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True), \ - patch("specify_cli.is_git_repo", return_value=True), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): - result = runner.invoke( - app, - ["init", "--here", "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], - input="y\n", - ) - - assert result.exit_code == 0 - assert (target / ".codex").exists() - assert (existing_prompts / "custom.md").exists() - def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path): """Fresh-directory Codex skills init should not leave legacy .codex from archive.""" target = tmp_path / "fresh-codex-proj" @@ -948,62 +756,6 @@ class TestNewProjectCommandSkip: assert not (tmp_path / "evil.txt").exists() - def test_commands_preserved_when_skills_fail(self, tmp_path): - """If skills fail, commands should NOT be removed (safety net).""" - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "fail-proj" - - def fake_download(project_path, *args, **kwargs): - self._fake_extract("claude", project_path) - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"]) - - assert result.exit_code == 0 - # Commands should still exist since skills failed - cmds_dir = target / ".claude" / "commands" - assert cmds_dir.exists() - assert (cmds_dir / "speckit.specify.md").exists() - - def test_here_mode_commands_preserved(self, tmp_path, monkeypatch): - """For --here on existing repos, commands must NOT be removed.""" - from typer.testing import CliRunner - - runner = CliRunner() - # Create a mock existing project with commands already present - target = tmp_path / "existing" - target.mkdir() - agent_folder = AGENT_CONFIG["claude"]["folder"] - cmds_dir = target / agent_folder.rstrip("/") / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text("# spec") - - # --here uses CWD, so chdir into the target - monkeypatch.chdir(target) - - def fake_download(project_path, *args, **kwargs): - pass # commands already exist, no need to re-create - - with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ - patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=True), \ - patch("specify_cli.is_git_repo", return_value=True), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke(app, ["init", "--here", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], input="y\n") - - assert result.exit_code == 0 - # Commands must remain for --here - assert cmds_dir.exists() - assert (cmds_dir / "speckit.specify.md").exists() - # ===== Skip-If-Exists Tests ===== @@ -1075,92 +827,61 @@ class TestSkillDescriptions: class TestCliValidation: """Test --ai-skills CLI flag validation.""" - def test_ai_skills_without_ai_fails(self): + def test_ai_skills_without_ai_fails(self, tmp_path): """--ai-skills without --ai should fail with exit code 1.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) assert result.exit_code == 1 assert "--ai-skills requires --ai" in result.output - def test_ai_skills_without_ai_shows_usage(self): + def test_ai_skills_without_ai_shows_usage(self, tmp_path): """Error message should include usage hint.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai-skills"]) + result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) assert "Usage:" in result.output assert "--ai" in result.output - def test_agy_without_ai_skills_fails(self): - """--ai agy without --ai-skills should fail with exit code 1.""" + def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): + """Interactive selector returning agy should auto-promote to integration path.""" from typer.testing import CliRunner - runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai", "agy"]) - - assert result.exit_code == 1 - assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output - assert "--ai-skills" in result.output - - def test_codex_without_ai_skills_fails(self): - """--ai codex without --ai-skills should fail with exit code 1.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"]) - - assert result.exit_code == 1 - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output - assert "--ai-skills" in result.output - - def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): - """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" - from typer.testing import CliRunner - - # Mock select_with_arrows to simulate the user picking 'agy' for AI, - # and return a deterministic default for any other prompts to avoid - # calling the real interactive implementation. def _fake_select_with_arrows(*args, **kwargs): options = kwargs.get("options") if options is None and len(args) >= 1: options = args[0] - # If the options include 'agy', simulate selecting it. if isinstance(options, dict) and "agy" in options: return "agy" if isinstance(options, (list, tuple)) and "agy" in options: return "agy" - # For any other prompt, return a deterministic, non-interactive default: - # pick the first option if available. if isinstance(options, dict) and options: return next(iter(options.keys())) if isinstance(options, (list, tuple)) and options: return options[0] - # If no options are provided, fall back to None (should not occur in normal use). return None monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - # Mock download_and_extract_template to prevent real HTTP downloads during testing - monkeypatch.setattr("specify_cli.download_and_extract_template", lambda *args, **kwargs: None) - # We need to bypass the `git init` step, wait, it has `--no-git` by default in tests maybe? + runner = CliRunner() - # Create temp dir to avoid directory already exists errors or whatever - with runner.isolated_filesystem(): - result = runner.invoke(app, ["init", "test-proj", "--no-git"]) + target = tmp_path / "test-agy-interactive" + result = runner.invoke(app, ["init", str(target), "--no-git"]) - # Interactive selection should NOT raise the deprecation error! - assert result.exit_code == 0 - assert "Explicit command support was deprecated" not in result.output + assert result.exit_code == 0 + # Should NOT raise the old deprecation error + assert "Explicit command support was deprecated" not in result.output + # Should use integration path (same as --ai agy) + assert "agy" in result.output - def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch): - """Interactive selector returning codex without --ai-skills should automatically enable --ai-skills.""" + def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): + """Interactive selector returning codex should auto-promote to integration path.""" from typer.testing import CliRunner def _fake_select_with_arrows(*args, **kwargs): @@ -1182,48 +903,18 @@ class TestCliValidation: monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - def _fake_download(*args, **kwargs): - project_path = Path(args[0]) - skill_dir = project_path / ".agents" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - with runner.isolated_filesystem(): - result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"]) + target = tmp_path / "test-codex-interactive" + result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"]) - assert result.exit_code == 0 - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output - assert ".agents/skills" in result.output - assert "$speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output - - def test_kimi_next_steps_show_skill_invocation(self, monkeypatch): - """Kimi next-steps guidance should display /skill:speckit-* usage.""" - from typer.testing import CliRunner - - def _fake_download(*args, **kwargs): - project_path = Path(args[0]) - skill_dir = project_path / ".kimi" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True, exist_ok=True) - (skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n") - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - - runner = CliRunner() - with runner.isolated_filesystem(): - result = runner.invoke( - app, - ["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"], - ) - - assert result.exit_code == 0 - assert "/skill:speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output + assert result.exit_code == 0 + # Should NOT raise the old deprecation error + assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output + # Skills should be installed via integration path + assert ".agents/skills" in result.output + assert "$speckit-constitution" in result.output + assert "/speckit.constitution" not in result.output + assert "Optional skills that you can use for your specs" in result.output def test_ai_skills_flag_appears_in_help(self): """--ai-skills should appear in init --help output.""" @@ -1232,45 +923,10 @@ class TestCliValidation: runner = CliRunner() result = runner.invoke(app, ["init", "--help"]) - plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output) + plain = strip_ansi(result.output) assert "--ai-skills" in plain assert "agent skills" in plain.lower() - def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): - """--ai kiro should normalize to canonical kiro-cli and auto-promote to integration path.""" - import os - from typer.testing import CliRunner - - runner = CliRunner() - target = tmp_path / "kiro-alias-proj" - target.mkdir() - - old_cwd = os.getcwd() - try: - os.chdir(target) - result = runner.invoke( - app, - [ - "init", - "--here", - "--ai", - "kiro", - "--ignore-agent-tools", - "--script", - "sh", - "--no-git", - ], - catch_exceptions=False, - ) - finally: - os.chdir(old_cwd) - - assert result.exit_code == 0 - # kiro alias should auto-promote to integration path with nudge - assert "--integration kiro-cli" in result.output - # Command files should be created via integration path - assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() - def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" assert "q" not in AGENT_CONFIG @@ -1327,12 +983,12 @@ class TestParameterOrderingIssue: output_lower = result.output.lower() assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"]) - def test_ai_commands_dir_consuming_flag(self): + def test_ai_commands_dir_consuming_flag(self, tmp_path): """--ai-commands-dir without value should not consume next flag.""" from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(app, ["init", "myproject", "--ai", "generic", "--ai-commands-dir", "--here"]) + result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"]) assert result.exit_code == 1 assert "Invalid value for --ai-commands-dir" in result.output diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a5ee4e03a..df269d86c 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -16,6 +16,7 @@ import shutil from pathlib import Path from datetime import datetime, timezone +from tests.conftest import strip_ansi from specify_cli.extensions import ( CatalogEntry, CORE_COMMAND_NAMES, @@ -3126,11 +3127,12 @@ class TestExtensionListCLI: result = runner.invoke(app, ["extension", "list"]) assert result.exit_code == 0, result.output + plain = strip_ansi(result.output) # Verify the extension ID is shown in the output - assert "test-ext" in result.output + assert "test-ext" in plain # Verify name and version are also shown - assert "Test Extension" in result.output - assert "1.0.0" in result.output + assert "Test Extension" in plain + assert "1.0.0" in plain class TestExtensionPriority: @@ -3360,7 +3362,8 @@ class TestExtensionPriorityCLI: result = runner.invoke(app, ["extension", "list"]) assert result.exit_code == 0, result.output - assert "Priority: 7" in result.output + plain = strip_ansi(result.output) + assert "Priority: 7" in plain def test_set_priority_changes_priority(self, extension_dir, project_dir): """Test set-priority command changes extension priority.""" @@ -3381,7 +3384,8 @@ class TestExtensionPriorityCLI: result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) assert result.exit_code == 0, result.output - assert "priority changed: 10 → 5" in result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain # Reload registry to see updated value manager2 = ExtensionManager(project_dir) @@ -3403,7 +3407,8 @@ class TestExtensionPriorityCLI: result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"]) assert result.exit_code == 0, result.output - assert "already has priority 5" in result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain def test_set_priority_invalid_value(self, extension_dir, project_dir): """Test set-priority rejects invalid priority values.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index 1b2704c57..cf02709b2 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -20,6 +20,7 @@ from datetime import datetime, timezone import yaml +from tests.conftest import strip_ansi from specify_cli.presets import ( PresetManifest, PresetRegistry, @@ -2441,7 +2442,8 @@ class TestPresetSetPriority: result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output - assert "priority changed: 10 → 5" in result.output + plain = strip_ansi(result.output) + assert "priority changed: 10 → 5" in plain # Reload registry to see updated value manager2 = PresetManager(project_dir) @@ -2463,7 +2465,8 @@ class TestPresetSetPriority: result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"]) assert result.exit_code == 0, result.output - assert "already has priority 5" in result.output + plain = strip_ansi(result.output) + assert "already has priority 5" in plain def test_set_priority_invalid_value(self, project_dir, pack_dir): """Test set-priority rejects invalid priority values.""" From d9ce7c1fc02745d2f914d6b0c708a228c74feb1f Mon Sep 17 00:00:00 2001 From: liuyiyu Date: Thu, 2 Apr 2026 21:47:39 +0800 Subject: [PATCH 4/7] Add repoindex 0402 (#2062) * Add repoindex to community catalog, -Extension ID: repoindex -Version 1.0.0 - Author: Yiyu Liu - Description: Generate repo index * udpate sort order for repoindex * Add repoindex to community catalog, -Extension ID: repoindex -Version 1.0.0 - Author: Yiyu Liu - Description: Generate repo index * udpate sort order for repoindex * Update main README adding repoindex intro * fix display issue * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/README.md b/README.md index 129d69413..782bd938b 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ The following community-contributed extensions are available in [`catalog.commun | QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) | +| Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f04b37067..dce3c06e2 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -941,6 +941,43 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "repoindex":{ + "name": "Repository Index", + "id": "repoindex", + "description": "Generate index of your repo for overview, architecuture and module", + "author": "Yiyu Liu", + "version": "1.0.0", + "download_url": "https://github.com/liuyiyu/spec-kit-repoindex/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/liuyiyu/spec-kit-repoindex", + "homepage": "https://github.com/liuyiyu/spec-kit-repoindex", + "documentation": "https://github.com/liuyiyu/spec-kit-repoindex/tree/main/docs", + "changelog": "https://github.com/liuyiyu/spec-kit-repoindex/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "no need", + "version": ">=1.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "utility", + "brownfield", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-23T13:30:00Z", + "updated_at": "2026-03-23T13:30:00Z" + }, "retro": { "name": "Retro Extension", "id": "retro", @@ -1331,5 +1368,6 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" } + } } From a858c1d6da861e27fe92a1f8ab2f5e72699fcc77 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Thu, 2 Apr 2026 17:44:48 +0300 Subject: [PATCH 5/7] Install Claude Code as native skills and align preset/integration flows (#2051) * Use Claude skills for generated commands * Fix Claude integration and preset skill flows * Group Claude tests in integration suite * Align Claude skill frontmatter across generators * Fix native skill preset cleanup * Keep legacy AI skills test on legacy path * Move Claude here-mode test to CLI suite --- README.md | 12 +- src/specify_cli/__init__.py | 55 +++- src/specify_cli/agents.py | 23 +- src/specify_cli/extensions.py | 18 +- .../integrations/claude/__init__.py | 95 +++++- src/specify_cli/presets.py | 77 ++--- tests/integrations/test_cli.py | 27 ++ tests/integrations/test_integration_claude.py | 293 +++++++++++++++++- tests/test_ai_skills.py | 3 +- tests/test_extension_skills.py | 1 + tests/test_presets.py | 108 +++++++ 11 files changed, 633 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 782bd938b..c581ce627 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ Community projects that extend, visualize, or build on Spec Kit: | [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | | [Amp](https://ampcode.com/) | ✅ | | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | -| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | +| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | | [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | | [Cursor](https://cursor.sh/) | ✅ | | @@ -401,8 +401,8 @@ specify init my-project --ai claude --debug # Use GitHub token for API requests (helpful for corporate environments) specify init my-project --ai claude --github-token ghp_your_token_here -# Install agent skills with the project -specify init my-project --ai claude --ai-skills +# Claude Code installs skills with the project by default +specify init my-project --ai claude # Initialize in current directory with agent skills specify init --here --ai gemini --ai-skills @@ -416,7 +416,11 @@ specify check ### Available Slash Commands -After running `specify init`, your AI coding agent will have access to these slash commands for structured development. +After running `specify init`, your AI coding agent will have access to these structured development commands. + +Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`. + +Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`. For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7d1ecbc00..26116430c 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1640,6 +1640,8 @@ def install_ai_skills( ``True`` if at least one skill was installed or all skills were already present (idempotent re-run), ``False`` otherwise. """ + from .agents import CommandRegistrar + # Locate command templates in the agent's extracted commands directory. # download_and_extract_template() already placed the .md files here. agent_config = AGENT_CONFIG.get(selected_ai, {}) @@ -1741,15 +1743,12 @@ def install_ai_skills( if source_name.endswith(".agent.md"): source_name = source_name[:-len(".agent.md")] + ".md" - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"templates/commands/{source_name}", - }, - } + frontmatter_data = CommandRegistrar.build_skill_frontmatter( + selected_ai, + skill_name, + enhanced_desc, + f"templates/commands/{source_name}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -1859,6 +1858,23 @@ def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: AGENT_SKILLS_MIGRATIONS = { + "claude": { + "error": ( + "Claude Code now installs spec-kit as agent skills; " + "legacy .claude/commands projects are kept for backwards compatibility." + ), + "usage": "specify init --ai claude", + "interactive_note": ( + "'claude' was selected interactively; enabling [cyan]--ai-skills[/cyan] " + "automatically so spec-kit is installed to [cyan].claude/skills[/cyan]." + ), + "explicit_note": ( + "'claude' now installs spec-kit as agent skills; enabling " + "[cyan]--ai-skills[/cyan] automatically so commands are written to " + "[cyan].claude/skills[/cyan]." + ), + "auto_enable_explicit": True, + }, "agy": { "error": "Explicit command support was deprecated in Antigravity version 1.20.5.", "usage": "specify init --ai agy --ai-skills", @@ -1943,7 +1959,7 @@ def init( specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --ai claude --ai-skills # Install agent skills + specify init my-project --ai claude # Claude installs skills by default specify init --here --ai gemini --ai-skills specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent specify init my-project --offline # Use bundled assets (no network access) @@ -1977,6 +1993,7 @@ def init( # Auto-promote: --ai → integration path with a nudge (if registered) use_integration = False + resolved_integration = None if integration: from .integrations import INTEGRATION_REGISTRY, get_integration resolved_integration = get_integration(integration) @@ -2098,11 +2115,13 @@ def init( # If selected interactively (no --ai provided), automatically enable # ai_skills so the agent remains usable without requiring an extra flag. # Preserve fail-fast behavior only for explicit '--ai ' without skills. - if ai_assistant: + migration = AGENT_SKILLS_MIGRATIONS[selected_ai] + if ai_assistant and not migration.get("auto_enable_explicit", False): _handle_agent_skills_migration(console, selected_ai) else: ai_skills = True - console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}") + note_key = "explicit_note" if ai_assistant else "interactive_note" + console.print(f"\n[yellow]Note:[/yellow] {migration[note_key]}") # Validate --ai-commands-dir usage. # Skip validation when --integration-options is provided — the integration @@ -2540,27 +2559,33 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy) should show skill invocation syntax - # regardless of whether --ai-skills was explicitly passed. + # Skills integrations (codex, claude, kimi, agy) should show skill + # invocation syntax regardless of whether --ai-skills was explicitly passed. _is_skills_integration = False if use_integration: from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) + claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration - native_skill_mode = codex_skill_mode or kimi_skill_mode or agy_skill_mode + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") step_num += 1 + if claude_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: if codex_skill_mode or agy_skill_mode: return f"$speckit-{name}" + if claude_skill_mode: + return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 8107ae701..50c01a22d 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -370,16 +370,35 @@ class CommandRegistrar: body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") + skill_frontmatter = self.build_skill_frontmatter( + agent_name, + skill_name, + description, + f"{source_id}:{source_file}", + ) + return self.render_frontmatter(skill_frontmatter) + "\n" + body + + @staticmethod + def build_skill_frontmatter( + agent_name: str, + skill_name: str, + description: str, + source: str, + ) -> dict: + """Build consistent SKILL.md frontmatter across all skill generators.""" skill_frontmatter = { "name": skill_name, "description": description, "compatibility": "Requires spec-kit project structure with .specify/ directory", "metadata": { "author": "github-spec-kit", - "source": f"{source_id}:{source_file}", + "source": source, }, } - return self.render_frontmatter(skill_frontmatter) + "\n" + body + if agent_name == "claude": + # Claude skills should only run when explicitly invoked. + skill_frontmatter["disable-model-invocation"] = True + return skill_frontmatter @staticmethod def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index b898c65f2..3420a7651 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -801,15 +801,12 @@ class ExtensionManager: original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" - frontmatter_data = { - "name": skill_name, - "description": description, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"extension:{manifest.id}", - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + skill_name, + description, + f"extension:{manifest.id}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() # Derive a human-friendly title from the command name @@ -2138,11 +2135,14 @@ class HookExecutor: init_options = self._load_init_options() selected_ai = init_options.get("ai") codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills")) + claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills")) kimi_skill_mode = selected_ai == "kimi" skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: return f"${skill_name}" + if claude_skill_mode and skill_name: + return f"/{skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 00375ead5..f192c876c 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -1,21 +1,106 @@ """Claude Code integration.""" -from ..base import MarkdownIntegration +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + +from ...agents import CommandRegistrar +from ..base import SkillsIntegration +from ..manifest import IntegrationManifest -class ClaudeIntegration(MarkdownIntegration): +class ClaudeIntegration(SkillsIntegration): + """Integration for Claude Code skills.""" + key = "claude" config = { "name": "Claude Code", "folder": ".claude/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, } registrar_config = { - "dir": ".claude/commands", + "dir": ".claude/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } context_file = "CLAUDE.md" + + def command_filename(self, template_name: str) -> str: + """Claude skills live at .claude/skills//SKILL.md.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + return f"{skill_name}/SKILL.md" + + def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str: + """Render a processed command template as a Claude skill.""" + skill_name = f"speckit-{template_name.replace('.', '-')}" + description = frontmatter.get( + "description", + f"Spec-kit workflow command: {template_name}", + ) + skill_frontmatter = CommandRegistrar.build_skill_frontmatter( + self.key, + skill_name, + description, + f"templates/commands/{template_name}.md", + ) + frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() + return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Install Claude skills into .claude/skills.""" + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.skills_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + registrar = CommandRegistrar() + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + processed = self.process_template(raw, self.key, script_type, arg_placeholder) + frontmatter, body = registrar.parse_frontmatter(processed) + if not isinstance(frontmatter, dict): + frontmatter = {} + + rendered = self._render_skill(src_file.stem, frontmatter, body) + dst_file = self.write_file_and_record( + rendered, + dest / self.command_filename(src_file.stem), + project_root, + manifest, + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index a3f640628..0c8bba175 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -714,7 +714,14 @@ class PresetManager: selected_ai = init_opts.get("ai") if not isinstance(selected_ai, str): return [] + ai_skills_enabled = bool(init_opts.get("ai_skills")) registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + # Native skill agents (e.g. codex/kimi/agy) materialize brand-new + # preset skills in _register_commands() because their detected agent + # directory is already the skills directory. This flag is only for + # command-backed agents that also mirror commands into skills. + create_missing_skills = ai_skills_enabled and agent_config.get("extension") != "/SKILL.md" written: List[str] = [] @@ -741,6 +748,10 @@ class PresetManager: target_skill_names.append(skill_name) if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) if not target_skill_names: continue @@ -760,15 +771,16 @@ class PresetManager: ) for target_skill_name in target_skill_names: - frontmatter_data = { - "name": target_skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"preset:{manifest.id}", - }, - } + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + skill_subdir.mkdir(parents=True, exist_ok=True) + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{manifest.id}", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -778,7 +790,7 @@ class PresetManager: f"{body}\n" ) - skill_file = skills_dir / target_skill_name / "SKILL.md" + skill_file = skill_subdir / "SKILL.md" skill_file.write_text(skill_content, encoding="utf-8") written.append(target_skill_name) @@ -850,15 +862,12 @@ class PresetManager: original_desc or f"Spec-kit workflow command: {short_name}", ) - frontmatter_data = { - "name": skill_name, - "description": enhanced_desc, - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": f"templates/commands/{short_name}.md", - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + enhanced_desc, + f"templates/commands/{short_name}.md", + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_title = self._skill_title_from_command(short_name) skill_content = ( @@ -883,15 +892,12 @@ class PresetManager: command_name = extension_restore["command_name"] title_name = self._skill_title_from_command(command_name) - frontmatter_data = { - "name": skill_name, - "description": frontmatter.get("description", f"Extension command: {command_name}"), - "compatibility": "Requires spec-kit project structure with .specify/ directory", - "metadata": { - "author": "github-spec-kit", - "source": extension_restore["source"], - }, - } + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, + frontmatter.get("description", f"Extension command: {command_name}"), + extension_restore["source"], + ) frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() skill_content = ( f"---\n" @@ -1040,14 +1046,15 @@ class PresetManager: if registered_skills: self._unregister_skills(registered_skills, pack_dir) try: - from . import NATIVE_SKILLS_AGENTS + from .agents import CommandRegistrar except ImportError: - NATIVE_SKILLS_AGENTS = set() - registered_commands = { - agent_name: cmd_names - for agent_name, cmd_names in registered_commands.items() - if agent_name not in NATIVE_SKILLS_AGENTS - } + CommandRegistrar = None + if CommandRegistrar is not None: + registered_commands = { + agent_name: cmd_names + for agent_name, cmd_names in registered_commands.items() + if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" + } # Unregister non-skill command files from AI agents. if registered_commands: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index cd0071783..260957192 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -76,6 +76,33 @@ class TestInitIntegrationFlag: assert "--integration copilot" in result.output assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-here-existing" + project.mkdir() + commands_dir = project / ".claude" / "commands" + commands_dir.mkdir(parents=True) + command_file = commands_dir / "speckit.specify.md" + command_file.write_text("# preexisting command\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert "--integration claude" in result.output + assert command_file.exists() + assert command_file.read_text(encoding="utf-8") == "# preexisting command\n" + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + def test_shared_infra_skips_existing_files(self, tmp_path): """Pre-existing shared files are not overwritten by _install_shared_infra.""" from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 6867a295e..8f8a6b05d 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,11 +1,290 @@ """Tests for ClaudeIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import json +import os +from unittest.mock import patch + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.manifest import IntegrationManifest -class TestClaudeIntegration(MarkdownIntegrationTests): - KEY = "claude" - FOLDER = ".claude/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".claude/commands" - CONTEXT_FILE = "CLAUDE.md" +class TestClaudeIntegration: + def test_registered(self): + assert "claude" in INTEGRATION_REGISTRY + assert get_integration("claude") is not None + + def test_is_base_integration(self): + assert isinstance(get_integration("claude"), IntegrationBase) + + def test_config_uses_skills(self): + integration = get_integration("claude") + assert integration.config["folder"] == ".claude/" + assert integration.config["commands_subdir"] == "skills" + + def test_registrar_config_uses_skill_layout(self): + integration = get_integration("claude") + assert integration.registrar_config["dir"] == ".claude/skills" + assert integration.registrar_config["format"] == "markdown" + assert integration.registrar_config["args"] == "$ARGUMENTS" + assert integration.registrar_config["extension"] == "/SKILL.md" + + def test_context_file(self): + integration = get_integration("claude") + assert integration.context_file == "CLAUDE.md" + + def test_setup_creates_skill_files(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + skill_files = [path for path in created if path.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".claude" / "skills" + assert skills_dir.is_dir() + + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists() + + content = plan_skill.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content + assert "{ARGS}" not in content + assert "__AGENT__" not in content + + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["name"] == "speckit-plan" + assert parsed["disable-model-invocation"] is True + assert parsed["metadata"]["source"] == "templates/commands/plan.md" + + def test_setup_installs_update_context_scripts(self, tmp_path): + integration = get_integration("claude") + manifest = IntegrationManifest("claude", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" + assert scripts_dir.is_dir() + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} + assert ".specify/integrations/claude/scripts/update-context.sh" in tracked + assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + + def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-promote" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + "claude", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert "--integration claude" in result.output + assert ".claude/skills" in result.output + assert "/speckit-plan" in result.output + assert "/speckit.plan" not in result.output + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert not (project / ".claude" / "commands").exists() + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_integration_flag_creates_skill_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-integration" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + def test_interactive_claude_selection_uses_integration_path(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "claude-interactive" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + with patch("specify_cli.select_with_arrows", return_value="claude"): + result = runner.invoke( + app, + [ + "init", + "--here", + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert (project / ".specify" / "integration.json").exists() + assert (project / ".specify" / "integrations" / "claude.manifest.json").exists() + + skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md" + assert skill_file.exists() + assert "disable-model-invocation: true" in skill_file.read_text(encoding="utf-8") + + init_options = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_options["ai"] == "claude" + assert init_options["ai_skills"] is True + assert init_options["integration"] == "claude" + + def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "fail-proj" + + with patch("specify_cli.ensure_executable_scripts"), \ + patch("specify_cli.ensure_constitution_from_template"), \ + patch("specify_cli.install_ai_skills", return_value=False), \ + patch("specify_cli.is_git_repo", return_value=False), \ + patch("specify_cli.shutil.which", return_value="/usr/bin/git"): + result = runner.invoke( + app, + ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], + ) + + assert result.exit_code == 0 + assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert not (target / ".claude" / "commands").exists() + + def test_claude_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "claude-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + } + ], + ) + + assert "Executing: `/speckit-plan`" in message + assert "EXECUTE_COMMAND: speckit.plan" in message + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): + from specify_cli import save_init_options + from specify_cli.presets import PresetManager + + project = tmp_path / "claude-preset-skill" + project.mkdir() + save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"}) + + skills_dir = project / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = tmp_path / "claude-skill-command" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Research workflow\n" + "---\n\n" + "preset:claude-skill-command\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "claude-skill-command", + "name": "Claude Skill Command", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text(encoding="utf-8") + assert "preset:claude-skill-command" in content + assert "name: speckit-research" in content + assert "disable-model-invocation: true" in content + + metadata = manager.registry.get("claude-skill-command") + assert "speckit-research" in metadata.get("registered_skills", []) diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py index e4ee41828..e43129c9c 100644 --- a/tests/test_ai_skills.py +++ b/tests/test_ai_skills.py @@ -756,7 +756,6 @@ class TestLegacyDownloadPath: assert not (tmp_path / "evil.txt").exists() - # ===== Skip-If-Exists Tests ===== class TestSkipIfExists: @@ -925,7 +924,7 @@ class TestCliValidation: plain = strip_ansi(result.output) assert "--ai-skills" in plain - assert "agent skills" in plain.lower() + assert "skills" in plain.lower() def test_q_removed_from_agent_config(self): """Amazon Q legacy key should not remain in AGENT_CONFIG.""" diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 47d40a3b9..5d5d84902 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -269,6 +269,7 @@ class TestExtensionSkillRegistration: assert isinstance(parsed, dict) assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed + assert parsed["disable-model-invocation"] is True def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): """No skills should be created when ai_skills is false.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index cf02709b2..f2a08b91b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1975,6 +1975,7 @@ class TestPresetSkills: assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" + assert "disable-model-invocation: true" in content # Verify it was recorded in registry metadata = manager.registry.get("self-test") @@ -2060,6 +2061,7 @@ class TestPresetSkills: content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" + assert "disable-model-invocation: true" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" @@ -2350,6 +2352,55 @@ class TestPresetSkills: metadata = manager.registry.get("self-test") assert "speckit-specify" in metadata.get("registered_skills", []) + def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): + """Kimi native skills should still receive brand-new preset commands.""" + self._write_init_options(project_dir, ai="kimi", ai_skills=False) + skills_dir = project_dir / ".kimi" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + + preset_dir = temp_dir / "kimi-new-skill" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.research.md").write_text( + "---\n" + "description: Kimi research workflow\n" + "---\n\n" + "preset:kimi-new-skill\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "kimi-new-skill", + "name": "Kimi New Skill", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.research", + "file": "commands/speckit.research.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-research" / "SKILL.md" + assert skill_file.exists() + content = skill_file.read_text() + assert "preset:kimi-new-skill" in content + assert "name: speckit-research" in content + + metadata = manager.registry.get("kimi-new-skill") + assert "speckit-research" in metadata.get("registered_skills", []) + def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") @@ -2402,6 +2453,63 @@ class TestPresetSkills: assert ".specify/memory/constitution.md" in content assert "for kimi" in content + def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): + """Agy preset removal should restore native skills instead of deleting them.""" + self._write_init_options(project_dir, ai="agy", ai_skills=True) + skills_dir = project_dir / ".agent" / "skills" + self._create_skill(skills_dir, "speckit-specify", body="before override") + + core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" + core_command.write_text( + "---\n" + "description: Restored core specify workflow\n" + "---\n\n" + "restored core body\n" + ) + + preset_dir = temp_dir / "agy-override" + preset_dir.mkdir() + (preset_dir / "commands").mkdir() + (preset_dir / "commands" / "speckit.specify.md").write_text( + "---\n" + "description: Agy override\n" + "---\n\n" + "preset agy body\n" + ) + manifest_data = { + "schema_version": "1.0", + "preset": { + "id": "agy-override", + "name": "Agy Override", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + } + ] + }, + } + with open(preset_dir / "preset.yml", "w") as f: + yaml.dump(manifest_data, f) + + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.5") + + skill_file = skills_dir / "speckit-specify" / "SKILL.md" + assert "preset agy body" in skill_file.read_text() + + assert manager.remove("agy-override") is True + assert skill_file.exists() + restored = skill_file.read_text() + assert "restored core body" in restored + assert "name: speckit-specify" in restored + def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir): """Non-dict init-options payloads should not crash preset install/remove flows.""" init_options = project_dir / ".specify" / "init-options.json" From b1832c9477c7956cabf810676823356db83ea035 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:34:34 -0500 Subject: [PATCH 6/7] =?UTF-8?q?Stage=206:=20Complete=20migration=20?= =?UTF-8?q?=E2=80=94=20remove=20legacy=20scaffold=20path=20(#1924)=20(#206?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stage 6: Complete migration — remove legacy scaffold path (#1924) Remove the legacy GitHub download and offline scaffold code paths. All 26 agents now use the integration system exclusively. Code removal (~1073 lines from __init__.py): - download_template_from_github(), download_and_extract_template() - scaffold_from_core_pack(), _locate_release_script() - install_ai_skills(), _get_skills_dir (restored slim version for presets) - _has_bundled_skills(), _migrate_legacy_kimi_dotted_skills() - AGENT_SKILLS_MIGRATIONS, _handle_agent_skills_migration() - _parse_rate_limit_headers(), _format_rate_limit_error() - Three-way branch in init() collapsed to integration-only Config derivation (single source of truth): - AGENT_CONFIG derived from INTEGRATION_REGISTRY (replaced 180-line dict) - CommandRegistrar.AGENT_CONFIGS derived from INTEGRATION_REGISTRY (replaced 160-line dict) - Backward-compat constants kept for presets/extensions: SKILL_DESCRIPTIONS, NATIVE_SKILLS_AGENTS, DEFAULT_SKILLS_DIR Release pipeline cleanup: - Deleted create-release-packages.sh/.ps1 (948 lines of ZIP packaging) - Deleted create-github-release.sh, generate-release-notes.sh - Deleted simulate-release.sh, get-next-version.sh, update-version.sh - Removed .github/workflows/scripts/ directory entirely - release.yml is now self-contained: check, notes, release all inlined - Install instructions use uv tool install with version tag Test cleanup: - Deleted test_ai_skills.py (tested removed functions) - Deleted test_core_pack_scaffold.py (tested removed scaffold) - Cleaned test_agent_config_consistency.py (removed 19 release-script tests) - Fixed test_branch_numbering.py (removed dead monkeypatches) - Updated auto-promote tests (verify files created, not tip messages) 1089 tests pass, 0 failures, ruff clean. * fix: resolve merge conflicts with #2051 (claude as skills) - Fix circular import: move CommandRegistrar import in claude integration to inside method bodies (was at module level) - Lazy-populate AGENT_CONFIGS via _ensure_configs() to avoid circular import at class definition time - Set claude registrar_config to .claude/commands (extension/preset target) since the integration handles .claude/skills in setup() - Update tests from #2051 to match: registrar_config assertions, remove --integration tip assertions, remove install_ai_skills mocks 1086 tests pass. * fix: properly preserve claude skills migration from #2051 Restore ClaudeIntegration.registrar_config to .claude/skills (not .claude/commands) so extension/preset registrations write to the correct skills directory. Update tests that simulate claude setup to use .claude/skills and check for SKILL.md layout. Some tests still need updating for the full skills path — 10 remaining failures from the #2051 test expectations around the extension/preset skill registration flow. WIP: 1076/1086 pass. * fix: properly handle SKILL.md paths in extension update rollback and tests Fix extension update rollback using _compute_output_name() for SKILL.md agents (converts dots to hyphens in skill directory names). Previously the backup and cleanup code constructed paths with raw command names (e.g. speckit.test-ext.hello/SKILL.md) instead of the correct computed names (speckit-test-ext-hello/SKILL.md). Test fixes for claude skills migration: - Update claude tests to use .claude/skills paths and SKILL.md layout - Use qwen (not claude) for skills-guard tests since claude's agent dir IS the skills dir — creating it triggers command registration - Fix test_extension_command_registered_when_extension_present to check skills path format 1086 tests pass, 0 failures, ruff clean. * fix: address PR review — lazy init, assertions, deprecated flags - _ensure_configs(): catch ImportError (not Exception), don't set _configs_loaded on failure so retries work - Move _ensure_configs() before unregister loop (not inside it) - Module-level try/except catches ImportError specifically - Remove tautology assertion (or True) in test_extensions.py - Strengthen preset provenance assertion to check source: field - Mark --offline, --skip-tls, --debug, --github-token as hidden deprecated no-ops in init() 1086 tests pass. * fix: remove deleted release scripts from pyproject.toml force-include Removes force-include entries for create-release-packages.sh/.ps1 which were deleted but still referenced in [tool.hatch.build]. --- .github/workflows/release.yml | 62 +- .../workflows/scripts/check-release-exists.sh | 21 - .../scripts/create-github-release.sh | 72 - .../scripts/create-release-packages.ps1 | 560 ------- .../scripts/create-release-packages.sh | 388 ----- .../scripts/generate-release-notes.sh | 40 - .github/workflows/scripts/get-next-version.sh | 24 - .github/workflows/scripts/simulate-release.sh | 161 -- .github/workflows/scripts/update-version.sh | 23 - pyproject.toml | 2 - src/specify_cli/__init__.py | 1457 ++--------------- src/specify_cli/agents.py | 198 +-- .../integrations/claude/__init__.py | 15 +- tests/integrations/test_cli.py | 12 +- tests/integrations/test_integration_agy.py | 8 +- .../test_integration_base_markdown.py | 4 +- .../test_integration_base_skills.py | 4 +- .../test_integration_base_toml.py | 4 +- tests/integrations/test_integration_claude.py | 19 +- tests/integrations/test_integration_codex.py | 8 +- .../integrations/test_integration_kiro_cli.py | 1 - tests/test_agent_config_consistency.py | 216 +-- tests/test_ai_skills.py | 994 ----------- tests/test_branch_numbering.py | 29 +- tests/test_core_pack_scaffold.py | 613 ------- tests/test_extension_skills.py | 4 +- tests/test_extensions.py | 35 +- tests/test_presets.py | 62 +- 28 files changed, 286 insertions(+), 4750 deletions(-) delete mode 100644 .github/workflows/scripts/check-release-exists.sh delete mode 100755 .github/workflows/scripts/create-github-release.sh delete mode 100644 .github/workflows/scripts/create-release-packages.ps1 delete mode 100755 .github/workflows/scripts/create-release-packages.sh delete mode 100644 .github/workflows/scripts/generate-release-notes.sh delete mode 100644 .github/workflows/scripts/get-next-version.sh delete mode 100755 .github/workflows/scripts/simulate-release.sh delete mode 100644 .github/workflows/scripts/update-version.sh delete mode 100644 tests/test_ai_skills.py delete mode 100644 tests/test_core_pack_scaffold.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e29592cc..7b903cf97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,35 +27,63 @@ jobs: - name: Check if release already exists id: check_release run: | - chmod +x .github/workflows/scripts/check-release-exists.sh - .github/workflows/scripts/check-release-exists.sh ${{ steps.version.outputs.tag }} + VERSION="${{ steps.version.outputs.tag }}" + if gh release view "$VERSION" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release $VERSION already exists, skipping..." + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Release $VERSION does not exist, proceeding..." + fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Create release package variants - if: steps.check_release.outputs.exists == 'false' - run: | - chmod +x .github/workflows/scripts/create-release-packages.sh - .github/workflows/scripts/create-release-packages.sh ${{ steps.version.outputs.tag }} - - name: Generate release notes if: steps.check_release.outputs.exists == 'false' - id: release_notes run: | - chmod +x .github/workflows/scripts/generate-release-notes.sh - # Get the previous tag for changelog generation - PREVIOUS_TAG=$(git describe --tags --abbrev=0 ${{ steps.version.outputs.tag }}^ 2>/dev/null || echo "") - # Default to v0.0.0 if no previous tag is found (e.g., first release) + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + + # Find previous tag + PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -v "^${VERSION}$" | head -n 1) if [ -z "$PREVIOUS_TAG" ]; then - PREVIOUS_TAG="v0.0.0" + PREVIOUS_TAG="" fi - .github/workflows/scripts/generate-release-notes.sh ${{ steps.version.outputs.tag }} "$PREVIOUS_TAG" + + # Get commits since previous tag + if [ -z "$PREVIOUS_TAG" ]; then + COMMIT_COUNT=$(git rev-list --count HEAD) + if [ "$COMMIT_COUNT" -gt 20 ]; then + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges HEAD~20..HEAD) + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges) + fi + else + COMMITS=$(git log --oneline --pretty=format:"- %s" --no-merges "$PREVIOUS_TAG"..HEAD) + fi + + cat > release_notes.md << NOTES_EOF + ## Install + + \`\`\`bash + uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@${VERSION} + specify init my-project + \`\`\` + + NOTES_EOF + + echo "## What's Changed" >> release_notes.md + echo "" >> release_notes.md + echo "$COMMITS" >> release_notes.md - name: Create GitHub Release if: steps.check_release.outputs.exists == 'false' run: | - chmod +x .github/workflows/scripts/create-github-release.sh - .github/workflows/scripts/create-github-release.sh ${{ steps.version.outputs.tag }} + VERSION="${{ steps.version.outputs.tag }}" + VERSION_NO_V=${VERSION#v} + gh release create "$VERSION" \ + --title "Spec Kit - $VERSION_NO_V" \ + --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scripts/check-release-exists.sh b/.github/workflows/scripts/check-release-exists.sh deleted file mode 100644 index 88ef174f5..000000000 --- a/.github/workflows/scripts/check-release-exists.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# check-release-exists.sh -# Check if a GitHub release already exists for the given version -# Usage: check-release-exists.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -if gh release view "$VERSION" >/dev/null 2>&1; then - echo "exists=true" >> $GITHUB_OUTPUT - echo "Release $VERSION already exists, skipping..." -else - echo "exists=false" >> $GITHUB_OUTPUT - echo "Release $VERSION does not exist, proceeding..." -fi diff --git a/.github/workflows/scripts/create-github-release.sh b/.github/workflows/scripts/create-github-release.sh deleted file mode 100755 index 4a67d8dfe..000000000 --- a/.github/workflows/scripts/create-github-release.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-github-release.sh -# Create a GitHub release with all template zip files -# Usage: create-github-release.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix from version for release title -VERSION_NO_V=${VERSION#v} - -gh release create "$VERSION" \ - .genreleases/spec-kit-template-copilot-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-copilot-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-claude-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-gemini-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-cursor-agent-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-opencode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-junie-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-junie-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codex-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kilocode-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-auggie-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-roo-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-codebuddy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-qodercli-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-qodercli-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-amp-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-shai-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-tabnine-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-tabnine-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kiro-cli-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kiro-cli-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-agy-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-bob-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-vibe-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-trae-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-trae-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-pi-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-pi-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \ - .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ - --title "Spec Kit Templates - $VERSION_NO_V" \ - --notes-file release_notes.md diff --git a/.github/workflows/scripts/create-release-packages.ps1 b/.github/workflows/scripts/create-release-packages.ps1 deleted file mode 100644 index 912dd00ec..000000000 --- a/.github/workflows/scripts/create-release-packages.ps1 +++ /dev/null @@ -1,560 +0,0 @@ -#!/usr/bin/env pwsh -#requires -Version 7.0 - -<# -.SYNOPSIS - Build Spec Kit template release archives for each supported AI assistant and script type. - -.DESCRIPTION - create-release-packages.ps1 (workflow-local) - Build Spec Kit template release archives for each supported AI assistant and script type. - -.PARAMETER Version - Version string with leading 'v' (e.g., v0.2.0) - -.PARAMETER Agents - Comma or space separated subset of agents to build (default: all) - Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic - -.PARAMETER Scripts - Comma or space separated subset of script types to build (default: both) - Valid scripts: sh, ps - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude,copilot -Scripts sh - -.EXAMPLE - .\create-release-packages.ps1 -Version v0.2.0 -Agents claude -Scripts ps -#> - -param( - [Parameter(Mandatory=$true, Position=0)] - [string]$Version, - - [Parameter(Mandatory=$false)] - [string]$Agents = "", - - [Parameter(Mandatory=$false)] - [string]$Scripts = "" -) - -$ErrorActionPreference = "Stop" - -# Validate version format -if ($Version -notmatch '^v\d+\.\d+\.\d+$') { - Write-Error "Version must look like v0.0.0" - exit 1 -} - -Write-Host "Building release packages for $Version" - -# Create and use .genreleases directory for all build artifacts -$GenReleasesDir = ".genreleases" -if (Test-Path $GenReleasesDir) { - Remove-Item -Path $GenReleasesDir -Recurse -Force -ErrorAction SilentlyContinue -} -New-Item -ItemType Directory -Path $GenReleasesDir -Force | Out-Null - -function Rewrite-Paths { - param([string]$Content) - - $Content = $Content -replace '(/?)\bmemory/', '.specify/memory/' - $Content = $Content -replace '(/?)\bscripts/', '.specify/scripts/' - $Content = $Content -replace '(/?)\btemplates/', '.specify/templates/' - return $Content -} - -function Generate-Commands { - param( - [string]$Agent, - [string]$Extension, - [string]$ArgFormat, - [string]$OutputDir, - [string]$ScriptVariant - ) - - New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null - - $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - - foreach ($template in $templates) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - - # Read file content and normalize line endings - $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - - # Extract description from YAML frontmatter - $description = "" - if ($fileContent -match '(?m)^description:\s*(.+)$') { - $description = $matches[1] - } - - # Extract script command from YAML frontmatter - $scriptCommand = "" - if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { - $scriptCommand = $matches[1] - } - - if ([string]::IsNullOrEmpty($scriptCommand)) { - Write-Warning "No script command found for $ScriptVariant in $($template.Name)" - $scriptCommand = "(Missing script command for $ScriptVariant)" - } - - # Extract agent_script command from YAML frontmatter if present - $agentScriptCommand = "" - if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { - $agentScriptCommand = $matches[1].Trim() - } - - # Replace {SCRIPT} placeholder with the script command - $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { - $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand - } - - # Remove the scripts: and agent_scripts: sections from frontmatter - $lines = $body -split "`n" - $outputLines = @() - $inFrontmatter = $false - $skipScripts = $false - $dashCount = 0 - - foreach ($line in $lines) { - if ($line -match '^---$') { - $outputLines += $line - $dashCount++ - if ($dashCount -eq 1) { - $inFrontmatter = $true - } else { - $inFrontmatter = $false - } - continue - } - - if ($inFrontmatter) { - if ($line -match '^(scripts|agent_scripts):$') { - $skipScripts = $true - continue - } - if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { - $skipScripts = $false - } - if ($skipScripts -and $line -match '^\s+') { - continue - } - } - - $outputLines += $line - } - - $body = $outputLines -join "`n" - - # Apply other substitutions - $body = $body -replace '\{ARGS\}', $ArgFormat - $body = $body -replace '__AGENT__', $Agent - $body = Rewrite-Paths -Content $body - - # Generate output file based on extension - $outputFile = Join-Path $OutputDir "speckit.$name.$Extension" - - switch ($Extension) { - 'toml' { - $body = $body -replace '\\', '\\' - $output = "description = `"$description`"`n`nprompt = `"`"`"`n$body`n`"`"`"" - Set-Content -Path $outputFile -Value $output -NoNewline - } - 'md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - 'agent.md' { - Set-Content -Path $outputFile -Value $body -NoNewline - } - } - } -} - -function Generate-CopilotPrompts { - param( - [string]$AgentsDir, - [string]$PromptsDir - ) - - New-Item -ItemType Directory -Path $PromptsDir -Force | Out-Null - - $agentFiles = Get-ChildItem -Path "$AgentsDir/speckit.*.agent.md" -File -ErrorAction SilentlyContinue - - foreach ($agentFile in $agentFiles) { - $basename = $agentFile.Name -replace '\.agent\.md$', '' - $promptFile = Join-Path $PromptsDir "$basename.prompt.md" - - $content = @" ---- -agent: $basename ---- -"@ - Set-Content -Path $promptFile -Value $content - } -} - -# Create skills in \\SKILL.md format. -# Skills use hyphenated names (e.g. speckit-plan). -# -# Technical debt note: -# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension -# overrides (at minimum: name/description/compatibility/metadata.{author,source}). -function New-Skills { - param( - [string]$SkillsDir, - [string]$ScriptVariant, - [string]$AgentName, - [string]$Separator = '-' - ) - - $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue - - foreach ($template in $templates) { - $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) - $skillName = "speckit${Separator}$name" - $skillDir = Join-Path $SkillsDir $skillName - New-Item -ItemType Directory -Force -Path $skillDir | Out-Null - - $fileContent = (Get-Content -Path $template.FullName -Raw) -replace "`r`n", "`n" - - # Extract description - $description = "Spec Kit: $name workflow" - if ($fileContent -match '(?m)^description:\s*(.+)$') { - $description = $matches[1] - } - - # Extract script command - $scriptCommand = "(Missing script command for $ScriptVariant)" - if ($fileContent -match "(?m)^\s*${ScriptVariant}:\s*(.+)$") { - $scriptCommand = $matches[1] - } - - # Extract agent_script command from frontmatter if present - $agentScriptCommand = "" - if ($fileContent -match "(?ms)agent_scripts:.*?^\s*${ScriptVariant}:\s*(.+?)$") { - $agentScriptCommand = $matches[1].Trim() - } - - # Replace {SCRIPT}, strip scripts sections, rewrite paths - $body = $fileContent -replace '\{SCRIPT\}', $scriptCommand - if (-not [string]::IsNullOrEmpty($agentScriptCommand)) { - $body = $body -replace '\{AGENT_SCRIPT\}', $agentScriptCommand - } - - $lines = $body -split "`n" - $outputLines = @() - $inFrontmatter = $false - $skipScripts = $false - $dashCount = 0 - - foreach ($line in $lines) { - if ($line -match '^---$') { - $outputLines += $line - $dashCount++ - $inFrontmatter = ($dashCount -eq 1) - continue - } - if ($inFrontmatter) { - if ($line -match '^(scripts|agent_scripts):$') { $skipScripts = $true; continue } - if ($line -match '^[a-zA-Z].*:' -and $skipScripts) { $skipScripts = $false } - if ($skipScripts -and $line -match '^\s+') { continue } - } - $outputLines += $line - } - - $body = $outputLines -join "`n" - $body = $body -replace '\{ARGS\}', '$ARGUMENTS' - $body = $body -replace '__AGENT__', $AgentName - $body = Rewrite-Paths -Content $body - - # Strip existing frontmatter, keep only body - $templateBody = "" - $fmCount = 0 - $inBody = $false - foreach ($line in ($body -split "`n")) { - if ($line -match '^---$') { - $fmCount++ - if ($fmCount -eq 2) { $inBody = $true } - continue - } - if ($inBody) { $templateBody += "$line`n" } - } - - $skillContent = "---`nname: `"$skillName`"`ndescription: `"$description`"`ncompatibility: `"Requires spec-kit project structure with .specify/ directory`"`nmetadata:`n author: `"github-spec-kit`"`n source: `"templates/commands/$name.md`"`n---`n`n$templateBody" - Set-Content -Path (Join-Path $skillDir "SKILL.md") -Value $skillContent -NoNewline - } -} - -function Build-Variant { - param( - [string]$Agent, - [string]$Script - ) - - $baseDir = Join-Path $GenReleasesDir "sdd-${Agent}-package-${Script}" - Write-Host "Building $Agent ($Script) package..." - New-Item -ItemType Directory -Path $baseDir -Force | Out-Null - - # Copy base structure but filter scripts by variant - $specDir = Join-Path $baseDir ".specify" - New-Item -ItemType Directory -Path $specDir -Force | Out-Null - - # Copy memory directory - if (Test-Path "memory") { - Copy-Item -Path "memory" -Destination $specDir -Recurse -Force - Write-Host "Copied memory -> .specify" - } - - # Only copy the relevant script variant directory - if (Test-Path "scripts") { - $scriptsDestDir = Join-Path $specDir "scripts" - New-Item -ItemType Directory -Path $scriptsDestDir -Force | Out-Null - - switch ($Script) { - 'sh' { - if (Test-Path "scripts/bash") { - Copy-Item -Path "scripts/bash" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/bash -> .specify/scripts" - } - } - 'ps' { - if (Test-Path "scripts/powershell") { - Copy-Item -Path "scripts/powershell" -Destination $scriptsDestDir -Recurse -Force - Write-Host "Copied scripts/powershell -> .specify/scripts" - } - } - } - - Get-ChildItem -Path "scripts" -File -ErrorAction SilentlyContinue | ForEach-Object { - Copy-Item -Path $_.FullName -Destination $scriptsDestDir -Force - } - } - - # Copy templates (excluding commands directory and vscode-settings.json) - if (Test-Path "templates") { - $templatesDestDir = Join-Path $specDir "templates" - New-Item -ItemType Directory -Path $templatesDestDir -Force | Out-Null - - Get-ChildItem -Path "templates" -Recurse -File | Where-Object { - $_.FullName -notmatch 'templates[/\\]commands[/\\]' -and $_.Name -ne 'vscode-settings.json' - } | ForEach-Object { - $relativePath = $_.FullName.Substring((Resolve-Path "templates").Path.Length + 1) - $destFile = Join-Path $templatesDestDir $relativePath - $destFileDir = Split-Path $destFile -Parent - New-Item -ItemType Directory -Path $destFileDir -Force | Out-Null - Copy-Item -Path $_.FullName -Destination $destFile -Force - } - Write-Host "Copied templates -> .specify/templates" - } - - # Generate agent-specific command files - switch ($Agent) { - 'claude' { - $cmdDir = Join-Path $baseDir ".claude/commands" - Generate-Commands -Agent 'claude' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'gemini' { - $cmdDir = Join-Path $baseDir ".gemini/commands" - Generate-Commands -Agent 'gemini' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/gemini/GEMINI.md") { - Copy-Item -Path "agent_templates/gemini/GEMINI.md" -Destination (Join-Path $baseDir "GEMINI.md") - } - } - 'copilot' { - $agentsDir = Join-Path $baseDir ".github/agents" - Generate-Commands -Agent 'copilot' -Extension 'agent.md' -ArgFormat '$ARGUMENTS' -OutputDir $agentsDir -ScriptVariant $Script - - $promptsDir = Join-Path $baseDir ".github/prompts" - Generate-CopilotPrompts -AgentsDir $agentsDir -PromptsDir $promptsDir - - $vscodeDir = Join-Path $baseDir ".vscode" - New-Item -ItemType Directory -Path $vscodeDir -Force | Out-Null - if (Test-Path "templates/vscode-settings.json") { - Copy-Item -Path "templates/vscode-settings.json" -Destination (Join-Path $vscodeDir "settings.json") - } - } - 'cursor-agent' { - $cmdDir = Join-Path $baseDir ".cursor/commands" - Generate-Commands -Agent 'cursor-agent' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qwen' { - $cmdDir = Join-Path $baseDir ".qwen/commands" - Generate-Commands -Agent 'qwen' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - if (Test-Path "agent_templates/qwen/QWEN.md") { - Copy-Item -Path "agent_templates/qwen/QWEN.md" -Destination (Join-Path $baseDir "QWEN.md") - } - } - 'opencode' { - $cmdDir = Join-Path $baseDir ".opencode/command" - Generate-Commands -Agent 'opencode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'windsurf' { - $cmdDir = Join-Path $baseDir ".windsurf/workflows" - Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'junie' { - $cmdDir = Join-Path $baseDir ".junie/commands" - Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codex' { - $skillsDir = Join-Path $baseDir ".agents/skills" - New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-' - } - 'kilocode' { - $cmdDir = Join-Path $baseDir ".kilocode/workflows" - Generate-Commands -Agent 'kilocode' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'auggie' { - $cmdDir = Join-Path $baseDir ".augment/commands" - Generate-Commands -Agent 'auggie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'roo' { - $cmdDir = Join-Path $baseDir ".roo/commands" - Generate-Commands -Agent 'roo' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'codebuddy' { - $cmdDir = Join-Path $baseDir ".codebuddy/commands" - Generate-Commands -Agent 'codebuddy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'amp' { - $cmdDir = Join-Path $baseDir ".agents/commands" - Generate-Commands -Agent 'amp' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'kiro-cli' { - $cmdDir = Join-Path $baseDir ".kiro/prompts" - Generate-Commands -Agent 'kiro-cli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'bob' { - $cmdDir = Join-Path $baseDir ".bob/commands" - Generate-Commands -Agent 'bob' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'qodercli' { - $cmdDir = Join-Path $baseDir ".qoder/commands" - Generate-Commands -Agent 'qodercli' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'shai' { - $cmdDir = Join-Path $baseDir ".shai/commands" - Generate-Commands -Agent 'shai' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'tabnine' { - $cmdDir = Join-Path $baseDir ".tabnine/agent/commands" - Generate-Commands -Agent 'tabnine' -Extension 'toml' -ArgFormat '{{args}}' -OutputDir $cmdDir -ScriptVariant $Script - $tabnineTemplate = Join-Path 'agent_templates' 'tabnine/TABNINE.md' - if (Test-Path $tabnineTemplate) { Copy-Item $tabnineTemplate (Join-Path $baseDir 'TABNINE.md') } - } - 'agy' { - $cmdDir = Join-Path $baseDir ".agent/commands" - Generate-Commands -Agent 'agy' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'vibe' { - $cmdDir = Join-Path $baseDir ".vibe/prompts" - Generate-Commands -Agent 'vibe' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'kimi' { - $skillsDir = Join-Path $baseDir ".kimi/skills" - New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null - New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' - } - 'trae' { - $rulesDir = Join-Path $baseDir ".trae/rules" - New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null - Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script - } - 'pi' { - $cmdDir = Join-Path $baseDir ".pi/prompts" - Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'iflow' { - $cmdDir = Join-Path $baseDir ".iflow/commands" - Generate-Commands -Agent 'iflow' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - 'generic' { - $cmdDir = Join-Path $baseDir ".speckit/commands" - Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script - } - default { - throw "Unsupported agent '$Agent'." - } - } - - # Create zip archive - $zipFile = Join-Path $GenReleasesDir "spec-kit-template-${Agent}-${Script}-${Version}.zip" - Compress-Archive -Path "$baseDir/*" -DestinationPath $zipFile -Force - Write-Host "Created $zipFile" -} - -# Define all agents and scripts -$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic') -$AllScripts = @('sh', 'ps') - -function Normalize-List { - param([string]$Value) - - if ([string]::IsNullOrEmpty($Value)) { - return @() - } - - $items = $Value -split '[,\s]+' | Where-Object { $_ } | Select-Object -Unique - return $items -} - -function Validate-Subset { - param( - [string]$Type, - [string[]]$Allowed, - [string[]]$Items - ) - - $ok = $true - foreach ($item in $Items) { - if ($item -notin $Allowed) { - Write-Error "Unknown $Type '$item' (allowed: $($Allowed -join ', '))" - $ok = $false - } - } - return $ok -} - -# Determine agent list -if (-not [string]::IsNullOrEmpty($Agents)) { - $AgentList = Normalize-List -Value $Agents - if (-not (Validate-Subset -Type 'agent' -Allowed $AllAgents -Items $AgentList)) { - exit 1 - } -} else { - $AgentList = $AllAgents -} - -# Determine script list -if (-not [string]::IsNullOrEmpty($Scripts)) { - $ScriptList = Normalize-List -Value $Scripts - if (-not (Validate-Subset -Type 'script' -Allowed $AllScripts -Items $ScriptList)) { - exit 1 - } -} else { - $ScriptList = $AllScripts -} - -Write-Host "Agents: $($AgentList -join ', ')" -Write-Host "Scripts: $($ScriptList -join ', ')" - -# Build all variants -foreach ($agent in $AgentList) { - foreach ($script in $ScriptList) { - Build-Variant -Agent $agent -Script $script - } -} - -Write-Host "`nArchives in ${GenReleasesDir}:" -Get-ChildItem -Path $GenReleasesDir -Filter "spec-kit-template-*-${Version}.zip" | ForEach-Object { - Write-Host " $($_.Name)" -} diff --git a/.github/workflows/scripts/create-release-packages.sh b/.github/workflows/scripts/create-release-packages.sh deleted file mode 100755 index a83494c3a..000000000 --- a/.github/workflows/scripts/create-release-packages.sh +++ /dev/null @@ -1,388 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# create-release-packages.sh (workflow-local) -# Build Spec Kit template release archives for each supported AI assistant and script type. -# Usage: .github/workflows/scripts/create-release-packages.sh -# Version argument should include leading 'v'. -# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. -# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all) -# SCRIPTS : space or comma separated subset of: sh ps (default: both) -# Examples: -# AGENTS=claude SCRIPTS=sh $0 v0.2.0 -# AGENTS="copilot,gemini" $0 v0.2.0 -# SCRIPTS=ps $0 v0.2.0 - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi -NEW_VERSION="$1" -if [[ ! $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Version must look like v0.0.0" >&2 - exit 1 -fi - -echo "Building release packages for $NEW_VERSION" - -# Create and use .genreleases directory for all build artifacts -# Override via GENRELEASES_DIR env var (e.g. for tests writing to a temp dir) -GENRELEASES_DIR="${GENRELEASES_DIR:-.genreleases}" - -# Guard against unsafe GENRELEASES_DIR values before cleaning -if [[ -z "$GENRELEASES_DIR" ]]; then - echo "GENRELEASES_DIR must not be empty" >&2 - exit 1 -fi -case "$GENRELEASES_DIR" in - '/'|'.'|'..') - echo "Refusing to use unsafe GENRELEASES_DIR value: $GENRELEASES_DIR" >&2 - exit 1 - ;; -esac -if [[ "$GENRELEASES_DIR" == *".."* ]]; then - echo "Refusing to use GENRELEASES_DIR containing '..' path segments: $GENRELEASES_DIR" >&2 - exit 1 -fi - -mkdir -p "$GENRELEASES_DIR" -rm -rf "${GENRELEASES_DIR%/}/"* || true - -rewrite_paths() { - sed -E \ - -e 's@(/?)memory/@.specify/memory/@g' \ - -e 's@(/?)scripts/@.specify/scripts/@g' \ - -e 's@(/?)templates/@.specify/templates/@g' \ - -e 's@\.specify\.specify/@.specify/@g' -} - -generate_commands() { - local agent=$1 ext=$2 arg_format=$3 output_dir=$4 script_variant=$5 - mkdir -p "$output_dir" - for template in templates/commands/*.md; do - [[ -f "$template" ]] || continue - local name description script_command agent_script_command body - name=$(basename "$template" .md) - - # Normalize line endings - file_content=$(tr -d '\r' < "$template") - - # Extract description and script command from YAML frontmatter - description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') - script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - - if [[ -z $script_command ]]; then - echo "Warning: no script command found for $script_variant in $template" >&2 - script_command="(Missing script command for $script_variant)" - fi - - # Extract agent_script command from YAML frontmatter if present - agent_script_command=$(printf '%s\n' "$file_content" | awk ' - /^agent_scripts:$/ { in_agent_scripts=1; next } - in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { - sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") - print - exit - } - in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } - ') - - # Replace {SCRIPT} placeholder with the script command - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - - # Replace {AGENT_SCRIPT} placeholder with the agent script command if found - if [[ -n $agent_script_command ]]; then - body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") - fi - - # Remove the scripts: and agent_scripts: sections from frontmatter while preserving YAML structure - body=$(printf '%s\n' "$body" | awk ' - /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } - in_frontmatter && /^scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } - in_frontmatter && skip_scripts && /^[[:space:]]/ { next } - { print } - ') - - # Apply other substitutions - body=$(printf '%s\n' "$body" | sed "s/{ARGS}/$arg_format/g" | sed "s/__AGENT__/$agent/g" | rewrite_paths) - - case $ext in - toml) - body=$(printf '%s\n' "$body" | sed 's/\\/\\\\/g') - { echo "description = \"$description\""; echo; echo "prompt = \"\"\""; echo "$body"; echo "\"\"\""; } > "$output_dir/speckit.$name.$ext" ;; - md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - agent.md) - echo "$body" > "$output_dir/speckit.$name.$ext" ;; - esac - done -} - -generate_copilot_prompts() { - local agents_dir=$1 prompts_dir=$2 - mkdir -p "$prompts_dir" - - # Generate a .prompt.md file for each .agent.md file - for agent_file in "$agents_dir"/speckit.*.agent.md; do - [[ -f "$agent_file" ]] || continue - - local basename=$(basename "$agent_file" .agent.md) - local prompt_file="$prompts_dir/${basename}.prompt.md" - - cat > "$prompt_file" <//SKILL.md format. -# Skills use hyphenated names (e.g. speckit-plan). -# -# Technical debt note: -# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension -# overrides (at minimum: name/description/compatibility/metadata.{author,source}). -create_skills() { - local skills_dir="$1" - local script_variant="$2" - local agent_name="$3" - local separator="${4:-"-"}" - - for template in templates/commands/*.md; do - [[ -f "$template" ]] || continue - local name - name=$(basename "$template" .md) - local skill_name="speckit${separator}${name}" - local skill_dir="${skills_dir}/${skill_name}" - mkdir -p "$skill_dir" - - local file_content - file_content=$(tr -d '\r' < "$template") - - # Extract description from frontmatter - local description - description=$(printf '%s\n' "$file_content" | awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); print; exit}') - [[ -z "$description" ]] && description="Spec Kit: ${name} workflow" - - # Extract script command - local script_command - script_command=$(printf '%s\n' "$file_content" | awk -v sv="$script_variant" '/^[[:space:]]*'"$script_variant"':[[:space:]]*/ {sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, ""); print; exit}') - [[ -z "$script_command" ]] && script_command="(Missing script command for $script_variant)" - - # Extract agent_script command from frontmatter if present - local agent_script_command - agent_script_command=$(printf '%s\n' "$file_content" | awk ' - /^agent_scripts:$/ { in_agent_scripts=1; next } - in_agent_scripts && /^[[:space:]]*'"$script_variant"':[[:space:]]*/ { - sub(/^[[:space:]]*'"$script_variant"':[[:space:]]*/, "") - print - exit - } - in_agent_scripts && /^[a-zA-Z]/ { in_agent_scripts=0 } - ') - - # Build body: replace placeholders, strip scripts sections, rewrite paths - local body - body=$(printf '%s\n' "$file_content" | sed "s|{SCRIPT}|${script_command}|g") - if [[ -n $agent_script_command ]]; then - body=$(printf '%s\n' "$body" | sed "s|{AGENT_SCRIPT}|${agent_script_command}|g") - fi - body=$(printf '%s\n' "$body" | awk ' - /^---$/ { print; if (++dash_count == 1) in_frontmatter=1; else in_frontmatter=0; next } - in_frontmatter && /^scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^agent_scripts:$/ { skip_scripts=1; next } - in_frontmatter && /^[a-zA-Z].*:/ && skip_scripts { skip_scripts=0 } - in_frontmatter && skip_scripts && /^[[:space:]]/ { next } - { print } - ') - body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths) - - # Strip existing frontmatter and prepend skills frontmatter. - local template_body - template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found') - - { - printf -- '---\n' - printf 'name: "%s"\n' "$skill_name" - printf 'description: "%s"\n' "$description" - printf 'compatibility: "%s"\n' "Requires spec-kit project structure with .specify/ directory" - printf -- 'metadata:\n' - printf ' author: "%s"\n' "github-spec-kit" - printf ' source: "%s"\n' "templates/commands/${name}.md" - printf -- '---\n\n' - printf '%s\n' "$template_body" - } > "$skill_dir/SKILL.md" - done -} - -build_variant() { - local agent=$1 script=$2 - local base_dir="$GENRELEASES_DIR/sdd-${agent}-package-${script}" - echo "Building $agent ($script) package..." - mkdir -p "$base_dir" - - # Copy base structure but filter scripts by variant - SPEC_DIR="$base_dir/.specify" - mkdir -p "$SPEC_DIR" - - [[ -d memory ]] && { cp -r memory "$SPEC_DIR/"; echo "Copied memory -> .specify"; } - - # Only copy the relevant script variant directory - if [[ -d scripts ]]; then - mkdir -p "$SPEC_DIR/scripts" - case $script in - sh) - [[ -d scripts/bash ]] && { cp -r scripts/bash "$SPEC_DIR/scripts/"; echo "Copied scripts/bash -> .specify/scripts"; } - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - ps) - [[ -d scripts/powershell ]] && { cp -r scripts/powershell "$SPEC_DIR/scripts/"; echo "Copied scripts/powershell -> .specify/scripts"; } - find scripts -maxdepth 1 -type f -exec cp {} "$SPEC_DIR/scripts/" \; 2>/dev/null || true - ;; - esac - fi - - [[ -d templates ]] && { mkdir -p "$SPEC_DIR/templates"; find templates -type f -not -path "templates/commands/*" -not -name "vscode-settings.json" | while IFS= read -r f; do d="$SPEC_DIR/$(dirname "$f")"; mkdir -p "$d"; cp "$f" "$d/"; done; echo "Copied templates -> .specify/templates"; } - - case $agent in - claude) - mkdir -p "$base_dir/.claude/commands" - generate_commands claude md "\$ARGUMENTS" "$base_dir/.claude/commands" "$script" ;; - gemini) - mkdir -p "$base_dir/.gemini/commands" - generate_commands gemini toml "{{args}}" "$base_dir/.gemini/commands" "$script" - [[ -f agent_templates/gemini/GEMINI.md ]] && cp agent_templates/gemini/GEMINI.md "$base_dir/GEMINI.md" ;; - copilot) - mkdir -p "$base_dir/.github/agents" - generate_commands copilot agent.md "\$ARGUMENTS" "$base_dir/.github/agents" "$script" - generate_copilot_prompts "$base_dir/.github/agents" "$base_dir/.github/prompts" - mkdir -p "$base_dir/.vscode" - [[ -f templates/vscode-settings.json ]] && cp templates/vscode-settings.json "$base_dir/.vscode/settings.json" - ;; - cursor-agent) - mkdir -p "$base_dir/.cursor/commands" - generate_commands cursor-agent md "\$ARGUMENTS" "$base_dir/.cursor/commands" "$script" ;; - qwen) - mkdir -p "$base_dir/.qwen/commands" - generate_commands qwen md "\$ARGUMENTS" "$base_dir/.qwen/commands" "$script" - [[ -f agent_templates/qwen/QWEN.md ]] && cp agent_templates/qwen/QWEN.md "$base_dir/QWEN.md" ;; - opencode) - mkdir -p "$base_dir/.opencode/command" - generate_commands opencode md "\$ARGUMENTS" "$base_dir/.opencode/command" "$script" ;; - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; - junie) - mkdir -p "$base_dir/.junie/commands" - generate_commands junie md "\$ARGUMENTS" "$base_dir/.junie/commands" "$script" ;; - codex) - mkdir -p "$base_dir/.agents/skills" - create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;; - kilocode) - mkdir -p "$base_dir/.kilocode/workflows" - generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;; - auggie) - mkdir -p "$base_dir/.augment/commands" - generate_commands auggie md "\$ARGUMENTS" "$base_dir/.augment/commands" "$script" ;; - roo) - mkdir -p "$base_dir/.roo/commands" - generate_commands roo md "\$ARGUMENTS" "$base_dir/.roo/commands" "$script" ;; - codebuddy) - mkdir -p "$base_dir/.codebuddy/commands" - generate_commands codebuddy md "\$ARGUMENTS" "$base_dir/.codebuddy/commands" "$script" ;; - qodercli) - mkdir -p "$base_dir/.qoder/commands" - generate_commands qodercli md "\$ARGUMENTS" "$base_dir/.qoder/commands" "$script" ;; - amp) - mkdir -p "$base_dir/.agents/commands" - generate_commands amp md "\$ARGUMENTS" "$base_dir/.agents/commands" "$script" ;; - shai) - mkdir -p "$base_dir/.shai/commands" - generate_commands shai md "\$ARGUMENTS" "$base_dir/.shai/commands" "$script" ;; - tabnine) - mkdir -p "$base_dir/.tabnine/agent/commands" - generate_commands tabnine toml "{{args}}" "$base_dir/.tabnine/agent/commands" "$script" - [[ -f agent_templates/tabnine/TABNINE.md ]] && cp agent_templates/tabnine/TABNINE.md "$base_dir/TABNINE.md" ;; - kiro-cli) - mkdir -p "$base_dir/.kiro/prompts" - generate_commands kiro-cli md "\$ARGUMENTS" "$base_dir/.kiro/prompts" "$script" ;; - agy) - mkdir -p "$base_dir/.agent/commands" - generate_commands agy md "\$ARGUMENTS" "$base_dir/.agent/commands" "$script" ;; - bob) - mkdir -p "$base_dir/.bob/commands" - generate_commands bob md "\$ARGUMENTS" "$base_dir/.bob/commands" "$script" ;; - vibe) - mkdir -p "$base_dir/.vibe/prompts" - generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; - kimi) - mkdir -p "$base_dir/.kimi/skills" - create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;; - trae) - mkdir -p "$base_dir/.trae/rules" - generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;; - pi) - mkdir -p "$base_dir/.pi/prompts" - generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;; - iflow) - mkdir -p "$base_dir/.iflow/commands" - generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;; - generic) - mkdir -p "$base_dir/.speckit/commands" - generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; - esac - ( cd "$base_dir" && zip -r "../spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" . ) - echo "Created $GENRELEASES_DIR/spec-kit-template-${agent}-${script}-${NEW_VERSION}.zip" -} - -# Determine agent list -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic) -ALL_SCRIPTS=(sh ps) - -validate_subset() { - local type=$1; shift - local allowed_str="$1"; shift - local invalid=0 - for it in "$@"; do - local found=0 - for a in $allowed_str; do - if [[ "$it" == "$a" ]]; then found=1; break; fi - done - if [[ $found -eq 0 ]]; then - echo "Error: unknown $type '$it' (allowed: $allowed_str)" >&2 - invalid=1 - fi - done - return $invalid -} - -read_list() { tr ',\n' ' ' | awk '{for(i=1;i<=NF;i++){if(!seen[$i]++){printf((out?" ":"") $i);out=1}}}END{printf("\n")}'; } - -if [[ -n ${AGENTS:-} ]]; then - read -ra AGENT_LIST <<< "$(printf '%s' "$AGENTS" | read_list)" - validate_subset agent "${ALL_AGENTS[*]}" "${AGENT_LIST[@]}" || exit 1 -else - AGENT_LIST=("${ALL_AGENTS[@]}") -fi - -if [[ -n ${SCRIPTS:-} ]]; then - read -ra SCRIPT_LIST <<< "$(printf '%s' "$SCRIPTS" | read_list)" - validate_subset script "${ALL_SCRIPTS[*]}" "${SCRIPT_LIST[@]}" || exit 1 -else - SCRIPT_LIST=("${ALL_SCRIPTS[@]}") -fi - -echo "Agents: ${AGENT_LIST[*]}" -echo "Scripts: ${SCRIPT_LIST[*]}" - -for agent in "${AGENT_LIST[@]}"; do - for script in "${SCRIPT_LIST[@]}"; do - build_variant "$agent" "$script" - done -done - -echo "Archives in $GENRELEASES_DIR:" -ls -1 "$GENRELEASES_DIR"/spec-kit-template-*-"${NEW_VERSION}".zip diff --git a/.github/workflows/scripts/generate-release-notes.sh b/.github/workflows/scripts/generate-release-notes.sh deleted file mode 100644 index d8f5dab1f..000000000 --- a/.github/workflows/scripts/generate-release-notes.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# generate-release-notes.sh -# Generate release notes from git history -# Usage: generate-release-notes.sh - -if [[ $# -ne 2 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -NEW_VERSION="$1" -LAST_TAG="$2" - -# Get commits since last tag -if [ "$LAST_TAG" = "v0.0.0" ]; then - # Check how many commits we have and use that as the limit - COMMIT_COUNT=$(git rev-list --count HEAD) - if [ "$COMMIT_COUNT" -gt 10 ]; then - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~10..HEAD) - else - COMMITS=$(git log --oneline --pretty=format:"- %s" HEAD~$COMMIT_COUNT..HEAD 2>/dev/null || git log --oneline --pretty=format:"- %s") - fi -else - COMMITS=$(git log --oneline --pretty=format:"- %s" $LAST_TAG..HEAD) -fi - -# Create release notes -cat > release_notes.md << EOF -This is the latest set of releases that you can use with your agent of choice. We recommend using the Specify CLI to scaffold your projects, however you can download these independently and manage them yourself. - -## Changelog - -$COMMITS - -EOF - -echo "Generated release notes:" -cat release_notes.md diff --git a/.github/workflows/scripts/get-next-version.sh b/.github/workflows/scripts/get-next-version.sh deleted file mode 100644 index 9770b9fdc..000000000 --- a/.github/workflows/scripts/get-next-version.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# get-next-version.sh -# Calculate the next version based on the latest git tag and output GitHub Actions variables -# Usage: get-next-version.sh - -# Get the latest tag, or use v0.0.0 if no tags exist -LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") -echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT - -# Extract version number and increment -VERSION=$(echo $LATEST_TAG | sed 's/v//') -IFS='.' read -ra VERSION_PARTS <<< "$VERSION" -MAJOR=${VERSION_PARTS[0]:-0} -MINOR=${VERSION_PARTS[1]:-0} -PATCH=${VERSION_PARTS[2]:-0} - -# Increment patch version -PATCH=$((PATCH + 1)) -NEW_VERSION="v$MAJOR.$MINOR.$PATCH" - -echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT -echo "New version will be: $NEW_VERSION" diff --git a/.github/workflows/scripts/simulate-release.sh b/.github/workflows/scripts/simulate-release.sh deleted file mode 100755 index a3960d031..000000000 --- a/.github/workflows/scripts/simulate-release.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# simulate-release.sh -# Simulate the release process locally without pushing to GitHub -# Usage: simulate-release.sh [version] -# If version is omitted, auto-increments patch version - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}🧪 Simulating Release Process Locally${NC}" -echo "======================================" -echo "" - -# Step 1: Determine version -if [[ -n "${1:-}" ]]; then - VERSION="${1#v}" - TAG="v$VERSION" - echo -e "${GREEN}📝 Using manual version: $VERSION${NC}" -else - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - echo -e "${BLUE}Latest tag: $LATEST_TAG${NC}" - - VERSION=$(echo $LATEST_TAG | sed 's/v//') - IFS='.' read -ra VERSION_PARTS <<< "$VERSION" - MAJOR=${VERSION_PARTS[0]:-0} - MINOR=${VERSION_PARTS[1]:-0} - PATCH=${VERSION_PARTS[2]:-0} - - PATCH=$((PATCH + 1)) - VERSION="$MAJOR.$MINOR.$PATCH" - TAG="v$VERSION" - echo -e "${GREEN}📝 Auto-incremented to: $VERSION${NC}" -fi - -echo "" - -# Step 2: Check if tag exists -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo -e "${RED}❌ Error: Tag $TAG already exists!${NC}" - echo " Please use a different version or delete the tag first." - exit 1 -fi -echo -e "${GREEN}✓ Tag $TAG is available${NC}" - -# Step 3: Backup current state -echo "" -echo -e "${YELLOW}💾 Creating backup of current state...${NC}" -BACKUP_DIR=$(mktemp -d) -cp pyproject.toml "$BACKUP_DIR/pyproject.toml.bak" -cp CHANGELOG.md "$BACKUP_DIR/CHANGELOG.md.bak" -echo -e "${GREEN}✓ Backup created at: $BACKUP_DIR${NC}" - -# Step 4: Update pyproject.toml -echo "" -echo -e "${YELLOW}📝 Updating pyproject.toml...${NC}" -sed -i.tmp "s/version = \".*\"/version = \"$VERSION\"/" pyproject.toml -rm -f pyproject.toml.tmp -echo -e "${GREEN}✓ Updated pyproject.toml to version $VERSION${NC}" - -# Step 5: Update CHANGELOG.md -echo "" -echo -e "${YELLOW}📝 Updating CHANGELOG.md...${NC}" -DATE=$(date +%Y-%m-%d) - -# Get the previous tag to compare commits -PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - -if [[ -n "$PREVIOUS_TAG" ]]; then - echo " Generating changelog from commits since $PREVIOUS_TAG" - # Get commits since last tag, format as bullet points - COMMITS=$(git log --oneline "$PREVIOUS_TAG"..HEAD --no-merges --pretty=format:"- %s" 2>/dev/null || echo "- Initial release") -else - echo " No previous tag found - this is the first release" - COMMITS="- Initial release" -fi - -# Create temp file with new entry -{ - head -n 8 CHANGELOG.md - echo "" - echo "## [$VERSION] - $DATE" - echo "" - echo "### Changed" - echo "" - echo "$COMMITS" - echo "" - tail -n +9 CHANGELOG.md -} > CHANGELOG.md.tmp -mv CHANGELOG.md.tmp CHANGELOG.md -echo -e "${GREEN}✓ Updated CHANGELOG.md with commits since $PREVIOUS_TAG${NC}" - -# Step 6: Show what would be committed -echo "" -echo -e "${YELLOW}📋 Changes that would be committed:${NC}" -git diff pyproject.toml CHANGELOG.md - -# Step 7: Create temporary tag (no push) -echo "" -echo -e "${YELLOW}🏷️ Creating temporary local tag...${NC}" -git tag -a "$TAG" -m "Simulated release $TAG" 2>/dev/null || true -echo -e "${GREEN}✓ Tag $TAG created locally${NC}" - -# Step 8: Simulate release artifact creation -echo "" -echo -e "${YELLOW}📦 Simulating release package creation...${NC}" -echo " (High-level simulation only; packaging script is not executed)" -echo "" - -# Check if script exists and is executable -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -x "$SCRIPT_DIR/create-release-packages.sh" ]]; then - echo -e "${BLUE}In a real release, the following command would be run to create packages:${NC}" - echo " $SCRIPT_DIR/create-release-packages.sh \"$TAG\"" - echo "" - echo "This simulation does not enumerate individual package files to avoid" - echo "drifting from the actual behavior of create-release-packages.sh." -else - echo -e "${RED}⚠️ create-release-packages.sh not found or not executable${NC}" -fi - -# Step 9: Simulate release notes generation -echo "" -echo -e "${YELLOW}📄 Simulating release notes generation...${NC}" -echo "" -PREVIOUS_TAG=$(git describe --tags --abbrev=0 $TAG^ 2>/dev/null || echo "") -if [[ -n "$PREVIOUS_TAG" ]]; then - echo -e "${BLUE}Changes since $PREVIOUS_TAG:${NC}" - git log --oneline "$PREVIOUS_TAG".."$TAG" | head -n 10 - echo "" -else - echo -e "${BLUE}No previous tag found - this would be the first release${NC}" -fi - -# Step 10: Summary -echo "" -echo -e "${GREEN}🎉 Simulation Complete!${NC}" -echo "======================================" -echo "" -echo -e "${BLUE}Summary:${NC}" -echo " Version: $VERSION" -echo " Tag: $TAG" -echo " Backup: $BACKUP_DIR" -echo "" -echo -e "${YELLOW}⚠️ SIMULATION ONLY - NO CHANGES PUSHED${NC}" -echo "" -echo -e "${BLUE}Next steps:${NC}" -echo " 1. Review the changes above" -echo " 2. To keep changes: git add pyproject.toml CHANGELOG.md && git commit" -echo " 3. To discard changes: git checkout pyproject.toml CHANGELOG.md && git tag -d $TAG" -echo " 4. To restore from backup: cp $BACKUP_DIR/* ." -echo "" -echo -e "${BLUE}To run the actual release:${NC}" -echo " Go to: https://github.com/github/spec-kit/actions/workflows/release-trigger.yml" -echo " Click 'Run workflow' and enter version: $VERSION" -echo "" diff --git a/.github/workflows/scripts/update-version.sh b/.github/workflows/scripts/update-version.sh deleted file mode 100644 index 12bd9cd1d..000000000 --- a/.github/workflows/scripts/update-version.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# update-version.sh -# Update version in pyproject.toml (for release artifacts only) -# Usage: update-version.sh - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -VERSION="$1" - -# Remove 'v' prefix for Python versioning -PYTHON_VERSION=${VERSION#v} - -if [ -f "pyproject.toml" ]; then - sed -i "s/version = \".*\"/version = \"$PYTHON_VERSION\"/" pyproject.toml - echo "Updated pyproject.toml version to $PYTHON_VERSION (for release artifacts only)" -else - echo "Warning: pyproject.toml not found, skipping version update" -fi diff --git a/pyproject.toml b/pyproject.toml index dbb24e59f..bfd5f25c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,6 @@ packages = ["src/specify_cli"] "templates/commands" = "specify_cli/core_pack/commands" "scripts/bash" = "specify_cli/core_pack/scripts/bash" "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" -".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh" -".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 26116430c..5f0cf0bdf 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -42,7 +42,6 @@ import typer import httpx from rich.console import Console from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn from rich.text import Text from rich.live import Live from rich.align import Align @@ -54,7 +53,7 @@ from typer.core import TyperGroup import readchar import ssl import truststore -from datetime import datetime, timezone +from datetime import datetime ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -68,248 +67,16 @@ def _github_auth_headers(cli_token: str | None = None) -> dict: 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") - if "X-RateLimit-Remaining" in headers: - info["remaining"] = headers.get("X-RateLimit-Remaining") - if "X-RateLimit-Reset" in headers: - reset_epoch = int(headers.get("X-RateLimit-Reset", "0")) - if reset_epoch: - reset_time = datetime.fromtimestamp(reset_epoch, tz=timezone.utc) - 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") - try: - info["retry_after_seconds"] = int(retry_after) - except ValueError: - # HTTP-date format - not implemented, just store as string - info["retry_after"] = retry_after - - return info +def _build_agent_config() -> dict[str, dict[str, Any]]: + """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" + from .integrations import INTEGRATION_REGISTRY + config: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if integration.config: + config[key] = dict(integration.config) + return config -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: - lines.append(f" • Rate Limit: {rate_info['limit']} requests/hour") - if "remaining" in rate_info: - lines.append(f" • Remaining: {rate_info['remaining']}") - if "reset_local" in rate_info: - reset_str = rate_info["reset_local"].strftime("%Y-%m-%d %H:%M:%S %Z") - lines.append(f" • Resets at: {reset_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(" environment variable to increase rate limits.") - 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, CLI tool requirement, and commands subdirectory -AGENT_CONFIG = { - "copilot": { - "name": "GitHub Copilot", - "folder": ".github/", - "commands_subdir": "agents", # Special: uses agents/ not commands/ - "install_url": None, # IDE-based, no CLI check needed - "requires_cli": False, - }, - "claude": { - "name": "Claude Code", - "folder": ".claude/", - "commands_subdir": "commands", - "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", - "requires_cli": True, - }, - "gemini": { - "name": "Gemini CLI", - "folder": ".gemini/", - "commands_subdir": "commands", - "install_url": "https://github.com/google-gemini/gemini-cli", - "requires_cli": True, - }, - "cursor-agent": { - "name": "Cursor", - "folder": ".cursor/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "qwen": { - "name": "Qwen Code", - "folder": ".qwen/", - "commands_subdir": "commands", - "install_url": "https://github.com/QwenLM/qwen-code", - "requires_cli": True, - }, - "opencode": { - "name": "opencode", - "folder": ".opencode/", - "commands_subdir": "command", # Special: singular 'command' not 'commands' - "install_url": "https://opencode.ai", - "requires_cli": True, - }, - "codex": { - "name": "Codex CLI", - "folder": ".agents/", - "commands_subdir": "skills", # Codex now uses project skills directly - "install_url": "https://github.com/openai/codex", - "requires_cli": True, - }, - "windsurf": { - "name": "Windsurf", - "folder": ".windsurf/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "junie": { - "name": "Junie", - "folder": ".junie/", - "commands_subdir": "commands", - "install_url": "https://junie.jetbrains.com/", - "requires_cli": True, - }, - "kilocode": { - "name": "Kilo Code", - "folder": ".kilocode/", - "commands_subdir": "workflows", # Special: uses workflows/ not commands/ - "install_url": None, # IDE-based - "requires_cli": False, - }, - "auggie": { - "name": "Auggie CLI", - "folder": ".augment/", - "commands_subdir": "commands", - "install_url": "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli", - "requires_cli": True, - }, - "codebuddy": { - "name": "CodeBuddy", - "folder": ".codebuddy/", - "commands_subdir": "commands", - "install_url": "https://www.codebuddy.ai/cli", - "requires_cli": True, - }, - "qodercli": { - "name": "Qoder CLI", - "folder": ".qoder/", - "commands_subdir": "commands", - "install_url": "https://qoder.com/cli", - "requires_cli": True, - }, - "roo": { - "name": "Roo Code", - "folder": ".roo/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "kiro-cli": { - "name": "Kiro CLI", - "folder": ".kiro/", - "commands_subdir": "prompts", # Special: uses prompts/ not commands/ - "install_url": "https://kiro.dev/docs/cli/", - "requires_cli": True, - }, - "amp": { - "name": "Amp", - "folder": ".agents/", - "commands_subdir": "commands", - "install_url": "https://ampcode.com/manual#install", - "requires_cli": True, - }, - "shai": { - "name": "SHAI", - "folder": ".shai/", - "commands_subdir": "commands", - "install_url": "https://github.com/ovh/shai", - "requires_cli": True, - }, - "tabnine": { - "name": "Tabnine CLI", - "folder": ".tabnine/agent/", - "commands_subdir": "commands", - "install_url": "https://docs.tabnine.com/main/getting-started/tabnine-cli", - "requires_cli": True, - }, - "agy": { - "name": "Antigravity", - "folder": ".agent/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "bob": { - "name": "IBM Bob", - "folder": ".bob/", - "commands_subdir": "commands", - "install_url": None, # IDE-based - "requires_cli": False, - }, - "vibe": { - "name": "Mistral Vibe", - "folder": ".vibe/", - "commands_subdir": "prompts", - "install_url": "https://github.com/mistralai/mistral-vibe", - "requires_cli": True, - }, - "kimi": { - "name": "Kimi Code", - "folder": ".kimi/", - "commands_subdir": "skills", # Kimi uses /skill: with .kimi/skills//SKILL.md - "install_url": "https://code.kimi.com/", - "requires_cli": True, - }, - "trae": { - "name": "Trae", - "folder": ".trae/", - "commands_subdir": "rules", # Trae uses .trae/rules/ for project rules - "install_url": None, # IDE-based - "requires_cli": False, - }, - "pi": { - "name": "Pi Coding Agent", - "folder": ".pi/", - "commands_subdir": "prompts", - "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", - "requires_cli": True, - }, - "iflow": { - "name": "iFlow CLI", - "folder": ".iflow/", - "commands_subdir": "commands", - "install_url": "https://docs.iflow.cn/en/cli/quickstart", - "requires_cli": True, - }, - "generic": { - "name": "Generic (bring your own agent)", - "folder": None, # Set dynamically via --ai-commands-dir - "commands_subdir": "commands", - "install_url": None, - "requires_cli": False, - }, -} +AGENT_CONFIG = _build_agent_config() AI_ASSISTANT_ALIASES = { "kiro": "kiro-cli", @@ -837,314 +604,6 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals 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]: - repo_owner = "github" - repo_name = "spec-kit" - if client is None: - client = httpx.Client(verify=ssl_context) - - if verbose: - console.print("[cyan]Fetching latest release information...[/cyan]") - api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" - - try: - response = client.get( - api_url, - timeout=30, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) - status = response.status_code - if status != 200: - # Format detailed error message with rate-limit info - error_msg = _format_rate_limit_error(status, response.headers, api_url) - if debug: - error_msg += f"\n\n[dim]Response body (truncated 500):[/dim]\n{response.text[:500]}" - raise RuntimeError(error_msg) - try: - release_data = response.json() - except ValueError as je: - raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}") - except Exception as e: - console.print("[red]Error fetching release information[/red]") - console.print(Panel(str(e), title="Fetch Error", border_style="red")) - raise typer.Exit(1) - - assets = release_data.get("assets", []) - pattern = f"spec-kit-template-{ai_assistant}-{script_type}" - matching_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")) - raise typer.Exit(1) - - download_url = asset["browser_download_url"] - filename = asset["name"] - file_size = asset["size"] - - if verbose: - console.print(f"[cyan]Found template:[/cyan] {filename}") - console.print(f"[cyan]Size:[/cyan] {file_size:,} bytes") - console.print(f"[cyan]Release:[/cyan] {release_data['tag_name']}") - - zip_path = download_dir / filename - if verbose: - console.print("[cyan]Downloading template...[/cyan]") - - try: - with client.stream( - "GET", - download_url, - timeout=60, - follow_redirects=True, - headers=_github_auth_headers(github_token), - ) 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) - 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: - if total_size == 0: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - else: - if show_progress: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - console=console, - ) as progress: - task = progress.add_task("Downloading...", total=total_size) - downloaded = 0 - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - downloaded += len(chunk) - progress.update(task, completed=downloaded) - else: - for chunk in response.iter_bytes(chunk_size=8192): - f.write(chunk) - except Exception as e: - console.print("[red]Error downloading template[/red]") - detail = str(e) - if zip_path.exists(): - zip_path.unlink() - console.print(Panel(detail, title="Download Error", border_style="red")) - raise typer.Exit(1) - if verbose: - console.print(f"Downloaded: {filename}") - metadata = { - "filename": filename, - "size": file_size, - "release": release_data["tag_name"], - "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, - *, - skip_legacy_codex_prompts: 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) - - Note: - ``skip_legacy_codex_prompts`` suppresses the legacy top-level - ``.codex`` directory from older template archives in Codex skills mode. - The name is kept for backward compatibility with existing callers. - """ - current_dir = Path.cwd() - - if tracker: - tracker.start("fetch", "contacting GitHub API") - try: - zip_path, meta = download_template_from_github( - ai_assistant, - current_dir, - script_type=script_type, - verbose=verbose and tracker is None, - show_progress=(tracker is None), - client=client, - debug=debug, - github_token=github_token - ) - if tracker: - tracker.complete("fetch", f"release {meta['release']} ({meta['size']:,} bytes)") - tracker.add("download", "Download template") - tracker.complete("download", meta['filename']) - except Exception as e: - if tracker: - tracker.error("fetch", str(e)) - else: - if verbose: - console.print(f"[red]Error downloading template:[/red] {e}") - raise - - if tracker: - tracker.add("extract", "Extract template") - tracker.start("extract") - elif verbose: - console.print("Extracting template...") - - try: - if not is_current_dir: - project_path.mkdir(parents=True) - - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - def _validate_zip_members_within(root: Path) -> None: - """Validate all ZIP members stay within ``root`` (Zip Slip guard).""" - root_resolved = root.resolve() - for member in zip_ref.namelist(): - member_path = (root / member).resolve() - try: - member_path.relative_to(root_resolved) - except ValueError: - raise RuntimeError( - f"Unsafe path in ZIP archive: {member} " - "(potential path traversal)" - ) - - zip_contents = zip_ref.namelist() - if tracker: - tracker.start("zip-list") - tracker.complete("zip-list", f"{len(zip_contents)} entries") - elif verbose: - console.print(f"[cyan]ZIP contains {len(zip_contents)} items[/cyan]") - - if is_current_dir: - with tempfile.TemporaryDirectory() as temp_dir: - temp_path = Path(temp_dir) - _validate_zip_members_within(temp_path) - zip_ref.extractall(temp_path) - - extracted_items = list(temp_path.iterdir()) - if tracker: - tracker.start("extracted-summary") - 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]") - - source_dir = temp_path - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - source_dir = extracted_items[0] - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print("[cyan]Found nested directory structure[/cyan]") - - for item in source_dir.iterdir(): - # In Codex skills mode, do not materialize the legacy - # top-level .codex directory from older prompt-based - # template archives. - if skip_legacy_codex_prompts and ai_assistant == "codex" and item.name == ".codex": - continue - 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('*'): - 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) - # 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) - 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}") - shutil.copy2(item, dest_path) - if verbose and not tracker: - console.print("[cyan]Template files merged into current directory[/cyan]") - else: - _validate_zip_members_within(project_path) - 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") - elif verbose: - 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'})") - - if len(extracted_items) == 1 and extracted_items[0].is_dir(): - nested_dir = extracted_items[0] - temp_move_dir = project_path.parent / f"{project_path.name}_temp" - - shutil.move(str(nested_dir), str(temp_move_dir)) - - project_path.rmdir() - - shutil.move(str(temp_move_dir), str(project_path)) - if tracker: - tracker.add("flatten", "Flatten nested directory") - tracker.complete("flatten") - elif verbose: - console.print("[cyan]Flattened nested directory structure[/cyan]") - - # For fresh-directory Codex skills init, suppress legacy - # top-level .codex layout extracted from older archives. - if skip_legacy_codex_prompts and ai_assistant == "codex": - legacy_codex_dir = project_path / ".codex" - if legacy_codex_dir.is_dir(): - shutil.rmtree(legacy_codex_dir, ignore_errors=True) - - except Exception as e: - if tracker: - tracker.error("extract", str(e)) - else: - if verbose: - console.print(f"[red]Error extracting template:[/red] {e}") - if debug: - console.print(Panel(str(e), title="Extraction Error", border_style="red")) - - if not is_current_dir and project_path.exists(): - shutil.rmtree(project_path) - raise typer.Exit(1) - else: - if tracker: - tracker.complete("extract") - finally: - if tracker: - tracker.add("cleanup", "Remove temporary archive") - - if zip_path.exists(): - zip_path.unlink() - if tracker: - tracker.complete("cleanup") - elif verbose: - console.print(f"Cleaned up: {zip_path.name}") - - return project_path - - def _locate_core_pack() -> Path | None: """Return the filesystem path to the bundled core_pack directory, or None. @@ -1162,41 +621,6 @@ def _locate_core_pack() -> Path | None: return None -def _locate_release_script() -> tuple[Path, str]: - """Return (script_path, shell_cmd) for the platform-appropriate release script. - - Checks the bundled core_pack first, then falls back to the source checkout. - Returns the bash script on Unix and the PowerShell script on Windows. - Raises FileNotFoundError if neither can be found. - """ - if os.name == "nt": - name = "create-release-packages.ps1" - shell = shutil.which("pwsh") - if not shell: - raise FileNotFoundError( - "'pwsh' (PowerShell 7+) not found on PATH. " - "The bundled release script requires PowerShell 7+ (pwsh), " - "not Windows PowerShell 5.x (powershell.exe). " - "Install from https://aka.ms/powershell to use offline scaffolding." - ) - else: - name = "create-release-packages.sh" - shell = "bash" - - # Wheel install: core_pack/release_scripts/ - candidate = Path(__file__).parent / "core_pack" / "release_scripts" / name - if candidate.is_file(): - return candidate, shell - - # Source-checkout fallback - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / ".github" / "workflows" / "scripts" / name - if candidate.is_file(): - return candidate, shell - - raise FileNotFoundError(f"Release script '{name}' not found in core_pack or source checkout") - - def _install_shared_infra( project_path: Path, script_type: str, @@ -1275,189 +699,6 @@ def _install_shared_infra( return True -def scaffold_from_core_pack( - project_path: Path, - ai_assistant: str, - script_type: str, - is_current_dir: bool = False, - *, - tracker: StepTracker | None = None, -) -> bool: - """Scaffold a project from bundled core_pack assets — no network access required. - - Invokes the bundled create-release-packages script (bash on Unix, PowerShell - on Windows) to generate the full project scaffold for a single agent. This - guarantees byte-for-byte parity between ``specify init`` and the GitHub - release ZIPs because both use the exact same script. - - Returns True on success. Returns False if offline scaffolding failed for - any reason, including missing or unreadable assets, missing required tools - (bash, pwsh, zip), release-script failure or timeout, or unexpected runtime - exceptions. When ``--offline`` is active the caller should treat False as - a hard error rather than falling back to a network download. - """ - # --- Locate asset sources --- - core = _locate_core_pack() - - # Command templates - if core and (core / "commands").is_dir(): - commands_dir = core / "commands" - else: - repo_root = Path(__file__).parent.parent.parent - commands_dir = repo_root / "templates" / "commands" - if not commands_dir.is_dir(): - if tracker: - tracker.error("scaffold", "command templates not found") - return False - - # Scripts directory (parent of bash/ and powershell/) - if core and (core / "scripts").is_dir(): - scripts_dir = core / "scripts" - else: - repo_root = Path(__file__).parent.parent.parent - scripts_dir = repo_root / "scripts" - if not scripts_dir.is_dir(): - if tracker: - tracker.error("scaffold", "scripts directory not found") - return False - - # Page templates (spec-template.md, plan-template.md, vscode-settings.json, etc.) - if core and (core / "templates").is_dir(): - templates_dir = core / "templates" - else: - repo_root = Path(__file__).parent.parent.parent - templates_dir = repo_root / "templates" - if not templates_dir.is_dir(): - if tracker: - tracker.error("scaffold", "page templates not found") - return False - - # Release script - try: - release_script, shell_cmd = _locate_release_script() - except FileNotFoundError as exc: - if tracker: - tracker.error("scaffold", str(exc)) - return False - - # Preflight: verify required external tools are available - if os.name != "nt": - if not shutil.which("bash"): - msg = "'bash' not found on PATH. Required for offline scaffolding." - if tracker: - tracker.error("scaffold", msg) - return False - if not shutil.which("zip"): - msg = "'zip' not found on PATH. Required for offline scaffolding. Install with: apt install zip / brew install zip" - if tracker: - tracker.error("scaffold", msg) - return False - - if tracker: - tracker.start("scaffold", "applying bundled assets") - - try: - if not is_current_dir: - project_path.mkdir(parents=True, exist_ok=True) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - - # Set up a repo-like directory layout in the temp dir so the - # release script finds templates/commands/, scripts/, etc. - tmpl_cmds = tmp / "templates" / "commands" - tmpl_cmds.mkdir(parents=True) - for f in commands_dir.iterdir(): - if f.is_file(): - shutil.copy2(f, tmpl_cmds / f.name) - - # Page templates (needed for vscode-settings.json etc.) - if templates_dir.is_dir(): - tmpl_root = tmp / "templates" - for f in templates_dir.iterdir(): - if f.is_file(): - shutil.copy2(f, tmpl_root / f.name) - - # Scripts (bash/ and powershell/) - for subdir in ("bash", "powershell"): - src = scripts_dir / subdir - if src.is_dir(): - dst = tmp / "scripts" / subdir - dst.mkdir(parents=True, exist_ok=True) - for f in src.iterdir(): - if f.is_file(): - shutil.copy2(f, dst / f.name) - - # Run the release script for this single agent + script type - env = os.environ.copy() - # Pin GENRELEASES_DIR inside the temp dir so a user-exported - # value cannot redirect output or cause rm -rf outside the sandbox. - env["GENRELEASES_DIR"] = str(tmp / ".genreleases") - if os.name == "nt": - cmd = [ - shell_cmd, "-File", str(release_script), - "-Version", "v0.0.0", - "-Agents", ai_assistant, - "-Scripts", script_type, - ] - else: - cmd = [shell_cmd, str(release_script), "v0.0.0"] - env["AGENTS"] = ai_assistant - env["SCRIPTS"] = script_type - - try: - result = subprocess.run( - cmd, cwd=str(tmp), env=env, - capture_output=True, text=True, - timeout=120, - ) - except subprocess.TimeoutExpired: - msg = "release script timed out after 120 seconds" - if tracker: - tracker.error("scaffold", msg) - else: - console.print(f"[red]Error:[/red] {msg}") - return False - - if result.returncode != 0: - msg = result.stderr.strip() or result.stdout.strip() or "unknown error" - if tracker: - tracker.error("scaffold", f"release script failed: {msg}") - else: - console.print(f"[red]Release script failed:[/red] {msg}") - return False - - # Copy the generated files to the project directory - build_dir = tmp / ".genreleases" / f"sdd-{ai_assistant}-package-{script_type}" - if not build_dir.is_dir(): - if tracker: - tracker.error("scaffold", "release script produced no output") - return False - - for item in build_dir.rglob("*"): - if item.is_file(): - rel = item.relative_to(build_dir) - dest = project_path / rel - dest.parent.mkdir(parents=True, exist_ok=True) - # When scaffolding into an existing directory (--here), - # use the same merge semantics as the GitHub-download path. - if is_current_dir and dest.name == "settings.json" and dest.parent.name == ".vscode": - handle_vscode_settings(item, dest, rel, verbose=False, tracker=tracker) - else: - shutil.copy2(item, dest) - - if tracker: - tracker.complete("scaffold", "bundled assets applied") - return True - - except Exception as e: - if tracker: - tracker.error("scaffold", str(e)) - else: - console.print(f"[red]Error scaffolding from bundled assets:[/red] {e}") - return False - - 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": @@ -1571,340 +812,35 @@ def load_init_options(project_path: Path) -> dict[str, Any]: return {} -# Default skills directory for agents not in AGENT_CONFIG -DEFAULT_SKILLS_DIR = ".agents/skills" - -# Agents whose downloaded template already contains skills in the final layout. -# -# Technical debt note: -# - Spec-kit currently has multiple SKILL.md generators: -# 1) release packaging scripts that build the template zip (native skills), -# 2) `install_ai_skills()` which converts extracted command templates to skills, -# 3) extension/preset overrides via `agents.CommandRegistrar.render_skill_command()`. -# - Keep the skills frontmatter schema aligned across all generators -# (at minimum: name/description/compatibility/metadata.{author,source}). -# - When adding fields here, update the release scripts and override writers too. -NATIVE_SKILLS_AGENTS = {"codex", "kimi"} - -# Enhanced descriptions for each spec-kit command skill -SKILL_DESCRIPTIONS = { - "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", - "plan": "Generate technical implementation plans from feature specifications. Use after creating a spec to define architecture, tech stack, and implementation phases. Creates plan.md with detailed technical design.", - "tasks": "Break down implementation plans into actionable task lists. Use after planning to create a structured task breakdown. Generates tasks.md with ordered, dependency-aware tasks.", - "implement": "Execute all tasks from the task breakdown to build the feature. Use after task generation to systematically implement the planned solution following TDD approach where applicable.", - "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md. Use after task generation to identify gaps, duplications, and inconsistencies before implementation.", - "clarify": "Structured clarification workflow for underspecified requirements. Use before planning to resolve ambiguities through coverage-based questioning. Records answers in spec clarifications section.", - "constitution": "Create or update project governing principles and development guidelines. Use at project start to establish code quality, testing standards, and architectural constraints that guide all development.", - "checklist": "Generate custom quality checklists for validating requirements completeness and clarity. Use to create unit tests for English that ensure spec quality before implementation.", - "taskstoissues": "Convert tasks from tasks.md into GitHub issues. Use after task breakdown to track work items in GitHub project management.", -} - - def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: - """Resolve the agent-specific skills directory for the given AI assistant. + """Resolve the agent-specific skills directory. - Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to - ``DEFAULT_SKILLS_DIR`` for unknown agents. + Returns ``project_path / / "skills"``, falling back + to ``project_path / ".agents/skills"`` for unknown agents. """ agent_config = AGENT_CONFIG.get(selected_ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: return project_path / agent_folder.rstrip("/") / "skills" - - return project_path / DEFAULT_SKILLS_DIR + return project_path / ".agents" / "skills" -def install_ai_skills( - project_path: Path, - selected_ai: str, - tracker: StepTracker | None = None, - *, - overwrite_existing: bool = False, -) -> bool: - """Install Prompt.MD files from templates/commands/ as agent skills. - - Skills are written to the agent-specific skills directory following the - `agentskills.io `_ specification. - Installation is additive by default — existing files are never removed and - prompt command files in the agent's commands directory are left untouched. - - Args: - project_path: Target project directory. - selected_ai: AI assistant key from ``AGENT_CONFIG``. - tracker: Optional progress tracker. - overwrite_existing: When True, overwrite any existing ``SKILL.md`` file - in the target skills directory (including user-authored content). - Defaults to False. - - Returns: - ``True`` if at least one skill was installed or all skills were - already present (idempotent re-run), ``False`` otherwise. - """ - from .agents import CommandRegistrar - - # Locate command templates in the agent's extracted commands directory. - # download_and_extract_template() already placed the .md files here. - agent_config = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_config.get("folder", "") - commands_subdir = agent_config.get("commands_subdir", "commands") - if agent_folder: - templates_dir = project_path / agent_folder.rstrip("/") / commands_subdir - else: - templates_dir = project_path / commands_subdir - - # Only consider speckit.*.md templates so that user-authored command - # files (e.g. custom slash commands, agent files) coexisting in the - # same commands directory are not incorrectly converted into skills. - template_glob = "speckit.*.md" - - if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): - # Fallback: try the repo-relative path (for running from source checkout) - # This also covers agents whose extracted commands are in a different - # format (e.g. gemini/tabnine use .toml, not .md). - script_dir = Path(__file__).parent.parent.parent # up from src/specify_cli/ - fallback_dir = script_dir / "templates" / "commands" - if fallback_dir.exists() and any(fallback_dir.glob("*.md")): - templates_dir = fallback_dir - template_glob = "*.md" - - if not templates_dir.exists() or not any(templates_dir.glob(template_glob)): - if tracker: - tracker.error("ai-skills", "command templates not found") - else: - console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") - return False - - command_files = sorted(templates_dir.glob(template_glob)) - if not command_files: - if tracker: - tracker.skip("ai-skills", "no command templates found") - else: - console.print("[yellow]No command templates found to install[/yellow]") - return False - - # Resolve the correct skills directory for this agent - skills_dir = _get_skills_dir(project_path, selected_ai) - skills_dir.mkdir(parents=True, exist_ok=True) - - if tracker: - tracker.start("ai-skills") - - installed_count = 0 - skipped_count = 0 - for command_file in command_files: - try: - content = command_file.read_text(encoding="utf-8") - - # Parse YAML frontmatter - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - frontmatter = {} - body = parts[2].strip() - else: - # File starts with --- but has no closing --- - console.print(f"[yellow]Warning: {command_file.name} has malformed frontmatter (no closing ---), treating as plain content[/yellow]") - frontmatter = {} - body = content - else: - frontmatter = {} - body = content - - command_name = command_file.stem - # Normalize: extracted commands may be named "speckit..md" - # or "speckit..agent.md"; strip the "speckit." prefix and - # any trailing ".agent" suffix so skill names stay clean and - # SKILL_DESCRIPTIONS lookups work. - if command_name.startswith("speckit."): - command_name = command_name[len("speckit."):] - if command_name.endswith(".agent"): - command_name = command_name[:-len(".agent")] - skill_name = f"speckit-{command_name.replace('.', '-')}" - - # Create skill directory (additive — never removes existing content) - skill_dir = skills_dir / skill_name - skill_dir.mkdir(parents=True, exist_ok=True) - - # Select the best description available - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get(command_name, original_desc or f"Spec-kit workflow command: {command_name}") - - # Build SKILL.md following agentskills.io spec - # Use yaml.safe_dump to safely serialise the frontmatter and - # avoid YAML injection from descriptions containing colons, - # quotes, or newlines. - # Normalize source filename for metadata — strip speckit. prefix - # so it matches the canonical templates/commands/.md path. - source_name = command_file.name - if source_name.startswith("speckit."): - source_name = source_name[len("speckit."):] - if source_name.endswith(".agent.md"): - source_name = source_name[:-len(".agent.md")] + ".md" - - frontmatter_data = CommandRegistrar.build_skill_frontmatter( - selected_ai, - skill_name, - enhanced_desc, - f"templates/commands/{source_name}", - ) - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {command_name.title()} Skill\n\n" - f"{body}\n" - ) - - skill_file = skill_dir / "SKILL.md" - if skill_file.exists(): - if not overwrite_existing: - # Default behavior: do not overwrite user-customized skills on re-runs - skipped_count += 1 - continue - skill_file.write_text(skill_content, encoding="utf-8") - installed_count += 1 - - except Exception as e: - console.print(f"[yellow]Warning: Failed to install skill {command_file.stem}: {e}[/yellow]") - continue - - if tracker: - if installed_count > 0 and skipped_count > 0: - tracker.complete("ai-skills", f"{installed_count} new + {skipped_count} existing skills in {skills_dir.relative_to(project_path)}") - elif installed_count > 0: - tracker.complete("ai-skills", f"{installed_count} skills → {skills_dir.relative_to(project_path)}") - elif skipped_count > 0: - tracker.complete("ai-skills", f"{skipped_count} skills already present") - else: - tracker.error("ai-skills", "no skills installed") - else: - if installed_count > 0: - console.print(f"[green]✓[/green] Installed {installed_count} agent skills to {skills_dir.relative_to(project_path)}/") - elif skipped_count > 0: - console.print(f"[green]✓[/green] {skipped_count} agent skills already present in {skills_dir.relative_to(project_path)}/") - else: - console.print("[yellow]No skills were installed[/yellow]") - - return installed_count > 0 or skipped_count > 0 - - -def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool: - """Return True when a native-skills agent has spec-kit bundled skills.""" - skills_dir = _get_skills_dir(project_path, selected_ai) - if not skills_dir.is_dir(): - return False - - return any(skills_dir.glob("speckit-*/SKILL.md")) - - -def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. - - Temporary migration helper: - - Intended removal window: after 2026-06-25. - - Purpose: one-time cleanup for projects initialized before Kimi moved to - hyphenated skills (speckit-xxx). - - Returns: - Tuple[migrated_count, removed_count] - - migrated_count: old dotted dir renamed to hyphenated dir - - removed_count: old dotted dir deleted when equivalent hyphenated dir existed - """ - if not skills_dir.is_dir(): - return (0, 0) - - migrated_count = 0 - removed_count = 0 - - for legacy_dir in sorted(skills_dir.glob("speckit.*")): - if not legacy_dir.is_dir(): - continue - if not (legacy_dir / "SKILL.md").exists(): - continue - - suffix = legacy_dir.name[len("speckit."):] - if not suffix: - continue - - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" - - if not target_dir.exists(): - shutil.move(str(legacy_dir), str(target_dir)) - migrated_count += 1 - continue - - # If the new target already exists, avoid destructive cleanup unless - # both SKILL.md files are byte-identical. - target_skill = target_dir / "SKILL.md" - legacy_skill = legacy_dir / "SKILL.md" - if target_skill.is_file(): - try: - if target_skill.read_bytes() == legacy_skill.read_bytes(): - # Preserve legacy directory when it contains extra user files. - has_extra_entries = any( - child.name != "SKILL.md" for child in legacy_dir.iterdir() - ) - if not has_extra_entries: - shutil.rmtree(legacy_dir) - removed_count += 1 - except OSError: - # Best-effort migration: preserve legacy dir on read failures. - pass - - return (migrated_count, removed_count) - - -AGENT_SKILLS_MIGRATIONS = { - "claude": { - "error": ( - "Claude Code now installs spec-kit as agent skills; " - "legacy .claude/commands projects are kept for backwards compatibility." - ), - "usage": "specify init --ai claude", - "interactive_note": ( - "'claude' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically so spec-kit is installed to [cyan].claude/skills[/cyan]." - ), - "explicit_note": ( - "'claude' now installs spec-kit as agent skills; enabling " - "[cyan]--ai-skills[/cyan] automatically so commands are written to " - "[cyan].claude/skills[/cyan]." - ), - "auto_enable_explicit": True, - }, - "agy": { - "error": "Explicit command support was deprecated in Antigravity version 1.20.5.", - "usage": "specify init --ai agy --ai-skills", - "interactive_note": ( - "'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically for compatibility (explicit .agent/commands usage is deprecated)." - ), - }, - "codex": { - "error": ( - "Custom prompt-based spec-kit initialization is deprecated for Codex CLI; " - "use agent skills instead." - ), - "usage": "specify init --ai codex --ai-skills", - "interactive_note": ( - "'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] " - "automatically for compatibility (.agents/skills is the recommended Codex layout)." - ), - }, +# Constants kept for backward compatibility with presets and extensions. +DEFAULT_SKILLS_DIR = ".agents/skills" +NATIVE_SKILLS_AGENTS = {"codex", "kimi"} +SKILL_DESCRIPTIONS = { + "specify": "Create or update feature specifications from natural language descriptions.", + "plan": "Generate technical implementation plans from feature specifications.", + "tasks": "Break down implementation plans into actionable task lists.", + "implement": "Execute all tasks from the task breakdown to build the feature.", + "analyze": "Perform cross-artifact consistency analysis across spec.md, plan.md, and tasks.md.", + "clarify": "Structured clarification workflow for underspecified requirements.", + "constitution": "Create or update project governing principles and development guidelines.", + "checklist": "Generate custom quality checklists for validating requirements completeness and clarity.", + "taskstoissues": "Convert tasks from tasks.md into GitHub issues.", } -def _handle_agent_skills_migration(console: Console, agent_key: str) -> None: - """Print a fail-fast migration error for agents that now require skills.""" - migration = AGENT_SKILLS_MIGRATIONS[agent_key] - console.print(f"\n[red]Error:[/red] {migration['error']}") - console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") - console.print(f"[yellow]Usage:[/yellow] {migration['usage']}") - raise typer.Exit(1) - @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)"), @@ -1915,11 +851,11 @@ def init( 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)"), + skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), + debug: bool = typer.Option(False, "--debug", help="Deprecated (no-op). Previously: show verbose diagnostic output.", hidden=True), + github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"), - offline: bool = typer.Option(False, "--offline", help="Use assets bundled in the specify-cli package instead of downloading from GitHub (no network access required). Bundled assets will become the default in v0.6.0 and this flag will be removed."), + offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, ...) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."), @@ -1988,45 +924,37 @@ def init( # --integration and --ai are mutually exclusive if integration and ai_assistant: console.print("[red]Error:[/red] --integration and --ai are mutually exclusive") - console.print("[yellow]Use:[/yellow] --integration for the new integration system, or --ai for the legacy path") raise typer.Exit(1) - # Auto-promote: --ai → integration path with a nudge (if registered) - use_integration = False - resolved_integration = None + # Resolve the integration — either from --integration or --ai + from .integrations import INTEGRATION_REGISTRY, get_integration if integration: - from .integrations import INTEGRATION_REGISTRY, get_integration resolved_integration = get_integration(integration) if not resolved_integration: console.print(f"[red]Error:[/red] Unknown integration: '{integration}'") available = ", ".join(sorted(INTEGRATION_REGISTRY)) console.print(f"[yellow]Available integrations:[/yellow] {available}") raise typer.Exit(1) - use_integration = True - # Map integration key to the ai_assistant variable for downstream compatibility ai_assistant = integration elif ai_assistant: - from .integrations import get_integration resolved_integration = get_integration(ai_assistant) - if resolved_integration: - use_integration = True - console.print( - f"[dim]Tip: Use [bold]--integration {ai_assistant}[/bold] instead of " - f"--ai {ai_assistant}. The --ai flag will be deprecated in a future release.[/dim]" - ) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") + raise typer.Exit(1) - # Deprecation warnings for --ai-skills and --ai-commands-dir when using integration path - if use_integration: + # Deprecation warnings for --ai-skills and --ai-commands-dir (only when + # an integration has been resolved from --ai or --integration) + if ai_assistant or integration: if ai_skills: from .integrations.base import SkillsIntegration as _SkillsCheck if isinstance(resolved_integration, _SkillsCheck): console.print( - "[dim]Note: --ai-skills is not needed with --integration; " + "[dim]Note: --ai-skills is not needed; " "skills are the default for this integration.[/dim]" ) else: console.print( - "[dim]Note: --ai-skills has no effect with --integration " + "[dim]Note: --ai-skills has no effect with " f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]" ) if ai_commands_dir and resolved_integration.key != "generic": @@ -2101,27 +1029,11 @@ def init( ) # Auto-promote interactively selected agents to the integration path - # when a matching integration is registered (same behavior as --ai). - if not use_integration: - from .integrations import get_integration as _get_int - _resolved = _get_int(selected_ai) - if _resolved: - use_integration = True - resolved_integration = _resolved - - # Agents that have moved from explicit commands/prompts to agent skills. - # Skip this check when using the integration path — skills are the default. - if not use_integration and selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills: - # If selected interactively (no --ai provided), automatically enable - # ai_skills so the agent remains usable without requiring an extra flag. - # Preserve fail-fast behavior only for explicit '--ai ' without skills. - migration = AGENT_SKILLS_MIGRATIONS[selected_ai] - if ai_assistant and not migration.get("auto_enable_explicit", False): - _handle_agent_skills_migration(console, selected_ai) - else: - ai_skills = True - note_key = "explicit_note" if ai_assistant else "interactive_note" - console.print(f"\n[yellow]Note:[/yellow] {migration[note_key]}") + if not ai_assistant: + resolved_integration = get_integration(selected_ai) + if not resolved_integration: + console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") + raise typer.Exit(1) # Validate --ai-commands-dir usage. # Skip validation when --integration-options is provided — the integration @@ -2129,15 +1041,8 @@ def init( if selected_ai == "generic" and not integration_options: if not ai_commands_dir: console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") - console.print("[dim]Example: specify init my-project --integration generic --integration-options=\"--commands-dir .myagent/commands/\"[/dim]") + console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') raise typer.Exit(1) - elif ai_commands_dir and not use_integration: - console.print( - f"[red]Error:[/red] --ai-commands-dir can only be used with the " - f"'generic' integration via --ai generic or --integration generic " - f"(not '{selected_ai}')" - ) - raise typer.Exit(1) current_dir = Path.cwd() @@ -2204,49 +1109,14 @@ def init( tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) - # Determine whether to use bundled assets or download from GitHub (default). - # --offline opts in to bundled assets; without it, always use GitHub. - # When --offline is set, scaffold_from_core_pack() will try the wheel's - # core_pack/ first, then fall back to source-checkout paths. If neither - # location has the required assets it returns False and we error out. - _core = _locate_core_pack() - - use_github = not offline - - if use_github and _core is not None: - console.print( - "[yellow]Note:[/yellow] Bundled assets are available in this install. " - "Use [bold]--offline[/bold] to skip the GitHub download — faster, " - "no network required, and guaranteed version match.\n" - "This will become the default in v0.6.0." - ) - - if use_integration: - tracker.add("integration", "Install integration") - tracker.add("shared-infra", "Install shared infrastructure") - elif use_github: - for key, label in [ - ("fetch", "Fetch latest release"), - ("download", "Download template"), - ("extract", "Extract template"), - ("zip-list", "Archive contents"), - ("extracted-summary", "Extraction summary"), - ]: - tracker.add(key, label) - else: - tracker.add("scaffold", "Apply bundled assets") + tracker.add("integration", "Install integration") + tracker.add("shared-infra", "Install shared infrastructure") for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ]: - tracker.add(key, label) - if ai_skills: - tracker.add("ai-skills", "Install agent skills") - for key, label in [ - ("cleanup", "Cleanup"), ("git", "Initialize git repository"), - ("final", "Finalize") + ("final", "Finalize"), ]: tracker.add(key, label) @@ -2256,170 +1126,53 @@ def init( 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 + # Integration-based scaffolding + from .integrations.manifest import IntegrationManifest + tracker.start("integration") + manifest = IntegrationManifest( + resolved_integration.key, project_path, version=get_speckit_version() + ) - if use_integration: - # Integration-based scaffolding (new path) - from .integrations.manifest import IntegrationManifest - tracker.start("integration") - manifest = IntegrationManifest( - resolved_integration.key, project_path, version=get_speckit_version() - ) + # Forward all legacy CLI flags to the integration as parsed_options. + # Integrations receive every option and decide what to use; + # irrelevant keys are simply ignored by the integration's setup(). + integration_parsed_options: dict[str, Any] = {} + if ai_commands_dir: + integration_parsed_options["commands_dir"] = ai_commands_dir + if ai_skills: + integration_parsed_options["skills"] = True - # Forward all legacy CLI flags to the integration as parsed_options. - # Integrations receive every option and decide what to use; - # irrelevant keys are simply ignored by the integration's setup(). - integration_parsed_options: dict[str, Any] = {} - if ai_commands_dir: - integration_parsed_options["commands_dir"] = ai_commands_dir - if ai_skills: - integration_parsed_options["skills"] = True + resolved_integration.setup( + project_path, manifest, + parsed_options=integration_parsed_options or None, + script_type=selected_script, + raw_options=integration_options, + ) + manifest.save() - resolved_integration.setup( - project_path, manifest, - parsed_options=integration_parsed_options or None, - script_type=selected_script, - raw_options=integration_options, - ) - manifest.save() + # Write .specify/integration.json + script_ext = "sh" if selected_script == "sh" else "ps1" + integration_json = project_path / ".specify" / "integration.json" + integration_json.parent.mkdir(parents=True, exist_ok=True) + integration_json.write_text(json.dumps({ + "integration": resolved_integration.key, + "version": get_speckit_version(), + "scripts": { + "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", + }, + }, indent=2) + "\n", encoding="utf-8") - # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" - integration_json = project_path / ".specify" / "integration.json" - integration_json.parent.mkdir(parents=True, exist_ok=True) - integration_json.write_text(json.dumps({ - "integration": resolved_integration.key, - "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, - }, indent=2) + "\n", encoding="utf-8") + tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) - tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) - - # Install shared infrastructure (scripts, templates) - tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker) - tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - - elif use_github: - with httpx.Client(verify=local_ssl_context) as local_client: - download_and_extract_template( - project_path, - selected_ai, - selected_script, - here, - skip_legacy_codex_prompts=(selected_ai == "codex" and ai_skills), - verbose=False, - tracker=tracker, - client=local_client, - debug=debug, - github_token=github_token, - ) - else: - scaffold_ok = scaffold_from_core_pack(project_path, selected_ai, selected_script, here, tracker=tracker) - if not scaffold_ok: - # --offline explicitly requested: never attempt a network download - console.print( - "\n[red]Error:[/red] --offline was specified but scaffolding from bundled assets failed.\n" - "Common causes: missing bash/pwsh, script permission errors, or incomplete wheel.\n" - "Remove --offline to attempt a GitHub download instead." - ) - # Surface the specific failure reason from the tracker - for step in tracker.steps: - if step["key"] == "scaffold" and step["detail"]: - console.print(f"[red]Detail:[/red] {step['detail']}") - break - # Clean up partial project directory (same as the GitHub-download failure path) - if not here and project_path.exists(): - shutil.rmtree(project_path) - raise typer.Exit(1) - # For generic agent, rename placeholder directory to user-specified path - if not use_integration and selected_ai == "generic" and ai_commands_dir: - placeholder_dir = project_path / ".speckit" / "commands" - target_dir = project_path / ai_commands_dir - if placeholder_dir.is_dir(): - target_dir.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(placeholder_dir), str(target_dir)) - # Clean up empty .speckit dir if it's now empty - speckit_dir = project_path / ".speckit" - if speckit_dir.is_dir() and not any(speckit_dir.iterdir()): - speckit_dir.rmdir() + # Install shared infrastructure (scripts, templates) + tracker.start("shared-infra") + _install_shared_infra(project_path, selected_script, tracker=tracker) + tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_executable_scripts(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker) - # Determine skills directory and migrate any legacy Kimi dotted skills. - # (Legacy path only — integration path handles skills in setup().) - migrated_legacy_kimi_skills = 0 - removed_legacy_kimi_skills = 0 - skills_dir: Optional[Path] = None - if not use_integration and selected_ai in NATIVE_SKILLS_AGENTS: - skills_dir = _get_skills_dir(project_path, selected_ai) - if selected_ai == "kimi" and skills_dir.is_dir(): - ( - migrated_legacy_kimi_skills, - removed_legacy_kimi_skills, - ) = _migrate_legacy_kimi_dotted_skills(skills_dir) - - if not use_integration and ai_skills: - if selected_ai in NATIVE_SKILLS_AGENTS: - bundled_found = _has_bundled_skills(project_path, selected_ai) - if bundled_found: - detail = f"bundled skills → {skills_dir.relative_to(project_path)}" - if migrated_legacy_kimi_skills or removed_legacy_kimi_skills: - detail += ( - f" (migrated {migrated_legacy_kimi_skills}, " - f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)" - ) - if tracker: - tracker.start("ai-skills") - tracker.complete("ai-skills", detail) - else: - console.print(f"[green]✓[/green] Using {detail}") - else: - # Compatibility fallback: convert command templates to skills - # when an older template archive does not include native skills. - # This keeps `specify init --here --ai codex --ai-skills` usable - # in repos that already contain unrelated skills under .agents/skills. - fallback_ok = install_ai_skills( - project_path, - selected_ai, - tracker=tracker, - overwrite_existing=True, - ) - if not fallback_ok: - raise RuntimeError( - f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, " - "but none were found and fallback conversion failed. " - "Re-run with an up-to-date template." - ) - else: - skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) - - # When --ai-skills is used on a NEW project and skills were - # successfully installed, remove the command files that the - # template archive just created. Skills replace commands, so - # keeping both would be confusing. For --here on an existing - # repo we leave pre-existing commands untouched to avoid a - # breaking change. We only delete AFTER skills succeed so the - # project always has at least one of {commands, skills}. - if skills_ok and not here: - agent_cfg = AGENT_CONFIG.get(selected_ai, {}) - agent_folder = agent_cfg.get("folder", "") - commands_subdir = agent_cfg.get("commands_subdir", "commands") - if agent_folder: - cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir - if cmds_dir.exists(): - try: - shutil.rmtree(cmds_dir) - except OSError: - # Best-effort cleanup: skills are already installed, - # so leaving stale commands is non-fatal. - console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") - if not no_git: tracker.start("git") if is_git_repo(project_path): @@ -2441,22 +1194,18 @@ def init( # Must be saved BEFORE preset install so _get_skills_dir() works. init_opts = { "ai": selected_ai, - "ai_skills": ai_skills, - "ai_commands_dir": ai_commands_dir, + "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", "here": here, "preset": preset, - "offline": offline, "script": selected_script, "speckit_version": get_speckit_version(), } - if use_integration: - init_opts["integration"] = resolved_integration.key - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): - init_opts["ai_skills"] = True + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist): + init_opts["ai_skills"] = True save_init_options(project_path, init_opts) # Install preset if specified @@ -2490,10 +1239,6 @@ def init( except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") - # Scaffold path has no zip archive to clean up - if not use_github: - tracker.skip("cleanup", "not needed (no download)") - tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise @@ -2559,12 +1304,9 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, claude, kimi, agy) should show skill - # invocation syntax regardless of whether --ai-skills was explicitly passed. - _is_skills_integration = False - if use_integration: - from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + # Skills integrations (codex, kimi, agy) should show skill invocation syntax. + from .integrations.base import SkillsIntegration as _SkillsInt + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) @@ -4414,6 +3156,7 @@ def extension_update( shutil.copy2(cfg_file, backup_config_dir / cfg_file.name) # 3. Backup command files for all agents + from .agents import CommandRegistrar as _AgentReg registered_commands = backup_registry_entry.get("registered_commands", {}) for agent_name, cmd_names in registered_commands.items(): if agent_name not in registrar.AGENT_CONFIGS: @@ -4422,7 +3165,8 @@ def extension_update( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" if cmd_file.exists(): backup_cmd_path = backup_commands_dir / agent_name / cmd_file.name backup_cmd_path.parent.mkdir(parents=True, exist_ok=True) @@ -4576,7 +3320,8 @@ def extension_update( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" + output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config) + cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" # Delete if it exists and wasn't in our backup if cmd_file.exists() and str(cmd_file) not in backed_up_command_files: cmd_file.unlink() diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 50c01a22d..386fa5df4 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -15,6 +15,18 @@ from copy import deepcopy import yaml +def _build_agent_configs() -> dict[str, Any]: + """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" + from specify_cli.integrations import INTEGRATION_REGISTRY + configs: dict[str, dict[str, Any]] = {} + for key, integration in INTEGRATION_REGISTRY.items(): + if key == "generic": + continue + if integration.registrar_config: + configs[key] = dict(integration.registrar_config) + return configs + + class CommandRegistrar: """Handles registration of commands with AI agents. @@ -23,159 +35,26 @@ class CommandRegistrar: and companion files (e.g. Copilot .prompt.md). """ - # Agent configurations with directory, format, and argument placeholder - AGENT_CONFIGS = { - "claude": { - "dir": ".claude/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "gemini": { - "dir": ".gemini/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "copilot": { - "dir": ".github/agents", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".agent.md" - }, - "cursor-agent": { - "dir": ".cursor/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qwen": { - "dir": ".qwen/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "opencode": { - "dir": ".opencode/command", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codex": { - "dir": ".agents/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - }, - "windsurf": { - "dir": ".windsurf/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "junie": { - "dir": ".junie/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kilocode": { - "dir": ".kilocode/workflows", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "auggie": { - "dir": ".augment/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "roo": { - "dir": ".roo/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "codebuddy": { - "dir": ".codebuddy/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "qodercli": { - "dir": ".qoder/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kiro-cli": { - "dir": ".kiro/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "pi": { - "dir": ".pi/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "amp": { - "dir": ".agents/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "shai": { - "dir": ".shai/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "tabnine": { - "dir": ".tabnine/agent/commands", - "format": "toml", - "args": "{{args}}", - "extension": ".toml" - }, - "bob": { - "dir": ".bob/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "kimi": { - "dir": ".kimi/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - }, - "trae": { - "dir": ".trae/rules", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "iflow": { - "dir": ".iflow/commands", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "vibe": { - "dir": ".vibe/prompts", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": ".md" - }, - "agy": { - "dir": ".agent/skills", - "format": "markdown", - "args": "$ARGUMENTS", - "extension": "/SKILL.md", - } - } + # Derived from INTEGRATION_REGISTRY — single source of truth. + # Populated lazily via _ensure_configs() on first use. + AGENT_CONFIGS: dict[str, dict[str, Any]] = {} + _configs_loaded: bool = False + + def __init__(self) -> None: + self._ensure_configs() + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + cls._ensure_configs() + + @classmethod + def _ensure_configs(cls) -> None: + if not cls._configs_loaded: + try: + cls.AGENT_CONFIGS = _build_agent_configs() + cls._configs_loaded = True + except ImportError: + pass # Circular import during module init; retry on next access @staticmethod def parse_frontmatter(content: str) -> tuple[dict, str]: @@ -506,6 +385,7 @@ class CommandRegistrar: Raises: ValueError: If agent is not supported """ + self._ensure_configs() if agent_name not in self.AGENT_CONFIGS: raise ValueError(f"Unsupported agent: {agent_name}") @@ -605,6 +485,7 @@ class CommandRegistrar: """ results = {} + self._ensure_configs() for agent_name, agent_config in self.AGENT_CONFIGS.items(): agent_dir = project_root / agent_config["dir"] @@ -632,6 +513,7 @@ class CommandRegistrar: registered_commands: Dict mapping agent names to command name lists project_root: Path to project root """ + self._ensure_configs() for agent_name, cmd_names in registered_commands.items(): if agent_name not in self.AGENT_CONFIGS: continue @@ -649,3 +531,13 @@ class CommandRegistrar: prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" if prompt_file.exists(): prompt_file.unlink() + + +# Populate AGENT_CONFIGS after class definition. +# Catches ImportError from circular imports during module loading; +# _configs_loaded stays False so the next explicit access retries. +try: + CommandRegistrar._ensure_configs() +except ImportError: + pass + diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index f192c876c..482572007 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -7,7 +7,6 @@ from typing import Any import yaml -from ...agents import CommandRegistrar from ..base import SkillsIntegration from ..manifest import IntegrationManifest @@ -43,15 +42,18 @@ class ClaudeIntegration(SkillsIntegration): "description", f"Spec-kit workflow command: {template_name}", ) - skill_frontmatter = CommandRegistrar.build_skill_frontmatter( - self.key, - skill_name, - description, - f"templates/commands/{template_name}.md", + skill_frontmatter = self._build_skill_fm( + skill_name, description, f"templates/commands/{template_name}.md" ) frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip() return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n" + def _build_skill_fm(self, name: str, description: str, source: str) -> dict: + from specify_cli.agents import CommandRegistrar + return CommandRegistrar.build_skill_frontmatter( + self.key, name, description, source + ) + def setup( self, project_root: Path, @@ -83,6 +85,7 @@ class ClaudeIntegration(SkillsIntegration): script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") + from specify_cli.agents import CommandRegistrar registrar = CommandRegistrar() created: list[Path] = [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 260957192..945ce6ac6 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -73,7 +73,6 @@ class TestInitIntegrationFlag: finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "--integration copilot" in result.output assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): @@ -82,9 +81,11 @@ class TestInitIntegrationFlag: project = tmp_path / "claude-here-existing" project.mkdir() - commands_dir = project / ".claude" / "commands" + commands_dir = project / ".claude" / "skills" commands_dir.mkdir(parents=True) - command_file = commands_dir / "speckit.specify.md" + skill_dir = commands_dir / "speckit-specify" + skill_dir.mkdir(parents=True) + command_file = skill_dir / "SKILL.md" command_file.write_text("# preexisting command\n", encoding="utf-8") old_cwd = os.getcwd() @@ -98,9 +99,10 @@ class TestInitIntegrationFlag: os.chdir(old_cwd) assert result.exit_code == 0, result.output - assert "--integration claude" in result.output assert command_file.exists() - assert command_file.read_text(encoding="utf-8") == "# preexisting command\n" + # init replaces skills (not additive); verify the file has valid skill content + assert command_file.exists() + assert "speckit-specify" in command_file.read_text(encoding="utf-8") assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() def test_shared_infra_skips_existing_files(self, tmp_path): diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 3efaa9936..21cb1d832 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -15,11 +15,13 @@ class TestAgyAutoPromote: """--ai agy auto-promotes to integration path.""" def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): - """--ai agy (without --ai-skills) should auto-promote to integration.""" + """--ai agy should work the same as --integration agy.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "agy"]) + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) - assert "--integration agy" in result.output + assert result.exit_code == 0, f"init --ai agy failed: {result.output}" + assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 75319eb94..e274b5224 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -176,7 +176,9 @@ class MarkdownIntegrationTests: finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 23505c306..007386611 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -261,7 +261,9 @@ class SkillsIntegrationTests: finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + skills_dir = i.skills_dest(project) + assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index e7b506782..8b0935290 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -226,7 +226,9 @@ class TomlIntegrationTests: finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" - assert f"--integration {self.KEY}" in result.output + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" def test_integration_flag_creates_files(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 8f8a6b05d..998485469 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -102,10 +102,6 @@ class TestClaudeIntegration: os.chdir(old_cwd) assert result.exit_code == 0, result.output - assert "--integration claude" in result.output - assert ".claude/skills" in result.output - assert "/speckit-plan" in result.output - assert "/speckit.plan" not in result.output assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() assert not (project / ".claude" / "commands").exists() @@ -189,25 +185,20 @@ class TestClaudeIntegration: assert init_options["integration"] == "claude" def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): + """Claude init should succeed even without install_ai_skills.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() target = tmp_path / "fail-proj" - with patch("specify_cli.ensure_executable_scripts"), \ - patch("specify_cli.ensure_constitution_from_template"), \ - patch("specify_cli.install_ai_skills", return_value=False), \ - patch("specify_cli.is_git_repo", return_value=False), \ - patch("specify_cli.shutil.which", return_value="/usr/bin/git"): - result = runner.invoke( - app, - ["init", str(target), "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git"], - ) + result = runner.invoke( + app, + ["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"], + ) assert result.exit_code == 0 assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists() - assert not (target / ".claude" / "commands").exists() def test_claude_hooks_render_skill_invocation(self, tmp_path): from specify_cli.extensions import HookExecutor diff --git a/tests/integrations/test_integration_codex.py b/tests/integrations/test_integration_codex.py index eb633f02b..cc15d27cb 100644 --- a/tests/integrations/test_integration_codex.py +++ b/tests/integrations/test_integration_codex.py @@ -15,11 +15,13 @@ class TestCodexAutoPromote: """--ai codex auto-promotes to integration path.""" def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path): - """--ai codex (without --ai-skills) should auto-promote to integration.""" + """--ai codex should work the same as --integration codex.""" from typer.testing import CliRunner from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai", "codex"]) + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"]) - assert "--integration codex" in result.output + assert result.exit_code == 0, f"init --ai codex failed: {result.output}" + assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index 6b2b27b77..e3b260bf0 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -36,5 +36,4 @@ class TestKiroAlias: os.chdir(old_cwd) assert result.exit_code == 0 - assert "--integration kiro-cli" in result.output assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists() diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index fe5c01cf7..8e293baa1 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,4 +1,4 @@ -"""Consistency checks for agent configuration across runtime and packaging scripts.""" +"""Consistency checks for agent configuration across runtime surfaces.""" import re from pathlib import Path @@ -41,52 +41,6 @@ class TestAgentConfigConsistency: assert AGENT_CONFIG["codex"]["folder"] == ".agents/" assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" - def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): - """Bash and PowerShell release scripts should agree on agent key set for Kiro.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "kiro-cli" in sh_agents - assert "kiro-cli" in ps_agents - assert "shai" in sh_agents - assert "shai" in ps_agents - assert "agy" in sh_agents - assert "agy" in ps_agents - assert "q" not in sh_agents - assert "q" not in ps_agents - - def test_release_ps_switch_has_shai_and_agy_generation(self): - """PowerShell release builder must generate files for shai and agy agents.""" - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert re.search(r"'shai'\s*\{.*?\.shai/commands", ps_text, re.S) is not None - assert re.search(r"'agy'\s*\{.*?\.agent/commands", ps_text, re.S) is not None - - def test_release_sh_switch_has_shai_and_agy_generation(self): - """Bash release builder must generate files for shai and agy agents.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - - assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None - assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None - - def test_release_scripts_generate_codex_skills(self): - """Release scripts should generate Codex skills in .agents/skills.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".agents/skills" in sh_text - assert ".agents/skills" in ps_text - assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None - assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None - def test_init_ai_help_includes_roo_and_kiro_alias(self): """CLI help text for --ai should stay in sync with agent config and alias guidance.""" assert "roo" in AI_ASSISTANT_HELP @@ -102,22 +56,6 @@ class TestAgentConfigConsistency: assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text - def test_release_output_targets_kiro_prompt_dir(self): - """Packaging and release scripts should no longer emit amazonq artifacts.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert ".kiro/prompts" in sh_text - assert ".kiro/prompts" in ps_text - assert ".amazonq/prompts" not in sh_text - assert ".amazonq/prompts" not in ps_text - - assert "spec-kit-template-kiro-cli-sh-" in gh_release_text - assert "spec-kit-template-kiro-cli-ps-" in gh_release_text - assert "spec-kit-template-q-sh-" not in gh_release_text - assert "spec-kit-template-q-ps-" not in gh_release_text - def test_agent_context_scripts_use_kiro_cli(self): """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -149,38 +87,6 @@ class TestAgentConfigConsistency: assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_release_agent_lists_include_tabnine(self): - """Bash and PowerShell release scripts should include tabnine in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "tabnine" in sh_agents - assert "tabnine" in ps_agents - - def test_release_scripts_generate_tabnine_toml_commands(self): - """Release scripts should generate TOML commands for tabnine in .tabnine/agent/commands.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".tabnine/agent/commands" in sh_text - assert ".tabnine/agent/commands" in ps_text - assert re.search(r"'tabnine'\s*\{.*?\.tabnine/agent/commands", ps_text, re.S) is not None - - def test_github_release_includes_tabnine_packages(self): - """GitHub release script should include tabnine template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-tabnine-sh-" in gh_release_text - assert "spec-kit-template-tabnine-ps-" in gh_release_text - def test_agent_context_scripts_include_tabnine(self): """Agent context scripts should support tabnine agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -213,22 +119,6 @@ class TestAgentConfigConsistency: assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include kimi in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "kimi" in sh_agents - assert "kimi" in ps_agents - def test_kimi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") @@ -239,13 +129,6 @@ class TestAgentConfigConsistency: assert "kimi" in validate_set_values - def test_kimi_in_github_release_output(self): - """GitHub release script should include kimi template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-kimi-sh-" in gh_release_text - assert "spec-kit-template-kimi-ps-" in gh_release_text - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -270,38 +153,6 @@ class TestAgentConfigConsistency: assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == ".md" - def test_trae_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include trae in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "trae" in sh_agents - assert "trae" in ps_agents - - def test_trae_in_release_scripts_generate_commands(self): - """Release scripts should generate markdown commands for trae in .trae/rules.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".trae/rules" in sh_text - assert ".trae/rules" in ps_text - assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None - - def test_trae_in_github_release_output(self): - """GitHub release script should include trae template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-trae-sh-" in gh_release_text - assert "spec-kit-template-trae-ps-" in gh_release_text - def test_trae_in_agent_context_scripts(self): """Agent context scripts should support trae agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -347,32 +198,6 @@ class TestAgentConfigConsistency: assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include pi in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "pi" in sh_agents - assert "pi" in ps_agents - - def test_release_scripts_generate_pi_prompt_templates(self): - """Release scripts should generate Markdown prompt templates for pi in .pi/prompts.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".pi/prompts" in sh_text - assert ".pi/prompts" in ps_text - assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None - assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None - def test_pi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") @@ -383,13 +208,6 @@ class TestAgentConfigConsistency: assert "pi" in validate_set_values - def test_pi_in_github_release_output(self): - """GitHub release script should include pi template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-pi-sh-" in gh_release_text - assert "spec-kit-template-pi-ps-" in gh_release_text - def test_agent_context_scripts_include_pi(self): """Agent context scripts should support pi agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") @@ -422,38 +240,6 @@ class TestAgentConfigConsistency: assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_release_agent_lists(self): - """Bash and PowerShell release scripts should include iflow in agent lists.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text) - assert sh_match is not None - sh_agents = sh_match.group(1).split() - - ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text) - assert ps_match is not None - ps_agents = re.findall(r"'([^']+)'", ps_match.group(1)) - - assert "iflow" in sh_agents - assert "iflow" in ps_agents - - def test_iflow_in_release_scripts_build_variant(self): - """Release scripts should generate Markdown commands for iflow in .iflow/commands.""" - sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8") - ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8") - - assert ".iflow/commands" in sh_text - assert ".iflow/commands" in ps_text - assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None - - def test_iflow_in_github_release_output(self): - """GitHub release script should include iflow template packages.""" - gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8") - - assert "spec-kit-template-iflow-sh-" in gh_release_text - assert "spec-kit-template-iflow-ps-" in gh_release_text - def test_iflow_in_agent_context_scripts(self): """Agent context scripts should support iflow agent type.""" bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") diff --git a/tests/test_ai_skills.py b/tests/test_ai_skills.py deleted file mode 100644 index e43129c9c..000000000 --- a/tests/test_ai_skills.py +++ /dev/null @@ -1,994 +0,0 @@ -""" -Unit tests for AI agent skills installation. - -Tests cover: -- Skills directory resolution for different agents (_get_skills_dir) -- YAML frontmatter parsing and SKILL.md generation (install_ai_skills) -- Cleanup of duplicate command files when --ai-skills is used -- Missing templates directory handling -- Malformed template error handling -- CLI validation: --ai-skills requires --ai -""" - -import zipfile -import pytest -import tempfile -import shutil -import yaml -import typer -from pathlib import Path -from unittest.mock import patch - -import specify_cli -from tests.conftest import strip_ansi - -from specify_cli import ( - _get_skills_dir, - _migrate_legacy_kimi_dotted_skills, - install_ai_skills, - DEFAULT_SKILLS_DIR, - SKILL_DESCRIPTIONS, - AGENT_CONFIG, - app, -) - - -# ===== Fixtures ===== - -@pytest.fixture -def temp_dir(): - """Create a temporary directory for tests.""" - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) - shutil.rmtree(tmpdir) - - -@pytest.fixture -def project_dir(temp_dir): - """Create a mock project directory.""" - proj_dir = temp_dir / "test-project" - proj_dir.mkdir() - return proj_dir - - -@pytest.fixture -def templates_dir(project_dir): - """Create mock command templates in the project's agent commands directory. - - This simulates what download_and_extract_template() does: it places - command .md files into project_path//commands/. - install_ai_skills() now reads from here instead of from the repo - source tree. - """ - tpl_root = project_dir / ".claude" / "commands" - tpl_root.mkdir(parents=True, exist_ok=True) - - # Template with valid YAML frontmatter - (tpl_root / "speckit.specify.md").write_text( - "---\n" - "description: Create or update the feature specification.\n" - "handoffs:\n" - " - label: Build Plan\n" - " agent: speckit.plan\n" - "scripts:\n" - " sh: scripts/bash/create-new-feature.sh\n" - "---\n" - "\n" - "# Specify Command\n" - "\n" - "Run this to create a spec.\n", - encoding="utf-8", - ) - - # Template with minimal frontmatter - (tpl_root / "speckit.plan.md").write_text( - "---\n" - "description: Generate implementation plan.\n" - "---\n" - "\n" - "# Plan Command\n" - "\n" - "Plan body content.\n", - encoding="utf-8", - ) - - # Template with no frontmatter - (tpl_root / "speckit.tasks.md").write_text( - "# Tasks Command\n" - "\n" - "Body without frontmatter.\n", - encoding="utf-8", - ) - - # Template with empty YAML frontmatter (yaml.safe_load returns None) - (tpl_root / "speckit.empty_fm.md").write_text( - "---\n" - "---\n" - "\n" - "# Empty Frontmatter Command\n" - "\n" - "Body with empty frontmatter.\n", - encoding="utf-8", - ) - - return tpl_root - - -@pytest.fixture -def commands_dir_claude(project_dir): - """Create a populated .claude/commands directory simulating template extraction.""" - cmd_dir = project_dir / ".claude" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: - (cmd_dir / name).write_text(f"# {name}\nContent here\n") - return cmd_dir - - -@pytest.fixture -def commands_dir_gemini(project_dir): - """Create a populated .gemini/commands directory (TOML format).""" - cmd_dir = project_dir / ".gemini" / "commands" - cmd_dir.mkdir(parents=True) - for name in ["speckit.specify.toml", "speckit.plan.toml", "speckit.tasks.toml"]: - (cmd_dir / name).write_text(f'[command]\nname = "{name}"\n') - return cmd_dir - - -@pytest.fixture -def commands_dir_qwen(project_dir): - """Create a populated .qwen/commands directory (Markdown format).""" - cmd_dir = project_dir / ".qwen" / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - for name in ["speckit.specify.md", "speckit.plan.md", "speckit.tasks.md"]: - (cmd_dir / name).write_text(f"# {name}\nContent here\n") - return cmd_dir - - -# ===== _get_skills_dir Tests ===== - -class TestGetSkillsDir: - """Test the _get_skills_dir() helper function.""" - - def test_claude_skills_dir(self, project_dir): - """Claude should use .claude/skills/.""" - result = _get_skills_dir(project_dir, "claude") - assert result == project_dir / ".claude" / "skills" - - def test_gemini_skills_dir(self, project_dir): - """Gemini should use .gemini/skills/.""" - result = _get_skills_dir(project_dir, "gemini") - assert result == project_dir / ".gemini" / "skills" - - def test_tabnine_skills_dir(self, project_dir): - """Tabnine should use .tabnine/agent/skills/.""" - result = _get_skills_dir(project_dir, "tabnine") - assert result == project_dir / ".tabnine" / "agent" / "skills" - - def test_copilot_skills_dir(self, project_dir): - """Copilot should use .github/skills/.""" - result = _get_skills_dir(project_dir, "copilot") - assert result == project_dir / ".github" / "skills" - - def test_codex_skills_dir_from_agent_config(self, project_dir): - """Codex should resolve skills directory from AGENT_CONFIG folder.""" - result = _get_skills_dir(project_dir, "codex") - assert result == project_dir / ".agents" / "skills" - - def test_cursor_agent_skills_dir(self, project_dir): - """Cursor should use .cursor/skills/.""" - result = _get_skills_dir(project_dir, "cursor-agent") - assert result == project_dir / ".cursor" / "skills" - - def test_kiro_cli_skills_dir(self, project_dir): - """Kiro CLI should use .kiro/skills/.""" - result = _get_skills_dir(project_dir, "kiro-cli") - assert result == project_dir / ".kiro" / "skills" - - def test_pi_skills_dir(self, project_dir): - """Pi should use .pi/skills/.""" - result = _get_skills_dir(project_dir, "pi") - assert result == project_dir / ".pi" / "skills" - - def test_unknown_agent_uses_default(self, project_dir): - """Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" - result = _get_skills_dir(project_dir, "nonexistent-agent") - assert result == project_dir / DEFAULT_SKILLS_DIR - - def test_all_configured_agents_resolve(self, project_dir): - """Every agent in AGENT_CONFIG should resolve to a valid path.""" - for agent_key in AGENT_CONFIG: - result = _get_skills_dir(project_dir, agent_key) - assert result is not None - assert str(result).startswith(str(project_dir)) - # Should always end with "skills" - assert result.name == "skills" - -class TestKimiLegacySkillMigration: - """Test temporary migration from Kimi dotted skill names to hyphenated names.""" - - def test_migrates_legacy_dotted_skill_directory(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 1 - assert removed == 0 - assert not legacy_dir.exists() - assert (skills_dir / "speckit-plan" / "SKILL.md").exists() - - def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 1 - assert not legacy_dir.exists() - assert (target_dir / "SKILL.md").read_text() == "legacy" - - def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("new") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 0 - assert legacy_dir.exists() - assert (legacy_dir / "SKILL.md").read_text() == "legacy" - assert (target_dir / "SKILL.md").read_text() == "new" - - def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir): - skills_dir = project_dir / ".kimi" / "skills" - legacy_dir = skills_dir / "speckit.plan" - legacy_dir.mkdir(parents=True) - (legacy_dir / "SKILL.md").write_text("legacy") - (legacy_dir / "notes.txt").write_text("custom") - target_dir = skills_dir / "speckit-plan" - target_dir.mkdir(parents=True) - (target_dir / "SKILL.md").write_text("legacy") - - migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir) - - assert migrated == 0 - assert removed == 0 - assert legacy_dir.exists() - assert (legacy_dir / "notes.txt").read_text() == "custom" - - -# ===== install_ai_skills Tests ===== - -class TestInstallAiSkills: - """Test SKILL.md generation and installation logic.""" - - def test_skills_installed_with_correct_structure(self, project_dir, templates_dir): - """Verify SKILL.md files have correct agentskills.io structure.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - - skills_dir = project_dir / ".claude" / "skills" - assert skills_dir.exists() - - # Check that skill directories were created - skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()]) - assert "speckit-plan" in skill_dirs - assert "speckit-specify" in skill_dirs - assert "speckit-tasks" in skill_dirs - assert "speckit-empty_fm" in skill_dirs - - # Verify SKILL.md content for speckit-specify - skill_file = skills_dir / "speckit-specify" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Check agentskills.io frontmatter - assert content.startswith("---\n") - assert "name: speckit-specify" in content - assert "description:" in content - assert "compatibility:" in content - assert "metadata:" in content - assert "author: github-spec-kit" in content - assert "source: templates/commands/specify.md" in content - - # Check body content is included - assert "# Speckit Specify Skill" in content - assert "Run this to create a spec." in content - - def test_generated_skill_has_parseable_yaml(self, project_dir, templates_dir): - """Generated SKILL.md should contain valid, parseable YAML frontmatter.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - - # Extract and parse frontmatter - assert content.startswith("---\n") - parts = content.split("---", 2) - assert len(parts) >= 3 - parsed = yaml.safe_load(parts[1]) - assert isinstance(parsed, dict) - assert "name" in parsed - assert parsed["name"] == "speckit-specify" - assert "description" in parsed - - def test_empty_yaml_frontmatter(self, project_dir, templates_dir): - """Templates with empty YAML frontmatter (---\\n---) should not crash.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - - skill_file = project_dir / ".claude" / "skills" / "speckit-empty_fm" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - assert "name: speckit-empty_fm" in content - assert "Body with empty frontmatter." in content - - def test_enhanced_descriptions_used_when_available(self, project_dir, templates_dir): - """SKILL_DESCRIPTIONS take precedence over template frontmatter descriptions.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - - # Parse the generated YAML to compare the description value - # (yaml.safe_dump may wrap long strings across multiple lines) - parts = content.split("---", 2) - parsed = yaml.safe_load(parts[1]) - - if "specify" in SKILL_DESCRIPTIONS: - assert parsed["description"] == SKILL_DESCRIPTIONS["specify"] - - def test_template_without_frontmatter(self, project_dir, templates_dir): - """Templates without YAML frontmatter should still produce valid skills.""" - install_ai_skills(project_dir, "claude") - - skill_file = project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md" - assert skill_file.exists() - content = skill_file.read_text() - - # Should still have valid SKILL.md structure - assert "name: speckit-tasks" in content - assert "Body without frontmatter." in content - - def test_missing_templates_directory(self, project_dir): - """Returns False when no command templates exist anywhere.""" - # No .claude/commands/ exists, and __file__ fallback won't find anything - fake_init = project_dir / "nonexistent" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - result = install_ai_skills(project_dir, "claude") - - assert result is False - - # Skills directory should not exist - skills_dir = project_dir / ".claude" / "skills" - assert not skills_dir.exists() - - def test_empty_templates_directory(self, project_dir): - """Returns False when commands directory has no .md files.""" - # Create empty .claude/commands/ - empty_cmds = project_dir / ".claude" / "commands" - empty_cmds.mkdir(parents=True) - - # Block the __file__ fallback so it can't find real templates - fake_init = project_dir / "nowhere" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - result = install_ai_skills(project_dir, "claude") - - assert result is False - - def test_malformed_yaml_frontmatter(self, project_dir): - """Malformed YAML in a template should be handled gracefully, not crash.""" - # Create .claude/commands/ with a broken template - cmds_dir = project_dir / ".claude" / "commands" - cmds_dir.mkdir(parents=True) - - (cmds_dir / "speckit.broken.md").write_text( - "---\n" - "description: [unclosed bracket\n" - " invalid: yaml: content: here\n" - "---\n" - "\n" - "# Broken\n", - encoding="utf-8", - ) - - # Should not raise — errors are caught per-file - result = install_ai_skills(project_dir, "claude") - - # The broken template should be skipped but not crash the process - assert result is False - - def test_additive_does_not_overwrite_other_files(self, project_dir, templates_dir): - """Installing skills should not remove non-speckit files in the skills dir.""" - # Pre-create a custom skill - custom_dir = project_dir / ".claude" / "skills" / "my-custom-skill" - custom_dir.mkdir(parents=True) - custom_file = custom_dir / "SKILL.md" - custom_file.write_text("# My Custom Skill\n") - - install_ai_skills(project_dir, "claude") - - # Custom skill should still exist - assert custom_file.exists() - assert custom_file.read_text() == "# My Custom Skill\n" - - def test_return_value(self, project_dir, templates_dir): - """install_ai_skills returns True when skills installed, False otherwise.""" - assert install_ai_skills(project_dir, "claude") is True - - def test_return_false_when_no_templates(self, project_dir): - """install_ai_skills returns False when no templates found.""" - fake_init = project_dir / "missing" / "src" / "specify_cli" / "__init__.py" - fake_init.parent.mkdir(parents=True, exist_ok=True) - fake_init.touch() - - with patch.object(specify_cli, "__file__", str(fake_init)): - assert install_ai_skills(project_dir, "claude") is False - - def test_non_md_commands_dir_falls_back(self, project_dir): - """When extracted commands are .toml (e.g. gemini), fall back to repo templates.""" - # Simulate gemini template extraction: .gemini/commands/ with .toml files only - cmds_dir = project_dir / ".gemini" / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.toml").write_text('[command]\nname = "specify"\n') - (cmds_dir / "speckit.plan.toml").write_text('[command]\nname = "plan"\n') - - # The __file__ fallback should find the real repo templates/commands/*.md - result = install_ai_skills(project_dir, "gemini") - - assert result is True - skills_dir = project_dir / ".gemini" / "skills" - assert skills_dir.exists() - # Should have installed skills from the fallback .md templates - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - # .toml commands should be untouched - assert (cmds_dir / "speckit.specify.toml").exists() - - def test_qwen_md_commands_dir_installs_skills(self, project_dir): - """Qwen now uses Markdown format; skills should install directly from .qwen/commands/.""" - cmds_dir = project_dir / ".qwen" / "commands" - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text( - "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" - ) - (cmds_dir / "speckit.plan.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "qwen") - - assert result is True - skills_dir = project_dir / ".qwen" / "skills" - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - # .md commands should be untouched - assert (cmds_dir / "speckit.specify.md").exists() - assert (cmds_dir / "speckit.plan.md").exists() - - def test_pi_prompt_dir_installs_skills(self, project_dir): - """Pi should install skills directly from .pi/prompts/.""" - prompts_dir = project_dir / ".pi" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "speckit.specify.md").write_text( - "---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n" - ) - (prompts_dir / "speckit.plan.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "pi") - - assert result is True - skills_dir = project_dir / ".pi" / "skills" - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert len(skill_dirs) >= 1 - assert (prompts_dir / "speckit.specify.md").exists() - assert (prompts_dir / "speckit.plan.md").exists() - - @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) - def test_skills_install_for_all_agents(self, temp_dir, agent_key): - """install_ai_skills should produce skills for every configured agent.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - # Place .md templates in the agent's commands directory - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - # Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md - fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md" - (cmds_dir / fname).write_text( - "---\ndescription: Test command\n---\n\n# Test\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - expected_skill_name = "speckit-specify" - assert expected_skill_name in skill_dirs - assert (skills_dir / expected_skill_name / "SKILL.md").exists() - - def test_copilot_ignores_non_speckit_agents(self, project_dir): - """Non-speckit markdown in .github/agents/ must not produce skills.""" - agents_dir = project_dir / ".github" / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - (agents_dir / "speckit.plan.agent.md").write_text( - "---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n" - ) - (agents_dir / "my-custom-agent.agent.md").write_text( - "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "copilot") - - assert result is True - skills_dir = _get_skills_dir(project_dir, "copilot") - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert "speckit-plan" in skill_dirs - assert "speckit-my-custom-agent.agent" not in skill_dirs - assert "speckit-my-custom-agent" not in skill_dirs - - @pytest.mark.parametrize("agent_key,custom_file", [ - ("claude", "review.md"), - ("cursor-agent", "deploy.md"), - ("qwen", "my-workflow.md"), - ]) - def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file): - """User-authored command files must not produce skills for any agent.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - (cmds_dir / "speckit.specify.md").write_text( - "---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n" - ) - (cmds_dir / custom_file).write_text( - "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert "speckit-specify" in skill_dirs - custom_stem = Path(custom_file).stem - assert f"speckit-{custom_stem}" not in skill_dirs - - def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir): - """Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files.""" - agents_dir = project_dir / ".github" / "agents" - agents_dir.mkdir(parents=True, exist_ok=True) - # Only a user-authored agent, no speckit.* templates - (agents_dir / "my-custom-agent.agent.md").write_text( - "---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(project_dir, "copilot") - - # Should succeed via fallback to templates/commands/ - assert result is True - skills_dir = _get_skills_dir(project_dir, "copilot") - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # Should have skills from fallback templates, not from the custom agent - assert "speckit-plan" in skill_dirs - assert not any("my-custom" in d for d in skill_dirs) - - @pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"]) - def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key): - """Fallback to templates/commands/ when agent dir has no speckit.*.md files.""" - proj = temp_dir / f"proj-{agent_key}" - proj.mkdir() - - agent_folder = AGENT_CONFIG[agent_key]["folder"] - commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands") - cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir - cmds_dir.mkdir(parents=True) - # Only a user-authored command, no speckit.* templates - (cmds_dir / "my-custom-command.md").write_text( - "---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n" - ) - - result = install_ai_skills(proj, agent_key) - - # Should succeed via fallback to templates/commands/ - assert result is True - skills_dir = _get_skills_dir(proj, agent_key) - assert skills_dir.exists() - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - assert not any("my-custom" in d for d in skill_dirs) - -class TestCommandCoexistence: - """Verify install_ai_skills never touches command files. - - Cleanup of freshly-extracted commands for NEW projects is handled - in init(), not in install_ai_skills(). These tests confirm that - install_ai_skills leaves existing commands intact. - """ - - def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): - """install_ai_skills must NOT remove pre-existing .claude/commands files.""" - # Verify commands exist before (templates_dir adds 4 speckit.* files, - # commands_dir_claude overlaps with 3 of them) - before = list(commands_dir_claude.glob("speckit.*")) - assert len(before) >= 3 - - install_ai_skills(project_dir, "claude") - - # Commands must still be there — install_ai_skills never touches them - remaining = list(commands_dir_claude.glob("speckit.*")) - assert len(remaining) == len(before) - - def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini): - """install_ai_skills must NOT remove pre-existing .gemini/commands files.""" - assert len(list(commands_dir_gemini.glob("speckit.*"))) == 3 - - install_ai_skills(project_dir, "gemini") - - remaining = list(commands_dir_gemini.glob("speckit.*")) - assert len(remaining) == 3 - - def test_existing_commands_preserved_qwen(self, project_dir, templates_dir, commands_dir_qwen): - """install_ai_skills must NOT remove pre-existing .qwen/commands files.""" - assert len(list(commands_dir_qwen.glob("speckit.*"))) == 3 - - install_ai_skills(project_dir, "qwen") - - remaining = list(commands_dir_qwen.glob("speckit.*")) - assert len(remaining) == 3 - - def test_commands_dir_not_removed(self, project_dir, templates_dir, commands_dir_claude): - """install_ai_skills must not remove the commands directory.""" - install_ai_skills(project_dir, "claude") - - assert commands_dir_claude.exists() - - def test_no_commands_dir_no_error(self, project_dir, templates_dir): - """No error when installing skills — commands dir has templates and is preserved.""" - result = install_ai_skills(project_dir, "claude") - - # Should succeed since templates are in .claude/commands/ via fixture - assert result is True - - -# ===== Legacy Download Path Tests ===== - -class TestLegacyDownloadPath: - """Tests for download_and_extract_template() called directly. - - These test the legacy download/extract code that still exists in - __init__.py. They do NOT go through CLI auto-promote. - """ - - def test_codex_ai_skills_fresh_dir_does_not_create_codex_dir(self, tmp_path): - """Fresh-directory Codex skills init should not leave legacy .codex from archive.""" - target = tmp_path / "fresh-codex-proj" - archive = tmp_path / "codex-template.zip" - - with zipfile.ZipFile(archive, "w") as zf: - zf.writestr("template-root/.codex/prompts/speckit.specify.md", "legacy") - zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") - - fake_meta = { - "filename": archive.name, - "size": archive.stat().st_size, - "release": "vtest", - "asset_url": "https://example.invalid/template.zip", - } - - with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): - specify_cli.download_and_extract_template( - target, - "codex", - "sh", - is_current_dir=False, - skip_legacy_codex_prompts=True, - verbose=False, - ) - - assert target.exists() - assert (target / ".specify").exists() - assert not (target / ".codex").exists() - - @pytest.mark.parametrize("is_current_dir", [False, True]) - def test_download_and_extract_template_blocks_zip_path_traversal(self, tmp_path, monkeypatch, is_current_dir): - """Extraction should reject ZIP members escaping the target directory.""" - target = (tmp_path / "here-proj") if is_current_dir else (tmp_path / "new-proj") - if is_current_dir: - target.mkdir() - monkeypatch.chdir(target) - - archive = tmp_path / "malicious-template.zip" - with zipfile.ZipFile(archive, "w") as zf: - zf.writestr("../evil.txt", "pwned") - zf.writestr("template-root/.specify/templates/constitution-template.md", "constitution") - - fake_meta = { - "filename": archive.name, - "size": archive.stat().st_size, - "release": "vtest", - "asset_url": "https://example.invalid/template.zip", - } - - with patch("specify_cli.download_template_from_github", return_value=(archive, fake_meta)): - with pytest.raises(typer.Exit): - specify_cli.download_and_extract_template( - target, - "codex", - "sh", - is_current_dir=is_current_dir, - skip_legacy_codex_prompts=True, - verbose=False, - ) - - assert not (tmp_path / "evil.txt").exists() - -# ===== Skip-If-Exists Tests ===== - -class TestSkipIfExists: - """Test that install_ai_skills does not overwrite existing SKILL.md files.""" - - def test_existing_skill_not_overwritten(self, project_dir, templates_dir): - """Pre-existing SKILL.md should not be replaced on re-run.""" - # Pre-create a custom SKILL.md for speckit-specify - skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True) - custom_content = "# My Custom Specify Skill\nUser-modified content\n" - (skill_dir / "SKILL.md").write_text(custom_content) - - result = install_ai_skills(project_dir, "claude") - - # The custom SKILL.md should be untouched - assert (skill_dir / "SKILL.md").read_text() == custom_content - - # But other skills should still be installed - assert result is True - assert (project_dir / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - assert (project_dir / ".claude" / "skills" / "speckit-tasks" / "SKILL.md").exists() - - def test_fresh_install_writes_all_skills(self, project_dir, templates_dir): - """On first install (no pre-existing skills), all should be written.""" - result = install_ai_skills(project_dir, "claude") - - assert result is True - skills_dir = project_dir / ".claude" / "skills" - skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] - # All 4 templates should produce skills (specify, plan, tasks, empty_fm) - assert len(skill_dirs) == 4 - - def test_existing_skill_overwritten_when_enabled(self, project_dir, templates_dir): - """When overwrite_existing=True, pre-existing SKILL.md should be replaced.""" - skill_dir = project_dir / ".claude" / "skills" / "speckit-specify" - skill_dir.mkdir(parents=True) - custom_content = "# My Custom Specify Skill\nUser-modified content\n" - skill_file = skill_dir / "SKILL.md" - skill_file.write_text(custom_content) - - result = install_ai_skills(project_dir, "claude", overwrite_existing=True) - - assert result is True - updated_content = skill_file.read_text() - assert updated_content != custom_content - assert "name: speckit-specify" in updated_content - - -# ===== SKILL_DESCRIPTIONS Coverage Tests ===== - -class TestSkillDescriptions: - """Test SKILL_DESCRIPTIONS constants.""" - - def test_all_known_commands_have_descriptions(self): - """All standard spec-kit commands should have enhanced descriptions.""" - expected_commands = [ - "specify", "plan", "tasks", "implement", "analyze", - "clarify", "constitution", "checklist", "taskstoissues", - ] - for cmd in expected_commands: - assert cmd in SKILL_DESCRIPTIONS, f"Missing description for '{cmd}'" - assert len(SKILL_DESCRIPTIONS[cmd]) > 20, f"Description for '{cmd}' is too short" - - -# ===== CLI Validation Tests ===== - -class TestCliValidation: - """Test --ai-skills CLI flag validation.""" - - def test_ai_skills_without_ai_fails(self, tmp_path): - """--ai-skills without --ai should fail with exit code 1.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) - - assert result.exit_code == 1 - assert "--ai-skills requires --ai" in result.output - - def test_ai_skills_without_ai_shows_usage(self, tmp_path): - """Error message should include usage hint.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "test-proj"), "--ai-skills"]) - - assert "Usage:" in result.output - assert "--ai" in result.output - - def test_interactive_agy_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): - """Interactive selector returning agy should auto-promote to integration path.""" - from typer.testing import CliRunner - - def _fake_select_with_arrows(*args, **kwargs): - options = kwargs.get("options") - if options is None and len(args) >= 1: - options = args[0] - - if isinstance(options, dict) and "agy" in options: - return "agy" - if isinstance(options, (list, tuple)) and "agy" in options: - return "agy" - - if isinstance(options, dict) and options: - return next(iter(options.keys())) - if isinstance(options, (list, tuple)) and options: - return options[0] - - return None - - monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - runner = CliRunner() - target = tmp_path / "test-agy-interactive" - result = runner.invoke(app, ["init", str(target), "--no-git"]) - - assert result.exit_code == 0 - # Should NOT raise the old deprecation error - assert "Explicit command support was deprecated" not in result.output - # Should use integration path (same as --ai agy) - assert "agy" in result.output - - def test_interactive_codex_without_ai_skills_uses_integration(self, tmp_path, monkeypatch): - """Interactive selector returning codex should auto-promote to integration path.""" - from typer.testing import CliRunner - - def _fake_select_with_arrows(*args, **kwargs): - options = kwargs.get("options") - if options is None and len(args) >= 1: - options = args[0] - - if isinstance(options, dict) and "codex" in options: - return "codex" - if isinstance(options, (list, tuple)) and "codex" in options: - return "codex" - - if isinstance(options, dict) and options: - return next(iter(options.keys())) - if isinstance(options, (list, tuple)) and options: - return options[0] - - return None - - monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows) - - runner = CliRunner() - target = tmp_path / "test-codex-interactive" - result = runner.invoke(app, ["init", str(target), "--no-git", "--ignore-agent-tools"]) - - assert result.exit_code == 0 - # Should NOT raise the old deprecation error - assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output - # Skills should be installed via integration path - assert ".agents/skills" in result.output - assert "$speckit-constitution" in result.output - assert "/speckit.constitution" not in result.output - assert "Optional skills that you can use for your specs" in result.output - - def test_ai_skills_flag_appears_in_help(self): - """--ai-skills should appear in init --help output.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--help"]) - - plain = strip_ansi(result.output) - assert "--ai-skills" in plain - assert "skills" in plain.lower() - - def test_q_removed_from_agent_config(self): - """Amazon Q legacy key should not remain in AGENT_CONFIG.""" - assert "q" not in AGENT_CONFIG - assert "kiro-cli" in AGENT_CONFIG - - -class TestParameterOrderingIssue: - """Test fix for GitHub issue #1641: parameter ordering issues.""" - - def test_ai_flag_consuming_here_flag(self): - """--ai without value should not consume --here flag (issue #1641).""" - from typer.testing import CliRunner - - runner = CliRunner() - # This used to fail with "Must specify project name" because --here was consumed by --ai - result = runner.invoke(app, ["init", "--ai-skills", "--ai", "--here"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai" in result.output - assert "--here" in result.output # Should mention the invalid value - - def test_ai_flag_consuming_ai_skills_flag(self): - """--ai without value should not consume --ai-skills flag.""" - from typer.testing import CliRunner - - runner = CliRunner() - # This should fail with helpful error about missing --ai value - result = runner.invoke(app, ["init", "--here", "--ai", "--ai-skills"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai" in result.output - assert "--ai-skills" in result.output # Should mention the invalid value - - def test_error_message_provides_hint(self): - """Error message should provide helpful hint about missing value.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--ai", "--here"]) - - assert result.exit_code == 1 - assert "Hint:" in result.output or "hint" in result.output.lower() - assert "forget to provide a value" in result.output.lower() - - def test_error_message_lists_available_agents(self): - """Error message should list available agents.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", "--ai", "--here"]) - - assert result.exit_code == 1 - # Should mention some known agents - output_lower = result.output.lower() - assert any(agent in output_lower for agent in ["claude", "copilot", "gemini"]) - - def test_ai_commands_dir_consuming_flag(self, tmp_path): - """--ai-commands-dir without value should not consume next flag.""" - from typer.testing import CliRunner - - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "myproject"), "--ai", "generic", "--ai-commands-dir", "--here"]) - - assert result.exit_code == 1 - assert "Invalid value for --ai-commands-dir" in result.output - assert "--here" in result.output diff --git a/tests/test_branch_numbering.py b/tests/test_branch_numbering.py index 74eadf22e..9b28082cb 100644 --- a/tests/test_branch_numbering.py +++ b/tests/test_branch_numbering.py @@ -30,18 +30,13 @@ class TestSaveBranchNumbering: saved = json.loads((tmp_path / ".specify/init-options.json").read_text()) assert saved["branch_numbering"] == "sequential" - def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path, monkeypatch): + def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - project_dir = tmp_path / "proj" runner = CliRunner() - result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 saved = json.loads((project_dir / ".specify/init-options.json").read_text()) @@ -56,34 +51,24 @@ class TestBranchNumberingValidation: from specify_cli import app runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"]) assert result.exit_code == 1 assert "Invalid --branch-numbering" in result.output - def test_valid_branch_numbering_sequential(self, tmp_path: Path, monkeypatch): + def test_valid_branch_numbering_sequential(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") - def test_valid_branch_numbering_timestamp(self, tmp_path: Path, monkeypatch): + def test_valid_branch_numbering_timestamp(self, tmp_path: Path): from typer.testing import CliRunner from specify_cli import app - def _fake_download(project_path, *args, **kwargs): - Path(project_path).mkdir(parents=True, exist_ok=True) - - monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download) - runner = CliRunner() - result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"]) + result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"]) assert result.exit_code == 0 assert "Invalid --branch-numbering" not in (result.output or "") diff --git a/tests/test_core_pack_scaffold.py b/tests/test_core_pack_scaffold.py deleted file mode 100644 index 92b747a29..000000000 --- a/tests/test_core_pack_scaffold.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Validation tests for offline/air-gapped scaffolding (PR #1803). - -For every supported AI agent (except "generic") the scaffold output is verified -against invariants and compared byte-for-byte with the canonical output produced -by create-release-packages.sh. - -Since scaffold_from_core_pack() now invokes the release script at runtime, the -parity test (section 9) runs the script independently and compares the results -to ensure the integration is correct. - -Per-agent invariants verified -────────────────────────────── - • Command files are written to the directory declared in AGENT_CONFIG - • File count matches the number of source templates - • Extension is correct: .toml (TOML agents), .agent.md (copilot), .md (rest) - • No unresolved placeholders remain ({SCRIPT}, {ARGS}, __AGENT__) - • Argument token is correct: {{args}} for TOML agents, $ARGUMENTS for others - • Path rewrites applied: scripts/ → .specify/scripts/ etc. - • TOML files have "description" and "prompt" fields - • Markdown files have parseable YAML frontmatter - • Copilot: companion speckit.*.prompt.md files are generated in prompts/ - • .specify/scripts/ contains at least one script file - • .specify/templates/ contains at least one template file - -Parity invariant -──────────────── - Every file produced by scaffold_from_core_pack() must be byte-for-byte - identical to the same file in the ZIP produced by the release script. -""" - -import os -import re -import shutil -import subprocess -import tomllib -import zipfile -from pathlib import Path - -import pytest -import yaml - -from specify_cli import ( - AGENT_CONFIG, - _TOML_AGENTS, - _locate_core_pack, - scaffold_from_core_pack, -) - -_REPO_ROOT = Path(__file__).parent.parent -_RELEASE_SCRIPT = _REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh" - - -def _find_bash() -> str | None: - """Return the path to a usable bash on this machine, or None.""" - # Prefer PATH lookup so non-standard install locations (Nix, CI) are found. - on_path = shutil.which("bash") - if on_path: - return on_path - candidates = [ - "/opt/homebrew/bin/bash", - "/usr/local/bin/bash", - "/bin/bash", - "/usr/bin/bash", - ] - for candidate in candidates: - try: - result = subprocess.run( - [candidate, "--version"], - capture_output=True, text=True, timeout=5, - ) - if result.returncode == 0: - return candidate - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - return None - - -def _run_release_script(agent: str, script_type: str, bash: str, output_dir: Path) -> Path: - """Run create-release-packages.sh for *agent*/*script_type* and return the - path to the generated ZIP. *output_dir* receives the build artifacts so - the repo working tree stays clean.""" - env = os.environ.copy() - env["AGENTS"] = agent - env["SCRIPTS"] = script_type - env["GENRELEASES_DIR"] = str(output_dir) - - result = subprocess.run( - [bash, str(_RELEASE_SCRIPT), "v0.0.0"], - capture_output=True, text=True, - cwd=str(_REPO_ROOT), - env=env, - timeout=300, - ) - - if result.returncode != 0: - pytest.fail( - f"Release script failed with exit code {result.returncode}\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - - zip_pattern = f"spec-kit-template-{agent}-{script_type}-v0.0.0.zip" - zip_path = output_dir / zip_pattern - if not zip_path.exists(): - pytest.fail( - f"Release script did not produce expected ZIP: {zip_path}\n" - f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" - ) - return zip_path - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -# Number of source command templates (one per .md file in templates/commands/) - - -def _commands_dir() -> Path: - """Return the command templates directory (source-checkout or core_pack).""" - core = _locate_core_pack() - if core and (core / "commands").is_dir(): - return core / "commands" - # Source-checkout fallback - repo_root = Path(__file__).parent.parent - return repo_root / "templates" / "commands" - - -def _get_source_template_stems() -> list[str]: - """Return the stems of source command template files (e.g. ['specify', 'plan', ...]).""" - return sorted(p.stem for p in _commands_dir().glob("*.md")) - - -def _expected_cmd_dir(project_path: Path, agent: str) -> Path: - """Return the expected command-files directory for a given agent.""" - cfg = AGENT_CONFIG[agent] - folder = (cfg.get("folder") or "").rstrip("/") - subdir = cfg.get("commands_subdir", "commands") - if folder: - return project_path / folder / subdir - return project_path / ".speckit" / subdir - - -# Agents whose commands are laid out as //SKILL.md. -# Maps agent -> separator used in skill directory names. -_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"} - - -def _expected_ext(agent: str) -> str: - if agent in _TOML_AGENTS: - return "toml" - if agent == "copilot": - return "agent.md" - if agent in _SKILL_AGENTS: - return "SKILL.md" - return "md" - - -def _list_command_files(cmd_dir: Path, agent: str) -> list[Path]: - """List generated command files, handling skills-based directory layouts.""" - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - return sorted(cmd_dir.glob(f"speckit{sep}*/SKILL.md")) - ext = _expected_ext(agent) - return sorted(cmd_dir.glob(f"speckit.*.{ext}")) - - -def _collect_relative_files(root: Path) -> dict[str, bytes]: - """Walk *root* and return {relative_posix_path: file_bytes}.""" - result: dict[str, bytes] = {} - for p in root.rglob("*"): - if p.is_file(): - result[p.relative_to(root).as_posix()] = p.read_bytes() - return result - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def source_template_stems() -> list[str]: - return _get_source_template_stems() - - -@pytest.fixture(scope="session") -def scaffolded_sh(tmp_path_factory): - """Session-scoped cache: scaffold once per agent with script_type='sh'.""" - cache = {} - def _get(agent: str) -> Path: - if agent not in cache: - project = tmp_path_factory.mktemp(f"scaffold_sh_{agent}") - ok = scaffold_from_core_pack(project, agent, "sh") - assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" - cache[agent] = project - return cache[agent] - return _get - - -@pytest.fixture(scope="session") -def scaffolded_ps(tmp_path_factory): - """Session-scoped cache: scaffold once per agent with script_type='ps'.""" - cache = {} - def _get(agent: str) -> Path: - if agent not in cache: - project = tmp_path_factory.mktemp(f"scaffold_ps_{agent}") - ok = scaffold_from_core_pack(project, agent, "ps") - assert ok, f"scaffold_from_core_pack returned False for agent '{agent}'" - cache[agent] = project - return cache[agent] - return _get - - -# --------------------------------------------------------------------------- -# Parametrize over all agents except "generic" -# --------------------------------------------------------------------------- - -_TESTABLE_AGENTS = [a for a in AGENT_CONFIG if a != "generic"] - - -# --------------------------------------------------------------------------- -# 1. Bundled scaffold — directory structure -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_scripts(agent, scaffolded_sh): - """scaffold_from_core_pack copies at least one script into .specify/scripts/.""" - project = scaffolded_sh(agent) - - scripts_dir = project / ".specify" / "scripts" / "bash" - assert scripts_dir.is_dir(), f".specify/scripts/bash/ missing for agent '{agent}'" - assert any(scripts_dir.iterdir()), f".specify/scripts/bash/ is empty for agent '{agent}'" - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_creates_specify_templates(agent, scaffolded_sh): - """scaffold_from_core_pack copies at least one page template into .specify/templates/.""" - project = scaffolded_sh(agent) - - tpl_dir = project / ".specify" / "templates" - assert tpl_dir.is_dir(), f".specify/templates/ missing for agent '{agent}'" - assert any(tpl_dir.iterdir()), ".specify/templates/ is empty" - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_dir_location(agent, scaffolded_sh): - """Command files land in the directory declared by AGENT_CONFIG.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - assert cmd_dir.is_dir(), ( - f"Command dir '{cmd_dir.relative_to(project)}' not created for agent '{agent}'" - ) - - -# --------------------------------------------------------------------------- -# 2. Bundled scaffold — file count -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_count(agent, scaffolded_sh, source_template_stems): - """One command file is generated per source template for every agent.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - generated = _list_command_files(cmd_dir, agent) - - if cmd_dir.is_dir(): - dir_listing = list(cmd_dir.iterdir()) - else: - dir_listing = f"" - - assert len(generated) == len(source_template_stems), ( - f"Agent '{agent}': expected {len(source_template_stems)} command files " - f"({_expected_ext(agent)}), found {len(generated)}. Dir: {dir_listing}" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_command_file_names(agent, scaffolded_sh, source_template_stems): - """Each source template stem maps to a corresponding speckit.. file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for stem in source_template_stems: - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - expected = cmd_dir / f"speckit{sep}{stem}" / "SKILL.md" - else: - ext = _expected_ext(agent) - expected = cmd_dir / f"speckit.{stem}.{ext}" - assert expected.is_file(), ( - f"Agent '{agent}': expected file '{expected.name}' not found in '{cmd_dir}'" - ) - - -# --------------------------------------------------------------------------- -# 3. Bundled scaffold — content invariants -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_script_placeholder(agent, scaffolded_sh): - """{SCRIPT} must not appear in any generated command file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "{SCRIPT}" not in content, ( - f"Unresolved {{SCRIPT}} in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_agent_placeholder(agent, scaffolded_sh): - """__AGENT__ must not appear in any generated command file.""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "__AGENT__" not in content, ( - f"Unresolved __AGENT__ in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_no_unresolved_args_placeholder(agent, scaffolded_sh): - """{ARGS} must not appear in any generated command file (replaced with agent-specific token).""" - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if f.is_file(): - content = f.read_text(encoding="utf-8") - assert "{ARGS}" not in content, ( - f"Unresolved {{ARGS}} in '{f.relative_to(project)}' for agent '{agent}'" - ) - - -# Build a set of template stems that actually contain {ARGS} in their source. -_TEMPLATES_WITH_ARGS: frozenset[str] = frozenset( - p.stem - for p in _commands_dir().glob("*.md") - if "{ARGS}" in p.read_text(encoding="utf-8") -) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_argument_token_format(agent, scaffolded_sh): - """For templates that carry an {ARGS} token: - - TOML agents must emit {{args}} - - Markdown agents must emit $ARGUMENTS - Templates without {ARGS} (e.g. implement, plan) are skipped. - """ - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - - for f in _list_command_files(cmd_dir, agent): - # Recover the stem from the file path - if agent in _SKILL_AGENTS: - sep = _SKILL_AGENTS[agent] - stem = f.parent.name.removeprefix(f"speckit{sep}") - else: - ext = _expected_ext(agent) - stem = f.name.removeprefix("speckit.").removesuffix(f".{ext}") - if stem not in _TEMPLATES_WITH_ARGS: - continue # this template has no argument token - - content = f.read_text(encoding="utf-8") - if agent in _TOML_AGENTS: - assert "{{args}}" in content, ( - f"TOML agent '{agent}': expected '{{{{args}}}}' in '{f.name}'" - ) - else: - assert "$ARGUMENTS" in content, ( - f"Markdown agent '{agent}': expected '$ARGUMENTS' in '{f.name}'" - ) - - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_path_rewrites_applied(agent, scaffolded_sh): - """Bare scripts/ and templates/ paths must be rewritten to .specify/ variants. - - YAML frontmatter 'source:' metadata fields are excluded — they reference - the original template path for provenance, not a runtime path. - """ - project = scaffolded_sh(agent) - - cmd_dir = _expected_cmd_dir(project, agent) - for f in cmd_dir.rglob("*"): - if not f.is_file(): - continue - content = f.read_text(encoding="utf-8") - - # Strip YAML frontmatter before checking — source: metadata is not a runtime path - body = content - if content.startswith("---"): - parts = content.split("---", 2) - if len(parts) >= 3: - body = parts[2] - - # Should not contain bare (non-.specify/) script paths - assert not re.search(r'(?= 3, f"Incomplete frontmatter in '{f.name}'" - fm = yaml.safe_load(parts[1]) - assert fm is not None, f"Empty frontmatter in '{f.name}'" - assert "description" in fm, ( - f"'description' key missing from frontmatter in '{f.name}' for agent '{agent}'" - ) - - -# --------------------------------------------------------------------------- -# 6. Copilot-specific: companion .prompt.md files -# --------------------------------------------------------------------------- - -def test_copilot_companion_prompt_files(scaffolded_sh, source_template_stems): - """Copilot: a speckit..prompt.md companion is created for every .agent.md file.""" - project = scaffolded_sh("copilot") - - prompts_dir = project / ".github" / "prompts" - assert prompts_dir.is_dir(), ".github/prompts/ not created for copilot" - - for stem in source_template_stems: - prompt_file = prompts_dir / f"speckit.{stem}.prompt.md" - assert prompt_file.is_file(), ( - f"Companion prompt file '{prompt_file.name}' missing for copilot" - ) - - -def test_copilot_prompt_file_content(scaffolded_sh, source_template_stems): - """Copilot companion .prompt.md files must reference their parent .agent.md.""" - project = scaffolded_sh("copilot") - - prompts_dir = project / ".github" / "prompts" - for stem in source_template_stems: - f = prompts_dir / f"speckit.{stem}.prompt.md" - content = f.read_text(encoding="utf-8") - assert f"agent: speckit.{stem}" in content, ( - f"Companion '{f.name}' does not reference 'speckit.{stem}'" - ) - - -# --------------------------------------------------------------------------- -# 7. PowerShell script variant -# --------------------------------------------------------------------------- - -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_scaffold_powershell_variant(agent, scaffolded_ps, source_template_stems): - """scaffold_from_core_pack with script_type='ps' creates correct files.""" - project = scaffolded_ps(agent) - - scripts_dir = project / ".specify" / "scripts" / "powershell" - assert scripts_dir.is_dir(), f".specify/scripts/powershell/ missing for '{agent}'" - assert any(scripts_dir.iterdir()), ".specify/scripts/powershell/ is empty" - - cmd_dir = _expected_cmd_dir(project, agent) - generated = _list_command_files(cmd_dir, agent) - assert len(generated) == len(source_template_stems) - - -# --------------------------------------------------------------------------- -# 8. Parity: bundled vs. real create-release-packages.sh ZIP -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def release_script_trees(tmp_path_factory): - """Session-scoped cache: run release script once per (agent, script_type).""" - cache: dict[tuple[str, str], dict[str, bytes]] = {} - bash = _find_bash() - - def _get(agent: str, script_type: str) -> dict[str, bytes] | None: - if bash is None: - return None - key = (agent, script_type) - if key not in cache: - tmp = tmp_path_factory.mktemp(f"release_{agent}_{script_type}") - gen_dir = tmp / "genreleases" - gen_dir.mkdir() - zip_path = _run_release_script(agent, script_type, bash, gen_dir) - extracted = tmp / "extracted" - extracted.mkdir() - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(extracted) - cache[key] = _collect_relative_files(extracted) - return cache[key] - return _get - - -@pytest.mark.parametrize("script_type", ["sh", "ps"]) -@pytest.mark.parametrize("agent", _TESTABLE_AGENTS) -def test_parity_bundled_vs_release_script(agent, script_type, scaffolded_sh, scaffolded_ps, release_script_trees): - """scaffold_from_core_pack() file tree is identical to the ZIP produced by - create-release-packages.sh for every agent and script type. - - This is the true end-to-end parity check: the Python offline path must - produce exactly the same artifacts as the canonical shell release script. - - Both sides are session-cached: each agent/script_type combination is - scaffolded and release-scripted only once across all tests. - """ - script_tree = release_script_trees(agent, script_type) - if script_tree is None: - pytest.skip("bash required to run create-release-packages.sh") - - # Reuse session-cached scaffold output - if script_type == "sh": - bundled_dir = scaffolded_sh(agent) - else: - bundled_dir = scaffolded_ps(agent) - - bundled_tree = _collect_relative_files(bundled_dir) - - only_bundled = set(bundled_tree) - set(script_tree) - only_script = set(script_tree) - set(bundled_tree) - - assert not only_bundled, ( - f"Agent '{agent}' ({script_type}): files only in bundled output (not in release ZIP):\n " - + "\n ".join(sorted(only_bundled)) - ) - assert not only_script, ( - f"Agent '{agent}' ({script_type}): files only in release ZIP (not in bundled output):\n " - + "\n ".join(sorted(only_script)) - ) - - for name in bundled_tree: - assert bundled_tree[name] == script_tree[name], ( - f"Agent '{agent}' ({script_type}): file '{name}' content differs between " - f"bundled output and release script ZIP" - ) - - -# --------------------------------------------------------------------------- -# Section 10 – pyproject.toml force-include covers all template files -# --------------------------------------------------------------------------- - -def test_pyproject_force_include_covers_all_templates(): - """Every file in templates/ (excluding commands/) must be listed in - pyproject.toml's [tool.hatch.build.targets.wheel.force-include] section. - - This prevents new template files from being silently omitted from the - wheel, which would break ``specify init --offline``. - """ - templates_dir = _REPO_ROOT / "templates" - # Collect all files directly in templates/ (not in subdirectories like commands/) - repo_template_files = sorted( - f.name for f in templates_dir.iterdir() - if f.is_file() - ) - assert repo_template_files, "Expected at least one template file in templates/" - - pyproject_path = _REPO_ROOT / "pyproject.toml" - with open(pyproject_path, "rb") as f: - pyproject = tomllib.load(f) - force_include = pyproject.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("force-include", {}) - - missing = [ - name for name in repo_template_files - if f"templates/{name}" not in force_include - ] - assert not missing, ( - "Template files not listed in pyproject.toml force-include " - "(offline scaffolding will miss them):\n " - + "\n ".join(missing) - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 5d5d84902..8a9f19e74 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -41,14 +41,14 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path: """Create and return the expected skills directory for the given agent.""" # Match the logic in _get_skills_dir() from specify_cli - from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR + from specify_cli import AGENT_CONFIG agent_config = AGENT_CONFIG.get(ai, {}) agent_folder = agent_config.get("folder", "") if agent_folder: skills_dir = project_root / agent_folder.rstrip("/") / "skills" else: - skills_dir = project_root / DEFAULT_SKILLS_DIR + skills_dir = project_root / ".agents" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) return skills_dir diff --git a/tests/test_extensions.py b/tests/test_extensions.py index df269d86c..350b368ea 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1017,7 +1017,7 @@ $ARGUMENTS def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) ExtensionManager(project_dir) # Initialize manager (side effects only) @@ -1034,13 +1034,12 @@ $ARGUMENTS assert "speckit.test-ext.hello" in registered # Check command file was created - cmd_file = claude_dir / "speckit.test-ext.hello.md" + cmd_file = claude_dir / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() content = cmd_file.read_text() assert "description: Test hello command" in content - assert "" in content - assert "" in content + assert "test-ext" in content def test_command_with_aliases(self, project_dir, temp_dir): """Test registering a command with aliases.""" @@ -1078,7 +1077,7 @@ $ARGUMENTS (ext_dir / "commands").mkdir() (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nTest") - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(ext_dir / "extension.yml") @@ -1088,8 +1087,8 @@ $ARGUMENTS assert len(registered) == 2 assert "speckit.ext-alias.cmd" in registered assert "speckit.ext-alias.shortcut" in registered - assert (claude_dir / "speckit.ext-alias.cmd.md").exists() - assert (claude_dir / "speckit.ext-alias.shortcut.md").exists() + assert (claude_dir / "speckit-ext-alias-cmd" / "SKILL.md").exists() + assert (claude_dir / "speckit-ext-alias-shortcut" / "SKILL.md").exists() def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir): """Codex skill cleanup should use the same mapped names as registration.""" @@ -1466,7 +1465,7 @@ Then {AGENT_SCRIPT} content = cmd_file.read_text() assert "description: Test hello command" in content - assert "" in content + assert "test-ext" in content def test_copilot_companion_prompt_created(self, extension_dir, project_dir): """Test that companion .prompt.md files are created in .github/prompts/.""" @@ -1541,7 +1540,7 @@ Then {AGENT_SCRIPT} def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): """Test that non-copilot agents do NOT create .prompt.md files.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manifest = ExtensionManifest(extension_dir / "extension.yml") @@ -1592,7 +1591,7 @@ class TestIntegration: def test_full_install_and_remove_workflow(self, extension_dir, project_dir): """Test complete installation and removal workflow.""" # Create Claude directory - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) @@ -1610,7 +1609,7 @@ class TestIntegration: assert installed[0]["id"] == "test-ext" # Verify command registered - cmd_file = project_dir / ".claude" / "commands" / "speckit.test-ext.hello.md" + cmd_file = project_dir / ".claude" / "skills" / "speckit-test-ext-hello" / "SKILL.md" assert cmd_file.exists() # Verify registry has registered commands (now a dict keyed by agent) @@ -3008,7 +3007,7 @@ class TestExtensionUpdateCLI: project_dir = tmp_path / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0", include_config=True) @@ -3057,7 +3056,7 @@ class TestExtensionUpdateCLI: project_dir = tmp_path / "project" project_dir.mkdir() (project_dir / ".specify").mkdir() - (project_dir / ".claude" / "commands").mkdir(parents=True) + (project_dir / ".claude" / "skills").mkdir(parents=True) manager = ExtensionManager(project_dir) v1_dir = self._create_extension_source(tmp_path, "1.0.0") @@ -3068,14 +3067,16 @@ class TestExtensionUpdateCLI: registered_commands = backup_registry_entry.get("registered_commands", {}) command_files = [] - registrar = CommandRegistrar() + from specify_cli.agents import CommandRegistrar as AgentRegistrar + agent_registrar = AgentRegistrar() for agent_name, cmd_names in registered_commands.items(): - if agent_name not in registrar.AGENT_CONFIGS: + if agent_name not in agent_registrar.AGENT_CONFIGS: continue - agent_cfg = registrar.AGENT_CONFIGS[agent_name] + agent_cfg = agent_registrar.AGENT_CONFIGS[agent_name] commands_dir = project_dir / agent_cfg["dir"] for cmd_name in cmd_names: - cmd_path = commands_dir / f"{cmd_name}{agent_cfg['extension']}" + output_name = AgentRegistrar._compute_output_name(agent_name, cmd_name, agent_cfg) + cmd_path = commands_dir / f"{output_name}{agent_cfg['extension']}" command_files.append(cmd_path) assert command_files, "Expected at least one registered command file" diff --git a/tests/test_presets.py b/tests/test_presets.py index f2a08b91b..d22264f80 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1772,19 +1772,20 @@ class TestSelfTestPreset: assert "preset:self-test" in content def test_self_test_registers_commands_for_claude(self, project_dir): - """Test that installing self-test registers commands in .claude/commands/.""" - # Create Claude agent directory to simulate Claude being set up - claude_dir = project_dir / ".claude" / "commands" + """Test that installing self-test registers skills in .claude/skills/.""" + # Create Claude skills directory to simulate Claude being set up + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") - # Check the command was registered - cmd_file = claude_dir / "speckit.specify.md" - assert cmd_file.exists(), "Command not registered in .claude/commands/" + # Check the skill was registered + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered in .claude/skills/" content = cmd_file.read_text() - assert "preset:self-test" in content + assert "self-test" in content + assert "source:" in content # skill frontmatter includes metadata.source def test_self_test_registers_commands_for_gemini(self, project_dir): """Test that installing self-test registers commands in .gemini/commands/ as TOML.""" @@ -1804,13 +1805,13 @@ class TestSelfTestPreset: def test_self_test_unregisters_commands_on_remove(self, project_dir): """Test that removing self-test cleans up registered commands.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") - cmd_file = claude_dir / "speckit.specify.md" + cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists() manager.remove("self-test") @@ -1826,7 +1827,7 @@ class TestSelfTestPreset: def test_extension_command_skipped_when_extension_missing(self, project_dir, temp_dir): """Test that extension command overrides are skipped if the extension isn't installed.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) preset_dir = temp_dir / "ext-override-preset" @@ -1869,7 +1870,7 @@ class TestSelfTestPreset: def test_extension_command_registered_when_extension_present(self, project_dir, temp_dir): """Test that extension command overrides ARE registered when the extension is installed.""" - claude_dir = project_dir / ".claude" / "commands" + claude_dir = project_dir / ".claude" / "skills" claude_dir.mkdir(parents=True) (project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True) @@ -1905,8 +1906,8 @@ class TestSelfTestPreset: manager = PresetManager(project_dir) manager.install_from_directory(preset_dir, "0.1.5") - cmd_file = claude_dir / "speckit.fakeext.cmd.md" - assert cmd_file.exists(), "Command not registered despite extension being present" + cmd_file = claude_dir / "speckit-fakeext-cmd" / "SKILL.md" + assert cmd_file.exists(), "Skill not registered despite extension being present" # ===== Init Options and Skills Tests ===== @@ -1964,7 +1965,7 @@ class TestPresetSkills: self._create_skill(skills_dir, "speckit-specify") # Also create the claude commands dir so commands get registered - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Install self-test preset (has a command override for speckit.specify) manager = PresetManager(project_dir) @@ -1983,12 +1984,10 @@ class TestPresetSkills: def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): """When --ai-skills was NOT used, preset install should not touch skills.""" - self._write_init_options(project_dir, ai="claude", ai_skills=False) - skills_dir = project_dir / ".claude" / "skills" + self._write_init_options(project_dir, ai="qwen", ai_skills=False) + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2019,18 +2018,16 @@ class TestPresetSkills: def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): """When no init-options.json exists, preset install should not touch skills.""" - skills_dir = project_dir / ".claude" / "skills" + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") skill_file = skills_dir / "speckit-specify" / "SKILL.md" - content = skill_file.read_text() - assert "untouched" in content + file_content = skill_file.read_text() + assert "untouched" in file_content def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): """When a preset is removed, skills should be restored from core templates.""" @@ -2038,7 +2035,7 @@ class TestPresetSkills: skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) # Set up core command template in the project so restoration works core_cmds = project_dir / ".specify" / "templates" / "commands" @@ -2068,7 +2065,7 @@ class TestPresetSkills: self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh") skills_dir = project_dir / ".claude" / "skills" self._create_skill(skills_dir, "speckit-specify", body="old") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".claude" / "skills").mkdir(parents=True, exist_ok=True) core_cmds = project_dir / ".specify" / "templates" / "commands" core_cmds.mkdir(parents=True, exist_ok=True) @@ -2094,13 +2091,11 @@ class TestPresetSkills: def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): """Preset install should skip non-directory skill targets.""" - self._write_init_options(project_dir, ai="claude") - skills_dir = project_dir / ".claude" / "skills" + self._write_init_options(project_dir, ai="qwen") + skills_dir = project_dir / ".qwen" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) (skills_dir / "speckit-specify").write_text("not-a-directory") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2114,8 +2109,6 @@ class TestPresetSkills: self._write_init_options(project_dir, ai="claude") # Don't create skills dir — simulate --ai-skills never created them - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) - manager = PresetManager(project_dir) SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(SELF_TEST_DIR, "0.1.5") @@ -2516,16 +2509,15 @@ class TestPresetSkills: init_options.parent.mkdir(parents=True, exist_ok=True) init_options.write_text("[]") - skills_dir = project_dir / ".claude" / "skills" + skills_dir = project_dir / ".qwen" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" manager.install_from_directory(self_test_dir, "0.1.5") - content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() - assert "untouched" in content + skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() + assert "untouched" in skill_content class TestPresetSetPriority: From 663d679f3baef6b2feeaeece05e553a5c0c146ee Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:38:48 -0500 Subject: [PATCH 7/7] chore: release 0.4.5, begin 0.4.6.dev0 development (#2064) * chore: bump version to 0.4.5 * chore: begin 0.4.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8394968a2..04e9ea9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ +## [0.4.5] - 2026-04-02 + +### Changed + +- Stage 6: Complete migration — remove legacy scaffold path (#1924) (#2063) +- Install Claude Code as native skills and align preset/integration flows (#2051) +- Add repoindex 0402 (#2062) +- Stage 5: Skills, Generic & Option-Driven Integrations (#1924) (#2052) +- feat(scripts): add --dry-run flag to create-new-feature (#1998) +- fix: support feature branch numbers with 4+ digits (#2040) +- Add community content disclaimers (#2058) +- docs: add community extensions website link to README and extensions docs (#2014) +- docs: remove dead Cognitive Squad and Understanding extension links and from extensions/catalog.community.json (#2057) +- Add fix-findings extension to community catalog (#2039) +- Stage 4: TOML integrations — gemini and tabnine migrated to plugin architecture (#2050) +- feat: add 5 lifecycle extensions to community catalog (#2049) +- Stage 3: Standard markdown integrations — 19 agents migrated to plugin architecture (#2038) +- chore: release 0.4.4, begin 0.4.5.dev0 development (#2048) + ## [0.4.4] - 2026-04-01 ### Changed diff --git a/pyproject.toml b/pyproject.toml index bfd5f25c2..3c476f22c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.4.5.dev0" +version = "0.4.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [