Compare commits

..

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
17810c8e85 fix: capitalize docstring and use touch instead of colon redirect
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/008835a0-7778-40bb-bdb2-4182b22be315
2026-03-23 21:58:41 +00:00
copilot-swe-agent[bot]
5e49ec6936 fix: address all PR review comments for git extension
- Fix bash common.sh sourcing: check .specify/scripts/bash/ first, then
  scripts/bash/, with explicit error if neither found
- Fix PowerShell common.ps1 sourcing: use $fallbackRoot for reliable
  path resolution, with explicit error if not found
- Remove undocumented branch_template and auto_fetch from extension.yml
  defaults and config-template.yml (scripts don't use them yet)
- Remove unused ExtensionError import in _install_bundled_git_extension
- Remove undocumented SPECKIT_GIT_BRANCH_NUMBERING env var from README
- Fix specify.md: skip step 2 when before_specify hook already executed
- Fix specify.md: add explicit FEATURE_DIR/SPEC_FILE in disabled-git path
- Fix specify.md: add PowerShell path to script resolution
- Add tests for git extension auto-install during specify init

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/008835a0-7778-40bb-bdb2-4182b22be315
2026-03-23 21:56:55 +00:00
copilot-swe-agent[bot]
312c37be25 fix: correct extension registry path in specify.md template
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/809a1dbf-1301-4312-b4d2-e18f9b3e8b2f
2026-03-23 21:33:29 +00:00
copilot-swe-agent[bot]
086421daf1 fix: resolve common.sh/ps1 sourcing from extension scripts directory
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/809a1dbf-1301-4312-b4d2-e18f9b3e8b2f
2026-03-23 21:30:29 +00:00
copilot-swe-agent[bot]
4f83308540 feat: create extensions/git with manifest, commands, scripts, and auto-install in init
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/809a1dbf-1301-4312-b4d2-e18f9b3e8b2f
2026-03-23 21:27:54 +00:00
copilot-swe-agent[bot]
e9c464db14 Initial plan 2026-03-23 21:16:53 +00:00
Manfred Riem
b72a5850fe chore: bump version to 0.4.0 (#1937)
* chore: bump version to 0.4.0

* fix: restore newest-first ordering in CHANGELOG.md for v0.4.0 (#1938)

* Initial plan

* fix: move 0.4.0 section above 0.3.2 to restore newest-first ordering in CHANGELOG.md

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1c1787ad-64df-4b8c-bd32-0bc5198f5029

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-03-23 09:53:19 -05:00
Seiya Kojima
a351c826ee fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936)
None of the yaml.dump() calls specify allow_unicode=True, causing
non-ASCII characters in extension descriptions to be escaped to
\uXXXX sequences in generated .agent.md frontmatter and config files.

Add allow_unicode=True to all 6 yaml.dump() call sites, and
encoding="utf-8" to all corresponding write_text() and read_text()
calls to ensure consistent UTF-8 handling across platforms.
2026-03-23 08:49:10 -05:00
Hamilton Snow
6223d10d84 fix(codex): native skills fallback refresh + legacy prompt suppression (#1930)
* fix(codex): skip legacy prompts and fallback when bundled skills missing

* fix(skills): allow native fallback to overwrite existing SKILL.md

* fix(codex): defer legacy .codex cleanup until after skills fallback

* fix(codex): preserve existing .codex while skipping legacy prompt extraction

* docs(skills): clarify overwrite_existing behavior

* test(codex): cover fresh-dir suppression of legacy .codex layout

* docs(codex): clarify skip_legacy_codex_prompts suppresses full .codex dir

* security(init): validate zip member paths before extraction
2026-03-23 07:35:11 -05:00
48 changed files with 1501 additions and 3756 deletions

View File

@@ -427,56 +427,4 @@ When adding new agents:
---
## Agent Pack System (new)
The agent pack system is a declarative, self-contained replacement for the legacy `AGENT_CONFIG` + case/switch architecture. Each agent is defined by a `speckit-agent.yml` manifest and an optional `bootstrap.py` module. When `bootstrap.py` is absent, the built-in `DefaultBootstrap` class derives its directory layout from the manifest's `commands_dir` field.
### `--agent` flag on `specify init`
`specify init --agent <id>` uses the pack-based init flow instead of the legacy `--ai` flow. Both accept the same agent IDs, but `--agent` additionally enables installed-file tracking so that `specify agent switch` can cleanly tear down agent files later.
```bash
specify init my-project --agent claude # Pack-based flow (with file tracking)
specify init --here --agent gemini --ai-skills # With skills
```
`--agent` and `--ai` are mutually exclusive. When `--agent` is used, `init-options.json` gains `"agent_pack": true`. The `generic` agent (which requires `--ai-commands-dir`) falls through to the legacy flow since it has no embedded pack.
### `specify agent` subcommands
| Command | Description |
| ------------------------------- | ----------- |
| `specify agent list` | List all available agent packs |
| `specify agent list --installed`| List only agents installed in the current project |
| `specify agent info <id>` | Show detailed information about an agent pack |
| `specify agent switch <id>` | Switch the active agent (tears down old, sets up new) |
| `specify agent search [query]` | Search agents by name, ID, description, or tags |
| `specify agent validate <path>` | Validate an agent pack directory |
| `specify agent export <id>` | Export an agent pack for editing |
| `specify agent add <id> --from <path>` | Install an agent pack from a local directory |
| `specify agent remove <id>` | Remove a cached/override agent pack |
> **Note:** `specify agent add <id>` without `--from <path>` is reserved for future catalog-based installation, which is not yet implemented.
### Pack resolution order
Agent packs resolve by priority (highest first):
1. **User-level** (`~/.specify/agents/<id>/`) — applies to all projects
2. **Project-level** (`.specify/agents/<id>/`) — project-specific override
3. **Catalog cache** (downloaded via `specify agent add`)
4. **Embedded** (bundled in the specify-cli wheel)
### Trust boundary
Agent packs can include a `bootstrap.py` module that is dynamically imported and executed. Pack authors can run arbitrary code through this mechanism. Only install packs from trusted sources. The 4-level resolution stack means that placing a pack in any of the resolution directories causes its code to run when the agent is loaded.
### Installed-file tracking
When using `--agent`, all installed files are recorded in `.specify/agent-manifest-<id>.json` with SHA-256 hashes. During `specify agent switch`, the CLI:
1. Checks for user-modified files before teardown
2. Prompts for confirmation if files were changed
3. Feeds tracked file lists into teardown for precise, file-level removal (directories are never deleted)
---
*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*

View File

@@ -1,11 +1,22 @@
# Changelog
## [0.3.2] - 2026-03-19
## [0.4.0] - 2026-03-23
### Changes
- chore: bump version to 0.3.2
- Add conduct extension to community catalog (#1908)
- fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936)
- fix(codex): native skills fallback refresh + legacy prompt suppression (#1930)
- feat(cli): embed core pack in wheel for offline/air-gapped deployment (#1803)
- ci: increase stale workflow operations-per-run to 250 (#1922)
- docs: update publishing guide with Category and Effect columns (#1913)
- fix: Align native skills frontmatter with install_ai_skills (#1920)
- feat: add timestamp-based branch naming option for `specify init` (#1911)
- docs: add Extension Comparison Guide for community extensions (#1897)
- docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910)
- Add support for Junie (#1831)
- feat: migrate Codex/agy init to native skills workflow (#1906)
- chore: bump version to 0.3.2 (#1909)
- feat(extensions): add verify-tasks extension to community catalog (#1871)
- feat(presets): add enable/disable toggle and update semantics (#1891)
- feat: add iFlow CLI support (#1875)
@@ -21,6 +32,13 @@
- Feature/spec kit add pi coding agent pullrequest (#1853)
- feat: register spec-kit-learn extension (#1883)
## [0.3.2] - 2026-03-19
### Changes
- chore: bump version to 0.3.2
- Add conduct extension to community catalog (#1908)
## [0.3.1] - 2026-03-17
### Changed

71
extensions/git/README.md Normal file
View File

@@ -0,0 +1,71 @@
# Git Branching Workflow Extension
Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection for Spec Kit.
## Overview
This extension provides Git branching operations as an optional, self-contained module. It manages:
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
## Commands
| Command | Description |
|---------|-------------|
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
## Hooks
| Event | Command | Optional | Description |
|-------|---------|----------|-------------|
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
| `after_implement` | `speckit.git.validate` | Yes | Validate branch naming after implementation |
## Configuration
Configuration is stored in `.specify/extensions/git/git-config.yml`:
```yaml
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
```
## Installation
```bash
# Install from the bundled extension
specify extension add git --from extensions/git/
# Or it auto-installs during specify init (migration period)
```
## Disabling
```bash
# Disable the git extension (spec creation continues without branching)
specify extension disable git
# Re-enable it
specify extension enable git
```
## Graceful Degradation
When Git is not installed or the directory is not a Git repository:
- Spec directories are still created under `specs/`
- Branch creation is skipped with a warning
- Branch validation is skipped with a warning
- Remote detection returns empty results
## Scripts
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -0,0 +1,66 @@
---
description: "Create a feature branch with sequential or timestamp numbering"
---
# Create Feature Branch
Create a new feature branch for the given specification.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
- **Bash**: `scripts/bash/create-new-feature.sh`
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- The script will still create the spec directory under `specs/`
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
- The workflow continues normally without branch creation
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `SPEC_FILE`: Path to the created spec file
- `FEATURE_NUM`: The numeric or timestamp prefix used

View File

@@ -0,0 +1,45 @@
---
description: "Detect Git remote URL for GitHub integration"
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View File

@@ -0,0 +1,49 @@
---
description: "Validate current branch follows feature branch naming conventions"
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3}-` (e.g., `001-feature-name`, `042-fix-bug`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the `###` portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View File

@@ -0,0 +1,5 @@
# Git Branching Workflow Extension Configuration
# Copy this file to .specify/extensions/git/git-config.yml to customize
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential

View File

@@ -0,0 +1,47 @@
schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
tools:
- name: git
required: false
provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
- name: speckit.git.remote
file: commands/speckit.git.remote.md
description: "Detect Git remote URL for GitHub integration"
hooks:
before_specify:
command: speckit.git.feature
optional: false
description: "Create feature branch before specification"
after_implement:
command: speckit.git.validate
optional: true
prompt: "Verify feature branch naming?"
description: "Validate branch naming after implementation"
tags:
- "git"
- "branching"
- "workflow"
defaults:
branch_numbering: sequential

View File

@@ -0,0 +1,385 @@
#!/usr/bin/env bash
set -e
JSON_MODE=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
# Check if the next argument is another option (starts with --)
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to find the repository root by searching for existing project markers
find_repo_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Only match sequential prefixes (###-*), skip timestamp dirs
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
local highest=0
# Get all branches (local and remote)
branches=$(git branch -a 2>/dev/null || echo "")
if [ -n "$branches" ]; then
while IFS= read -r branch; do
# Clean branch name: remove leading markers and remote prefixes
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
# Extract feature number if branch matches pattern ###-*
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done <<< "$branches"
fi
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number
check_existing_branches() {
local specs_dir="$1"
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
# Get highest number from ALL branches (not just matching short name)
local highest_branch=$(get_highest_from_branches)
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# Take the maximum of both
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
# Return next number
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialised with --no-git.
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source common.sh using the following priority:
# 1. common.sh next to this script (source checkout layout)
# 2. .specify/scripts/bash/common.sh under the project root (installed project)
# 3. scripts/bash/common.sh under the project root (source checkout fallback)
# 4. git-common.sh next to this script (minimal fallback)
_common_loaded=false
if [ -f "$SCRIPT_DIR/common.sh" ]; then
source "$SCRIPT_DIR/common.sh"
_common_loaded=true
else
# When running from an extension install (.specify/extensions/git/scripts/bash/),
# resolve to .specify/ (4 levels up), then to the project root (5 levels up).
_dot_specify="$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)"
_project_root="$(cd "$SCRIPT_DIR/../../../../.." 2>/dev/null && pwd)"
if [ -n "$_dot_specify" ] && [ -f "$_dot_specify/scripts/bash/common.sh" ]; then
source "$_dot_specify/scripts/bash/common.sh"
_common_loaded=true
elif [ -n "$_project_root" ] && [ -f "$_project_root/scripts/bash/common.sh" ]; then
source "$_project_root/scripts/bash/common.sh"
_common_loaded=true
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
_common_loaded=true
fi
fi
if [ "$_common_loaded" != "true" ]; then
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
exit 1
fi
if git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
HAS_GIT=true
else
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
if [ -z "$REPO_ROOT" ]; then
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
exit 1
fi
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
mkdir -p "$SPECS_DIR"
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -q "\b${word^^}\b"; then
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words+=("$word")
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
# Fallback to original logic if no meaningful words found
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
# Use provided short name, just clean it up
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
# Generate from description with smart filtering
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$HAS_GIT" = true ]; then
if ! git checkout -b "$BRANCH_NAME" 2>/dev/null; then
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
fi
exit 1
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
exit 1
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
SPEC_FILE="$FEATURE_DIR/spec.md"
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
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Git-specific common functions for the git extension.
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.
# Check if we have git available
has_git() {
git rev-parse --show-toplevel >/dev/null 2>&1
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-*) or timestamp (YYYYMMDD-HHMMSS-*) formats.
check_feature_branch() {
local branch="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$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
return 1
fi
return 0
}

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env pwsh
# Create a new feature
[CmdletBinding()]
param(
[switch]$Json,
[string]$ShortName,
[Parameter()]
[int]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Examples:"
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
exit 0
}
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
# Validate description is not empty after trimming (e.g., user passed only whitespace)
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialized with --no-git.
function Find-RepositoryRoot {
param(
[string]$StartDir,
[string[]]$Markers = @('.git', '.specify')
)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in $Markers) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) {
# Reached filesystem root without finding markers
return $null
}
$current = $parent
}
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3})-') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
$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 feature number if branch matches pattern ###-*
if ($cleanBranch -match '^(\d{3})-') {
$num = [int]$matches[1]
if ($num -gt $highest) { $highest = $num }
}
}
}
} catch {
# If git command fails, return 0
Write-Verbose "Could not check Git branches: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir
)
# 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
}
# 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
# Take the maximum of both
$maxNum = [Math]::Max($highestBranch, $highestSpec)
# Return next number
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
if (-not $fallbackRoot) {
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
exit 1
}
# Load common functions (includes Resolve-Template).
# Search locations in priority order:
# 1. common.ps1 next to this script (source checkout layout)
# 2. .specify/scripts/powershell/common.ps1 under the project root (installed project)
# 3. scripts/powershell/common.ps1 under the project root (source checkout fallback)
# 4. git-common.ps1 next to this script (minimal fallback)
$commonLoaded = $false
if (Test-Path "$PSScriptRoot/common.ps1") {
. "$PSScriptRoot/common.ps1"
$commonLoaded = $true
} else {
$coreCommonCandidates = @()
if ($fallbackRoot) {
$coreCommonCandidates += (Join-Path $fallbackRoot ".specify/scripts/powershell/common.ps1")
$coreCommonCandidates += (Join-Path $fallbackRoot "scripts/powershell/common.ps1")
}
$coreCommonCandidates += "$PSScriptRoot/git-common.ps1"
foreach ($candidate in $coreCommonCandidates) {
if ($candidate -and (Test-Path $candidate)) {
. $candidate
$commonLoaded = $true
break
}
}
}
if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}
try {
$repoRoot = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
$hasGit = $true
} else {
throw "Git not available"
}
} catch {
$repoRoot = $fallbackRoot
$hasGit = $false
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
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',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
# Convert to lowercase and 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
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
$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 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
# Fallback to original logic if no meaningful words found
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Generate branch name
if ($ShortName) {
# Use provided short name, just clean it up
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
# Generate from description with smart filtering
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Warn if -Number and -Timestamp are both specified
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
# Determine branch prefix
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# Determine branch number
if ($Number -eq 0) {
if ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# 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
}
if (-not $branchCreated) {
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
}
exit 1
} else {
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"
}
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
$specFile = Join-Path $featureDir 'spec.md'
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]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
$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"
}

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
# branch validation and detection logic.
function Test-HasGit {
try {
git rev-parse --show-toplevel 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
if ($Branch -notmatch '^[0-9]{3}-' -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"
return $false
}
return $true
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.3.2"
version = "0.4.0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -41,10 +41,9 @@ 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"
"extensions/git" = "specify_cli/core_pack/extensions/git"
".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"
# Official agent packs (embedded in wheel for zero-config offline operation)
"src/specify_cli/core_pack/agents" = "specify_cli/core_pack/agents"
[project.optional-dependencies]
test = [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -47,12 +47,6 @@ class CommandRegistrar:
"args": "$ARGUMENTS",
"extension": ".md"
},
"cursor-agent": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qwen": {
"dir": ".qwen/commands",
"format": "markdown",
@@ -213,7 +207,7 @@ class CommandRegistrar:
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "agy"
name: "Antigravity"
version: "1.0.0"
description: "Antigravity IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'antigravity']
command_registration:
commands_dir: ".agent/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "amp"
name: "Amp"
version: "1.0.0"
description: "Amp CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://ampcode.com/manual#install"
cli_tool: "amp"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'amp']
command_registration:
commands_dir: ".agents/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "auggie"
name: "Auggie CLI"
version: "1.0.0"
description: "Auggie CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.augmentcode.com/cli/setup-auggie/install-auggie-cli"
cli_tool: "auggie"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'augment', 'auggie']
command_registration:
commands_dir: ".augment/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "bob"
name: "IBM Bob"
version: "1.0.0"
description: "IBM Bob IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'ibm', 'bob']
command_registration:
commands_dir: ".bob/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "claude"
name: "Claude Code"
version: "1.0.0"
description: "Anthropic's Claude Code CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.anthropic.com/en/docs/claude-code/setup"
cli_tool: "claude"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'anthropic', 'claude']
command_registration:
commands_dir: ".claude/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "codebuddy"
name: "CodeBuddy"
version: "1.0.0"
description: "CodeBuddy CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://www.codebuddy.ai/cli"
cli_tool: "codebuddy"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'codebuddy']
command_registration:
commands_dir: ".codebuddy/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "codex"
name: "Codex CLI"
version: "1.0.0"
description: "OpenAI Codex CLI with project skills support"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/openai/codex"
cli_tool: "codex"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'openai', 'codex', 'skills']
command_registration:
commands_dir: ".agents/skills"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: "/SKILL.md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "copilot"
name: "GitHub Copilot"
version: "1.0.0"
description: "GitHub Copilot for AI-assisted development in VS Code"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'github', 'copilot']
command_registration:
commands_dir: ".github/agents"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".agent.md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "cursor-agent"
name: "Cursor"
version: "1.0.0"
description: "Cursor IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'cursor']
command_registration:
commands_dir: ".cursor/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "gemini"
name: "Gemini CLI"
version: "1.0.0"
description: "Google's Gemini CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/google-gemini/gemini-cli"
cli_tool: "gemini"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'google', 'gemini']
command_registration:
commands_dir: ".gemini/commands"
format: "toml"
arg_placeholder: "{{args}}"
file_extension: ".toml"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "iflow"
name: "iFlow CLI"
version: "1.0.0"
description: "iFlow CLI by iflow-ai for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.iflow.cn/en/cli/quickstart"
cli_tool: "iflow"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'iflow']
command_registration:
commands_dir: ".iflow/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "junie"
name: "Junie"
version: "1.0.0"
description: "Junie by JetBrains for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://junie.jetbrains.com/"
cli_tool: "junie"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'jetbrains', 'junie']
command_registration:
commands_dir: ".junie/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "kilocode"
name: "Kilo Code"
version: "1.0.0"
description: "Kilo Code IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'kilocode']
command_registration:
commands_dir: ".kilocode/workflows"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "kimi"
name: "Kimi Code"
version: "1.0.0"
description: "Kimi Code CLI by Moonshot AI with skills support"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://code.kimi.com/"
cli_tool: "kimi"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'moonshot', 'kimi', 'skills']
command_registration:
commands_dir: ".kimi/skills"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: "/SKILL.md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "kiro-cli"
name: "Kiro CLI"
version: "1.0.0"
description: "Kiro CLI by Amazon for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://kiro.dev/docs/cli/"
cli_tool: "kiro-cli"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'amazon', 'kiro']
command_registration:
commands_dir: ".kiro/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "opencode"
name: "opencode"
version: "1.0.0"
description: "opencode CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://opencode.ai"
cli_tool: "opencode"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'opencode']
command_registration:
commands_dir: ".opencode/command"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "pi"
name: "Pi Coding Agent"
version: "1.0.0"
description: "Pi terminal coding agent for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://www.npmjs.com/package/@mariozechner/pi-coding-agent"
cli_tool: "pi"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'pi']
command_registration:
commands_dir: ".pi/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "qodercli"
name: "Qoder CLI"
version: "1.0.0"
description: "Qoder CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://qoder.com/cli"
cli_tool: "qodercli"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'qoder']
command_registration:
commands_dir: ".qoder/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "qwen"
name: "Qwen Code"
version: "1.0.0"
description: "Alibaba's Qwen Code CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/QwenLM/qwen-code"
cli_tool: "qwen"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'alibaba', 'qwen']
command_registration:
commands_dir: ".qwen/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "roo"
name: "Roo Code"
version: "1.0.0"
description: "Roo Code IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'roo']
command_registration:
commands_dir: ".roo/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "shai"
name: "SHAI"
version: "1.0.0"
description: "SHAI CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/ovh/shai"
cli_tool: "shai"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'ovh', 'shai']
command_registration:
commands_dir: ".shai/commands"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "tabnine"
name: "Tabnine CLI"
version: "1.0.0"
description: "Tabnine CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://docs.tabnine.com/main/getting-started/tabnine-cli"
cli_tool: "tabnine"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'tabnine']
command_registration:
commands_dir: ".tabnine/agent/commands"
format: "toml"
arg_placeholder: "{{args}}"
file_extension: ".toml"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "trae"
name: "Trae"
version: "1.0.0"
description: "Trae IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'trae']
command_registration:
commands_dir: ".trae/rules"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,25 +0,0 @@
schema_version: "1.0"
agent:
id: "vibe"
name: "Mistral Vibe"
version: "1.0.0"
description: "Mistral Vibe CLI for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: true
install_url: "https://github.com/mistralai/mistral-vibe"
cli_tool: "vibe"
requires:
speckit_version: ">=0.1.0"
tags: ['cli', 'mistral', 'vibe']
command_registration:
commands_dir: ".vibe/prompts"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -1,23 +0,0 @@
schema_version: "1.0"
agent:
id: "windsurf"
name: "Windsurf"
version: "1.0.0"
description: "Windsurf IDE for AI-assisted development"
author: "github"
license: "MIT"
runtime:
requires_cli: false
requires:
speckit_version: ">=0.1.0"
tags: ['ide', 'windsurf']
command_registration:
commands_dir: ".windsurf/workflows"
format: "markdown"
arg_placeholder: "$ARGUMENTS"
file_extension: ".md"

View File

@@ -975,8 +975,8 @@ class ExtensionCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError) as e:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise ValidationError(
f"Failed to read catalog config {config_path}: {e}"
)
@@ -1467,8 +1467,8 @@ class ConfigManager:
return {}
try:
return yaml.safe_load(file_path.read_text()) or {}
except (yaml.YAMLError, OSError):
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError):
return {}
def _get_extension_defaults(self) -> Dict[str, Any]:
@@ -1659,8 +1659,8 @@ class HookExecutor:
}
try:
return yaml.safe_load(self.config_file.read_text()) or {}
except (yaml.YAMLError, OSError):
return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError):
return {
"installed": [],
"settings": {"auto_execute_hooks": True},
@@ -1675,7 +1675,8 @@ class HookExecutor:
"""
self.config_file.parent.mkdir(parents=True, exist_ok=True)
self.config_file.write_text(
yaml.dump(config, default_flow_style=False, sort_keys=False)
yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
def register_hooks(self, manifest: ExtensionManifest):

View File

@@ -1062,8 +1062,8 @@ class PresetCatalog:
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError) as e:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e:
raise PresetValidationError(
f"Failed to read catalog config {config_path}: {e}"
)

View File

@@ -73,12 +73,39 @@ Given that feature description, do this:
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
2. **Create the feature branch** (unless already handled by a `before_specify` hook — see Pre-Execution Checks above). If a mandatory `before_specify` hook for `speckit.git.feature` already executed and created the branch, **skip this step entirely** and use the branch/spec information from the hook result. Otherwise:
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
**Git extension check**: Before running the branch creation script, check if the git extension is enabled:
- Check if `.specify/extensions/.registry` exists (a single JSON file tracking all extensions)
- If it exists, read the JSON and look for `extensions.git`; verify its `"enabled"` field is `true` (or not explicitly `false`)
- If the git extension is **disabled** (explicitly `"enabled": false`), **skip branch creation entirely** — do **not** run the branch creation script. Instead:
- Derive a spec directory name from the short name, e.g. `specs/<short-name>/`
- Explicitly set the following variables so later steps can use them:
- `FEATURE_DIR="specs/<short-name>"`
- `SPEC_FILE="$FEATURE_DIR/spec.md"`
- Ensure the directory and spec file exist:
- Bash:
- `mkdir -p "$FEATURE_DIR"`
- `touch "$SPEC_FILE"`
- PowerShell:
- `New-Item -ItemType Directory -Path $env:FEATURE_DIR -Force | Out-Null`
- `New-Item -ItemType File -Path $env:SPEC_FILE -Force | Out-Null`
- Then proceed directly to step 3 using `FEATURE_DIR` and `SPEC_FILE`
- If the registry file does not exist, proceed with branch creation using the default behavior (backward compatibility)
Run the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
**Branch numbering mode**: Before running the script, determine the branch numbering strategy:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value (extension config takes precedence)
2. If not found, check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
- If `"sequential"` or absent, do not add any extra flag (default behavior)
**Script resolution**: Use the extension's bundled scripts when available, falling back to core scripts:
- **Bash**: If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it; otherwise, fall back to `{SCRIPT}`
- **PowerShell**: If `.specify/extensions/git/scripts/powershell/create-new-feature.ps1` exists, use it; otherwise, fall back to `{SCRIPT}`
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,12 @@ Tests cover:
"""
import re
import zipfile
import pytest
import tempfile
import shutil
import yaml
import typer
from pathlib import Path
from unittest.mock import patch
@@ -720,8 +722,8 @@ class TestNewProjectCommandSkip:
mock_skills.assert_not_called()
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
"""Codex native skills init should fail if bundled skills are missing."""
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()
@@ -730,7 +732,7 @@ class TestNewProjectCommandSkip:
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") as mock_skills, \
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(
@@ -739,11 +741,13 @@ class TestNewProjectCommandSkip:
)
assert result.exit_code == 1
mock_skills.assert_not_called()
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 not satisfy Codex bundled-skills validation."""
"""Non-spec-kit SKILL.md files should trigger fallback conversion, not hard-fail."""
from typer.testing import CliRunner
runner = CliRunner()
@@ -757,7 +761,7 @@ class TestNewProjectCommandSkip:
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.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(
@@ -765,9 +769,100 @@ class TestNewProjectCommandSkip:
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
)
assert result.exit_code == 1
mock_skills.assert_not_called()
assert "Expected bundled agent skills" in result.output
assert result.exit_code == 0
mock_skills.assert_called_once()
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
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"
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()
def test_commands_preserved_when_skills_fail(self, tmp_path):
"""If skills fail, commands should NOT be removed (safety net)."""
@@ -859,6 +954,21 @@ class TestSkipIfExists:
# 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 =====

View File

@@ -87,3 +87,54 @@ class TestBranchNumberingValidation:
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")
class TestGitExtensionAutoInstall:
"""Tests for bundled git extension auto-install during specify init."""
def test_git_extension_installed_during_init(self, tmp_path: Path, monkeypatch):
"""Verify that `specify init` auto-installs the bundled git extension."""
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"])
assert result.exit_code == 0
# Extension files should exist
ext_dir = project_dir / ".specify" / "extensions" / "git"
assert ext_dir.is_dir(), "git extension directory not created"
assert (ext_dir / "extension.yml").is_file(), "extension.yml not installed"
# Registry should contain the git extension
registry_file = project_dir / ".specify" / "extensions" / ".registry"
assert registry_file.is_file(), "extension registry not created"
registry = json.loads(registry_file.read_text())
assert "git" in registry.get("extensions", {}), "git not in registry"
assert registry["extensions"]["git"]["enabled"] is True
def test_git_extension_noop_when_already_installed(self, tmp_path: Path):
"""_install_bundled_git_extension should no-op if git is already installed."""
from specify_cli import _install_bundled_git_extension
from specify_cli.extensions import ExtensionManager
project_dir = tmp_path / "proj"
(project_dir / ".specify").mkdir(parents=True)
# First install
result1 = _install_bundled_git_extension(project_dir)
assert result1 is True
# Second install should also succeed (no-op)
result2 = _install_bundled_git_extension(project_dir)
assert result2 is True
# Only one entry in registry
manager = ExtensionManager(project_dir)
assert manager.registry.is_installed("git")

View File

@@ -747,6 +747,18 @@ $ARGUMENTS
assert output.endswith("---\n")
assert "description: Test command" in output
def test_render_frontmatter_unicode(self):
"""Test rendering frontmatter preserves non-ASCII characters."""
frontmatter = {
"description": "Prüfe Konformität der Implementierung"
}
registrar = CommandRegistrar()
output = registrar.render_frontmatter(frontmatter)
assert "Prüfe Konformität" in output
assert "\\u" not in output
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory