Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
38b800cde3 chore: bump version to 0.10.0 2026-06-09 11:19:45 +00:00
Copilot
927f54feea feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0

- Remove --no-git parameter from specify init command
- Remove git extension auto-installation from init flow
- Git repository initialization (git init) still runs when git is available
- Remove --no-git from all test invocations across the test suite
- Update docs to reflect opt-in git extension behavior
- Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests

BREAKING CHANGE: specify init no longer auto-installs the git extension.
Use `specify extension add git` to install it explicitly.
The --no-git flag has been removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(scripts): remove git operations from core scripts

Git functionality is now entirely managed by the git extension.
Core scripts only handle directory-based feature creation and numbering.

- Remove has_git(), check_feature_branch(), git branch creation from core
- Simplify number detection to use only spec directory scanning
- Remove HAS_GIT output from get_feature_paths()
- Remove git remote fetching and branch querying
- Keep BRANCH_NAME output key for backward compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: remove all git operations from core

- Remove is_git_repo() and init_git_repo() dead code from _utils.py
- Remove --branch-numbering from init command
- Remove git from 'specify check' (now extension-only)
- Update docs: git is optional prerequisite, check command description
- Fix tests to reflect no-git-in-core reality (fallback to main)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(scripts): remove directory scanning and branch fallback from core

Core scripts now resolve feature context exclusively from:
1. SPECIFY_FEATURE env var (set by git extension)
2. .specify/feature.json (persisted by specify command)

Removed find_feature_dir_by_prefix() and directory scanning heuristics —
these are the git extension's responsibility. Scripts error clearly when
no feature context is available.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: introduce feature_numbering, deprecate branch_numbering in init-options

- specify command template now reads feature_numbering (preferred) with
  fallback to branch_numbering (deprecated) from init-options.json
- Git extension reads git-config.yml > feature_numbering > branch_numbering
- init now writes feature_numbering: sequential to init-options.json
- Deprecation warning emitted when branch_numbering is used as fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove trailing whitespace in common.ps1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json

When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the
value to .specify/feature.json so future sessions without the env var can
still resolve the feature directory. The write is idempotent — it skips
when the file already contains the same value.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address review feedback — error messages and docs

- Update error messages in common.sh and common.ps1 to reference
  SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer
  resolves feature directories)
- Fix get_current_branch comment (returns empty string, not error)
- Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct
  example paths
- Update local-development.md troubleshooting: replace stale 'Git step
  skipped' row with actionable git extension guidance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(scripts): harden feature.json persistence

- Use json_escape in printf fallback when jq is unavailable (common.sh)
- Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell
  5.1 compatibility (common.ps1)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(scripts): remove dead feature_json_matches_feature_dir functions

These guards are no longer needed since the branch-name validation they
protected against has been removed from check-prerequisites.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(git-ext): rename create-new-feature to create-new-feature-branch

The git extension's script only creates the git branch — rename it to
reflect that responsibility. The core create-new-feature.sh/.ps1 handles
feature directory creation and feature.json persistence.

Also includes fixes from review feedback:
- common.sh: _persist_feature_json uses json_escape fallback
- common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat
- common.ps1: case-sensitive path stripping on non-Windows
- create-new-feature.sh/ps1: output both SPECIFY_FEATURE and
  SPECIFY_FEATURE_DIRECTORY
- setup-tasks.sh: fix stale 'Validate branch' comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tests): update references to renamed git extension scripts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tests): remove duplicate EXT_CREATE_FEATURE assignments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-09 06:13:07 -05:00
adaumann
90832d19bf [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
* Update preset-fiction-book-writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.5.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections

* Add fiction-book-writing preset to community catalog

- Preset ID: fiction-book-writing
- Version: 1.6.0
- Author: Andreas Daumann
- Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes

* Update presets/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixed update_at for fiction-book-writing preset

* Update README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fixed description for fiction-book-writing

* Update Fiction Book Writing to community catalog

- Preset ID: fiction-book-writing
- Version: 1.9.0
- Author: Andreas Daumann
- Description: Update added illustration support

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-08 15:45:01 -05:00
WOLIKIMCHENG
d8a81b23b5 test(workflows): cover executable override fallback preflight (#2843) 2026-06-08 15:33:37 -05:00
Gary
a0305fc511 Add GitHub Copilot CLI guidance to readme (#2891)
* Update README with GitHub Copilot CLI details

Added mention of GitHub Copilot CLI for agent selection based on docs at https://docs.github.com/en/copilot/how-tos/copilot-cli/use-copilot-cli/invoke-custom-agents#use-custom-agents

* Fix typo in README regarding GitHub Copilot CLI
2026-06-08 14:17:15 -05:00
Manfred Riem
d977feea01 Update Security Review extension to v1.5.3 (#2898)
* Update Security Review extension to v1.5.3

Update security-review extension submitted by @DyanGalih:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #2869

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix author field to match extension.yml manifest

Update security-review author from 'DyanGalih' to 'Spec-Kit Security Team'
to match the extension's extension.yml declaration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 10:20:20 -05:00
Manfred Riem
c53a08802c Update Architecture Guard extension to v1.8.17 (#2897)
Update architecture-guard extension submitted by @DyanGalih:
- extensions/catalog.community.json (version, download_url, documentation, updated_at)

Closes #2868

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-08 10:06:44 -05:00
Seiya Kojima
4ec4635dd1 feat(extensions): per-event hook lists with priority ordering (#2798)
* feat(extensions): per-event hook lists with priority ordering

The manifest validator restricted each hook event to a single mapping,
even though HookExecutor stores entries as a list per event. This blocked
an extension from running multiple commands on one event (e.g. a
verification step plus a doc-generation step after speckit.plan), and
get_hooks_for_event returned entries in raw insertion order with no way
to influence execution order across or within extensions.

This change:

1. Validator: accept hooks.<event> as either a single mapping or a list
   of mappings. Each entry is validated individually and may carry an
   optional integer `priority` (>= 1, default 10; bool rejected).
2. Command-ref normalization: apply rename / alias->canonical rewriting
   to every entry in the list, not just the head.
3. register_hooks: expand list entries, persist `priority`, and
   purge-and-replace all entries owned by the extension on each event so a
   reinstall whose shape changed (single<->list, or a shorter list) leaves
   no orphaned entries behind.
4. get_hooks_for_event: sort enabled entries by `priority` ascending with
   a stable sort (ties keep insertion order). The existing
   normalize_priority helper is reused as the sort key so corrupted
   on-disk values fall back to the default instead of raising.

Backward compatible: existing single-mapping manifests parse and register
unchanged with priority defaulting to 10. The extension-level `priority`
used by preset/template resolution is independent of the new hook-entry
`priority`.

Implements #2378

* fix(extensions): harden register_hooks per PR review

- Skip non-dict hook entries before .get() so a manifest that bypasses
  validation can't crash register_hooks with AttributeError.
- Normalize `priority` on save via normalize_priority so the on-disk
  config stays clean, mirroring the read-side defense in
  get_hooks_for_event.
- Tests: cover the non-dict-entry skip and add encoding="utf-8" to the
  new tests' manifest writes.

* fix(extensions): purge dropped-event hook orphans on reinstall

register_hooks only purged events the new manifest still declared, so an
extension that dropped an event on reinstall left stale entries for it in
the project config. Purge this extension's entries from undeclared events
(and prune emptied events) before registering; scoped to this extension,
and a no-op for the install/update flow where unregister_hooks runs first.

* fix(extensions): reject boolean priority and complete orphan purge

- normalize_priority falls back to default for bool values
- dedup deletes duplicate commands before re-insert for last-wins ties
- register_hooks purges orphans even when all hooks are dropped

* docs(extensions): document per-event hook lists and priority

- EXTENSION-API-REFERENCE: hook event accepts a mapping or list; add
  priority field reference and last-wins dedup note
- EXTENSION-DEVELOPMENT-GUIDE: add list-form example with priority

* docs(extensions): show both single and list hook forms in schema snippet

* docs(extensions): reference DEFAULT_HOOK_PRIORITY in normalize_priority

normalize_priority hard-coded the default as the literal 10 in both its
signature and docstring, duplicating DEFAULT_HOOK_PRIORITY. Reference the
constant in the signature and drop the literal from the docstring so the
default has a single source of truth.
2026-06-08 08:03:46 -05:00
Copilot
7106858c4e feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
* Initial plan

* feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags at 0.10.0

* refactor(tests): rename stale test_ai_help_* methods to test_agent_config_*

* fix: address review — derive agent folder for generic integration and remove redundant test

- Security notice now falls back to integration_parsed_options['commands_dir']
  when AGENT_CONFIG folder is None (generic integration).
- Remove test_agent_config_includes_kiro_cli which duplicates the assertion
  in test_runtime_config_uses_kiro_cli_and_removes_q.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: scrub all remaining --ai flag references from source and tests

- Remove dead AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, and
  _build_ai_assistant_help() from _agent_config.py
- Update comments/docstrings in extensions.py, presets.py, and
  integration subpackages to reference 'skills mode' or
  '--integration' instead of the removed flags
- Fix catalog.json generic integration description
- Update test docstrings/comments in test_extension_skills.py,
  test_extensions.py, and test_presets.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: remove legacy --ai flag rejection tests

The flags are fully removed from the CLI; typer handles unknown options
generically. No custom rejection logic exists to test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* revert: remove manual CHANGELOG.md entry

CHANGELOG is generated automatically; manual edits should not be made.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: make generic catalog description self-explanatory

Include the required --commands-dir sub-option in the description so
readers don't need to look up integration docs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tests): rename duplicate test classes to avoid shadowing

The rename from Test*AutoPromote to Test*Integration collided with the
existing Test*Integration(SkillsIntegrationTests) base classes, causing
the shared test suites to be silently overwritten. Rename the CLI init
flow classes to Test*InitFlow instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 14:56:28 -05:00
Manfred Riem
072b32cba0 chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
* chore: bump version to 0.9.5

* chore: begin 0.9.6.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 12:57:32 -05:00
71 changed files with 1515 additions and 2039 deletions

View File

@@ -2,6 +2,34 @@
<!-- insert new changelog below this comment --> <!-- insert new changelog below this comment -->
## [0.10.0] - 2026-06-09
### Changed
- feat: make git extension opt-in and remove --no-git at v0.10.0 (#2873)
- [Preset] UpdateFiction book writing v1.9.0 - Illustration support (#2821)
- test(workflows): cover executable override fallback preflight (#2843)
- Add GitHub Copilot CLI guidance to readme (#2891)
- Update Security Review extension to v1.5.3 (#2898)
- Update Architecture Guard extension to v1.8.17 (#2897)
- feat(extensions): per-event hook lists with priority ordering (#2798)
- feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags (0.10.0) (#2872)
- chore: release 0.9.5, begin 0.9.6.dev0 development (#2875)
## [0.9.5] - 2026-06-05
### Changed
- feat(extensions): add bundled bug triage workflow extension (#2871)
- fix: resolve GitHub release asset API URL for private repo preset and workflow downloads (#2855)
- chore(deps): bump github/gh-aw-actions from 0.77.0 to 0.78.1 (#2860)
- chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 (#2859)
- chore(deps): bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#2858)
- chore(deps): bump github/codeql-action from 4.36.0 to 4.36.2 (#2857)
- fix(workflows): render gate show_file contents in the interactive prompt (#2810)
- feat: add support for rovodev (#2539)
- chore: release 0.9.4, begin 0.9.5.dev0 development (#2853)
## [0.9.4] - 2026-06-04 ## [0.9.4] - 2026-06-04
### Changed ### Changed

View File

@@ -79,7 +79,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
### 3. Establish project principles ### 3. Establish project principles
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.

View File

@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) | | Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |

View File

@@ -6,7 +6,7 @@
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation - [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/) - [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
## Installation ## Installation

View File

@@ -162,7 +162,7 @@ rm -rf .venv dist build *.egg-info
|---------|-----| |---------|-----|
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` | | `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git step skipped | You passed `--no-git` or Git not installed | | Git commands unavailable | Install the git extension with `specify extension add git` |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | | TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |

View File

@@ -15,16 +15,13 @@ specify init [<project_name>]
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--here` | Initialize in the current directory instead of creating a new one | | `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory | | `--force` | Force merge/overwrite when initializing in an existing directory |
| `--no-git` | Skip git repository initialization |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | | `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
| `--preset <id>` | Install a preset during initialization | | `--preset <id>` | Install a preset during initialization |
| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` |
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
> [!NOTE] > [!NOTE]
> The git extension is currently enabled by default during `specify init`. > Git repository initialization and branching are managed by the **git extension**, which is not installed by default. Run `specify extension add git` after init to enable git workflows.
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
@@ -45,14 +42,8 @@ specify init --here --force --integration copilot
# Use PowerShell scripts (Windows/cross-platform) # Use PowerShell scripts (Windows/cross-platform)
specify init my-project --integration copilot --script ps specify init my-project --integration copilot --script ps
# Skip git initialization
specify init my-project --integration copilot --no-git
# Install a preset during initialization # Install a preset during initialization
specify init my-project --integration copilot --preset compliance specify init my-project --integration copilot --preset compliance
# Use timestamp-based branch numbering (useful for distributed teams)
specify init my-project --integration copilot --branch-numbering timestamp
``` ```
### Environment Variables ### Environment Variables
@@ -67,7 +58,7 @@ specify init my-project --integration copilot --branch-numbering timestamp
specify check specify check
``` ```
Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool. Checks that CLI-based AI coding agents are available on your system. IDE-based agents are skipped since they don't require a CLI tool.
This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release. This command stays offline. If a command behaves like an older Spec Kit version or an expected CLI feature is missing, run `specify self check` to check whether your local CLI is behind the latest release.

View File

@@ -257,70 +257,38 @@ rm speckit.old-command-name.md
# Restart your IDE # Restart your IDE
``` ```
### Scenario 4: "I'm working on a project without Git" ### Scenario 4: "I don't want the git extension"
If you initialized your project with `--no-git`, you can still upgrade: The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
```bash ```bash
# Manually back up files you customized # Manually back up files you customized
cp .specify/memory/constitution.md /tmp/constitution-backup.md cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
# Run upgrade # Run upgrade
specify init --here --force --integration copilot --no-git specify init --here --force --integration copilot
# Restore customizations # Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
``` ```
The `--no-git` flag skips git initialization but doesn't affect file updates. If you later decide you want the git extension's commands and hooks, install it explicitly:
---
## Using `--no-git` Flag
The `--no-git` flag tells Spec Kit to **skip git repository initialization**. This is useful when:
- You manage version control differently (Mercurial, SVN, etc.)
- Your project is part of a larger monorepo with existing git setup
- You're experimenting and don't want version control yet
**During initial setup:**
```bash ```bash
specify init my-project --integration copilot --no-git specify extension add git
``` ```
**During upgrade:** Projects that do not use Git can still work with Spec Kit by setting `SPECIFY_FEATURE_DIRECTORY` to the feature directory path before planning commands:
```bash
specify init --here --force --integration copilot --no-git
```
### What `--no-git` does NOT do
❌ Does NOT prevent file updates
❌ Does NOT skip slash command installation
❌ Does NOT affect template merging
It **only** skips running `git init` and creating the initial commit.
### Working without Git
If you use `--no-git`, you'll need to manage feature directories manually:
**Set the `SPECIFY_FEATURE` environment variable** before using planning commands:
```bash ```bash
# Bash/Zsh # Bash/Zsh
export SPECIFY_FEATURE="001-my-feature" export SPECIFY_FEATURE_DIRECTORY="specs/001-my-feature"
# PowerShell # PowerShell
$env:SPECIFY_FEATURE = "001-my-feature" $env:SPECIFY_FEATURE_DIRECTORY = "specs/001-my-feature"
``` ```
This tells Spec Kit which feature directory to use when creating specs, plans, and tasks. Alternatively, run the `/speckit.specify` command which creates `.specify/feature.json` automatically.
**Why this matters:** Without git, Spec Kit can't detect your current branch name to determine the active feature. The environment variable provides that context manually.
--- ---

View File

@@ -52,13 +52,19 @@ provides:
description: string description: string
required: boolean # Default: false required: boolean # Default: false
hooks: # Optional, event hooks hooks: # Optional, event hooks. Each event accepts either form below.
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement" event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
command: string # Command to execute command: string # Command to execute
priority: integer # Optional, >= 1, default 10 (lower runs first)
optional: boolean # Default: true optional: boolean # Default: true
prompt: string # Prompt text for optional hooks prompt: string # Prompt text for optional hooks
description: string # Hook description description: string # Hook description
condition: string # Optional, condition expression condition: string # Optional, condition expression
another_event: # Any event may instead use a list of mappings (multiple commands)
- command: string # Same fields as the single mapping, per entry
priority: integer
- command: string
priority: integer
tags: # Optional, array of tags (2-10 recommended) tags: # Optional, array of tags (2-10 recommended)
- string - string
@@ -109,8 +115,10 @@ defaults: # Optional, default configuration values
- **Type**: object - **Type**: object
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`) - **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
- **Value**: A single hook mapping, or a list of hook mappings to register multiple commands on one event
- **Description**: Hooks that execute at lifecycle events - **Description**: Hooks that execute at lifecycle events
- **Events**: Defined by core spec-kit commands - **Events**: Defined by core spec-kit commands
- **Ordering**: Within an event, hooks run by ascending `priority` (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order via a stable sort)
--- ---
@@ -535,7 +543,9 @@ Examples:
### Hook Definition ### Hook Definition
**In extension.yml**: Each event accepts either a single hook mapping or a list of mappings. A list registers multiple commands on the same event.
**Single mapping (in extension.yml)**:
```yaml ```yaml
hooks: hooks:
@@ -547,6 +557,24 @@ hooks:
condition: null condition: null
``` ```
**List of mappings with priority**:
```yaml
hooks:
after_plan:
- command: "speckit.my-ext.verify"
priority: 5
optional: false
description: "Verify the plan"
- command: "speckit.my-ext.report"
priority: 10
optional: true
prompt: "Generate the report?"
description: "Generate a report from the plan"
```
Within a single manifest list, a repeated `command` is deduped as "last wins" and moved to the end, so it also breaks equal-priority ties in authoring order.
### Hook Events ### Hook Events
Standard events (defined by core): Standard events (defined by core):

View File

@@ -206,9 +206,12 @@ Available hook points:
- `before_constitution` / `after_constitution`: Before/after constitution update - `before_constitution` / `after_constitution`: Before/after constitution update
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion - `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
Each event accepts a single hook object or a list of hook objects (multiple commands on one event).
Hook object: Hook object:
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command) - `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
- `priority`: Run order within the event (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order)
- `optional`: If true, prompt user before executing - `optional`: If true, prompt user before executing
- `prompt`: Prompt text for optional hooks - `prompt`: Prompt text for optional hooks
- `description`: Hook description - `description`: Hook description
@@ -655,6 +658,23 @@ hooks:
description: "Analyze tasks after generation" description: "Analyze tasks after generation"
``` ```
Multiple commands on one event, ordered by `priority` (lower runs first):
```yaml
# extension.yml
hooks:
after_plan:
- command: "speckit.my-ext.verify"
priority: 5
optional: false
description: "Verify the plan"
- command: "speckit.my-ext.report"
priority: 10
optional: true
prompt: "Generate the report?"
description: "Generate a report from the plan"
```
--- ---
## Troubleshooting ## Troubleshooting

View File

@@ -1,6 +1,6 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-06-04T00:00:00Z", "updated_at": "2026-06-08T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": { "extensions": {
"aide": { "aide": {
@@ -242,11 +242,11 @@
"id": "architecture-guard", "id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.", "description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"author": "DyanGalih", "author": "DyanGalih",
"version": "1.8.9", "version": "1.8.17",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip", "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
"changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases", "changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases",
"license": "MIT", "license": "MIT",
"requires": { "requires": {
@@ -269,7 +269,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-05-05T07:26:00Z", "created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-05-27T00:00:00Z" "updated_at": "2026-06-08T00:00:00Z"
}, },
"archive": { "archive": {
"name": "Archive Extension", "name": "Archive Extension",
@@ -2554,9 +2554,9 @@
"name": "Security Review", "name": "Security Review",
"id": "security-review", "id": "security-review",
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
"author": "DyanGalih", "author": "Spec-Kit Security Team",
"version": "1.5.0", "version": "1.5.3",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip", "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip",
"repository": "https://github.com/DyanGalih/spec-kit-security-review", "repository": "https://github.com/DyanGalih/spec-kit-security-review",
"homepage": "https://github.com/DyanGalih/spec-kit-security-review", "homepage": "https://github.com/DyanGalih/spec-kit-security-review",
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
@@ -2580,7 +2580,7 @@
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-04-03T03:24:03Z", "created_at": "2026-04-03T03:24:03Z",
"updated_at": "2026-05-11T14:58:00Z" "updated_at": "2026-06-08T00:00:00Z"
}, },
"sf": { "sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development", "name": "SFSpeckit — Salesforce Spec-Driven Development",

View File

@@ -94,7 +94,7 @@ When Git is not installed or the directory is not a Git repository:
The extension bundles cross-platform scripts: The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation - `scripts/bash/create-new-feature-branch.sh` — Bash implementation (branch creation only)
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash) - `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation - `scripts/powershell/create-new-feature-branch.ps1` — PowerShell implementation (branch creation only)
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell) - `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View File

@@ -31,8 +31,9 @@ If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variabl
Determine the branch numbering strategy by checking configuration in this order: Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value 1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility) 2. Check `.specify/init-options.json` for `feature_numbering` value (inherit from core)
3. Default to `sequential` if neither exists 3. Check `.specify/init-options.json` for `branch_numbering` value (deprecated, backward compatibility — will be removed in a future release)
4. Default to `sequential` if none of the above exist
## Execution ## Execution
@@ -43,10 +44,10 @@ Generate a concise short name (2-4 words) for the branch:
Run the appropriate script based on your platform: 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**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.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>"` - **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature-branch.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**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.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>"` - **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature-branch.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**: **IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically - Do NOT pass `--number` — the script determines the correct next number automatically

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Git extension: create-new-feature.sh # Git extension: create-new-feature-branch.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout. # Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.sh script.
# Sources common.sh from the project's installed scripts, falling back to # Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers. # git-common.sh for minimal git helpers.

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1 # Git extension: create-new-feature-branch.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout. # Creates a git feature branch only. The feature directory and spec file
# are created by the core create-new-feature.ps1 script.
# Sources common.ps1 from the project's installed scripts, falling back to # Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers. # git-common.ps1 for minimal git helpers.
[CmdletBinding()] [CmdletBinding()]
@@ -19,7 +20,7 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
if ($Help) { if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" Write-Host "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host "" Write-Host ""
Write-Host "Options:" Write-Host "Options:"
Write-Host " -Json Output in JSON format" Write-Host " -Json Output in JSON format"
@@ -37,7 +38,7 @@ if ($Help) {
} }
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) { if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>" Write-Error "Usage: ./create-new-feature-branch.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1 exit 1
} }

View File

@@ -79,6 +79,14 @@ hooks:
# optional: false # Auto-execute without prompting # optional: false # Auto-execute without prompting
# description: "Runs automatically after implementation" # description: "Runs automatically after implementation"
# MULTIPLE COMMANDS ON ONE EVENT: use a list of entries.
# Add optional `priority` (integer >= 1, default 10) to order them, lowest first.
# after_plan:
# - command: "speckit.my-extension.verify"
# priority: 5
# - command: "speckit.my-extension.report"
# priority: 10
# CUSTOMIZE: Add relevant tags (2-5 recommended) # CUSTOMIZE: Add relevant tags (2-5 recommended)
# Used for discovery in catalog # Used for discovery in catalog
tags: tags:

View File

@@ -277,7 +277,7 @@
"id": "generic", "id": "generic",
"name": "Generic (bring your own agent)", "name": "Generic (bring your own agent)",
"version": "1.0.0", "version": "1.0.0",
"description": "Generic integration for any agent via --ai-commands-dir", "description": "Generic integration for any agent via --integration-options=\"--commands-dir <dir>\"",
"author": "spec-kit-core", "author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit", "repository": "https://github.com/github/spec-kit",
"tags": ["generic"] "tags": ["generic"]

View File

@@ -224,11 +224,11 @@
"fiction-book-writing": { "fiction-book-writing": {
"name": "Fiction Book Writing", "name": "Fiction Book Writing",
"id": "fiction-book-writing", "id": "fiction-book-writing",
"version": "1.8.1", "version": "1.9.0",
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.", "description": "Spec-Driven Development for novel and long-form fiction. 34 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, illustrations, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
"author": "Andreas Daumann", "author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip", "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.9.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT", "license": "MIT",
@@ -236,8 +236,8 @@
"speckit_version": ">=0.5.0" "speckit_version": ">=0.5.0"
}, },
"provides": { "provides": {
"templates": 25, "templates": 26,
"commands": 33, "commands": 34,
"scripts": 2 "scripts": 2
}, },
"tags": [ "tags": [
@@ -256,7 +256,7 @@
"language-support" "language-support"
], ],
"created_at": "2026-04-09T08:00:00Z", "created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-05-24T08:00:00Z" "updated_at": "2026-06-02T08:00:00Z"
}, },
"game-narrative-writing": { "game-narrative-writing": {
"name": "Game Narrative Writing", "name": "Game Narrative Writing",

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "specify-cli" name = "specify-cli"
version = "0.9.5.dev0" version = "0.10.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 = [

View File

@@ -111,9 +111,6 @@ if $PATHS_ONLY; then
exit 0 exit 0
fi fi
# Validate branch name
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files # Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2 echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2

View File

@@ -24,8 +24,8 @@ find_specify_root() {
return 1 return 1
} }
# Get repository root, prioritizing .specify directory over git # Get repository root, prioritizing .specify directory
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory # This prevents using a parent repository when spec-kit is initialized in a subdirectory
get_repo_root() { get_repo_root() {
# First, look for .specify directory (spec-kit's own marker) # First, look for .specify directory (spec-kit's own marker)
local specify_root local specify_root
@@ -34,123 +34,24 @@ get_repo_root() {
return return
fi fi
# Fallback to git if no .specify found # Final fallback to script location
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd) (cd "$script_dir/../../.." && pwd)
} }
# Get current branch, with fallback for non-git repositories # Get current feature name from explicit state only.
# Returns the feature identifier or empty string if none is set.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
get_current_branch() { get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
echo "$SPECIFY_FEATURE" echo "$SPECIFY_FEATURE"
return return
fi fi
# Then check git if available at the spec-kit root (not parent) # No explicit feature set — caller must handle this via feature.json
local repo_root=$(get_repo_root) # in get_feature_paths(). Return empty to signal "unknown".
if has_git; then echo ""
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
fi
# For non-git repos, try to find the latest feature directory
local specs_dir="$repo_root/specs"
if [[ -d "$specs_dir" ]]; then
local latest_feature=""
local highest=0
local latest_timestamp=""
for dir in "$specs_dir"/*; do
if [[ -d "$dir" ]]; then
local dirname=$(basename "$dir")
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
# Timestamp-based branch: compare lexicographically
local ts="${BASH_REMATCH[1]}"
if [[ "$ts" > "$latest_timestamp" ]]; then
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
highest=$number
# Only update if no timestamp branch found yet
if [[ -z "$latest_timestamp" ]]; then
latest_feature=$dirname
fi
fi
fi
fi
done
if [[ -n "$latest_feature" ]]; then
echo "$latest_feature"
return
fi
fi
echo "main" # Final fallback
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local raw="$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
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
} }
# Safely read .specify/feature.json's "feature_directory" value. # Safely read .specify/feature.json's "feature_directory" value.
@@ -185,105 +86,66 @@ read_feature_json_feature_directory() {
return 0 return 0
} }
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory # Persist a feature_directory value to .specify/feature.json.
# and matches the resolved active FEATURE_DIR (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks). # Writes only when the file is missing or the value differs from what's stored.
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`. # Accepts the raw (possibly relative) path — callers should pass the original
feature_json_matches_feature_dir() { # user-supplied value, not the normalized absolute path.
_persist_feature_json() {
local repo_root="$1" local repo_root="$1"
local active_feature_dir="$2" local feature_dir_value="$2"
local fj="$repo_root/.specify/feature.json"
local _fd # Strip repo_root prefix if the value is absolute and under repo_root
_fd=$(read_feature_json_feature_directory "$repo_root") if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
feature_dir_value="${feature_dir_value#"$repo_root/"}"
[[ -n "$_fd" ]] || return 1
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
[[ -d "$_fd" ]] || return 1
local norm_json norm_active
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
[[ "$norm_json" == "$norm_active" ]]
}
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
# If branch doesn't have a recognized prefix, fall back to exact match
echo "$specs_dir/$branch_name"
return
fi fi
# Search for directories in specs/ that start with this prefix # Read current value (if any) and skip write when unchanged
local matches=() local current_val
if [[ -d "$specs_dir" ]]; then current_val=$(read_feature_json_feature_directory "$repo_root")
for dir in "$specs_dir"/"$prefix"-*; do if [[ "$current_val" == "$feature_dir_value" ]]; then
if [[ -d "$dir" ]]; then return 0
matches+=("$(basename "$dir")")
fi
done
fi fi
# Handle results # Ensure .specify/ directory exists
if [[ ${#matches[@]} -eq 0 ]]; then mkdir -p "$repo_root/.specify"
# No match found - return the branch name path (will fail later with clear error)
echo "$specs_dir/$branch_name" # Write feature.json — prefer jq for safe JSON, fall back to printf
elif [[ ${#matches[@]} -eq 1 ]]; then if command -v jq >/dev/null 2>&1; then
# Exactly one match - perfect! jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
echo "$specs_dir/${matches[0]}"
else else
# Multiple matches - this shouldn't happen with proper naming convention printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per prefix." >&2
return 1
fi fi
} }
get_feature_paths() { get_feature_paths() {
local repo_root=$(get_repo_root) local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch) local current_branch=$(get_current_branch)
local has_git_repo="false"
if has_git; then
has_git_repo="true"
fi
# Resolve feature directory. Priority: # Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Branch-name-based prefix lookup (legacy fallback) # 3. Error — no feature context available
local feature_dir local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY" feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root # Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
# Persist to feature.json so future sessions without the env var still work
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd local _fd
_fd=$(read_feature_json_feature_directory "$repo_root") _fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then if [[ -n "$_fd" ]]; then
feature_dir="$_fd" feature_dir="$_fd"
# Normalize relative paths to absolute under repo root # Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then else
echo "ERROR: Failed to resolve feature directory" >&2 echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
return 1 return 1
fi fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then else
echo "ERROR: Failed to resolve feature directory" >&2 echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&2
return 1 return 1
fi fi
@@ -291,7 +153,6 @@ get_feature_paths() {
# via crafted branch names or paths containing special characters # via crafted branch names or paths containing special characters
printf 'REPO_ROOT=%q\n' "$repo_root" printf 'REPO_ROOT=%q\n' "$repo_root"
printf 'CURRENT_BRANCH=%q\n' "$current_branch" printf 'CURRENT_BRANCH=%q\n' "$current_branch"
printf 'HAS_GIT=%q\n' "$has_git_repo"
printf 'FEATURE_DIR=%q\n' "$feature_dir" printf 'FEATURE_DIR=%q\n' "$feature_dir"
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md" printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md" printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"

View File

@@ -57,9 +57,9 @@ while [ $i -le $# ]; do
echo "" echo ""
echo "Options:" echo "Options:"
echo " --json Output in JSON format" echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files" echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch" echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Specify branch number manually (overrides auto-detection)" echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message" echo " --help, -h Show this help message"
@@ -113,94 +113,18 @@ get_highest_from_specs() {
echo "$highest" echo "$highest"
} }
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number.
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
# Side-effect-free: query remotes via ls-remote
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# 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 # Function to clean and format a branch name
clean_branch_name() { clean_branch_name() {
local name="$1" local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
} }
# Resolve repository root using common.sh functions which prioritize .specify over git # Resolve repository root using common.sh functions which prioritize .specify
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
REPO_ROOT=$(get_repo_root) REPO_ROOT=$(get_repo_root)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT" cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs" SPECS_DIR="$REPO_ROOT/specs"
@@ -276,23 +200,10 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S) FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else else
# Determine branch number # Determine branch number from existing feature directories
if [ -z "$BRANCH_NUMBER" ]; then if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch) BRANCH_NUMBER=$((HIGHEST + 1))
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
# Dry-run without git: local spec dirs only
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal) # Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
@@ -326,43 +237,13 @@ FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md" SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then if [ -d "$FEATURE_DIR" ] && [ "$ALLOW_EXISTING" != true ]; then
branch_create_error="" if [ "$USE_TIMESTAMP" = true ]; then
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Rerun to get a new timestamp or use a different --short-name."
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" else
# Check if branch already exists >&2 echo "Error: Feature directory '$FEATURE_DIR' already exists. Please use a different feature name or specify a different number with --number."
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# If we're already on the branch, continue without another checkout.
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
# Otherwise switch to the existing branch instead of failing.
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi fi
else exit 1
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi fi
mkdir -p "$FEATURE_DIR" mkdir -p "$FEATURE_DIR"
@@ -377,8 +258,12 @@ if [ "$DRY_RUN" != true ]; then
fi fi
fi fi
# Inform the user how to persist the feature variable in their own shell # Persist to .specify/feature.json so downstream commands can find the feature
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
fi fi
if $JSON_MODE; then if $JSON_MODE; then
@@ -409,5 +294,6 @@ else
echo "FEATURE_NUM: $FEATURE_NUM" echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
fi fi
fi fi

View File

@@ -32,11 +32,6 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output" eval "$_paths_output"
unset _paths_output unset _paths_output
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
# Ensure the feature directory exists # Ensure the feature directory exists
mkdir -p "$FEATURE_DIR" mkdir -p "$FEATURE_DIR"
@@ -75,17 +70,15 @@ if $JSON_MODE; then
--arg impl_plan "$IMPL_PLAN" \ --arg impl_plan "$IMPL_PLAN" \
--arg specs_dir "$FEATURE_DIR" \ --arg specs_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \ --arg branch "$CURRENT_BRANCH" \
--arg has_git "$HAS_GIT" \ '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch}'
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
else else
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")"
fi fi
else else
echo "FEATURE_SPEC: $FEATURE_SPEC" echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN" echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR" echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH" echo "BRANCH: $CURRENT_BRANCH"
echo "HAS_GIT: $HAS_GIT"
fi fi

View File

@@ -27,12 +27,7 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p
eval "$_paths_output" eval "$_paths_output"
unset _paths_output unset _paths_output
# Validate branch # Validate required files
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2 echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2

View File

@@ -81,11 +81,6 @@ if ($PathsOnly) {
exit 0 exit 0
} }
# Validate branch name
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GIT)) {
exit 1
}
# Validate required directories and files # Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) { if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)" Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"

View File

@@ -24,8 +24,8 @@ function Find-SpecifyRoot {
} }
} }
# Get repository root, prioritizing .specify directory over git # Get repository root, prioritizing .specify directory
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory # This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot { function Get-RepoRoot {
# First, look for .specify directory (spec-kit's own marker) # First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot $specifyRoot = Find-SpecifyRoot
@@ -33,263 +33,81 @@ function Get-RepoRoot {
return $specifyRoot return $specifyRoot
} }
# Fallback to git if no .specify found # Final fallback to script location
try {
$result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# Final fallback to script location for non-git repos
# Use -LiteralPath to handle paths with wildcard characters # Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
} }
function Get-CurrentBranch { function Get-CurrentBranch {
# First check if SPECIFY_FEATURE environment variable is set # Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
if ($env:SPECIFY_FEATURE) { if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE return $env:SPECIFY_FEATURE
} }
# Then check git if available at the spec-kit root (not parent) # No explicit feature set - return empty to signal "unknown".
$repoRoot = Get-RepoRoot return ""
if (Test-HasGit) { }
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
if ($onWin) {
$cmp = [System.StringComparison]::OrdinalIgnoreCase
} else {
$cmp = [System.StringComparison]::Ordinal
}
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
}
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
try { try {
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null $raw = Get-Content -LiteralPath $fjPath -Raw
if ($LASTEXITCODE -eq 0) { $cfg = $raw | ConvertFrom-Json
return $result if ($cfg.feature_directory -eq $FeatureDirectory) {
return
} }
} catch { } catch {
# Git command failed # File is corrupt or unreadable - overwrite it
} }
} }
# For non-git repos, try to find the latest feature directory # Ensure .specify/ directory exists
$specsDir = Join-Path $repoRoot "specs" $specifyDir = Join-Path $RepoRoot '.specify'
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
if (Test-Path $specsDir) { New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
$latestFeature = ""
$highest = 0
$latestTimestamp = ""
Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{8}-\d{6})-') {
# Timestamp-based branch: compare lexicographically
$ts = $matches[1]
if ($ts -gt $latestTimestamp) {
$latestTimestamp = $ts
$latestFeature = $_.Name
}
} elseif ($_.Name -match '^(\d{3,})-') {
$num = [long]$matches[1]
if ($num -gt $highest) {
$highest = $num
# Only update if no timestamp branch found yet
if (-not $latestTimestamp) {
$latestFeature = $_.Name
}
}
}
}
if ($latestFeature) {
return $latestFeature
}
}
# Final fallback
return "main"
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
function Test-HasGit {
# First check if git command is available (before calling Get-RepoRoot which may use git)
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
return $false
}
$repoRoot = Get-RepoRoot
# Check if .git exists (directory or file for worktrees/submodules)
# Use -LiteralPath to handle paths with wildcard characters
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
return $false
}
# Verify it's actually a valid git work tree
try {
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
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
} }
$raw = $Branch # Write feature.json
$Branch = Get-SpecKitEffectiveBranchName $raw $json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
# Accept sequential prefix (3+ digits) but exclude malformed timestamps [System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}
# True when .specify/feature.json pins an existing feature directory that matches the
# active FEATURE_DIR from Get-FeaturePathsEnv (so __SPECKIT_COMMAND_PLAN__ can skip git branch pattern checks).
function Test-FeatureJsonMatchesFeatureDir {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$ActiveFeatureDir
)
$featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
return $false
}
try {
$raw = Get-Content -LiteralPath $featureJson -Raw
$cfg = $raw | ConvertFrom-Json
} catch {
return $false
}
$fd = $cfg.feature_directory
if ([string]::IsNullOrWhiteSpace([string]$fd)) {
return $false
}
if (-not [System.IO.Path]::IsPathRooted($fd)) {
$fd = Join-Path $RepoRoot $fd
}
if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
return $false
}
# Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
# symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
# Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
$resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
if ($resolvedJson) {
$normJson = $resolvedJson.Path
} else {
$normJson = [System.IO.Path]::GetFullPath($fd)
}
$resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
if ($resolvedActive) {
$normActive = $resolvedActive.Path
} else {
$normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
}
# Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
# PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
# absence as "we're on Windows".
if ($null -ne $IsWindows) {
$onWindows = $IsWindows
} else {
$onWindows = $true
}
if ($onWindows) {
$comparison = [System.StringComparison]::OrdinalIgnoreCase
} else {
$comparison = [System.StringComparison]::Ordinal
}
return [string]::Equals($normJson, $normActive, $comparison)
}
# Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
function Find-FeatureDirByPrefix {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$Branch
)
$specsDir = Join-Path $RepoRoot 'specs'
$branchName = Get-SpecKitEffectiveBranchName $Branch
$prefix = $null
if ($branchName -match '^(\d{8}-\d{6})-') {
$prefix = $Matches[1]
} elseif ($branchName -match '^(\d{3,})-') {
$prefix = $Matches[1]
} else {
return (Join-Path $specsDir $branchName)
}
$dirMatches = @()
if (Test-Path -LiteralPath $specsDir -PathType Container) {
$dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
}
if ($dirMatches.Count -eq 0) {
return (Join-Path $specsDir $branchName)
}
if ($dirMatches.Count -eq 1) {
return $dirMatches[0].FullName
}
$names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
[Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
[Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
return $null
}
# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
function Get-FeatureDirFromBranchPrefixOrExit {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$CurrentBranch
)
$resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
if ($null -eq $resolved) {
[Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
exit 1
}
return $resolved
} }
function Get-FeaturePathsEnv { function Get-FeaturePathsEnv {
$repoRoot = Get-RepoRoot $repoRoot = Get-RepoRoot
$currentBranch = Get-CurrentBranch $currentBranch = Get-CurrentBranch
$hasGit = Test-HasGit
# Resolve feature directory. Priority: # Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by __SPECKIT_COMMAND_SPECIFY__) # 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) # 3. Error - no feature context available
$featureJson = Join-Path $repoRoot '.specify/feature.json' $featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) { if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY $featureDir = $env:SPECIFY_FEATURE_DIRECTORY
@@ -297,6 +115,8 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) { if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir $featureDir = Join-Path $repoRoot $featureDir
} }
# Persist to feature.json so future sessions without the env var still work
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
} elseif (Test-Path $featureJson) { } elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try { try {
@@ -312,16 +132,17 @@ function Get-FeaturePathsEnv {
$featureDir = Join-Path $repoRoot $featureDir $featureDir = Join-Path $repoRoot $featureDir
} }
} else { } else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
exit 1
} }
} else { } else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
exit 1
} }
[PSCustomObject]@{ [PSCustomObject]@{
REPO_ROOT = $repoRoot REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch CURRENT_BRANCH = $currentBranch
HAS_GIT = $hasGit
FEATURE_DIR = $featureDir FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md' FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md' IMPL_PLAN = Join-Path $featureDir 'plan.md'

View File

@@ -21,9 +21,9 @@ if ($Help) {
Write-Host "" Write-Host ""
Write-Host "Options:" Write-Host "Options:"
Write-Host " -Json Output in JSON format" Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch" Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)" 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 " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message" Write-Host " -Help Show this help message"
@@ -67,111 +67,17 @@ function Get-HighestNumberFromSpecs {
return $highest return $highest
} }
# Extract the highest sequential feature number from a list of branch/ref names.
# Shared by Get-HighestNumberFromBranches and Get-HighestNumberFromRemoteRefs.
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
# Return next available branch number. When SkipFetch is true, queries remotes
# via ls-remote (read-only) instead of fetching.
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
# Side-effect-free: query remotes via ls-remote
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
try {
git fetch --all --prune 2>$null | Out-Null
} catch {
# Ignore fetch errors
}
$highestBranch = Get-HighestNumberFromBranches
}
# Get highest number from ALL 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 { function ConvertTo-CleanBranchName {
param([string]$Name) param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
} }
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template) # Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1" . "$PSScriptRoot/common.ps1"
# Use common.ps1 functions which prioritize .specify over git # Use common.ps1 functions which prioritize .specify
$repoRoot = Get-RepoRoot $repoRoot = Get-RepoRoot
# Check if git is available at this repo root (not a parent)
$hasGit = Test-HasGit
Set-Location $repoRoot Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs' $specsDir = Join-Path $repoRoot 'specs'
@@ -244,21 +150,9 @@ if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix" $branchName = "$featureNum-$branchSuffix"
} else { } else {
# Determine branch number # Determine branch number from existing feature directories
if ($Number -eq 0) { if ($Number -eq 0) {
if ($DryRun -and $hasGit) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
# Dry-run without git: local spec dirs only
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
# Check existing branches on remotes
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
# Fall back to local directory check
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
} }
$featureNum = ('{0:000}' -f $Number) $featureNum = ('{0:000}' -f $Number)
@@ -291,58 +185,13 @@ $featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md' $specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) { if (-not $DryRun) {
if ($hasGit) { if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
$branchCreated = $false if ($Timestamp) {
$branchCreateError = '' Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
try { } else {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
} }
exit 1
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
# Check if branch already exists
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
# If we're already on the branch, continue without another checkout.
if ($currentBranch -eq $branchName) {
# Already on the target branch -- nothing to do
} else {
# Otherwise switch to the existing branch instead of failing.
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} 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"
} }
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
@@ -359,8 +208,12 @@ if (-not $DryRun) {
} }
} }
# Set the SPECIFY_FEATURE environment variable for the current session # Persist to .specify/feature.json so downstream commands can find the feature
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName $env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
} }
if ($Json) { if ($Json) {
@@ -368,7 +221,6 @@ if ($Json) {
BRANCH_NAME = $branchName BRANCH_NAME = $branchName
SPEC_FILE = $specFile SPEC_FILE = $specFile
FEATURE_NUM = $featureNum FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
} }
if ($DryRun) { if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
@@ -378,8 +230,8 @@ if ($Json) {
Write-Output "BRANCH_NAME: $branchName" Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile" Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum" Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) { if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" Write-Output "SPECIFY_FEATURE set to: $branchName"
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
} }
} }

View File

@@ -23,13 +23,6 @@ if ($Help) {
# Get all paths and variables from common functions # Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv $paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
# Ensure the feature directory exists # Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
@@ -61,7 +54,6 @@ if ($Json) {
IMPL_PLAN = $paths.IMPL_PLAN IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH BRANCH = $paths.CURRENT_BRANCH
HAS_GIT = $paths.HAS_GIT
} }
$result | ConvertTo-Json -Compress $result | ConvertTo-Json -Compress
} else { } else {
@@ -69,5 +61,4 @@ if ($Json) {
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)" Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "HAS_GIT: $($paths.HAS_GIT)"
} }

View File

@@ -16,16 +16,9 @@ if ($Help) {
# Source common functions # Source common functions
. "$PSScriptRoot/common.ps1" . "$PSScriptRoot/common.ps1"
# Get feature paths and validate branch # Get feature paths
$paths = Get-FeaturePathsEnv $paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT $planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT

View File

@@ -69,8 +69,6 @@ from ._utils import (
_display_project_path, _display_project_path,
check_tool as check_tool, check_tool as check_tool,
handle_vscode_settings as handle_vscode_settings, handle_vscode_settings as handle_vscode_settings,
init_git_repo as init_git_repo,
is_git_repo as is_git_repo,
merge_json_files as merge_json_files, merge_json_files as merge_json_files,
run_command as run_command, run_command as run_command,
) )
@@ -82,8 +80,6 @@ from ._version import (
) )
from ._agent_config import ( from ._agent_config import (
AGENT_CONFIG as AGENT_CONFIG, AGENT_CONFIG as AGENT_CONFIG,
AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES,
AI_ASSISTANT_HELP as AI_ASSISTANT_HELP,
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION, DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES, SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
) )
@@ -455,9 +451,6 @@ def check():
tracker = StepTracker("Check Available Tools") tracker = StepTracker("Check Available Tools")
tracker.add("git", "Git version control")
git_ok = check_tool("git", tracker=tracker)
agent_results = {} agent_results = {}
for agent_key, agent_config in AGENT_CONFIG.items(): for agent_key, agent_config in AGENT_CONFIG.items():
if agent_key == "generic": if agent_key == "generic":
@@ -485,9 +478,6 @@ def check():
console.print("\n[bold green]Specify CLI is ready to use![/bold green]") console.print("\n[bold green]Specify CLI is ready to use![/bold green]")
if not git_ok:
console.print("[dim]Tip: Install git for repository management[/dim]")
if not any(agent_results.values()): if not any(agent_results.values()):
console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") console.print("[dim]Tip: Install a coding agent for the best experience[/dim]")

View File

@@ -17,29 +17,4 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
DEFAULT_INIT_INTEGRATION = "copilot" DEFAULT_INIT_INTEGRATION = "copilot"
AI_ASSISTANT_ALIASES: dict[str, str] = {
"kiro": "kiro-cli",
}
def _build_ai_assistant_help() -> str:
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
base_help = (
f"AI assistant to use: {', '.join(non_generic_agents)}, "
"or generic (requires --ai-commands-dir)."
)
if not AI_ASSISTANT_ALIASES:
return base_help
alias_phrases = []
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
if len(alias_phrases) == 1:
aliases_text = alias_phrases[0]
else:
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
return base_help + " Use " + aliases_text + "."
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}

View File

@@ -77,51 +77,6 @@ def check_tool(tool: str, tracker=None) -> bool:
return found return found
def is_git_repo(path: Path | None = None) -> bool:
"""Check if the specified path is inside a git repository."""
if path is None:
path = Path.cwd()
if not path.is_dir():
return False
try:
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
check=True,
capture_output=True,
cwd=path,
)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, str | None]:
"""Initialize a git repository in the specified path."""
try:
original_cwd = Path.cwd()
os.chdir(project_path)
if not quiet:
console.print("[cyan]Initializing git repository...[/cyan]")
subprocess.run(["git", "init"], check=True, capture_output=True, text=True)
subprocess.run(["git", "add", "."], check=True, capture_output=True, text=True)
subprocess.run(["git", "commit", "-m", "Initial commit from Specify template"], check=True, capture_output=True, text=True)
if not quiet:
console.print("[green]✓[/green] Git repository initialized")
return True, None
except subprocess.CalledProcessError as e:
error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}"
if e.stderr:
error_msg += f"\nError: {e.stderr.strip()}"
elif e.stdout:
error_msg += f"\nOutput: {e.stdout.strip()}"
if not quiet:
console.print(f"[red]Error initializing git repository:[/red] {e}")
return False, error_msg
finally:
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files. """Handle merging or copying of .vscode/settings.json files.

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import shutil import shutil
import sys import sys
from pathlib import Path from pathlib import Path
@@ -14,8 +13,6 @@ from rich.panel import Panel
from .._agent_config import ( from .._agent_config import (
AGENT_CONFIG, AGENT_CONFIG,
AI_ASSISTANT_ALIASES,
AI_ASSISTANT_HELP,
DEFAULT_INIT_INTEGRATION, DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES, SCRIPT_TYPE_CHOICES,
) )
@@ -26,32 +23,7 @@ from .._assets import (
get_speckit_version, get_speckit_version,
) )
from .._console import StepTracker, console, select_with_arrows, show_banner from .._console import StepTracker, console, select_with_arrows, show_banner
from .._utils import check_tool, init_git_repo, is_git_repo from .._utils import check_tool
def _build_integration_equivalent(
integration_key: str,
ai_commands_dir: str | None = None,
) -> str:
parts = [f"--integration {integration_key}"]
if integration_key == "generic" and ai_commands_dir:
parts.append(
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
)
return " ".join(parts)
def _build_ai_deprecation_warning(
integration_key: str,
ai_commands_dir: str | None = None,
) -> str:
replacement = _build_integration_equivalent(
integration_key,
ai_commands_dir=ai_commands_dir,
)
return (
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
f"Use [bold]{replacement}[/bold] instead."
)
def _stdin_is_interactive() -> bool: def _stdin_is_interactive() -> bool:
@@ -97,21 +69,16 @@ def register(app: typer.Typer) -> None:
@app.command() @app.command()
def init( def init(
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"),
force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"),
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True), skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True), debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True), github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True), offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"), preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"), integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
): ):
""" """
@@ -122,18 +89,16 @@ def register(app: typer.Typer) -> None:
match the installed CLI version. match the installed CLI version.
This command will: This command will:
1. Check that required tools are installed (git is optional) 1. Check that required tools are installed
2. Let you choose your coding agent integration, or default to Copilot 2. Let you choose your coding agent integration, or default to Copilot
in non-interactive sessions in non-interactive sessions
3. Install bundled Spec Kit templates, scripts, workflow, and shared 3. Install bundled Spec Kit templates, scripts, workflow, and shared
project infrastructure project infrastructure
4. Initialize a fresh git repository (if not --no-git and no existing repo) 4. Set up coding agent integration commands and optional presets
5. Set up coding agent integration commands and optional presets
Examples: Examples:
specify init my-project specify init my-project
specify init my-project --integration claude specify init my-project --integration claude
specify init my-project --integration copilot --no-git
specify init --ignore-agent-tools my-project specify init --ignore-agent-tools my-project
specify init . --integration claude # Initialize in current directory specify init . --integration claude # Initialize in current directory
specify init . # Initialize in current directory (interactive integration selection) specify init . # Initialize in current directory (interactive integration selection)
@@ -163,27 +128,6 @@ def register(app: typer.Typer) -> None:
from ..integration_runtime import with_integration_setting as _with_integration_setting from ..integration_runtime import with_integration_setting as _with_integration_setting
show_banner() show_banner()
ai_deprecation_warning: str | None = None
if ai_assistant and ai_assistant.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1)
if ai_commands_dir and ai_commands_dir.startswith("--"):
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
raise typer.Exit(1)
if ai_assistant:
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
if integration and ai_assistant:
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
raise typer.Exit(1)
from ..integrations import INTEGRATION_REGISTRY, get_integration from ..integrations import INTEGRATION_REGISTRY, get_integration
if integration: if integration:
@@ -193,42 +137,6 @@ def register(app: typer.Typer) -> None:
available = ", ".join(sorted(INTEGRATION_REGISTRY)) available = ", ".join(sorted(INTEGRATION_REGISTRY))
console.print(f"[yellow]Available integrations:[/yellow] {available}") console.print(f"[yellow]Available integrations:[/yellow] {available}")
raise typer.Exit(1) raise typer.Exit(1)
ai_assistant = integration
elif ai_assistant:
resolved_integration = get_integration(ai_assistant)
if not resolved_integration:
console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
raise typer.Exit(1)
ai_deprecation_warning = _build_ai_deprecation_warning(
resolved_integration.key,
ai_commands_dir=ai_commands_dir,
)
if ai_assistant or integration:
if ai_skills:
from ..integrations.base import SkillsIntegration as _SkillsCheck
if isinstance(resolved_integration, _SkillsCheck):
console.print(
"[dim]Note: --ai-skills is not needed; "
"skills are the default for this integration.[/dim]"
)
else:
console.print(
"[dim]Note: --ai-skills has no effect with "
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
)
if ai_commands_dir and resolved_integration.key != "generic":
console.print(
"[dim]Note: --ai-commands-dir is deprecated; "
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
)
if no_git:
console.print(
"[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n"
"[yellow]The git extension will no longer be enabled by default "
"— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]"
)
if project_name == ".": if project_name == ".":
here = True here = True
@@ -242,15 +150,7 @@ def register(app: typer.Typer) -> None:
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag") console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
raise typer.Exit(1) raise typer.Exit(1)
if ai_skills and not ai_assistant:
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
raise typer.Exit(1)
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
raise typer.Exit(1)
dir_existed_before = False dir_existed_before = False
if here: if here:
@@ -295,11 +195,11 @@ def register(app: typer.Typer) -> None:
console.print(error_panel) console.print(error_panel)
raise typer.Exit(1) raise typer.Exit(1)
if ai_assistant: if integration:
if ai_assistant not in AGENT_CONFIG: if integration not in AGENT_CONFIG:
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") console.print(f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
raise typer.Exit(1) raise typer.Exit(1)
selected_ai = ai_assistant selected_ai = integration
elif not _stdin_is_interactive(): elif not _stdin_is_interactive():
console.print( console.print(
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. " f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
@@ -314,17 +214,16 @@ def register(app: typer.Typer) -> None:
DEFAULT_INIT_INTEGRATION, DEFAULT_INIT_INTEGRATION,
) )
if not ai_assistant: if not integration:
resolved_integration = get_integration(selected_ai) resolved_integration = get_integration(selected_ai)
if not resolved_integration: if not resolved_integration:
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'") console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
raise typer.Exit(1) raise typer.Exit(1)
if selected_ai == "generic" and not integration_options: if selected_ai == "generic" and not integration_options:
if not ai_commands_dir: console.print("[red]Error:[/red] --integration generic requires --integration-options with --commands-dir")
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic") console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]') raise typer.Exit(1)
raise typer.Exit(1)
current_dir = Path.cwd() current_dir = Path.cwd()
@@ -340,12 +239,6 @@ def register(app: typer.Typer) -> None:
console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))) console.print(Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)))
should_init_git = False
if not no_git:
should_init_git = check_tool("git")
if not should_init_git:
console.print("[yellow]Git not found - will skip repository initialization[/yellow]")
if not ignore_agent_tools: if not ignore_agent_tools:
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config and agent_config["requires_cli"]: if agent_config and agent_config["requires_cli"]:
@@ -395,15 +288,12 @@ def register(app: typer.Typer) -> None:
for key, label in [ for key, label in [
("chmod", "Ensure scripts executable"), ("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"), ("constitution", "Constitution setup"),
("git", "Install git extension"),
("workflow", "Install bundled workflow"), ("workflow", "Install bundled workflow"),
("agent-context", "Install agent-context extension"), ("agent-context", "Install agent-context extension"),
("final", "Finalize"), ("final", "Finalize"),
]: ]:
tracker.add(key, label) tracker.add(key, label)
git_default_notice = False
with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live:
tracker.attach_refresh(lambda: live.update(tracker.render())) tracker.attach_refresh(lambda: live.update(tracker.render()))
try: try:
@@ -414,10 +304,6 @@ def register(app: typer.Typer) -> None:
) )
integration_parsed_options: dict[str, Any] = {} integration_parsed_options: dict[str, Any] = {}
if ai_commands_dir:
integration_parsed_options["commands_dir"] = ai_commands_dir
if ai_skills:
integration_parsed_options["skills"] = True
if integration_options: if integration_options:
extra = _parse_integration_options(resolved_integration, integration_options) extra = _parse_integration_options(resolved_integration, integration_options)
if extra: if extra:
@@ -460,55 +346,6 @@ def register(app: typer.Typer) -> None:
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
if not no_git:
tracker.start("git")
git_messages = []
git_has_error = False
if is_git_repo(project_path):
git_messages.append("existing repo detected")
elif should_init_git:
success, error_msg = init_git_repo(project_path, quiet=True)
if success:
git_messages.append("initialized")
else:
git_has_error = True
if error_msg:
sanitized = error_msg.replace('\n', ' ').strip()
git_messages.append(f"init failed: {sanitized[:120]}")
else:
git_messages.append("init failed")
else:
git_messages.append("git not available")
try:
from ..extensions import ExtensionManager
bundled_path = _locate_bundled_extension("git")
if bundled_path:
manager = ExtensionManager(project_path)
if manager.registry.is_installed("git"):
git_messages.append("extension already installed")
else:
manager.install_from_directory(
bundled_path, get_speckit_version()
)
git_default_notice = True
git_messages.append("extension installed")
else:
git_has_error = True
git_messages.append("bundled extension not found")
except Exception as ext_err:
git_has_error = True
sanitized_ext = str(ext_err).replace('\n', ' ').strip()
git_messages.append(
f"extension install failed: {sanitized_ext[:120]}"
)
summary = "; ".join(git_messages)
if git_has_error:
tracker.error("git", summary)
else:
tracker.complete("git", summary)
else:
tracker.skip("git", "--no-git flag")
try: try:
bundled_wf = _locate_bundled_workflow("speckit") bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf: if bundled_wf:
@@ -542,9 +379,9 @@ def register(app: typer.Typer) -> None:
init_opts = { init_opts = {
"ai": selected_ai, "ai": selected_ai,
"integration": resolved_integration.key, "integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"here": here, "here": here,
"script": selected_script, "script": selected_script,
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(), "speckit_version": get_speckit_version(),
} }
from ..integrations.base import SkillsIntegration as _SkillsPersist from ..integrations.base import SkillsIntegration as _SkillsPersist
@@ -675,7 +512,7 @@ def register(app: typer.Typer) -> None:
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)
if agent_config: if agent_config:
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"] agent_folder = agent_config["folder"] or integration_parsed_options.get("commands_dir")
if agent_folder: if agent_folder:
security_notice = Panel( security_notice = Panel(
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n" f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
@@ -687,28 +524,6 @@ def register(app: typer.Typer) -> None:
console.print() console.print()
console.print(security_notice) console.print(security_notice)
if ai_deprecation_warning:
deprecation_notice = Panel(
ai_deprecation_warning,
title="[bold red]Deprecation Warning[/bold red]",
border_style="red",
padding=(1, 2),
)
console.print()
console.print(deprecation_notice)
if git_default_notice:
default_change_notice = Panel(
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
"Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n"
"Use [bold]specify extension add git[/bold] after init when needed.",
title="[yellow]Notice: Git Default Changing[/yellow]",
border_style="yellow",
padding=(1, 2),
)
console.print()
console.print(default_change_notice)
steps_lines = [] steps_lines = []
if not here: if not here:
steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]")
@@ -720,24 +535,24 @@ def register(app: typer.Typer) -> None:
from ..integrations.base import SkillsIntegration as _SkillsInt from ..integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) codex_skill_mode = selected_ai == "codex" and _is_skills_integration
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and _is_skills_integration
kimi_skill_mode = selected_ai == "kimi" kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae" trae_skill_mode = selected_ai == "trae"
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin" devin_skill_mode = selected_ai == "devin"
cline_skill_mode = selected_ai == "cline" cline_skill_mode = selected_ai == "cline"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
if codex_skill_mode and not ai_skills: if codex_skill_mode:
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]") steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
step_num += 1 step_num += 1
if claude_skill_mode and not ai_skills: if claude_skill_mode:
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
step_num += 1 step_num += 1
if cursor_agent_skill_mode and not ai_skills: if cursor_agent_skill_mode:
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
step_num += 1 step_num += 1
if devin_skill_mode: if devin_skill_mode:

View File

@@ -41,6 +41,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
}) })
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
DEFAULT_HOOK_PRIORITY = 10
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
@@ -89,19 +91,21 @@ class CompatibilityError(ExtensionError):
pass pass
def normalize_priority(value: Any, default: int = 10) -> int: def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
"""Normalize a stored priority value for sorting and display. """Normalize a stored priority value for sorting and display.
Corrupted registry data may contain missing, non-numeric, or non-positive Corrupted registry data may contain missing, non-numeric, non-positive, or
values. In those cases, fall back to the default priority. boolean values. In those cases, fall back to the default priority.
Args: Args:
value: Priority value to normalize (may be int, str, None, etc.) value: Priority value to normalize (may be int, str, None, etc.)
default: Default priority to use for invalid values (default: 10) default: Default priority to use for invalid values
Returns: Returns:
Normalized priority as positive integer (>= 1) Normalized priority as positive integer (>= 1)
""" """
if isinstance(value, bool):
return default
try: try:
priority = int(value) priority = int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -109,6 +113,15 @@ def normalize_priority(value: Any, default: int = 10) -> int:
return priority if priority >= 1 else default return priority if priority >= 1 else default
def coerce_hook_entries(hook_config: Any) -> List[Any]:
"""Return a hook event's config as a list of entries.
A hook event may be declared as a single mapping or a list of mappings.
Both shapes are normalized to a list so callers can iterate uniformly.
"""
return hook_config if isinstance(hook_config, list) else [hook_config]
@dataclass @dataclass
class CatalogEntry(BaseCatalogEntry): class CatalogEntry(BaseCatalogEntry):
"""Represents a single catalog entry in the catalog stack.""" """Represents a single catalog entry in the catalog stack."""
@@ -215,17 +228,36 @@ class ExtensionManifest:
"Extension must provide at least one command or hook" "Extension must provide at least one command or hook"
) )
# Validate hook values (if present) # Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
if hooks: if hooks:
for hook_name, hook_config in hooks.items(): for hook_name, hook_config in hooks.items():
if not isinstance(hook_config, dict): if isinstance(hook_config, list) and not hook_config:
raise ValidationError( raise ValidationError(
f"Invalid hook '{hook_name}': expected a mapping" f"Invalid hook '{hook_name}': list must contain at least one entry"
)
if not hook_config.get("command"):
raise ValidationError(
f"Hook '{hook_name}' missing required 'command' field"
) )
for entry in coerce_hook_entries(hook_config):
if not isinstance(entry, dict):
raise ValidationError(
f"Invalid hook '{hook_name}': "
"expected a mapping or list of mappings"
)
if not entry.get("command"):
raise ValidationError(
f"Hook '{hook_name}' missing required 'command' field"
)
if "priority" in entry:
priority = entry["priority"]
if not isinstance(priority, int) or isinstance(priority, bool):
raise ValidationError(
f"Hook '{hook_name}' has invalid 'priority': "
"must be an integer"
)
if priority < 1:
raise ValidationError(
f"Hook '{hook_name}' has invalid 'priority': "
"must be >= 1"
)
# Validate commands; track renames so hook references can be rewritten. # Validate commands; track renames so hook references can be rewritten.
rename_map: Dict[str, str] = {} rename_map: Dict[str, str] = {}
@@ -275,28 +307,30 @@ class ExtensionManifest:
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when # an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
# the reference is changed so extension authors know to update the manifest. # the reference is changed so extension authors know to update the manifest.
for hook_name, hook_data in self.data.get("hooks", {}).items(): for hook_name, hook_data in self.data.get("hooks", {}).items():
if not isinstance(hook_data, dict): for entry in coerce_hook_entries(hook_data):
raise ValidationError( if not isinstance(entry, dict):
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}" raise ValidationError(
) f"Hook '{hook_name}' must be a mapping or list of mappings, "
command_ref = hook_data.get("command") f"got {type(entry).__name__}"
if not isinstance(command_ref, str): )
continue command_ref = entry.get("command")
# Step 1: apply any rename from the auto-correction pass. if not isinstance(command_ref, str):
after_rename = rename_map.get(command_ref, command_ref) continue
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'. # Step 1: apply any rename from the auto-correction pass.
parts = after_rename.split(".") after_rename = rename_map.get(command_ref, command_ref)
if len(parts) == 2 and parts[0] == ext["id"]: # Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
final_ref = f"speckit.{ext['id']}.{parts[1]}" parts = after_rename.split(".")
else: if len(parts) == 2 and parts[0] == ext["id"]:
final_ref = after_rename final_ref = f"speckit.{ext['id']}.{parts[1]}"
if final_ref != command_ref: else:
hook_data["command"] = final_ref final_ref = after_rename
self.warnings.append( if final_ref != command_ref:
f"Hook '{hook_name}' referenced command '{command_ref}'; " entry["command"] = final_ref
f"updated to canonical form '{final_ref}'. " self.warnings.append(
f"The extension author should update the manifest." f"Hook '{hook_name}' referenced command '{command_ref}'; "
) f"updated to canonical form '{final_ref}'. "
f"The extension author should update the manifest."
)
@staticmethod @staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]: def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
@@ -889,7 +923,7 @@ class ExtensionManager:
For every command in the extension manifest, creates a SKILL.md For every command in the extension manifest, creates a SKILL.md
file in the agent's skills directory following the agentskills.io file in the agent's skills directory following the agentskills.io
specification. This is only done when ``--ai-skills`` was used specification. This is only done when skills mode was used
during project initialisation. during project initialisation.
Args: Args:
@@ -1295,7 +1329,7 @@ class ExtensionManager:
create_missing_active_skills_dir=True, create_missing_active_skills_dir=True,
) )
# Auto-register extension commands as agent skills when --ai-skills # Auto-register extension commands as agent skills when skills mode
# was used during project initialisation (feature parity). # was used during project initialisation (feature parity).
registered_skills = self._register_extension_skills( registered_skills = self._register_extension_skills(
manifest, dest_dir, link_outputs=link_commands manifest, dest_dir, link_outputs=link_commands
@@ -2734,9 +2768,6 @@ class HookExecutor:
# Always ensure the extension is in the installed list # Always ensure the extension is in the installed list
self.register_extension(manifest.id) self.register_extension(manifest.id)
if not hasattr(manifest, "hooks") or not manifest.hooks:
return
config = self.get_project_config() config = self.get_project_config()
# Ensure config is a dict (defensive) # Ensure config is a dict (defensive)
@@ -2762,39 +2793,68 @@ class HookExecutor:
config["hooks"][h_name] = sanitized_h_list config["hooks"][h_name] = sanitized_h_list
changed = True changed = True
# Purge this extension's entries from events the new manifest no longer
# declares, so dropping an event on reinstall leaves no orphans.
declared_events = set(manifest.hooks.keys())
for h_name in list(config["hooks"].keys()):
if h_name in declared_events:
continue
kept = [
h for h in config["hooks"][h_name]
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
]
if kept != config["hooks"][h_name]:
config["hooks"][h_name] = kept
changed = True
# Register each hook # Register each hook
for hook_name, hook_config in manifest.hooks.items(): for hook_name, hook_config in manifest.hooks.items():
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list): if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
config["hooks"][hook_name] = [] config["hooks"][hook_name] = []
changed = True changed = True
# Add hook entry # Key by command to dedup within the manifest. Deleting before
hook_entry = { # re-insert moves a duplicate to the end so "last wins" also breaks ties.
"extension": manifest.id, new_entries: Dict[str, Dict[str, Any]] = {}
"command": hook_config.get("command"), for entry in coerce_hook_entries(hook_config):
"enabled": True, if not isinstance(entry, dict):
"optional": hook_config.get("optional", True), continue
"prompt": hook_config.get( command = entry.get("command")
"prompt", f"Execute {hook_config.get('command')}?" if not command:
), continue
"description": hook_config.get("description", ""), if command in new_entries:
"condition": hook_config.get("condition"), del new_entries[command]
} new_entries[command] = {
"extension": manifest.id,
"command": command,
"enabled": True,
"optional": entry.get("optional", True),
"priority": normalize_priority(
entry.get("priority"), DEFAULT_HOOK_PRIORITY
),
"prompt": entry.get("prompt", f"Execute {command}?"),
"description": entry.get("description", ""),
"condition": entry.get("condition"),
}
# Deduplicate: remove all existing entries for this extension on this # Purge then re-add all of this extension's entries for the event.
# hook event, then append the single canonical entry. This prevents # A reinstall with a changed shape (single<->list or a shorter list)
# multiple hooks firing when hand-edited or older versions leave # then leaves no orphaned entries behind.
# duplicate entries behind. (Feedback from review)
original_list = config["hooks"][hook_name] original_list = config["hooks"][hook_name]
deduped = [ deduped = [
h for h in original_list h for h in original_list
if not (isinstance(h, dict) and h.get("extension") == manifest.id) if not (isinstance(h, dict) and h.get("extension") == manifest.id)
] ]
deduped.append(hook_entry) deduped.extend(new_entries.values())
if deduped != original_list: if deduped != original_list:
config["hooks"][hook_name] = deduped config["hooks"][hook_name] = deduped
changed = True changed = True
non_empty = {name: hooks for name, hooks in config["hooks"].items() if hooks}
if non_empty != config["hooks"]:
config["hooks"] = non_empty
changed = True
if changed: if changed:
self.save_project_config(config) self.save_project_config(config)
@@ -2838,19 +2898,26 @@ class HookExecutor:
self.save_project_config(config) self.save_project_config(config)
def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]: def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]:
"""Get all registered hooks for a specific event. """Get all enabled hooks for a specific event, sorted by priority ascending.
Lower ``priority`` runs first. Ties keep insertion order via a stable
sort. Missing or corrupted on-disk priorities fall back to the default.
Args: Args:
event_name: Name of the event (e.g., 'after_tasks') event_name: Name of the event (e.g., 'after_tasks')
Returns: Returns:
List of hook configurations List of enabled hook configurations sorted by priority.
""" """
config = self.get_project_config() config = self.get_project_config()
hooks = config.get("hooks", {}).get(event_name, []) hooks = config.get("hooks", {}).get(event_name, [])
# Filter to enabled hooks only # Filter to enabled hooks only
return [h for h in hooks if h.get("enabled", True)] enabled = [h for h in hooks if h.get("enabled", True)]
return sorted(
enabled,
key=lambda h: normalize_priority(h.get("priority"), DEFAULT_HOOK_PRIORITY),
)
def should_execute_hook(self, hook: Dict[str, Any]) -> bool: def should_execute_hook(self, hook: Dict[str, Any]) -> bool:
"""Determine if a hook should be executed based on its condition. """Determine if a hook should be executed based on its condition.

View File

@@ -22,7 +22,7 @@ class CursorAgentIntegration(SkillsIntegration):
"folder": ".cursor/", "folder": ".cursor/",
"commands_subdir": "skills", "commands_subdir": "skills",
"install_url": "https://docs.cursor.com/en/cli/overview", "install_url": "https://docs.cursor.com/en/cli/overview",
# IDE-first integration: ``specify init --ai cursor-agent`` must # IDE-first integration: ``specify init --integration cursor-agent`` must
# work without the ``cursor-agent`` CLI installed (the IDE flow # work without the ``cursor-agent`` CLI installed (the IDE flow
# uses skills directly). Workflow dispatch additionally requires # uses skills directly). Workflow dispatch additionally requires
# the CLI on PATH, but that's enforced at dispatch time via # the CLI on PATH, but that's enforced at dispatch time via

View File

@@ -7,7 +7,7 @@ AI agent framework by Nous Research. It stores skills in
Usage:: Usage::
specify init my-project --integration hermes specify init my-project --integration hermes
specify init --here --ai hermes specify init --here --integration hermes
""" """
from __future__ import annotations from __future__ import annotations

View File

@@ -1219,7 +1219,7 @@ class PresetManager:
directory. If so, the skill is overwritten with content derived directory. If so, the skill is overwritten with content derived
from the preset's command file. This ensures that presets that from the preset's command file. This ensures that presets that
override commands also propagate to the agentskills.io skill override commands also propagate to the agentskills.io skill
layer when ``--ai-skills`` was used during project initialisation. layer when skills mode was used during project initialisation.
Args: Args:
manifest: Preset manifest. manifest: Preset manifest.
@@ -1559,7 +1559,7 @@ class PresetManager:
"registered_commands": registered_commands, "registered_commands": registered_commands,
}) })
# Update corresponding skills when --ai-skills was previously used # Update corresponding skills when skills mode was previously used
# and persist that result as well. # and persist that result as well.
registered_skills = self._register_skills(manifest, dest_dir) registered_skills = self._register_skills(manifest, dest_dir)
self.registry.update(manifest.id, { self.registry.update(manifest.id, {

View File

@@ -83,11 +83,12 @@ Given that feature description, do this:
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`: 2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `branch_numbering` - Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`) - Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>` - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
**Create the directory and spec file**: **Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY` - `mkdir -p SPECIFY_FEATURE_DIRECTORY`

View File

@@ -3,7 +3,7 @@ Tests for the bundled git extension (extensions/git/).
Validates: Validates:
- extension.yml manifest - extension.yml manifest
- Bash scripts (create-new-feature.sh, initialize-repo.sh, auto-commit.sh, git-common.sh) - Bash scripts (create-new-feature-branch.sh, initialize-repo.sh, auto-commit.sh, git-common.sh)
- PowerShell scripts (where pwsh is available) - PowerShell scripts (where pwsh is available)
- Config reading from git-config.yml - Config reading from git-config.yml
- Extension install via ExtensionManager - Extension install via ExtensionManager
@@ -193,11 +193,11 @@ class TestGitExtensionInstall:
manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False) manager.install_from_directory(EXT_DIR, "0.5.0", register_commands=False)
ext_installed = tmp_path / ".specify" / "extensions" / "git" ext_installed = tmp_path / ".specify" / "extensions" / "git"
assert (ext_installed / "scripts" / "bash" / "create-new-feature.sh").is_file() assert (ext_installed / "scripts" / "bash" / "create-new-feature-branch.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file() assert (ext_installed / "scripts" / "bash" / "initialize-repo.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file() assert (ext_installed / "scripts" / "bash" / "auto-commit.sh").is_file()
assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file() assert (ext_installed / "scripts" / "bash" / "git-common.sh").is_file()
assert (ext_installed / "scripts" / "powershell" / "create-new-feature.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "create-new-feature-branch.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "initialize-repo.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "auto-commit.ps1").is_file()
assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file() assert (ext_installed / "scripts" / "powershell" / "git-common.ps1").is_file()
@@ -270,16 +270,16 @@ class TestInitializeRepoPowerShell:
assert result.returncode == 0 assert result.returncode == 0
# ── create-new-feature.sh Tests ────────────────────────────────────────────── # ── create-new-feature-branch.sh Tests ──────────────────────────────────────────────
@requires_bash @requires_bash
class TestCreateFeatureBash: class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path): def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.sh creates sequential branch.""" """Extension create-new-feature-branch.sh creates sequential branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "user-auth", "Add user authentication", "--json", "--short-name", "user-auth", "Add user authentication",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -288,10 +288,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "001" assert data["FEATURE_NUM"] == "001"
def test_creates_branch_timestamp(self, tmp_path: Path): def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.sh creates timestamp branch.""" """Extension create-new-feature-branch.sh creates timestamp branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--timestamp", "--short-name", "feat", "Feature", "--json", "--timestamp", "--short-name", "feat", "Feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -305,7 +305,7 @@ class TestCreateFeatureBash:
(project / "specs" / "002-second").mkdir(parents=True) (project / "specs" / "002-second").mkdir(parents=True)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "third", "Third feature", "--json", "--short-name", "third", "Third feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -313,10 +313,10 @@ class TestCreateFeatureBash:
assert data["FEATURE_NUM"] == "003" assert data["FEATURE_NUM"] == "003"
def test_no_git_graceful_degradation(self, tmp_path: Path): def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.sh works without git (outputs branch name, skips branch creation).""" """create-new-feature-branch.sh works without git (outputs branch name, skips branch creation)."""
project = _setup_project(tmp_path, git=False) project = _setup_project(tmp_path, git=False)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--short-name", "no-git", "No git feature", "--json", "--short-name", "no-git", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -329,7 +329,7 @@ class TestCreateFeatureBash:
"""--dry-run computes branch name without creating anything.""" """--dry-run computes branch name without creating anything."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_bash( result = _run_bash(
"create-new-feature.sh", project, "create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "dry", "Dry run test", "--json", "--dry-run", "--short-name", "dry", "Dry run test",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -341,10 +341,10 @@ class TestCreateFeatureBash:
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestCreateFeaturePowerShell: class TestCreateFeaturePowerShell:
def test_creates_branch_sequential(self, tmp_path: Path): def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates sequential branch.""" """Extension create-new-feature-branch.ps1 creates sequential branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "user-auth", "Add user authentication", "-Json", "-ShortName", "user-auth", "Add user authentication",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -352,10 +352,10 @@ class TestCreateFeaturePowerShell:
assert data["BRANCH_NAME"] == "001-user-auth" assert data["BRANCH_NAME"] == "001-user-auth"
def test_creates_branch_timestamp(self, tmp_path: Path): def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature.ps1 creates timestamp branch.""" """Extension create-new-feature-branch.ps1 creates timestamp branch."""
project = _setup_project(tmp_path) project = _setup_project(tmp_path)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-Timestamp", "-ShortName", "feat", "Feature", "-Json", "-Timestamp", "-ShortName", "feat", "Feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
@@ -363,10 +363,10 @@ class TestCreateFeaturePowerShell:
assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"])
def test_no_git_graceful_degradation(self, tmp_path: Path): def test_no_git_graceful_degradation(self, tmp_path: Path):
"""create-new-feature.ps1 works without git.""" """create-new-feature-branch.ps1 works without git."""
project = _setup_project(tmp_path, git=False) project = _setup_project(tmp_path, git=False)
result = _run_pwsh( result = _run_pwsh(
"create-new-feature.ps1", project, "create-new-feature-branch.ps1", project,
"-Json", "-ShortName", "no-git", "No git feature", "-Json", "-ShortName", "no-git", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr

View File

@@ -43,16 +43,6 @@ class TestCliDiagnosticFormatting:
class TestInitIntegrationFlag: class TestInitIntegrationFlag:
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, [
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
])
assert result.exit_code != 0
assert "mutually exclusive" in result.output
def test_unknown_integration_rejected(self, tmp_path): def test_unknown_integration_rejected(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -73,7 +63,7 @@ class TestInitIntegrationFlag:
try: try:
os.chdir(project) os.chdir(project)
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -121,7 +111,7 @@ class TestInitIntegrationFlag:
runner = CliRunner() runner = CliRunner()
project = tmp_path / "noninteractive" project = tmp_path / "noninteractive"
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools", "init", str(project), "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@@ -131,7 +121,7 @@ class TestInitIntegrationFlag:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
def test_ai_copilot_auto_promotes(self, tmp_path): def test_integration_copilot_auto_promotes(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "promote-test" project = tmp_path / "promote-test"
@@ -141,66 +131,13 @@ class TestInitIntegrationFlag:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "warn-ai"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Deprecation Warning" in normalized_output
assert "--ai" in normalized_output
assert "deprecated" in normalized_output
assert "no longer be available" in normalized_output
assert "0.10.0" in normalized_output
assert "--integration copilot" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "warn-generic"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands",
"--script", "sh", "--no-git",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "Deprecation Warning" in normalized_output
assert "--integration generic" in normalized_output
assert "--integration-options" in normalized_output
assert ".myagent/commands" in normalized_output
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
def test_init_optional_preset_failure_reports_target_and_continues( def test_init_optional_preset_failure_reports_target_and_continues(
self, tmp_path, monkeypatch self, tmp_path, monkeypatch
): ):
@@ -223,7 +160,6 @@ class TestInitIntegrationFlag:
"copilot", "copilot",
"--script", "--script",
"sh", "sh",
"--no-git",
"--preset", "--preset",
"lean", "lean",
], ],
@@ -237,7 +173,7 @@ class TestInitIntegrationFlag:
assert "Continuing without the optional preset" in normalized assert "Continuing without the optional preset" in normalized
assert "Project ready" in normalized assert "Project ready" in normalized
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -255,7 +191,7 @@ class TestInitIntegrationFlag:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools", "init", "--here", "--force", "--integration", "claude", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -696,7 +632,6 @@ class TestInitIntegrationFlag:
"init", "--here", "--force", "init", "--here", "--force",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -726,7 +661,6 @@ class TestInitIntegrationFlag:
"init", "--here", "init", "--here",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
], input="y\n", catch_exceptions=False) ], input="y\n", catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -755,7 +689,7 @@ class TestForceExistingDirectory:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "--force", "init", str(target), "--integration", "copilot", "--force",
"--no-git", "--script", "sh", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 0, f"init --force failed: {result.output}" assert result.exit_code == 0, f"init --force failed: {result.output}"
@@ -778,29 +712,29 @@ class TestForceExistingDirectory:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "--integration", "copilot", "init", str(target), "--integration", "copilot",
"--no-git", "--script", "sh", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
assert result.exit_code == 1 assert result.exit_code == 1
assert "already exists" in _normalize_cli_output(result.output) assert "already exists" in _normalize_cli_output(result.output)
class TestGitExtensionAutoInstall: class TestGitExtensionOptIn:
"""Tests for auto-installation of the git extension during specify init.""" """Tests verifying that the git extension is opt-in (not auto-installed) during specify init."""
def test_git_extension_auto_installed(self, tmp_path): def test_git_extension_not_auto_installed(self, tmp_path):
"""Without --no-git, the git extension is installed during init.""" """Git extension is NOT installed automatically during init."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "git-auto" project = tmp_path / "git-opt-in"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh", "init", "--here", "--integration", "claude", "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -808,114 +742,44 @@ class TestGitExtensionAutoInstall:
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
# Check that the tracker didn't report a git error # Git extension directory should NOT be present after init
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
# Git extension files should be installed
ext_dir = project / ".specify" / "extensions" / "git" ext_dir = project / ".specify" / "extensions" / "git"
assert ext_dir.exists(), "git extension directory not installed" assert not ext_dir.exists(), "git extension should not be auto-installed"
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
# Hooks should be registered def test_no_git_flag_is_rejected(self, tmp_path):
extensions_yml = project / ".specify" / "extensions.yml" """--no-git flag has been removed; passing it should fail."""
assert extensions_yml.exists(), "extensions.yml not created"
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
assert "hooks" in hooks_data
assert "before_specify" in hooks_data["hooks"]
assert "before_constitution" in hooks_data["hooks"]
def test_no_git_skips_extension(self, tmp_path):
"""With --no-git, the git extension is NOT installed."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "no-git" project = tmp_path / "no-git-rejected"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh", "init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools", "--no-git", "--ignore-agent-tools",
], catch_exceptions=False) ])
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code != 0, "--no-git should be rejected as an unknown option"
assert "No such option" in result.output or "no such option" in result.output.lower()
# Git extension should NOT be installed def test_git_extension_commands_not_registered_by_default(self, tmp_path):
ext_dir = project / ".specify" / "extensions" / "git" """Git extension commands are NOT registered with the agent during default init."""
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
def test_no_git_emits_deprecation_warning(self, tmp_path):
"""Using --no-git emits a visible deprecation warning."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
project = tmp_path / "no-git-warn" project = tmp_path / "git-cmds-absent"
project.mkdir() project.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
try: try:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh", "init", "--here", "--integration", "claude", "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
assert "--no-git" in normalized_output
assert "deprecated" in normalized_output
assert "0.10.0" in normalized_output
assert "specify extension" in normalized_output
assert "will be removed" in normalized_output
assert "git extension will no longer be enabled by default" in normalized_output
def test_default_git_auto_enable_emits_notice(self, tmp_path):
"""Default git auto-enable emits notice about the v0.10.0 opt-in change."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-default-notice"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
normalized_output = _normalize_cli_output(result.output)
assert result.exit_code == 0, result.output
# Check for key message components (notice may have box-drawing chars)
assert "git extension is currently enabled by default" in normalized_output
assert "v0.10.0" in normalized_output
assert "explicit opt-in" in normalized_output
assert "specify extension add git" in normalized_output
def test_git_extension_commands_registered(self, tmp_path):
"""Git extension commands are registered with the agent during init."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "git-cmds"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", "claude", "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -923,11 +787,11 @@ class TestGitExtensionAutoInstall:
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
# Git extension commands should be registered with the agent # Git extension skill commands should NOT be present
claude_skills = project / ".claude" / "skills" claude_skills = project / ".claude" / "skills"
assert claude_skills.exists(), "Claude skills directory was not created" assert claude_skills.exists(), "Claude skills directory was not created"
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
assert len(git_skills) > 0, "no git extension commands registered" assert len(git_skills) == 0, "git extension commands should not be registered by default"
class TestSharedInfraCommandRefs: class TestSharedInfraCommandRefs:
@@ -1046,7 +910,6 @@ class TestSharedInfraCommandRefs:
"init", str(project), "init", str(project),
"--integration", "claude", "--integration", "claude",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -1077,7 +940,6 @@ class TestSharedInfraCommandRefs:
"init", str(project), "init", str(project),
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -1109,7 +971,6 @@ class TestSharedInfraCommandRefs:
"--integration", "copilot", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -29,19 +29,19 @@ class TestAgyIntegration(SkillsIntegrationTests):
assert i.config["install_url"] == "https://antigravity.google/" assert i.config["install_url"] == "https://antigravity.google/"
class TestAgyAutoPromote: class TestAgyInitFlow:
"""--ai agy auto-promotes to integration path.""" """--integration agy creates expected files."""
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): def test_integration_agy_creates_skills(self, tmp_path):
"""--ai agy should work the same as --integration agy.""" """--integration agy should create skills directory."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"]) result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0, f"init --ai agy failed: {result.output}" assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_agy_setup_warning(self, tmp_path): def test_agy_setup_warning(self, tmp_path):
@@ -52,7 +52,7 @@ class TestAgyAutoPromote:
# Click >= 8.2 separates stdout and stderr natively # Click >= 8.2 separates stdout and stderr natively
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj2" target = tmp_path / "test-proj2"
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"]) result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr

View File

@@ -179,9 +179,9 @@ class MarkdownIntegrationTests:
assert "<!-- SPECKIT END -->" not in remaining assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining assert "# My Rules" in remaining
# -- CLI auto-promote ------------------------------------------------- # -- CLI integration flag -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path): def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -192,15 +192,15 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY) i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project) cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path): def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -213,7 +213,7 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -238,7 +238,7 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -321,13 +321,13 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()) for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n" f"Missing: {sorted(set(expected) - set(actual))}\n"
@@ -346,13 +346,13 @@ class MarkdownIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps", "init", "--here", "--integration", self.KEY, "--script", "ps",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file()) for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n" f"Missing: {sorted(set(expected) - set(actual))}\n"

View File

@@ -312,9 +312,9 @@ class SkillsIntegrationTests:
assert "<!-- SPECKIT END -->" not in remaining assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining assert "# My Rules" in remaining
# -- CLI auto-promote ------------------------------------------------- # -- CLI integration flag -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path): def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -325,15 +325,15 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY) i = get_integration(self.KEY)
skills_dir = i.skills_dest(project) skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory" assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
def test_integration_flag_creates_files(self, tmp_path): def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -346,7 +346,7 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -369,7 +369,7 @@ class SkillsIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -471,15 +471,15 @@ class SkillsIntegrationTests:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY, "--script", "sh",
"--script", "sh", "--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -498,15 +498,15 @@ class SkillsIntegrationTests:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY, "--script", "ps",
"--script", "ps", "--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -388,9 +388,9 @@ class TomlIntegrationTests:
assert "<!-- SPECKIT END -->" not in remaining assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining assert "# My Rules" in remaining
# -- CLI auto-promote ------------------------------------------------- # -- CLI integration flag -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path): def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -405,21 +405,20 @@ class TomlIntegrationTests:
[ [
"init", "init",
"--here", "--here",
"--ai", "--integration",
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
) )
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY) i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project) cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path): def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -440,7 +439,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -469,7 +467,7 @@ class TomlIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -580,7 +578,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -589,7 +586,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -616,7 +613,6 @@ class TomlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"ps", "ps",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -625,7 +621,7 @@ class TomlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -267,9 +267,9 @@ class YamlIntegrationTests:
assert "<!-- SPECKIT END -->" not in remaining assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining assert "# My Rules" in remaining
# -- CLI auto-promote ------------------------------------------------- # -- CLI integration flag -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path): def test_integration_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -284,21 +284,20 @@ class YamlIntegrationTests:
[ [
"init", "init",
"--here", "--here",
"--ai", "--integration",
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
) )
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
i = get_integration(self.KEY) i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project) cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path): def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -319,7 +318,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -348,7 +346,7 @@ class YamlIntegrationTests:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -459,7 +457,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -468,7 +465,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("sh") expected = self._expected_files("sh")
assert actual == expected, ( assert actual == expected, (
@@ -495,7 +492,6 @@ class YamlIntegrationTests:
self.KEY, self.KEY,
"--script", "--script",
"ps", "ps",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -504,7 +500,7 @@ class YamlIntegrationTests:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = self._expected_files("ps") expected = self._expected_files("ps")
assert actual == expected, ( assert actual == expected, (

View File

@@ -458,7 +458,6 @@ class TestIntegrationListCatalog:
"init", "--here", "init", "--here",
"--integration", "copilot", "--integration", "copilot",
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -556,7 +555,6 @@ class TestIntegrationUpgrade:
"init", "--here", "init", "--here",
"--integration", integration, "--integration", integration,
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -118,7 +118,7 @@ class TestClaudeIntegration:
assert b"<!-- SPECKIT" not in remaining assert b"<!-- SPECKIT" not in remaining
assert b"# CLAUDE.md" in remaining assert b"# CLAUDE.md" in remaining
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): def test_integration_flag_creates_skill_files_cli(self, tmp_path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -133,11 +133,10 @@ class TestClaudeIntegration:
[ [
"init", "init",
"--here", "--here",
"--ai", "--integration",
"claude", "claude",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -175,7 +174,6 @@ class TestClaudeIntegration:
"claude", "claude",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -208,7 +206,6 @@ class TestClaudeIntegration:
"--here", "--here",
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,
@@ -234,7 +231,7 @@ class TestClaudeIntegration:
assert init_options["integration"] == "claude" assert init_options["integration"] == "claude"
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path): def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
"""Claude init should succeed even without install_ai_skills.""" """Claude init should succeed even without install_skills."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -243,7 +240,7 @@ class TestClaudeIntegration:
result = runner.invoke( result = runner.invoke(
app, app,
["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"], ["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
) )
assert result.exit_code == 0 assert result.exit_code == 0

View File

@@ -139,7 +139,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
self.KEY, self.KEY,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,

View File

@@ -14,19 +14,19 @@ class TestCodexIntegration(SkillsIntegrationTests):
CONTEXT_FILE = "AGENTS.md" CONTEXT_FILE = "AGENTS.md"
class TestCodexAutoPromote: class TestCodexInitFlow:
"""--ai codex auto-promotes to integration path.""" """--integration codex creates expected files."""
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path): def test_integration_codex_creates_skills(self, tmp_path):
"""--ai codex should work the same as --integration codex.""" """--integration codex should create skills in .agents/skills."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"]) result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --ai codex failed: {result.output}" assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -186,12 +186,12 @@ class TestCopilotIntegration:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git", "init", "--here", "--integration", "copilot", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.analyze.agent.md",
@@ -256,12 +256,12 @@ class TestCopilotIntegration:
try: try:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "--script", "ps", "--no-git", "init", "--here", "--integration", "copilot", "--script", "ps",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0 assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.analyze.agent.md",
@@ -622,7 +622,7 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -648,12 +648,12 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([ expected = sorted([
# Skill files (core + extension-installed agent-context command) # Skill files (core + extension-installed agent-context command)
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
@@ -775,7 +775,6 @@ class TestCopilotSkillsMode:
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "copilot", "init", "--here", "--integration", "copilot",
"--integration-options", "--skills", "--integration-options", "--skills",
"--script", "sh", "--no-git",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)

View File

@@ -92,19 +92,19 @@ class TestCursorMdcFrontmatter:
assert not ctx_path.exists() assert not ctx_path.exists()
class TestCursorAgentAutoPromote: class TestCursorAgentInitFlow:
"""--ai cursor-agent auto-promotes to integration path.""" """--integration cursor-agent creates expected files."""
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): def test_integration_cursor_agent_creates_skills(self, tmp_path):
"""--ai cursor-agent should work the same as --integration cursor-agent.""" """--integration cursor-agent should create skills in .cursor/skills."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"]) result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
@@ -120,7 +120,7 @@ class TestCursorAgentCliDispatch:
def test_requires_cli_is_false_for_ide_first_flow(self): def test_requires_cli_is_false_for_ide_first_flow(self):
"""``requires_cli`` must stay False so the IDE-only flow keeps working. """``requires_cli`` must stay False so the IDE-only flow keeps working.
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``) ``specify init --integration cursor-agent`` (without ``--ignore-agent-tools``)
treats ``requires_cli=True`` as a hard precheck and fails when the treats ``requires_cli=True`` as a hard precheck and fails when the
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE ``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
/ skills flow can run without it. Workflow dispatch support is / skills flow can run without it. Workflow dispatch support is

View File

@@ -56,11 +56,11 @@ class TestDevinBuildExecArgs:
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"] assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
class TestDevinAutoPromote: class TestDevinInitFlow:
"""--ai devin auto-promotes to integration path.""" """--integration devin creates expected files."""
def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path): def test_integration_devin_creates_skills(self, tmp_path):
"""--ai devin should work the same as --integration devin.""" """--integration devin should create skills directory."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -68,8 +68,8 @@ class TestDevinAutoPromote:
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke( result = runner.invoke(
app, app,
["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"], ["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
) )
assert result.exit_code == 0, f"init --ai devin failed: {result.output}" assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -245,16 +245,14 @@ class TestGenericIntegration:
# -- CLI -------------------------------------------------------------- # -- CLI --------------------------------------------------------------
def test_cli_generic_without_commands_dir_fails(self, tmp_path): def test_cli_generic_without_commands_dir_fails(self, tmp_path):
"""--integration generic without --ai-commands-dir should fail.""" """--integration generic without --integration-options should fail."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(tmp_path / "test-generic"), "--integration", "generic", "init", str(tmp_path / "test-generic"), "--integration", "generic",
"--script", "sh", "--no-git",
]) ])
# Generic requires --commands-dir / --ai-commands-dir # Generic requires --commands-dir via --integration-options
# The integration path validates via setup()
assert result.exit_code != 0 assert result.exit_code != 0
def test_init_options_includes_context_file(self, tmp_path): def test_init_options_includes_context_file(self, tmp_path):
@@ -270,8 +268,8 @@ class TestGenericIntegration:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -281,7 +279,7 @@ class TestGenericIntegration:
assert ext_cfg.get("context_file") == "AGENTS.md" assert ext_cfg.get("context_file") == "AGENTS.md"
def test_complete_file_inventory_sh(self, tmp_path): def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -292,15 +290,15 @@ class TestGenericIntegration:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "sh", "--no-git", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = sorted([ expected = sorted([
"AGENTS.md", "AGENTS.md",
@@ -345,7 +343,7 @@ class TestGenericIntegration:
) )
def test_complete_file_inventory_ps(self, tmp_path): def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps.""" """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script ps."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
@@ -356,15 +354,15 @@ class TestGenericIntegration:
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic", "init", "--here", "--integration", "generic",
"--ai-commands-dir", ".myagent/commands", "--integration-options=--commands-dir .myagent/commands",
"--script", "ps", "--no-git", "--script", "ps",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}" assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted( actual = sorted(
p.relative_to(project).as_posix() p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
) )
expected = sorted([ expected = sorted([
"AGENTS.md", "AGENTS.md",

View File

@@ -232,7 +232,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY,
"--script", "sh", "--no-git", "--ignore-agent-tools", "--script", "sh", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -270,7 +270,7 @@ class TestHermesIntegration(SkillsIntegrationTests):
os.chdir(project) os.chdir(project)
result = CliRunner().invoke(app, [ result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "init", "--here", "--integration", self.KEY,
"--script", "ps", "--no-git", "--ignore-agent-tools", "--script", "ps", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -326,12 +326,11 @@ class TestHermesIntegration(SkillsIntegrationTests):
) )
class TestHermesAutoPromote: class TestHermesInitFlow:
"""--ai hermes auto-promotes to integration path.""" """--integration hermes creates expected files."""
def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch): def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch):
"""--ai hermes should work the same as --integration hermes, """--integration hermes should create global skills and a local marker."""
creating global skills and a local marker."""
home = _fake_home(tmp_path) home = _fake_home(tmp_path)
monkeypatch.setattr(Path, "home", lambda: home) monkeypatch.setattr(Path, "home", lambda: home)
@@ -342,13 +341,12 @@ class TestHermesAutoPromote:
target = tmp_path / "test-proj" target = tmp_path / "test-proj"
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", str(target), "init", str(target),
"--ai", "hermes", "--integration", "hermes",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
"--script", "sh", "--script", "sh",
]) ])
assert result.exit_code == 0, f"init --ai hermes failed: {result.output}" assert result.exit_code == 0, f"init --integration hermes failed: {result.output}"
# Skills should be in global ~/.hermes/skills/ # Skills should be in global ~/.hermes/skills/
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
# Local marker should exist # Local marker should exist

View File

@@ -137,7 +137,7 @@ class TestKimiNextSteps:
os.chdir(project) os.chdir(project)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "kimi", "--no-git", "init", "--here", "--integration", "kimi",
"--ignore-agent-tools", "--script", "sh", "--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:

View File

@@ -123,15 +123,15 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
) )
class TestKiroAlias: class TestKiroIntegration:
"""--ai kiro alias normalizes to kiro-cli and auto-promotes.""" """--integration kiro-cli creates expected files."""
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path): def test_integration_kiro_cli_creates_files(self, tmp_path):
"""--ai kiro should normalize to canonical kiro-cli and auto-promote.""" """--integration kiro-cli should create files in .kiro/prompts."""
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
target = tmp_path / "kiro-alias-proj" target = tmp_path / "kiro-proj"
target.mkdir() target.mkdir()
old_cwd = os.getcwd() old_cwd = os.getcwd()
@@ -139,8 +139,8 @@ class TestKiroAlias:
os.chdir(target) os.chdir(target)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, [ result = runner.invoke(app, [
"init", "--here", "--ai", "kiro", "init", "--here", "--integration", "kiro-cli",
"--ignore-agent-tools", "--script", "sh", "--no-git", "--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
os.chdir(old_cwd) os.chdir(old_cwd)

View File

@@ -25,8 +25,7 @@ def _run_init(project, *flags: str) -> Result:
os.chdir(project) os.chdir(project)
return CliRunner().invoke( return CliRunner().invoke(
app, app,
["init", "--here", *flags, "--script", "sh", ["init", "--here", *flags, "--script", "sh", "--ignore-agent-tools"],
"--no-git", "--ignore-agent-tools"],
catch_exceptions=False, catch_exceptions=False,
) )
finally: finally:
@@ -294,11 +293,11 @@ class TestRovodevIntegration:
assert init_options.get("ai_skills") is True assert init_options.get("ai_skills") is True
assert init_options.get("script") == "sh" assert init_options.get("script") == "sh"
def test_ai_flag_auto_promotes_to_integration(self, tmp_path): def test_integration_flag_creates_expected_files(self, tmp_path):
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``.""" """``--integration rovodev`` should create all expected rovodev files."""
project = tmp_path / "rovodev-ai" project = tmp_path / "rovodev-int"
project.mkdir() project.mkdir()
result = _run_init(project, "--ai", "rovodev") result = _run_init(project, "--integration", "rovodev")
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists() assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".rovodev" / "prompts.yml").exists() assert (project / ".rovodev" / "prompts.yml").exists()

View File

@@ -23,7 +23,6 @@ def _init_project(tmp_path, integration="copilot"):
"init", "--here", "init", "--here",
"--integration", integration, "--integration", integration,
"--script", "sh", "--script", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], catch_exceptions=False) ], catch_exceptions=False)
finally: finally:
@@ -961,7 +960,7 @@ class TestIntegrationSwitch:
def test_switch_refreshes_managed_shared_script_refs(self, tmp_path): def test_switch_refreshes_managed_shared_script_refs(self, tmp_path):
"""Switching refreshes managed shared scripts to the target command style.""" """Switching refreshes managed shared scripts to the target command style."""
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert shared_script.exists() assert shared_script.exists()
shared_content = shared_script.read_text(encoding="utf-8") shared_content = shared_script.read_text(encoding="utf-8")
assert "/speckit-plan" in shared_content assert "/speckit-plan" in shared_content
@@ -987,7 +986,7 @@ class TestIntegrationSwitch:
import hashlib import hashlib
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
# Simulate a stale vendored script: write truncated content as bytes # Simulate a stale vendored script: write truncated content as bytes
@@ -999,7 +998,7 @@ class TestIntegrationSwitch:
manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json" manifest_path = project / ".specify" / "integrations" / "speckit.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8")) manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"][".specify/scripts/bash/common.sh"] = ( manifest_data["files"][".specify/scripts/bash/setup-tasks.sh"] = (
hashlib.sha256(stale_bytes).hexdigest() hashlib.sha256(stale_bytes).hexdigest()
) )
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
@@ -1048,7 +1047,7 @@ class TestIntegrationSwitch:
def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path): def test_switch_refresh_shared_infra_overwrites_customizations(self, tmp_path):
"""--refresh-shared-infra explicitly overwrites user customizations on switch.""" """--refresh-shared-infra explicitly overwrites user customizations on switch."""
project = _init_project(tmp_path, "claude") project = _init_project(tmp_path, "claude")
shared_script = project / ".specify" / "scripts" / "bash" / "common.sh" shared_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
assert "/speckit-plan" in shared_script.read_text(encoding="utf-8") assert "/speckit-plan" in shared_script.read_text(encoding="utf-8")
rendered_bytes = shared_script.read_bytes() rendered_bytes = shared_script.read_bytes()

View File

@@ -254,7 +254,6 @@ class TestMultiInstallSafeContracts:
initial, initial,
"--script", "--script",
"sh", "sh",
"--no-git",
"--ignore-agent-tools", "--ignore-agent-tools",
], ],
catch_exceptions=False, catch_exceptions=False,

View File

@@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP from specify_cli import AGENT_CONFIG
from specify_cli.extensions import CommandRegistrar from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -39,13 +39,6 @@ class TestAgentConfigConsistency:
assert AGENT_CONFIG["codex"]["folder"] == ".agents/" assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills" assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
def test_init_ai_help_includes_roo_and_kiro_alias(self):
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
assert "roo" in AI_ASSISTANT_HELP
for alias, target in AI_ASSISTANT_ALIASES.items():
assert alias in AI_ASSISTANT_HELP
assert target in AI_ASSISTANT_HELP
def test_devcontainer_kiro_installer_uses_pinned_checksum(self): def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
"""Devcontainer installer should always verify Kiro installer via pinned SHA256.""" """Devcontainer installer should always verify Kiro installer via pinned SHA256."""
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text( post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
@@ -80,9 +73,9 @@ class TestAgentConfigConsistency:
assert cfg["args"] == "{{args}}" assert cfg["args"] == "{{args}}"
assert cfg["extension"] == ".toml" assert cfg["extension"] == ".toml"
def test_ai_help_includes_tabnine(self): def test_agent_config_includes_tabnine(self):
"""CLI help text for --ai should include tabnine.""" """AGENT_CONFIG should include tabnine."""
assert "tabnine" in AI_ASSISTANT_HELP assert "tabnine" in AGENT_CONFIG
# --- Kimi Code CLI consistency checks --- # --- Kimi Code CLI consistency checks ---
@@ -102,9 +95,9 @@ class TestAgentConfigConsistency:
assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["dir"] == ".kimi/skills"
assert kimi_cfg["extension"] == "/SKILL.md" assert kimi_cfg["extension"] == "/SKILL.md"
def test_ai_help_includes_kimi(self): def test_agent_config_includes_kimi(self):
"""CLI help text for --ai should include kimi.""" """AGENT_CONFIG should include kimi."""
assert "kimi" in AI_ASSISTANT_HELP assert "kimi" in AGENT_CONFIG
# --- Trae IDE consistency checks --- # --- Trae IDE consistency checks ---
@@ -126,9 +119,9 @@ class TestAgentConfigConsistency:
assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["args"] == "$ARGUMENTS"
assert trae_cfg["extension"] == "/SKILL.md" assert trae_cfg["extension"] == "/SKILL.md"
def test_ai_help_includes_trae(self): def test_agent_config_includes_trae(self):
"""CLI help text for --ai should include trae.""" """AGENT_CONFIG should include trae."""
assert "trae" in AI_ASSISTANT_HELP assert "trae" in AGENT_CONFIG
# --- Pi Coding Agent consistency checks --- # --- Pi Coding Agent consistency checks ---
@@ -151,9 +144,9 @@ class TestAgentConfigConsistency:
assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["args"] == "$ARGUMENTS"
assert pi_cfg["extension"] == ".md" assert pi_cfg["extension"] == ".md"
def test_ai_help_includes_pi(self): def test_agent_config_includes_pi(self):
"""CLI help text for --ai should include pi.""" """AGENT_CONFIG should include pi."""
assert "pi" in AI_ASSISTANT_HELP assert "pi" in AGENT_CONFIG
# --- iFlow CLI consistency checks --- # --- iFlow CLI consistency checks ---
@@ -173,9 +166,9 @@ class TestAgentConfigConsistency:
assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["format"] == "markdown"
assert cfg["iflow"]["args"] == "$ARGUMENTS" assert cfg["iflow"]["args"] == "$ARGUMENTS"
def test_ai_help_includes_iflow(self): def test_agent_config_includes_iflow(self):
"""CLI help text for --ai should include iflow.""" """AGENT_CONFIG should include iflow."""
assert "iflow" in AI_ASSISTANT_HELP assert "iflow" in AGENT_CONFIG
# --- Goose consistency checks --- # --- Goose consistency checks ---
@@ -195,9 +188,9 @@ class TestAgentConfigConsistency:
assert cfg["goose"]["format"] == "yaml" assert cfg["goose"]["format"] == "yaml"
assert cfg["goose"]["args"] == "{{args}}" assert cfg["goose"]["args"] == "{{args}}"
def test_ai_help_includes_goose(self): def test_agent_config_includes_goose(self):
"""CLI help text for --ai should include goose.""" """AGENT_CONFIG should include goose."""
assert "goose" in AI_ASSISTANT_HELP assert "goose" in AGENT_CONFIG
# --- invoke_separator propagation checks --- # --- invoke_separator propagation checks ---
@@ -304,6 +297,6 @@ class TestAgentConfigConsistency:
assert rovodev_cfg["args"] == "$ARGUMENTS" assert rovodev_cfg["args"] == "$ARGUMENTS"
assert rovodev_cfg["extension"] == "/SKILL.md" assert rovodev_cfg["extension"] == "/SKILL.md"
def test_ai_help_includes_rovodev(self): def test_agent_config_includes_rovodev(self):
"""CLI help text for --ai should include rovodev.""" """AGENT_CONFIG should include rovodev."""
assert "rovodev" in AI_ASSISTANT_HELP assert "rovodev" in AGENT_CONFIG

View File

@@ -1,74 +1,24 @@
""" """
Unit tests for branch numbering options (sequential vs timestamp). Unit tests verifying --branch-numbering removal (v0.10.0).
Tests cover: Branch numbering is now managed entirely by the git extension's config.
- Persisting branch_numbering in init-options.json The --branch-numbering flag was removed from `specify init`.
- Default value when branch_numbering is None
- Validation of branch_numbering values
""" """
import json
from pathlib import Path from pathlib import Path
from specify_cli import save_init_options
class TestBranchNumberingFlagRemoved:
"""--branch-numbering flag was removed in v0.10.0."""
class TestSaveBranchNumbering: def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
"""Tests for save_init_options with branch_numbering."""
def test_save_branch_numbering_timestamp(self, tmp_path: Path):
opts = {"branch_numbering": "timestamp", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "timestamp"
def test_save_branch_numbering_sequential(self, tmp_path: Path):
opts = {"branch_numbering": "sequential", "ai": "claude"}
save_init_options(tmp_path, opts)
saved = json.loads((tmp_path / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
def test_branch_numbering_defaults_to_sequential(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
project_dir = tmp_path / "proj"
runner = CliRunner()
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
assert saved["branch_numbering"] == "sequential"
class TestBranchNumberingValidation:
"""Tests for branch_numbering CLI validation via CliRunner."""
def test_invalid_branch_numbering_rejected(self, tmp_path: Path):
from typer.testing import CliRunner from typer.testing import CliRunner
from specify_cli import app from specify_cli import app
runner = CliRunner() runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"]) result = runner.invoke(app, [
assert result.exit_code == 1 "init", str(tmp_path / "proj"), "--integration", "claude",
assert "Invalid --branch-numbering" in result.output "--branch-numbering", "sequential", "--ignore-agent-tools",
])
def test_valid_branch_numbering_sequential(self, tmp_path: Path): assert result.exit_code != 0, "--branch-numbering should be rejected"
from typer.testing import CliRunner assert "No such option" in result.output or "no such option" in result.output.lower()
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")
def test_valid_branch_numbering_timestamp(self, tmp_path: Path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
assert result.exit_code == 0
assert "Invalid --branch-numbering" not in (result.output or "")

View File

@@ -34,6 +34,15 @@ def _install_ps_scripts(repo: Path) -> None:
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1") shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -69,7 +78,10 @@ def prereq_repo(tmp_path: Path) -> Path:
@requires_bash @requires_bash
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must return paths without branch validation (main branch).""" """--paths-only must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json", "--paths-only"], ["bash", str(script), "--json", "--paths-only"],
@@ -88,20 +100,20 @@ def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""--paths-only must also work on a properly named spec branch.""" """--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
subprocess.run( feat = prereq_repo / "specs" / "001-my-feature"
["git", "checkout", "-q", "-b", "001-my-feature"], feat.mkdir(parents=True, exist_ok=True)
cwd=prereq_repo, _write_feature_json(prereq_repo)
check=True,
)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json", "--paths-only"], ["bash", str(script), "--json", "--paths-only"],
cwd=prereq_repo, cwd=prereq_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=_clean_env(), env=env,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -111,7 +123,10 @@ def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None: def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
"""--paths-only without --json must return text paths on a non-spec branch.""" """--paths-only without --json must return text paths from feature.json."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--paths-only"], ["bash", str(script), "--paths-only"],
@@ -128,7 +143,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash @requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None: def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, branch validation must still fail on main.""" """Without --paths-only, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh" script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json"], ["bash", str(script), "--json"],
@@ -139,7 +154,7 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
# ── PowerShell tests ────────────────────────────────────────────────────── # ── PowerShell tests ──────────────────────────────────────────────────────
@@ -147,7 +162,10 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None: def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must return paths without branch validation (main branch).""" """-PathsOnly must return paths when feature.json pins the feature dir."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -167,21 +185,26 @@ def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None: def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
"""-PathsOnly must also work on a properly named spec branch.""" """-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
subprocess.run( subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"], ["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=prereq_repo, cwd=prereq_repo,
check=True, check=True,
) )
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
env = _clean_env()
env["SPECIFY_FEATURE"] = "001-my-feature"
result = subprocess.run( result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"], [exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
cwd=prereq_repo, cwd=prereq_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
env=_clean_env(), env=env,
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -190,7 +213,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None: def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, branch validation must still fail on main.""" """Without -PathsOnly, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1" script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -202,4 +225,5 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr combined = result.stdout + result.stderr
assert "Feature directory not found" in combined

View File

@@ -16,14 +16,10 @@ def test_commands_init_importable():
def test_agent_config_importable(): def test_agent_config_importable():
from specify_cli._agent_config import ( from specify_cli._agent_config import (
AGENT_CONFIG, AGENT_CONFIG,
AI_ASSISTANT_ALIASES,
AI_ASSISTANT_HELP,
DEFAULT_INIT_INTEGRATION, DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES, SCRIPT_TYPE_CHOICES,
) )
assert isinstance(AGENT_CONFIG, dict) assert isinstance(AGENT_CONFIG, dict)
assert isinstance(AI_ASSISTANT_ALIASES, dict)
assert isinstance(AI_ASSISTANT_HELP, str)
assert DEFAULT_INIT_INTEGRATION == "copilot" assert DEFAULT_INIT_INTEGRATION == "copilot"
assert "sh" in SCRIPT_TYPE_CHOICES assert "sh" in SCRIPT_TYPE_CHOICES

View File

@@ -2,7 +2,7 @@
Unit tests for extension skill auto-registration. Unit tests for extension skill auto-registration.
Tests cover: Tests cover:
- SKILL.md generation when --ai-skills was used during init - SKILL.md generation when skills mode was used during init
- No skills created when ai_skills not active - No skills created when ai_skills not active
- SKILL.md content correctness - SKILL.md content correctness
- Existing user-modified skills not overwritten - Existing user-modified skills not overwritten
@@ -162,7 +162,7 @@ def extension_dir(temp_dir):
@pytest.fixture @pytest.fixture
def skills_project(project_dir): def skills_project(project_dir):
"""Create a project with --ai-skills enabled and skills directory.""" """Create a project with skills mode enabled and skills directory."""
_create_init_options(project_dir, ai="claude", ai_skills=True) _create_init_options(project_dir, ai="claude", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="claude") skills_dir = _create_skills_dir(project_dir, ai="claude")
return project_dir, skills_dir return project_dir, skills_dir
@@ -170,7 +170,7 @@ def skills_project(project_dir):
@pytest.fixture @pytest.fixture
def no_skills_project(project_dir): def no_skills_project(project_dir):
"""Create a project without --ai-skills.""" """Create a project without skills mode."""
_create_init_options(project_dir, ai="claude", ai_skills=False) _create_init_options(project_dir, ai="claude", ai_skills=False)
return project_dir return project_dir

View File

@@ -23,6 +23,7 @@ from tests.conftest import strip_ansi
from specify_cli.extensions import ( from specify_cli.extensions import (
CatalogEntry, CatalogEntry,
CORE_COMMAND_NAMES, CORE_COMMAND_NAMES,
DEFAULT_HOOK_PRIORITY,
ExtensionManifest, ExtensionManifest,
ExtensionRegistry, ExtensionRegistry,
ExtensionManager, ExtensionManager,
@@ -190,6 +191,12 @@ class TestNormalizePriority:
assert normalize_priority(None, default=20) == 20 assert normalize_priority(None, default=20) == 20
assert normalize_priority("invalid", default=1) == 1 assert normalize_priority("invalid", default=1) == 1
def test_boolean_returns_default(self):
"""Booleans fall back to the default rather than acting as int 0/1."""
assert normalize_priority(True) == 10
assert normalize_priority(False) == 10
assert normalize_priority(True, default=5) == 5
# ===== ExtensionManifest Tests ===== # ===== ExtensionManifest Tests =====
@@ -458,6 +465,137 @@ class TestExtensionManifest:
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"): with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
ExtensionManifest(manifest_path) ExtensionManifest(manifest_path)
def test_hook_single_mapping_still_accepted(self, extension_dir):
"""Existing single-mapping hook manifests parse unchanged (regression)."""
manifest_path = extension_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path)
assert "after_tasks" in manifest.hooks
assert isinstance(manifest.hooks["after_tasks"], dict)
assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.hello"
def test_hook_list_of_mappings_accepted(self, temp_dir, valid_manifest_data):
"""A hook event may be configured as a list of mappings."""
import yaml
valid_manifest_data["provides"]["commands"].append({
"name": "speckit.test-ext.bye",
"file": "commands/bye.md",
"description": "Second test command",
})
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "speckit.test-ext.hello", "description": "first"},
{"command": "speckit.test-ext.bye", "description": "second"},
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
entries = manifest.hooks["after_tasks"]
assert isinstance(entries, list)
assert [e["command"] for e in entries] == [
"speckit.test-ext.hello",
"speckit.test-ext.bye",
]
def test_hook_list_with_non_mapping_entry_rejected(self, temp_dir, valid_manifest_data):
"""A list entry that is not a mapping must raise ValidationError."""
import yaml
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "speckit.test-ext.hello"},
"not-a-mapping",
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(
ValidationError,
match="Invalid hook 'after_tasks': expected a mapping or list of mappings",
):
ExtensionManifest(manifest_path)
def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data):
"""Alias-form command refs are lifted to canonical form for every entry
in a list hook, each emitting a warning."""
import yaml
valid_manifest_data["provides"]["commands"].append({
"name": "speckit.test-ext.bye",
"file": "commands/bye.md",
"description": "Second test command",
})
valid_manifest_data["hooks"]["after_tasks"] = [
{"command": "test-ext.hello"},
{"command": "test-ext.bye"},
]
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert [e["command"] for e in manifest.hooks["after_tasks"]] == [
"speckit.test-ext.hello",
"speckit.test-ext.bye",
]
lifted = [w for w in manifest.warnings if "updated to canonical form" in w]
assert len(lifted) == 2
def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data):
"""An empty list for a hook event is rejected rather than silently
registering nothing."""
import yaml
valid_manifest_data["hooks"]["after_tasks"] = []
manifest_path = temp_dir / "extension.yml"
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="must contain at least one entry"):
ExtensionManifest(manifest_path)
def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data):
"""Hook entry ``priority`` must be a positive integer when provided."""
import yaml
manifest_path = temp_dir / "extension.yml"
valid_manifest_data["hooks"]["after_tasks"] = {
"command": "speckit.test-ext.hello",
"priority": "high",
}
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
ExtensionManifest(manifest_path)
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 0
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*>= 1"):
ExtensionManifest(manifest_path)
# bool is a subclass of int, so it must be rejected explicitly.
valid_manifest_data["hooks"]["after_tasks"]["priority"] = True
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
ExtensionManifest(manifest_path)
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 5
with open(manifest_path, 'w', encoding="utf-8") as f:
yaml.dump(valid_manifest_data, f)
manifest = ExtensionManifest(manifest_path)
assert manifest.hooks["after_tasks"]["priority"] == 5
def test_manifest_hash(self, extension_dir): def test_manifest_hash(self, extension_dir):
"""Test manifest hash calculation.""" """Test manifest hash calculation."""
manifest_path = extension_dir / "extension.yml" manifest_path = extension_dir / "extension.yml"
@@ -4906,6 +5044,405 @@ class TestExtensionPriorityBackwardsCompatibility:
assert result[2][0] == "ext-low-priority" assert result[2][0] == "ext-low-priority"
class _StubManifest(ExtensionManifest):
"""ExtensionManifest stub for HookExecutor tests.
Subclasses the real manifest so it satisfies ``register_hooks``'s type
while bypassing the file-based parsing/validation pipeline. The inherited
``id`` and ``hooks`` properties read from ``data``, so populating ``data``
is enough.
"""
def __init__(self, ext_id: str, hooks: dict):
self.data = {"extension": {"id": ext_id}, "hooks": hooks}
class TestHookExecutorRegistration:
"""Tests for HookExecutor.register_hooks / get_hooks_for_event with
multi-entry hook events and per-entry priority ordering."""
def test_register_hooks_single_mapping_back_compat(self, project_dir):
"""Single-mapping form continues to register exactly one entry with
default priority."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
config = executor.get_project_config()
entries = config["hooks"]["after_tasks"]
assert len(entries) == 1
assert entries[0]["extension"] == "ext-a"
assert entries[0]["command"] == "speckit.ext-a.go"
assert entries[0]["priority"] == DEFAULT_HOOK_PRIORITY
def test_register_hooks_multiple_entries_same_event(self, project_dir):
"""A list of mappings registers each entry under the same event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first", "description": "1st"},
{"command": "speckit.ext-a.second", "description": "2nd"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert len(entries) == 2
assert [e["command"] for e in entries] == [
"speckit.ext-a.first",
"speckit.ext-a.second",
]
assert all(e["extension"] == "ext-a" for e in entries)
def test_register_hooks_dedup_on_extension_and_command(self, project_dir):
"""Re-registering the same (extension, command) updates in place
rather than appending a duplicate entry."""
executor = HookExecutor(project_dir)
manifest = _StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first", "description": "v1"},
{"command": "speckit.ext-a.second", "description": "v1"},
]
},
)
executor.register_hooks(manifest)
manifest.hooks["after_tasks"][0]["description"] = "v2"
executor.register_hooks(manifest)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert len(entries) == 2
first = next(e for e in entries if e["command"] == "speckit.ext-a.first")
assert first["description"] == "v2"
def test_register_hooks_shape_change_removes_orphans(self, project_dir):
"""Reinstalling with a shorter hook shape (list → single mapping, or a
shrunk list) purges the dropped commands instead of leaving orphans."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.first"]
def test_register_hooks_single_to_list_reinstall_adds_entries(self, project_dir):
"""Reinstalling a single-mapping hook as a list adds the new entries."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == [
"speckit.ext-a.first",
"speckit.ext-a.second",
]
def test_register_hooks_skips_entry_without_command(self, project_dir):
"""An entry lacking a command is skipped (defensive; validated
manifests never reach this state)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.go"},
{"optional": True},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
def test_register_hooks_skips_non_dict_entry(self, project_dir):
"""A non-dict entry in a hook list is skipped rather than crashing
(defensive; validated manifests never reach this state)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{"after_tasks": [{"command": "speckit.ext-a.go"}, "not-a-mapping"]},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
def test_register_hooks_purges_dropped_event_orphans(self, project_dir):
"""Re-registering without an event it previously declared purges this
extension's entries from that event, scoped to this extension."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": {"command": "speckit.ext-a.tasks"},
"after_plan": {"command": "speckit.ext-a.plan"},
"after_implement": {"command": "speckit.ext-a.impl"},
},
)
)
executor.register_hooks(
_StubManifest("ext-b", {"after_plan": {"command": "speckit.ext-b.plan"}})
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.tasks"}})
)
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-a.tasks"]
assert [e["command"] for e in hooks["after_plan"]] == ["speckit.ext-b.plan"]
assert "after_implement" not in hooks
def test_register_hooks_dropping_all_hooks_purges_orphans(self, project_dir):
"""Reinstalling with an empty hooks mapping still purges this
extension's entries, scoped to this extension."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
executor.register_hooks(_StubManifest("ext-a", {}))
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_dir):
"""A corrupt non-dict entry already on disk does not break the
empty-hooks orphan purge; it is dropped and valid entries survive."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
config = executor.get_project_config()
config["hooks"]["after_tasks"].append("corrupt-non-dict-entry")
executor.save_project_config(config)
executor.register_hooks(_StubManifest("ext-a", {}))
hooks = executor.get_project_config()["hooks"]
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
def test_register_hooks_duplicate_command_moves_to_end(self, project_dir):
"""A command repeated in one manifest keeps the last value and the last
insertion position, so equal-priority tie order is 'last wins'."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.dup", "description": "first"},
{"command": "speckit.ext-a.other"},
{"command": "speckit.ext-a.dup", "description": "last"},
]
},
)
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert [e["command"] for e in entries] == [
"speckit.ext-a.other",
"speckit.ext-a.dup",
]
assert entries[-1]["description"] == "last"
def test_register_hooks_preserves_other_extensions(self, project_dir):
"""Re-registering one extension must not disturb another extension's
entries on the same event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
)
executor.register_hooks(
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
)
entries = executor.get_project_config()["hooks"]["after_tasks"]
assert sorted(e["extension"] for e in entries) == ["ext-a", "ext-b"]
def test_get_hooks_for_event_sorts_by_priority(self, project_dir):
"""Returned entries are sorted by priority ascending; equal priorities
preserve insertion order via stable sort."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.mid", "priority": 10},
{"command": "speckit.ext-a.first", "priority": 1},
{"command": "speckit.ext-a.late", "priority": 20},
{"command": "speckit.ext-a.mid-tied", "priority": 10},
]
},
)
)
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.ext-a.first",
"speckit.ext-a.mid",
"speckit.ext-a.mid-tied",
"speckit.ext-a.late",
]
def test_get_hooks_for_event_orders_across_extensions(self, project_dir):
"""Priority controls execution order across extensions regardless of
install order (Issue #2378 use case)."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-report",
{"after_plan": {"command": "speckit.ext-report.run", "priority": 20}},
)
)
executor.register_hooks(
_StubManifest(
"ext-verify",
{"after_plan": {"command": "speckit.ext-verify.run", "priority": 5}},
)
)
ordered = executor.get_hooks_for_event("after_plan")
assert [e["command"] for e in ordered] == [
"speckit.ext-verify.run",
"speckit.ext-report.run",
]
def test_get_hooks_for_event_treats_missing_priority_as_default(self, project_dir):
"""Entries persisted before priority was introduced should be sorted
as if their priority equaled DEFAULT_HOOK_PRIORITY."""
executor = HookExecutor(project_dir)
# Legacy on-disk entry with no priority key.
# register_hooks now always sets one, so write this state directly.
executor.save_project_config({
"installed": [],
"settings": {"auto_execute_hooks": True},
"hooks": {
"after_tasks": [
{
"extension": "legacy",
"command": "speckit.legacy.go",
"enabled": True,
},
{
"extension": "newer",
"command": "speckit.newer.first",
"enabled": True,
"priority": 1,
},
]
},
})
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.newer.first",
"speckit.legacy.go",
]
def test_get_hooks_for_event_tolerates_corrupted_priority(self, project_dir):
"""A corrupted on-disk ``priority`` (non-numeric, None, or < 1) is
normalized to the default instead of raising during sort."""
executor = HookExecutor(project_dir)
executor.save_project_config({
"installed": [],
"settings": {"auto_execute_hooks": True},
"hooks": {
"after_tasks": [
{
"extension": "corrupt",
"command": "speckit.corrupt.go",
"enabled": True,
"priority": "not-a-number",
},
{
"extension": "early",
"command": "speckit.early.go",
"enabled": True,
"priority": 1,
},
]
},
})
ordered = executor.get_hooks_for_event("after_tasks")
assert [e["command"] for e in ordered] == [
"speckit.early.go",
"speckit.corrupt.go",
]
def test_unregister_hooks_removes_all_extension_entries(self, project_dir):
"""unregister_hooks removes every entry for the extension regardless
of how many were registered to a given event."""
executor = HookExecutor(project_dir)
executor.register_hooks(
_StubManifest(
"ext-a",
{
"after_tasks": [
{"command": "speckit.ext-a.first"},
{"command": "speckit.ext-a.second"},
]
},
)
)
executor.register_hooks(
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.solo"}})
)
executor.unregister_hooks("ext-a")
entries = executor.get_project_config()["hooks"].get("after_tasks", [])
assert [e["extension"] for e in entries] == ["ext-b"]
class TestHookInvocationRendering: class TestHookInvocationRendering:
"""Test hook invocation formatting for different agent modes.""" """Test hook invocation formatting for different agent modes."""
@@ -4932,7 +5469,7 @@ class TestHookInvocationRendering:
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir): def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
"""Codex projects with --ai-skills should render $speckit-* invocations.""" """Codex projects with skills mode should render $speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json" init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True) init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True})) init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))

View File

@@ -2557,8 +2557,8 @@ class TestPresetSkills:
return preset_dir return preset_dir
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
"""When --ai-skills was used, a preset command override should update the skill.""" """When skills mode was used, a preset command override should update the skill."""
# Simulate --ai-skills having been used: write init-options + create skill # Simulate skills mode having been used: write init-options + create skill
self._write_init_options(project_dir, ai="claude") self._write_init_options(project_dir, ai="claude")
skills_dir = project_dir / ".claude" / "skills" skills_dir = project_dir / ".claude" / "skills"
self._create_skill(skills_dir, "speckit-specify") self._create_skill(skills_dir, "speckit-specify")
@@ -2843,7 +2843,7 @@ class TestPresetSkills:
assert "override taskstoissues body" in content assert "override taskstoissues body" in content
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
"""When --ai-skills was NOT used, preset install should not touch skills.""" """When skills mode was NOT used, preset install should not touch skills."""
self._write_init_options(project_dir, ai="qwen", ai_skills=False) self._write_init_options(project_dir, ai="qwen", ai_skills=False)
skills_dir = project_dir / ".qwen" / "skills" skills_dir = project_dir / ".qwen" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched") self._create_skill(skills_dir, "speckit-specify", body="untouched")
@@ -2962,7 +2962,7 @@ class TestPresetSkills:
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir): def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
"""Skills should not be created when no existing skill dir is found.""" """Skills should not be created when no existing skill dir is found."""
self._write_init_options(project_dir, ai="claude") self._write_init_options(project_dir, ai="claude")
# Don't create skills dir — simulate --ai-skills never created them # Don't create skills dir — simulate skills mode never created them
manager = PresetManager(project_dir) manager = PresetManager(project_dir)
install_self_test_preset(manager) install_self_test_preset(manager)
@@ -4123,7 +4123,7 @@ class TestWrapStrategy:
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
) )
# Set up skills dir (simulating --ai claude) # Set up skills dir (simulating --integration claude)
skills_dir = project_dir / ".claude" / "skills" skills_dir = project_dir / ".claude" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True) skills_dir.mkdir(parents=True, exist_ok=True)
skill_subdir = skills_dir / "speckit-wrap-test" skill_subdir = skills_dir / "speckit-wrap-test"

View File

@@ -41,6 +41,13 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(repo: Path, feature_directory: str) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
"""Return a copy of the current environment with any SPECIFY_* vars removed. """Return a copy of the current environment with any SPECIFY_* vars removed.
@@ -89,10 +96,7 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text( _write_feature_json(plan_repo, "specs/001-tiny-notes-app")
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -107,12 +111,8 @@ def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path
@requires_bash @requires_bash
def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None: def test_setup_plan_errors_without_feature_context(plan_repo: Path) -> None:
subprocess.run( """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-plan must error."""
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -123,13 +123,14 @@ def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) ->
env=_clean_env(), env=_clean_env(),
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
@requires_bash @requires_bash
def test_setup_plan_numbered_branch_unchanged_without_feature_json( def test_setup_plan_numbered_branch_works_with_feature_json(
plan_repo: Path, plan_repo: Path,
) -> None: ) -> None:
"""A numbered branch still works when feature.json explicitly pins the spec dir."""
subprocess.run( subprocess.run(
["git", "checkout", "-q", "-b", "001-tiny-notes-app"], ["git", "checkout", "-q", "-b", "001-tiny-notes-app"],
cwd=plan_repo, cwd=plan_repo,
@@ -138,6 +139,7 @@ def test_setup_plan_numbered_branch_unchanged_without_feature_json(
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(plan_repo, "specs/001-tiny-notes-app")
script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script)], ["bash", str(script)],
@@ -161,10 +163,7 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
feat = plan_repo / "specs" / "001-tiny-notes-app" feat = plan_repo / "specs" / "001-tiny-notes-app"
feat.mkdir(parents=True) feat.mkdir(parents=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(plan_repo / ".specify" / "feature.json").write_text( _write_feature_json(plan_repo, "specs/001-tiny-notes-app")
json.dumps({"feature_directory": "specs/001-tiny-notes-app"}),
encoding="utf-8",
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -180,14 +179,9 @@ def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: P
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_plan_ps_fails_custom_branch_without_feature_json( def test_setup_plan_ps_errors_without_feature_context(
plan_repo: Path, plan_repo: Path,
) -> None: ) -> None:
subprocess.run(
["git", "checkout", "-q", "-b", "feature/my-feature-branch"],
cwd=plan_repo,
check=True,
)
script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
@@ -198,5 +192,6 @@ def test_setup_plan_ps_fails_custom_branch_without_feature_json(
check=False, check=False,
env=_clean_env(), env=_clean_env(),
) )
combined = result.stderr + result.stdout
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in combined

View File

@@ -41,6 +41,15 @@ def _minimal_templates(repo: Path) -> None:
shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -74,6 +83,7 @@ def plan_repo(tmp_path: Path) -> Path:
_minimal_templates(repo) _minimal_templates(repo)
_install_bash_scripts(repo) _install_bash_scripts(repo)
_install_ps_scripts(repo) _install_ps_scripts(repo)
_write_feature_json(repo)
return repo return repo

View File

@@ -1,4 +1,4 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation.""" """Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
import json import json
import os import os
@@ -50,6 +50,15 @@ def _install_core_tasks_template(repo: Path) -> None:
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md") shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _minimal_feature(repo: Path) -> Path: def _minimal_feature(repo: Path) -> Path:
""" """
Create a numbered branch-style feature directory with spec.md and plan.md Create a numbered branch-style feature directory with spec.md and plan.md
@@ -60,6 +69,7 @@ def _minimal_feature(repo: Path) -> Path:
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(repo)
return feat return feat
@@ -85,7 +95,7 @@ def _write_integration_state(repo: Path, integration: str = "claude", separator:
def _clean_env() -> dict[str, str]: def _clean_env() -> dict[str, str]:
""" """
Return os.environ with all SPECIFY_* variables stripped so the scripts Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on git branch + feature.json state set up by each fixture. rely purely on feature.json and on-disk feature directories set up by each fixture.
""" """
env = os.environ.copy() env = os.environ.copy()
for key in list(env): for key in list(env):
@@ -153,7 +163,8 @@ def tasks_repo(tmp_path: Path) -> Path:
repo.mkdir() repo.mkdir()
_git_init(repo) _git_init(repo)
# Switch to a numbered branch so branch validation passes without feature.json # Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
subprocess.run( subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"], ["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo, cwd=repo,
@@ -492,6 +503,7 @@ def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -
feat = tasks_repo / "specs" / "001-my-feature" feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -550,11 +562,7 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
@@ -571,21 +579,17 @@ def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
@requires_bash @requires_bash
def test_setup_tasks_bash_fails_custom_branch_without_feature_json( def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path, tasks_repo: Path,
) -> None: ) -> None:
""" """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
On a non-standard branch with no feature.json, setup-tasks.sh must fail main_feat = tasks_repo / "specs" / "main"
and report that we are not on a feature branch. main_feat.mkdir(parents=True, exist_ok=True)
""" (main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
subprocess.run( (main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--json"], ["bash", str(script), "--json"],
cwd=tasks_repo, cwd=tasks_repo,
@@ -596,7 +600,7 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
) )
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in result.stderr
# =========================================================================== # ===========================================================================
# POWERSHELL TESTS # POWERSHELL TESTS
@@ -731,6 +735,7 @@ def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) ->
feat = tasks_repo / "specs" / "001-my-feature" feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
@@ -793,11 +798,7 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
feat.mkdir(parents=True, exist_ok=True) feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8") (feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8") (feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
@@ -815,22 +816,18 @@ def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") @pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_fails_custom_branch_without_feature_json( def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path, tasks_repo: Path,
) -> None: ) -> None:
""" """Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail main_feat = tasks_repo / "specs" / "main"
and report that we are not on a feature branch. main_feat.mkdir(parents=True, exist_ok=True)
""" (main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
subprocess.run( (main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run( result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"], [exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo, cwd=tasks_repo,
@@ -839,6 +836,7 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
check=False, check=False,
env=_clean_env(), env=_clean_env(),
) )
output = result.stderr + result.stdout
assert result.returncode != 0 assert result.returncode != 0
assert "Not on a feature branch" in result.stderr assert "Feature directory not found" in output

View File

@@ -19,14 +19,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
EXT_CREATE_FEATURE = ( EXT_CREATE_FEATURE = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
) )
EXT_CREATE_FEATURE_PS = ( EXT_CREATE_FEATURE_PS = (
PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
) )
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh"
EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1"
HAS_PWSH = shutil.which("pwsh") is not None HAS_PWSH = shutil.which("pwsh") is not None
@@ -77,7 +75,7 @@ def ext_git_repo(tmp_path: Path) -> Path:
# Copy extension script # Copy extension script
ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash"
ext_dir.mkdir(parents=True) ext_dir.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature-branch.sh")
# Also copy git-common.sh if it exists # Also copy git-common.sh if it exists
git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh"
if git_common.exists(): if git_common.exists():
@@ -106,7 +104,7 @@ def ext_ps_git_repo(tmp_path: Path) -> Path:
# Copy extension script # Copy extension script
ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell"
ext_ps.mkdir(parents=True) ext_ps.mkdir(parents=True)
shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature-branch.ps1")
git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1"
if git_common_ps.exists(): if git_common_ps.exists():
shutil.copy(git_common_ps, ext_ps / "git-common.ps1") shutil.copy(git_common_ps, ext_ps / "git-common.ps1")
@@ -279,64 +277,13 @@ class TestSequentialBranchPowerShell:
@requires_bash @requires_bash
class TestCheckFeatureBranch: class TestCoreCommonRemovesGitHelpers:
def test_accepts_timestamp_branch(self): def test_check_feature_branch_removed(self):
"""Test 6: check_feature_branch accepts timestamp branch.""" result = source_and_call('declare -F check_feature_branch >/dev/null')
result = source_and_call('check_feature_branch "20260319-143022-feat" "true"')
assert result.returncode == 0
def test_accepts_sequential_branch(self):
"""Test 7: check_feature_branch accepts sequential branch."""
result = source_and_call('check_feature_branch "004-feat" "true"')
assert result.returncode == 0
def test_rejects_main(self):
"""Test 8: check_feature_branch rejects main."""
result = source_and_call('check_feature_branch "main" "true"')
assert result.returncode != 0 assert result.returncode != 0
def test_accepts_four_digit_sequential_branch(self): def test_has_git_removed(self):
"""check_feature_branch accepts 4+ digit sequential branch.""" result = source_and_call('declare -F has_git >/dev/null')
result = source_and_call('check_feature_branch "1234-feat" "true"')
assert result.returncode == 0
def test_rejects_partial_timestamp(self):
"""Test 9: check_feature_branch rejects 7-digit date."""
result = source_and_call('check_feature_branch "2026031-143022-feat" "true"')
assert result.returncode != 0
def test_rejects_timestamp_without_slug(self):
"""check_feature_branch rejects timestamp-like branch missing trailing slug."""
result = source_and_call('check_feature_branch "20260319-143022" "true"')
assert result.returncode != 0
def test_rejects_7digit_timestamp_without_slug(self):
"""check_feature_branch rejects 7-digit date + 6-digit time without slug."""
result = source_and_call('check_feature_branch "2026031-143022" "true"')
assert result.returncode != 0
def test_accepts_single_prefix_sequential(self):
"""Optional gitflow-style prefix: one segment + sequential feature name."""
result = source_and_call('check_feature_branch "feat/004-my-feature" "true"')
assert result.returncode == 0
def test_accepts_single_prefix_timestamp(self):
"""Optional prefix + timestamp-style feature name."""
result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"')
assert result.returncode == 0
def test_rejects_invalid_suffix_with_single_prefix(self):
result = source_and_call('check_feature_branch "feat/main" "true"')
assert result.returncode != 0
assert "feat/main" in result.stderr
def test_rejects_two_level_prefix_before_feature(self):
"""More than one slash: no stripping; whole name must match (fails)."""
result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"')
assert result.returncode != 0
def test_rejects_malformed_timestamp_with_prefix(self):
result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"')
assert result.returncode != 0 assert result.returncode != 0
@@ -344,50 +291,11 @@ class TestCheckFeatureBranch:
@requires_bash @requires_bash
class TestFindFeatureDirByPrefix: class TestFindFeatureDirByPrefixRemoved:
def test_timestamp_branch(self, tmp_path: Path): def test_find_feature_dir_by_prefix_removed(self):
"""Test 10: find_feature_dir_by_prefix with timestamp branch.""" """Directory scanning helper is removed from core common.sh."""
(tmp_path / "specs" / "20260319-143022-user-auth").mkdir(parents=True) result = source_and_call('declare -F find_feature_dir_by_prefix >/dev/null')
result = source_and_call( assert result.returncode != 0
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-user-auth"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-user-auth"
def test_cross_branch_prefix(self, tmp_path: Path):
"""Test 11: find_feature_dir_by_prefix cross-branch (different suffix, same timestamp)."""
(tmp_path / "specs" / "20260319-143022-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "20260319-143022-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-original-feat"
def test_four_digit_sequential_prefix(self, tmp_path: Path):
"""find_feature_dir_by_prefix resolves 4+ digit sequential prefix."""
(tmp_path / "specs" / "1000-original-feat").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "1000-different-name"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat"
def test_sequential_with_single_path_prefix(self, tmp_path: Path):
"""Strip one optional prefix segment before prefix directory lookup."""
(tmp_path / "specs" / "004-only-dir").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir"
def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
(tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True)
result = source_and_call(
f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"'
)
assert result.returncode == 0
assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical"
# ── get_feature_paths + single-prefix integration ─────────────────────────── # ── get_feature_paths + single-prefix integration ───────────────────────────
@@ -395,26 +303,29 @@ class TestFindFeatureDirByPrefix:
class TestGetFeaturePathsSinglePrefix: class TestGetFeaturePathsSinglePrefix:
@requires_bash @requires_bash
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): def test_bash_specify_feature_prefixed_requires_explicit_feature_context(
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" self, tmp_path: Path
):
"""SPECIFY_FEATURE alone no longer triggers path lookup in bash."""
(tmp_path / ".specify").mkdir() (tmp_path / ".specify").mkdir()
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True) (tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
cmd = ( cmd = (
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' f'source "{COMMON_SH}" && get_feature_paths'
) )
result = subprocess.run( result = subprocess.run(
["bash", "-c", cmd], ["bash", "-c", cmd],
capture_output=True, capture_output=True,
text=True, text=True,
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") assert "Feature directory not found" in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): def test_ps_specify_feature_prefixed_requires_explicit_feature_context(
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" self, git_repo: Path
):
"""PowerShell also requires feature.json or SPECIFY_FEATURE_DIRECTORY."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
spec_dir = git_repo / "specs" / "001-ps-prefix-spec" spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
@@ -426,14 +337,8 @@ class TestGetFeaturePathsSinglePrefix:
text=True, text=True,
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
for line in result.stdout.splitlines(): assert "Feature directory not found" in (result.stderr + result.stdout)
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip()
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── get_current_branch Tests ───────────────────────────────────────────────── # ── get_current_branch Tests ─────────────────────────────────────────────────
@@ -453,12 +358,11 @@ class TestGetCurrentBranch:
@requires_bash @requires_bash
class TestNoGitTimestamp: class TestNoGitTimestamp:
def test_no_git_timestamp(self, no_git_dir: Path): def test_no_git_timestamp(self, no_git_dir: Path):
"""Test 13: No-git repo + timestamp creates spec dir with warning.""" """Test 13: Timestamp mode works without git and creates a spec dir."""
result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature") result = run_script(no_git_dir, "--timestamp", "--short-name", "no-git-feat", "No git feature")
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else [] spec_dirs = list((no_git_dir / "specs").iterdir()) if (no_git_dir / "specs").exists() else []
assert len(spec_dirs) > 0, "spec dir not created" assert len(spec_dirs) > 0, "spec dir not created"
assert "git" in result.stderr.lower() or "warning" in result.stderr.lower()
# ── E2E Flow Tests ─────────────────────────────────────────────────────────── # ── E2E Flow Tests ───────────────────────────────────────────────────────────
@@ -467,32 +371,65 @@ class TestNoGitTimestamp:
@requires_bash @requires_bash
class TestE2EFlow: class TestE2EFlow:
def test_e2e_timestamp(self, git_repo: Path): def test_e2e_timestamp(self, git_repo: Path):
"""Test 14: E2E timestamp flow — branch, dir, validation.""" """Test 14: E2E timestamp flow creates only a feature directory."""
run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test") before = subprocess.run(
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=True,
).stdout.strip() ).stdout.strip()
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch), f"branch: {branch}" result = run_script(git_repo, "--timestamp", "--short-name", "e2e-ts", "E2E timestamp test")
assert (git_repo / "specs" / branch).is_dir() assert result.returncode == 0, result.stderr
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0 branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name is not None
assert re.match(r"^\d{8}-\d{6}-e2e-ts$", branch_name), f"branch: {branch_name}"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
def test_e2e_sequential(self, git_repo: Path): def test_e2e_sequential(self, git_repo: Path):
"""Test 15: E2E sequential flow (regression guard).""" """Test 15: E2E sequential flow creates only a feature directory."""
run_script(git_repo, "--short-name", "seq-feat", "Sequential feature") before = subprocess.run(
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
check=True,
).stdout.strip() ).stdout.strip()
assert re.match(r"^\d{3,}-seq-feat$", branch), f"branch: {branch}" result = run_script(git_repo, "--short-name", "seq-feat", "Sequential feature")
assert (git_repo / "specs" / branch).is_dir() assert result.returncode == 0, result.stderr
val = source_and_call(f'check_feature_branch "{branch}" "true"')
assert val.returncode == 0 branch_name = None
for line in result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
branch_name = line.split(":", 1)[1].strip()
break
assert branch_name == "001-seq-feat"
assert (git_repo / "specs" / branch_name).is_dir()
after = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert after == before
# ── Allow Existing Branch Tests ────────────────────────────────────────────── # ── Allow Existing Branch Tests ──────────────────────────────────────────────
@@ -500,67 +437,22 @@ class TestE2EFlow:
@requires_bash @requires_bash
class TestAllowExistingBranch: class TestAllowExistingBranch:
def test_allow_existing_switches_to_branch(self, git_repo: Path): def test_allow_existing_reuses_existing_feature_dir(self, git_repo: Path):
"""T006: Pre-create branch, verify script switches to it.""" """T006: Existing feature directory can be reused when the flag is set."""
subprocess.run( feature_dir = git_repo / "specs" / "004-pre-exist"
["git", "checkout", "-b", "004-pre-exist"], feature_dir.mkdir(parents=True)
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "pre-exist", git_repo, "--allow-existing-branch", "--short-name", "pre-exist",
"--number", "4", "Pre-existing feature", "--number", "4", "Pre-existing feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
current = subprocess.run( assert feature_dir.is_dir()
["git", "rev-parse", "--abbrev-ref", "HEAD"], assert (feature_dir / "spec.md").exists()
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert current == "004-pre-exist", f"expected 004-pre-exist, got {current}"
def test_allow_existing_already_on_branch(self, git_repo: Path):
"""T007: Verify success when already on the target branch."""
subprocess.run(
["git", "checkout", "-b", "005-already-on"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "already-on",
"--number", "5", "Already on branch",
)
assert result.returncode == 0, result.stderr
def test_allow_existing_creates_spec_dir(self, git_repo: Path):
"""T008: Verify spec directory created on existing branch."""
subprocess.run(
["git", "checkout", "-b", "006-spec-dir"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "spec-dir",
"--number", "6", "Spec dir feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "006-spec-dir").is_dir()
assert (git_repo / "specs" / "006-spec-dir" / "spec.md").exists()
def test_without_flag_still_errors(self, git_repo: Path): def test_without_flag_still_errors(self, git_repo: Path):
"""T009: Verify backwards compatibility (error without flag).""" """T009: Existing feature directories still fail without the flag."""
subprocess.run( (git_repo / "specs" / "007-no-flag").mkdir(parents=True)
["git", "checkout", "-b", "007-no-flag"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature", git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
) )
@@ -569,18 +461,11 @@ class TestAllowExistingBranch:
def test_allow_existing_no_overwrite_spec(self, git_repo: Path): def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
"""T010: Pre-create spec.md with content, verify it is preserved.""" """T010: Pre-create spec.md with content, verify it is preserved."""
subprocess.run(
["git", "checkout", "-b", "008-no-overwrite"],
cwd=git_repo, check=True, capture_output=True,
)
spec_dir = git_repo / "specs" / "008-no-overwrite" spec_dir = git_repo / "specs" / "008-no-overwrite"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
spec_file = spec_dir / "spec.md" spec_file = spec_dir / "spec.md"
spec_file.write_text("# My custom spec content\n") spec_file.write_text("# My custom spec content\n")
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "no-overwrite", git_repo, "--allow-existing-branch", "--short-name", "no-overwrite",
"--number", "8", "No overwrite feature", "--number", "8", "No overwrite feature",
@@ -588,31 +473,20 @@ class TestAllowExistingBranch:
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert spec_file.read_text() == "# My custom spec content\n" assert spec_file.read_text() == "# My custom spec content\n"
def test_allow_existing_creates_branch_if_not_exists(self, git_repo: Path): def test_allow_existing_creates_feature_dir_when_missing(self, git_repo: Path):
"""T011: Verify normal creation when branch doesn't exist.""" """T011: Verify normal directory creation when the feature dir does not exist."""
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "new-branch", git_repo, "--allow-existing-branch", "--short-name", "new-branch",
"New branch feature", "New branch feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
current = subprocess.run( assert (git_repo / "specs" / "001-new-branch").is_dir()
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, capture_output=True, text=True,
).stdout.strip()
assert "new-branch" in current
def test_allow_existing_with_json(self, git_repo: Path): def test_allow_existing_with_json(self, git_repo: Path):
"""T012: Verify JSON output is correct.""" """T012: Verify JSON output is correct."""
import json import json
subprocess.run( (git_repo / "specs" / "009-json-test").mkdir(parents=True)
["git", "checkout", "-b", "009-json-test"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
result = run_script( result = run_script(
git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test", git_repo, "--allow-existing-branch", "--json", "--short-name", "json-test",
"--number", "9", "JSON test", "--number", "9", "JSON test",
@@ -622,64 +496,26 @@ class TestAllowExistingBranch:
assert data["BRANCH_NAME"] == "009-json-test" assert data["BRANCH_NAME"] == "009-json-test"
def test_allow_existing_no_git(self, no_git_dir: Path): def test_allow_existing_no_git(self, no_git_dir: Path):
"""T013: Verify flag is silently ignored in non-git repos.""" """T013: Verify flag also works in non-git repos."""
result = run_script( result = run_script(
no_git_dir, "--allow-existing-branch", "--short-name", "no-git", no_git_dir, "--allow-existing-branch", "--short-name", "no-git",
"No git feature", "No git feature",
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
def test_allow_existing_surfaces_checkout_error(self, git_repo: Path):
"""Checkout failures on an existing branch should include Git's stderr."""
shared_file = git_repo / "shared.txt"
shared_file.write_text("base\n")
subprocess.run(
["git", "add", "shared.txt"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "commit", "-m", "add shared file", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-b", "010-checkout-failure"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("branch version\n")
subprocess.run(
["git", "commit", "-am", "branch change", "-q"],
cwd=git_repo, check=True, capture_output=True,
)
subprocess.run(
["git", "checkout", "-"],
cwd=git_repo, check=True, capture_output=True,
)
shared_file.write_text("uncommitted main change\n")
result = run_script(
git_repo, "--allow-existing-branch", "--short-name", "checkout-failure",
"--number", "10", "Checkout failure",
)
assert result.returncode != 0, "checkout should fail with conflicting local changes"
assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr
assert "would be overwritten by checkout" in result.stderr
assert "shared.txt" in result.stderr
class TestAllowExistingBranchPowerShell: class TestAllowExistingBranchPowerShell:
def test_powershell_supports_allow_existing_branch_flag(self): def test_powershell_supports_allow_existing_branch_flag(self):
"""Static guard: PS script exposes and uses -AllowExistingBranch.""" """Static guard: PS script exposes and uses -AllowExistingBranch."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "-AllowExistingBranch" in contents assert "-AllowExistingBranch" in contents
# Ensure the flag is referenced in script logic, not just declared
assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "")
def test_powershell_surfaces_checkout_errors(self): def test_powershell_reuses_existing_feature_dir(self):
"""Static guard: PS script preserves checkout stderr on existing-branch failures.""" """Static guard: PS script handles existing feature directories without git."""
contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") contents = CREATE_FEATURE_PS.read_text(encoding="utf-8")
assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents assert "Feature directory '$featureDir' already exists" in contents
assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents assert "-not $AllowExistingBranch" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -754,20 +590,27 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}" assert branch == "003-new-feat", f"expected 003-new-feat, got: {branch}"
def test_dry_run_no_branch_created(self, git_repo: Path): def test_dry_run_does_not_change_git_branch(self, git_repo: Path):
"""T010: Dry-run does not create a git branch.""" """T010: Dry-run leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script( result = run_script(
git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature" git_repo, "--dry-run", "--short-name", "no-branch", "No branch feature"
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
branches = subprocess.run( after = subprocess.run(
["git", "branch", "--list", "*no-branch*"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "", "branch should not exist after dry-run" assert after == before
def test_dry_run_no_spec_dir_created(self, git_repo: Path): def test_dry_run_no_spec_dir_created(self, git_repo: Path):
"""T011: Dry-run does not create any directories (including root specs/).""" """T011: Dry-run does not create any directories (including root specs/)."""
@@ -832,50 +675,22 @@ class TestDryRun:
real_branch = line.split(":", 1)[1].strip() real_branch = line.split(":", 1)[1].strip()
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}" assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path): def test_dry_run_ignores_git_branches(self, git_repo: Path):
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering.""" """Dry-run uses only spec directories for numbering."""
(git_repo / "specs" / "001-existing").mkdir(parents=True) (git_repo / "specs" / "001-existing").mkdir(parents=True)
# Set up a bare remote and push (use subdirs of git_repo for isolation)
remote_dir = git_repo / "test-remote.git"
subprocess.run( subprocess.run(
["git", "init", "--bare", str(remote_dir)], ["git", "checkout", "-b", "005-git-only"],
check=True, capture_output=True, cwd=git_repo,
check=True,
capture_output=True,
) )
subprocess.run( subprocess.run(
["git", "remote", "add", "origin", str(remote_dir)], ["git", "checkout", "-"],
check=True, cwd=git_repo, capture_output=True, cwd=git_repo,
) check=True,
subprocess.run( capture_output=True,
["git", "push", "-u", "origin", "HEAD"],
check=True, cwd=git_repo, capture_output=True,
) )
# Clone into a second copy, create a higher-numbered branch, push it
second_clone = git_repo / "test-second-clone"
subprocess.run(
["git", "clone", str(remote_dir), str(second_clone)],
check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.email", "test@example.com"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test User"],
cwd=second_clone, check=True, capture_output=True,
)
# Create branch 005 on the remote (higher than local 001)
subprocess.run(
["git", "checkout", "-b", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
subprocess.run(
["git", "push", "origin", "005-remote-only"],
cwd=second_clone, check=True, capture_output=True,
)
# Primary repo: dry-run should see 005 via ls-remote and return 006
dry_result = run_script( dry_result = run_script(
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test" git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
) )
@@ -884,7 +699,7 @@ class TestDryRun:
for line in dry_result.stdout.splitlines(): for line in dry_result.stdout.splitlines():
if line.startswith("BRANCH_NAME:"): if line.startswith("BRANCH_NAME:"):
dry_branch = line.split(":", 1)[1].strip() dry_branch = line.split(":", 1)[1].strip()
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}" assert dry_branch == "002-remote-test", f"expected 002-remote-test, got: {dry_branch}"
def test_dry_run_json_includes_field(self, git_repo: Path): def test_dry_run_json_includes_field(self, git_repo: Path):
"""T015: JSON output includes DRY_RUN field when --dry-run is active.""" """T015: JSON output includes DRY_RUN field when --dry-run is active."""
@@ -910,7 +725,14 @@ class TestDryRun:
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
def test_dry_run_with_timestamp(self, git_repo: Path): def test_dry_run_with_timestamp(self, git_repo: Path):
"""T017: Dry-run works with --timestamp flag.""" """T017: Dry-run works with --timestamp flag without mutating git state."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_script( result = run_script(
git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature" git_repo, "--dry-run", "--timestamp", "--short-name", "ts-feat", "Timestamp feature"
) )
@@ -921,15 +743,14 @@ class TestDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch is not None, "no BRANCH_NAME in output" assert branch is not None, "no BRANCH_NAME in output"
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}" assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
# Verify no side effects after = subprocess.run(
branches = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "branch", "--list", "*ts-feat*"],
cwd=git_repo, cwd=git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "" assert after == before
def test_dry_run_with_number(self, git_repo: Path): def test_dry_run_with_number(self, git_repo: Path):
"""T018: Dry-run works with --number flag.""" """T018: Dry-run works with --number flag."""
@@ -989,20 +810,27 @@ class TestPowerShellDryRun:
branch = line.split(":", 1)[1].strip() branch = line.split(":", 1)[1].strip()
assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}" assert branch == "002-ps-feat", f"expected 002-ps-feat, got: {branch}"
def test_ps_dry_run_no_branch_created(self, ps_git_repo: Path): def test_ps_dry_run_does_not_change_git_branch(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create a git branch.""" """PowerShell -DryRun leaves the current git branch untouched."""
before = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo,
capture_output=True,
text=True,
check=True,
).stdout.strip()
result = run_ps_script( result = run_ps_script(
ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch" ps_git_repo, "-DryRun", "-ShortName", "no-ps-branch", "No branch"
) )
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
branches = subprocess.run( after = subprocess.run(
["git", "branch", "--list", "*no-ps-branch*"], ["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=ps_git_repo, cwd=ps_git_repo,
capture_output=True, capture_output=True,
text=True, text=True,
) check=True,
assert branches.returncode == 0, f"'git branch --list' failed: {branches.stderr}" ).stdout.strip()
assert branches.stdout.strip() == "", "branch should not exist after dry-run" assert after == before
def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path): def test_ps_dry_run_no_spec_dir_created(self, ps_git_repo: Path):
"""PowerShell -DryRun does not create specs/ directory.""" """PowerShell -DryRun does not create specs/ directory."""
@@ -1046,10 +874,10 @@ class TestPowerShellDryRun:
@requires_bash @requires_bash
class TestGitBranchNameOverrideBash: class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.sh."""
def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str):
script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
cmd = ["bash", str(script), "--json", *extra_args, "ignored"] cmd = ["bash", str(script), "--json", *extra_args, "ignored"]
return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True,
env={**os.environ, **env_extras}) env={**os.environ, **env_extras})
@@ -1101,10 +929,10 @@ class TestGitBranchNameOverrideBash:
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
class TestGitBranchNameOverridePowerShell: class TestGitBranchNameOverridePowerShell:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature-branch.ps1."""
def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict):
script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature-branch.ps1"
return subprocess.run( return subprocess.run(
["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"],
cwd=ext_ps_git_repo, capture_output=True, text=True, cwd=ext_ps_git_repo, capture_output=True, text=True,
@@ -1230,9 +1058,8 @@ class TestFeatureDirectoryResolution:
pytest.fail("FEATURE_DIR not found in output") pytest.fail("FEATURE_DIR not found in output")
@requires_bash @requires_bash
def test_fallback_to_branch_lookup(self, git_repo: Path): def test_errors_without_env_var_or_feature_json(self, git_repo: Path):
"""Without env var or feature.json, falls back to branch-based lookup.""" """Without env var or feature.json, get_feature_paths now errors."""
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
spec_dir = git_repo / "specs" / "001-test-feat" spec_dir = git_repo / "specs" / "001-test-feat"
spec_dir.mkdir(parents=True) spec_dir.mkdir(parents=True)
@@ -1242,14 +1069,8 @@ class TestFeatureDirectoryResolution:
capture_output=True, capture_output=True,
text=True, text=True,
) )
assert result.returncode == 0, result.stderr assert result.returncode != 0
for line in result.stdout.splitlines(): assert "Feature directory not found" in result.stderr
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip("'\"")
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in output")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path):
@@ -1343,7 +1164,7 @@ class TestDescriptionQuoting:
ids=["apostrophe", "double-quotes", "backslashes", "mixed"], ids=["apostrophe", "double-quotes", "backslashes", "mixed"],
) )
def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str): def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str):
"""Extension create-new-feature.sh succeeds with special characters in description.""" """Extension create-new-feature-branch.sh succeeds with special characters in description."""
script = ( script = (
ext_git_repo ext_git_repo
/ ".specify" / ".specify"
@@ -1351,7 +1172,7 @@ class TestDescriptionQuoting:
/ "git" / "git"
/ "scripts" / "scripts"
/ "bash" / "bash"
/ "create-new-feature.sh" / "create-new-feature-branch.sh"
) )
result = subprocess.run( result = subprocess.run(
["bash", str(script), "--dry-run", "--short-name", "feat", description], ["bash", str(script), "--dry-run", "--short-name", "feat", description],

View File

@@ -1,6 +1,6 @@
"""Regression guard: utility and asset symbols importable from specify_cli.""" """Regression guard: utility and asset symbols importable from specify_cli."""
from specify_cli import ( from specify_cli import (
check_tool, is_git_repo, merge_json_files, check_tool, merge_json_files,
get_speckit_version, get_speckit_version,
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH, CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
) )
@@ -9,7 +9,6 @@ from pathlib import Path
def test_utils_symbols_importable(): def test_utils_symbols_importable():
assert callable(check_tool) assert callable(check_tool)
assert callable(merge_json_files) assert callable(merge_json_files)
assert callable(is_git_repo)
def test_get_speckit_version_returns_string(): def test_get_speckit_version_returns_string():
version = get_speckit_version() version = get_speckit_version()

View File

@@ -658,6 +658,47 @@ class TestCommandStep:
# Claude is a SkillsIntegration so uses /speckit-specify # Claude is a SkillsIntegration so uses /speckit-specify
assert "/speckit-specify login" in call_args[0][0][2] assert "/speckit-specify login" in call_args[0][0][2]
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
"""Command preflight falls back to build_exec_args() argv[0]."""
from unittest.mock import MagicMock, patch
from specify_cli.workflows.steps.command import CommandStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
seen_which: list[str] = []
def fake_which(name: str) -> str | None:
seen_which.append(name)
return name if name == "/opt/claude" else None
step = CommandStep()
ctx = StepContext(
inputs={"name": "login"},
default_integration="claude",
project_root=str(tmp_path),
)
config = {
"id": "test",
"command": "speckit.specify",
"input": {"args": "{{ inputs.name }}"},
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = '{"result": "done"}'
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert seen_which[:2] == ["claude", "/opt/claude"]
call_args = mock_run.call_args
assert call_args[0][0][0] == "/opt/claude"
assert "/speckit-specify login" in call_args[0][0][2]
def test_dispatch_failure_returns_failed_status(self, tmp_path): def test_dispatch_failure_returns_failed_status(self, tmp_path):
"""When the CLI exits non-zero, the step should fail.""" """When the CLI exits non-zero, the step should fail."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -810,6 +851,46 @@ class TestPromptStep:
assert result.output["dispatched"] is True assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0 assert result.output["exit_code"] == 0
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
"""Prompt preflight falls back to build_exec_args() argv[0]."""
from unittest.mock import MagicMock, patch
from specify_cli.workflows.steps.prompt import PromptStep
from specify_cli.workflows.base import StepContext, StepStatus
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
seen_which: list[str] = []
def fake_which(name: str) -> str | None:
seen_which.append(name)
return name if name == "/opt/claude" else None
step = PromptStep()
ctx = StepContext(
default_integration="claude",
project_root=str(tmp_path),
)
config = {
"id": "ask",
"type": "prompt",
"prompt": "Explain this code",
}
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "Here is the explanation"
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert seen_which[:2] == ["claude", "/opt/claude"]
call_args = mock_run.call_args
assert call_args[0][0][0] == "/opt/claude"
assert call_args[0][0][2] == "Explain this code"
def test_validate_missing_prompt(self): def test_validate_missing_prompt(self):
from specify_cli.workflows.steps.prompt import PromptStep from specify_cli.workflows.steps.prompt import PromptStep