Compare commits

..

13 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
7396683bfb fix: detect stale registry and reinstall git extension when directory missing
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/d5cbfcf4-4651-4784-9234-c4a42a333438
2026-03-23 22:47:32 +00:00
copilot-swe-agent[bot]
a5466f08de fix: quiet git checkout in JSON mode and clarify registry check logic
1. Use `git checkout -q -b` with stdout redirect to prevent status
   messages from contaminating JSON output in create-new-feature.sh
2. Clarify specify.md registry check: only skip branching when
   extensions.git.enabled is present and explicitly false; proceed
   normally in all other cases (missing registry, missing entry, etc.)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/67a6414f-e8ce-4455-95e5-299c72d86624
2026-03-23 22:31:01 +00:00
copilot-swe-agent[bot]
363b7dcab7 fix: use unescaped $SCRIPT_DIR in bash error message for actual path
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/6bcb7cb8-c7da-49d6-8206-1187766f92e1
2026-03-23 22:15:04 +00:00
copilot-swe-agent[bot]
4f1b63a65a fix: address second round of review comments
1. Log warning on auto-install failure instead of silent catch
2. Fix README install command (--from → --dev)
3. Bash: fail fast if resolve_template/json_escape unavailable after git-common.sh fallback
4. PowerShell: fail fast if Resolve-Template unavailable after git-common.ps1 fallback
5. Fix PowerShell $env:FEATURE_DIR → $FEATURE_DIR in specify.md
6. Fix docstring to reflect already-installed return value

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/6bcb7cb8-c7da-49d6-8206-1187766f92e1
2026-03-23 22:13:37 +00:00
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 1562 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.* *This documentation should be updated whenever new agents are added to maintain accuracy and completeness.*

View File

@@ -1,11 +1,22 @@
# Changelog # Changelog
## [0.3.2] - 2026-03-19 ## [0.4.0] - 2026-03-23
### Changes ### Changes
- chore: bump version to 0.3.2 - fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936)
- Add conduct extension to community catalog (#1908) - 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(extensions): add verify-tasks extension to community catalog (#1871)
- feat(presets): add enable/disable toggle and update semantics (#1891) - feat(presets): add enable/disable toggle and update semantics (#1891)
- feat: add iFlow CLI support (#1875) - feat: add iFlow CLI support (#1875)
@@ -21,6 +32,13 @@
- Feature/spec kit add pi coding agent pullrequest (#1853) - Feature/spec kit add pi coding agent pullrequest (#1853)
- feat: register spec-kit-learn extension (#1883) - 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 ## [0.3.1] - 2026-03-17
### Changed ### 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 directory
specify extension add extensions/git --dev
# 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,394 @@
#!/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 only git-common.sh was loaded, verify that the required helpers
# (resolve_template, json_escape) are available. These are provided by the
# core common.sh; git-common.sh only supplies has_git / check_feature_branch.
if ! type resolve_template >/dev/null 2>&1 || ! type json_escape >/dev/null 2>&1; then
echo "Error: resolve_template/json_escape not defined. The core common.sh is required but could not be located." >&2
echo "Tried: $SCRIPT_DIR/common.sh, .specify/scripts/bash/common.sh, scripts/bash/common.sh" >&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 -q -b "$BRANCH_NAME" >/dev/null 2>&1; 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,368 @@
#!/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."
}
# If only git-common.ps1 was loaded, verify that Resolve-Template is available.
# Resolve-Template is provided by the core common.ps1; git-common.ps1 only
# supplies Test-HasGit / Test-FeatureBranch.
if (-not (Get-Command Resolve-Template -ErrorAction SilentlyContinue)) {
throw ("Resolve-Template not defined. The core common.ps1 is required but could not be located. " +
"Tried: $PSScriptRoot/common.ps1, .specify/scripts/powershell/common.ps1, scripts/powershell/common.ps1")
}
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] [project]
name = "specify-cli" 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)." description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -41,10 +41,9 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands" "templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash" "scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell" "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.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" ".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] [project.optional-dependencies]
test = [ 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", "args": "$ARGUMENTS",
"extension": ".md" "extension": ".md"
}, },
"cursor-agent": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qwen": { "qwen": {
"dir": ".qwen/commands", "dir": ".qwen/commands",
"format": "markdown", "format": "markdown",
@@ -213,7 +207,7 @@ class CommandRegistrar:
if not fm: if not fm:
return "" 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" return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict: 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(): if not config_path.exists():
return None return None
try: try:
data = yaml.safe_load(config_path.read_text()) or {} data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError) as e: except (yaml.YAMLError, OSError, UnicodeError) as e:
raise ValidationError( raise ValidationError(
f"Failed to read catalog config {config_path}: {e}" f"Failed to read catalog config {config_path}: {e}"
) )
@@ -1467,8 +1467,8 @@ class ConfigManager:
return {} return {}
try: try:
return yaml.safe_load(file_path.read_text()) or {} return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError): except (yaml.YAMLError, OSError, UnicodeError):
return {} return {}
def _get_extension_defaults(self) -> Dict[str, Any]: def _get_extension_defaults(self) -> Dict[str, Any]:
@@ -1659,8 +1659,8 @@ class HookExecutor:
} }
try: try:
return yaml.safe_load(self.config_file.read_text()) or {} return yaml.safe_load(self.config_file.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError): except (yaml.YAMLError, OSError, UnicodeError):
return { return {
"installed": [], "installed": [],
"settings": {"auto_execute_hooks": True}, "settings": {"auto_execute_hooks": True},
@@ -1675,7 +1675,8 @@ class HookExecutor:
""" """
self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config_file.parent.mkdir(parents=True, exist_ok=True)
self.config_file.write_text( 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): def register_hooks(self, manifest: ExtensionManifest):

View File

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

View File

@@ -73,12 +73,40 @@ Given that feature description, do this:
- "Create a dashboard for analytics" → "analytics-dashboard" - "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout" - "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 an `extensions.git` entry
- **Only skip branching** when `extensions.git.enabled` is **present and explicitly `false`**. In all other cases — including when the registry file is missing, when `extensions.git` has no entry, or when `"enabled"` is `true` or absent — proceed with normal branch creation (extension script if present, else core `{SCRIPT}` fallback)
- 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 $FEATURE_DIR -Force | Out-Null`
- `New-Item -ItemType File -Path $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 `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
- If `"sequential"` or absent, do not add any extra flag (default behavior) - 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 example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
- Bash (timestamp): `{SCRIPT} --json --timestamp --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"` - 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 re
import zipfile
import pytest import pytest
import tempfile import tempfile
import shutil import shutil
import yaml import yaml
import typer
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -720,8 +722,8 @@ class TestNewProjectCommandSkip:
mock_skills.assert_not_called() mock_skills.assert_not_called()
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists() assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_codex_native_skills_missing_fails_clearly(self, tmp_path): def test_codex_native_skills_missing_falls_back_then_fails_cleanly(self, tmp_path):
"""Codex native skills init should fail if bundled skills are missing.""" """Codex should attempt fallback conversion when bundled skills are missing."""
from typer.testing import CliRunner from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
@@ -730,7 +732,7 @@ class TestNewProjectCommandSkip:
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \ with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \ 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.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke( result = runner.invoke(
@@ -739,11 +741,13 @@ class TestNewProjectCommandSkip:
) )
assert result.exit_code == 1 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 "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): 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 from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
@@ -757,7 +761,7 @@ class TestNewProjectCommandSkip:
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \ with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \ patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \ 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.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"): patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke( result = runner.invoke(
@@ -765,9 +769,100 @@ class TestNewProjectCommandSkip:
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"], ["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
) )
assert result.exit_code == 1 assert result.exit_code == 0
mock_skills.assert_not_called() mock_skills.assert_called_once()
assert "Expected bundled agent skills" in result.output 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): def test_commands_preserved_when_skills_fail(self, tmp_path):
"""If skills fail, commands should NOT be removed (safety net).""" """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) # All 4 templates should produce skills (specify, plan, tasks, empty_fm)
assert len(skill_dirs) == 4 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 ===== # ===== SKILL_DESCRIPTIONS Coverage Tests =====

View File

@@ -87,3 +87,84 @@ class TestBranchNumberingValidation:
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"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "") 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")
def test_git_extension_reinstalls_when_directory_missing(self, tmp_path: Path):
"""_install_bundled_git_extension should reinstall if registry says installed but directory is gone."""
import shutil
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
ext_dir = project_dir / ".specify" / "extensions" / "git"
assert ext_dir.is_dir()
# Simulate stale registry: delete extension directory but keep registry
shutil.rmtree(ext_dir)
assert not ext_dir.exists()
# Registry still says installed
manager = ExtensionManager(project_dir)
assert manager.registry.is_installed("git")
# Re-install should detect missing directory and reinstall
result2 = _install_bundled_git_extension(project_dir)
assert result2 is True
assert ext_dir.is_dir(), "extension directory should be reinstalled"
assert (ext_dir / "extension.yml").is_file(), "extension.yml should be reinstalled"

View File

@@ -747,6 +747,18 @@ $ARGUMENTS
assert output.endswith("---\n") assert output.endswith("---\n")
assert "description: Test command" in output 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): def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent.""" """Test registering commands for Claude agent."""
# Create .claude directory # Create .claude directory