Compare commits

..

24 Commits

Author SHA1 Message Date
Manfred Riem
b59b82813c fix: only delete manifest when full tracked set was processed
When remove_tracked_files is called with an explicit files dict (subset),
skip manifest deletion to avoid losing tracking of entries not in the
subset. Manifest cleanup only runs when the full set is read from the
manifest itself (files=None).
2026-03-23 15:15:28 -05:00
Manfred Riem
38ae759568 fix: stale manifest cleanup, resolve with project_path, AGENTS.md add docs
- remove_tracked_files: count only still-existing files as remaining;
  user-deleted files no longer prevent manifest cleanup
- init --agent: pass project_path to resolve_agent_pack so project-level
  overrides (.specify/agents/) are honored during --here init
- AGENTS.md: update agent add to show --from <path> requirement and note
  catalog fetch is not yet implemented
2026-03-23 12:44:25 -05:00
Manfred Riem
ca9c73da0f docs: explain extension file overlap in finalize_setup scan comment 2026-03-23 12:18:57 -05:00
Manfred Riem
433502b72d fix: force flag passthrough, cross-platform hashes, manifest retention, docstring accuracy
- agent_switch: pass force=force (user's actual flag) instead of
  force=True so hash-check protection is preserved for unconfirmed files
- _hash_file_list: use as_posix() for POSIX-stable manifest keys;
  guard relative_to with try/except to skip files outside project root
- remove_tracked_files: updated docstring to accurately describe hash
  comparison behavior (values ARE used, not ignored); manifest is only
  deleted when all tracked files were removed (preserves tracking of
  skipped modified files)
2026-03-23 12:17:19 -05:00
Manfred Riem
720ac509d2 fix: path traversal guard, rollback extension re-registration, lifecycle docs
- remove_tracked_files: validate resolved path stays within project_path
  before unlinking; reject entries with '../' that escape the project root
- Rollback: call _reregister_extension_commands() during rollback (same
  as success path) so extension files are properly restored
- AgentBootstrap: comprehensive lifecycle flow docstring documenting the
  setup → finalize_setup → get_tracked_files → check_modified → teardown
  chain and explaining why tracking all files is safe (hash check)
2026-03-23 11:56:50 -05:00
Manfred Riem
48392ea865 fix: hash-check before deletion, track all files, fix overrides bug, update help text
- remove_tracked_files: always compare SHA-256 hash before deleting,
  even when called with explicit files dict; skip modified files unless
  --force is set (was unconditionally deleting all tracked files)
- finalize_setup: track ALL files from setup() (no agent-root filter);
  safe because removal now checks hashes
- list_all_agents: track embedded versions in separate dict so overrides
  always reference the correct embedded version, not a catalog/project
  pack that overwrote the seen dict
- --ai-skills help text: updated to say 'requires --ai or --agent'
2026-03-23 11:24:53 -05:00
Manfred Riem
34fa61e1cc docs: clarify that tracking all setup files is intentional (safe due to hash check) 2026-03-23 11:00:42 -05:00
Manfred Riem
790448294e fix: address PR review round 2 — legacy rmtree confirmation, agent_pack flag, registrar alias, manifest ID validation
- Legacy rmtree: prompt user before deleting agent directory in legacy
  fallback path (both no-manifest and AgentPackError cases), respects --force
- Set options['agent_pack'] = True during agent_switch so projects
  originally created with --ai reflect pack-based management after switch
- Add cursor-agent alias in CommandRegistrar.AGENT_CONFIGS so extension
  re-registration works when switching to/from cursor-agent
- Validate manifest.id matches agent_id in resolve_agent_pack() to
  prevent malicious override packs from injecting different IDs
2026-03-23 10:58:58 -05:00
Manfred Riem
b94e541234 fix: address PR review — legacy teardown, generic agent, ~/.specify paths, 3-segment commands_dir, full file tracking
- Legacy --ai teardown: detect empty tracked files and fall back to
  AGENT_CONFIG-based directory removal during agent switch
- --agent generic: falls through to legacy flow (no embedded pack)
- User/catalog dirs: use ~/.specify/ instead of platformdirs for
  consistency with extensions/presets
- DefaultBootstrap: join all path segments after first for COMMANDS_SUBDIR
  (fixes 3+-segment commands_dir like .tabnine/agent/commands)
- agent_add --from: validate manifest.id matches provided agent_id
- finalize_setup: track all files from setup(), not just agent-root files
- setup() docstring: reference --agent not --ai
- AGENTS.md: document generic agent fallback behavior
2026-03-23 10:28:15 -05:00
copilot-swe-agent[bot]
ab8c58ff23 fix: improve test match specificity and rollback error message per code review
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3
2026-03-23 14:35:15 +00:00
copilot-swe-agent[bot]
00117c5074 feat: address all 10 code quality issues — ID validation, rollback, DefaultBootstrap, logging, CLI fixes, docs
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/40d5aec5-d8e9-4e3f-ae60-6cf67ff491f3
2026-03-23 14:32:46 +00:00
copilot-swe-agent[bot]
795f1e7703 fix: add explanatory comments to all empty except clauses (code quality)
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bb495c08-5d15-410f-9ba9-89d3fc413904
2026-03-23 14:01:21 +00:00
copilot-swe-agent[bot]
55bcbd3977 fix: resolve all ruff check failures (F541 f-string placeholders, F401 unused imports, F841 unused variable)
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/e19bd25e-f084-4f38-85b6-8105cbb50494
2026-03-23 13:36:36 +00:00
copilot-swe-agent[bot]
978addc390 refactor: simplify finalize_setup scan to agent_root only, improve comments
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/054690bb-c048-41e0-b553-377d5cb36b78
2026-03-20 22:32:36 +00:00
copilot-swe-agent[bot]
9b580a536b feat: setup() owns scaffolding and returns actual installed files
- AgentBootstrap._scaffold_project() calls scaffold_from_core_pack,
  snapshots before/after, returns all new files
- finalize_setup() filters agent_files to only track files under the
  agent's own directory tree (shared .specify/ files not tracked)
- All 25 bootstrap setup() methods call _scaffold_project() and return
  the actual file list instead of []
- --agent init flow routes through setup() for scaffolding instead of
  calling scaffold_from_core_pack directly
- 100 new tests (TestSetupReturnsFiles): verify every agent's setup()
  returns non-empty, existing, absolute paths including agent-dir files
- Parity tests use CliRunner to invoke the real init command
- finalize_setup bug fix: skills-migrated agents (agy) now have their
  skills directory scanned correctly
- 1262 tests pass (452 in test_agent_pack.py alone)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/054690bb-c048-41e0-b553-377d5cb36b78
2026-03-20 22:29:33 +00:00
copilot-swe-agent[bot]
d6016ab9db style: simplify --agent help text, normalize comment spelling
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/930d8c4d-ce42-41fb-a40f-561fb1468e81
2026-03-20 21:54:56 +00:00
copilot-swe-agent[bot]
c2227a7ffd feat: add --agent flag to init for pack-based flow with file tracking
- `specify init --agent claude` resolves through the pack system and
  records all installed files in .specify/agent-manifest-<id>.json via
  finalize_setup() after the init pipeline finishes
- --agent and --ai are mutually exclusive; --agent additionally enables
  tracked teardown/switch
- init-options.json gains "agent_pack" key when --agent is used
- 4 new parity tests verify: pack resolution matches AGENT_CONFIG,
  commands_dir parity, finalize_setup records pipeline-created files,
  pack metadata matches CommandRegistrar configuration

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/930d8c4d-ce42-41fb-a40f-561fb1468e81
2026-03-20 21:53:03 +00:00
copilot-swe-agent[bot]
c3efd1fb71 style: fix f-string formatting in _reregister_extension_commands
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56
2026-03-20 21:37:27 +00:00
copilot-swe-agent[bot]
e190116d13 refactor: setup reports files, CLI checks modifications before teardown, categorised manifest
- setup() returns List[Path] of installed files so CLI can record them
- finalize_setup() accepts agent_files + extension_files for combined tracking
- Install manifest categorises files: agent_files and extension_files
- get_tracked_files() returns (agent_files, extension_files) split
- remove_tracked_files() accepts explicit files dict for CLI-driven teardown
- agent_switch checks for modifications BEFORE teardown and prompts user
- _reregister_extension_commands() returns List[Path] of created files
- teardown() accepts files parameter to receive explicit file lists
- All 25 bootstraps updated with new signatures
- 5 new tests: categorised manifest, get_tracked_files, explicit file teardown,
  extension file modification detection

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/32e470fc-6bf5-453c-bf6c-79a8521efa56
2026-03-20 21:34:59 +00:00
copilot-swe-agent[bot]
a63c248c80 Move file recording to finalize_setup() — called after init pipeline writes files
Address code review: setup() now only creates directories, while
finalize_setup() (on base class) scans the agent's commands_dir
for all files and records them. This ensures files are tracked
after the full init pipeline has written them, not before.

- Add AgentBootstrap.finalize_setup() that scans commands_dir
- Remove premature record_installed_files() from all 25 setup() methods
- agent_switch calls finalize_setup() after setup() completes
- Update test helper to match new pattern

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a
2026-03-20 21:20:22 +00:00
copilot-swe-agent[bot]
b5a5e3fc35 Add installed-file tracking with SHA-256 hashes for safe agent teardown
Setup records installed files and their SHA-256 hashes in
.specify/agent-manifest-<agent_id>.json. Teardown uses the manifest
to remove only individual files (never directories). If any tracked
file was modified since installation, teardown requires --force.

- Add record_installed_files(), check_modified_files(), remove_tracked_files()
  and AgentFileModifiedError to agent_pack.py
- Update all 25 bootstrap modules to use file-tracked setup/teardown
- Add --force flag to 'specify agent switch'
- Add 11 new tests for file tracking (record, check, remove, force,
  directory preservation, deleted-file handling, manifest structure)

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/779eabf6-21d5-428b-9f01-dd363df4c84a
2026-03-20 21:15:48 +00:00
copilot-swe-agent[bot]
ec5471af61 Fix code review issues: safe teardown for shared dirs, less brittle test assertions
- Copilot: only remove .github/agents/ (preserves workflows, templates)
- Tabnine: only remove .tabnine/agent/ (preserves other config)
- Amp/Codex: only remove respective subdirs (commands/skills)
  to avoid deleting each other's files in shared .agents/ dir
- Tests: use flexible assertions instead of hardcoded >= 25 counts

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b
2026-03-20 21:03:34 +00:00
copilot-swe-agent[bot]
3212309e7c Add agent pack infrastructure with embedded packs, manifest validation, resolution, and CLI commands
- Create src/specify_cli/agent_pack.py with AgentBootstrap base class,
  AgentManifest schema/validation, pack resolution (user > project > catalog > embedded)
- Generate all 25 official agent packs under src/specify_cli/core_pack/agents/
  with speckit-agent.yml manifests and bootstrap.py modules
- Add 'specify agent' CLI subcommands: list, info, validate, export,
  switch, search, add, remove
- Update pyproject.toml to bundle agent packs in the wheel
- Add comprehensive tests (39 tests): manifest validation, bootstrap API,
  resolution order, discovery, consistency with AGENT_CONFIG

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ef8b4682-7f1a-4b04-a112-df0878236b6b
2026-03-20 21:01:16 +00:00
copilot-swe-agent[bot]
8b20d0b336 Initial plan 2026-03-20 20:46:50 +00:00
48 changed files with 3756 additions and 1562 deletions

View File

@@ -427,4 +427,56 @@ 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,22 +1,11 @@
# Changelog # Changelog
## [0.4.0] - 2026-03-23 ## [0.3.2] - 2026-03-19
### Changes ### Changes
- fix(cli): add allow_unicode=True and encoding="utf-8" to YAML I/O (#1936) - chore: bump version to 0.3.2
- fix(codex): native skills fallback refresh + legacy prompt suppression (#1930) - Add conduct extension to community catalog (#1908)
- 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)
@@ -32,13 +21,6 @@
- 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

View File

@@ -1,71 +0,0 @@
# 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

@@ -1,66 +0,0 @@
---
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

@@ -1,45 +0,0 @@
---
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

@@ -1,49 +0,0 @@
---
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

@@ -1,5 +0,0 @@
# 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

@@ -1,47 +0,0 @@
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

@@ -1,394 +0,0 @@
#!/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

@@ -1,30 +0,0 @@
#!/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

@@ -1,368 +0,0 @@
#!/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

@@ -1,33 +0,0 @@
#!/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.4.0" version = "0.3.2"
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,9 +41,10 @@ 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,6 +47,12 @@ 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",
@@ -207,7 +213,7 @@ class CommandRegistrar:
if not fm: if not fm:
return "" return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True) yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,23 @@
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(encoding="utf-8")) or {} data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e: except (yaml.YAMLError, OSError) 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(encoding="utf-8")) or {} return yaml.safe_load(file_path.read_text()) or {}
except (yaml.YAMLError, OSError, UnicodeError): except (yaml.YAMLError, OSError):
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(encoding="utf-8")) or {} return yaml.safe_load(self.config_file.read_text()) or {}
except (yaml.YAMLError, OSError, UnicodeError): except (yaml.YAMLError, OSError):
return { return {
"installed": [], "installed": [],
"settings": {"auto_execute_hooks": True}, "settings": {"auto_execute_hooks": True},
@@ -1675,8 +1675,7 @@ 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, allow_unicode=True), yaml.dump(config, default_flow_style=False, sort_keys=False)
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(encoding="utf-8")) or {} data = yaml.safe_load(config_path.read_text()) or {}
except (yaml.YAMLError, OSError, UnicodeError) as e: except (yaml.YAMLError, OSError) 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,40 +73,12 @@ 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** (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: 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:
**Git extension check**: Before running the branch creation script, check if the git extension is enabled: **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
- 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"`

1353
tests/test_agent_pack.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,12 +11,10 @@ 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
@@ -722,8 +720,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_falls_back_then_fails_cleanly(self, tmp_path): def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
"""Codex should attempt fallback conversion when bundled skills are missing.""" """Codex native skills init should fail if bundled skills are missing."""
from typer.testing import CliRunner from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
@@ -732,7 +730,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", return_value=False) as mock_skills, \ patch("specify_cli.install_ai_skills") 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(
@@ -741,13 +739,11 @@ class TestNewProjectCommandSkip:
) )
assert result.exit_code == 1 assert result.exit_code == 1
mock_skills.assert_called_once() mock_skills.assert_not_called()
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 trigger fallback conversion, not hard-fail.""" """Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation."""
from typer.testing import CliRunner from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
@@ -761,7 +757,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", return_value=True) as mock_skills, \ patch("specify_cli.install_ai_skills") 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(
@@ -769,100 +765,9 @@ 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 == 0 assert result.exit_code == 1
mock_skills.assert_called_once() mock_skills.assert_not_called()
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True assert "Expected bundled agent skills" in result.output
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)."""
@@ -954,21 +859,6 @@ 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,84 +87,3 @@ 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,18 +747,6 @@ $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