Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
f8f7f1c603 chore: bump version to 0.3.1 2026-03-17 16:30:05 +00:00
Manfred Riem
489ced56ba docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878) 2026-03-17 11:05:37 -05:00
darkglow-net
6644f69a96 fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
* fix(ai-skills): exclude non-speckit copilot agent markdown from skill generation

* Potential fix for pull request finding

Fix missing `.agent` filename suffix

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

* Fix test assertion speckit.plan.md to speckit.plan.agent


Fix test assertion speckit.plan.md to speckit.plan.agent

* Fix filter glob based on review suggestions

fix(ai-skills): normalize Copilot .agent template names and align template fallback filtering

* Add template glob for fallback directory

* GH Copilot Suggestions

Clarify comment regarding Copilot's use of templates in tests.
Add extra test assertion

* fix(ai-skills): normalize Copilot .agent templates and preserve fallback behavior

fix(ai-skills): handle Copilot .agent templates and fallback filtering

Normalize Copilot command template names by stripping the .agent suffix
when deriving skill names and metadata sources, so files like
speckit.plan.agent.md produce speckit-plan and map to plan.md metadata.

Also align Copilot template discovery with speckit.* filtering while
preserving fallback to templates/commands/ when .github/agents contains
only user-authored markdown files, and add regression coverage for both
non-speckit agent exclusion and fallback behavior.

* fix(ai-skills): ignore non-speckit markdown commands

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 10:25:18 -05:00
黄黄汪
a177a1a6d1 feat: add Trae IDE support as a new agent (#1817)
* feat: add Trae IDE support as a new agent

Add Trae (https://www.trae.ai/) as a supported AI agent in spec-kit.
Trae is an IDE-based agent that uses .trae/rules/ directory for
project-level rules in Markdown format.

Changes across 9 files:
- src/specify_cli/__init__.py: Add trae to AGENT_CONFIG (IDE-based,
  .trae/ folder, rules subdir, no CLI required)
- src/specify_cli/extensions.py: Add trae to CommandRegistrar.AGENT_CONFIGS
  (.trae/rules, markdown format, .md extension)
- README.md: Add Trae to supported agents table, CLI examples, and
  --ai option description
- .github/workflows/scripts/create-release-packages.sh: Add trae to
  ALL_AGENTS array and build case statement
- .github/workflows/scripts/create-release-packages.ps1: Add trae to
  AllAgents array and switch statement
- .github/workflows/scripts/create-github-release.sh: Add trae template
  zip files to release assets
- scripts/bash/update-agent-context.sh: Add TRAE_FILE, trae case in
  update function, and auto-detect block
- scripts/powershell/update-agent-context.ps1: Add TRAE_FILE, ValidateSet
  entry, switch case, and auto-detect block
- tests/test_agent_config_consistency.py: Add 8 consistency tests for
  trae following established kimi/tabnine patterns

* fix: correct Generate-Commands parameter names for trae in PowerShell release script

Fix incorrect parameter names in the trae case of Build-Variant:
- -Format -> -Extension
- -ArgsToken -> -ArgFormat
- -OutDir -> -OutputDir

These now match the Generate-Commands function signature and all other
agent entries in the script.

Co-authored-by: Copilot <copilot@github.com>

* Update release packaging scripts and agent docs

* Update Agent.md

* Restore format

* Adjust order

* Potential fix for pull request finding

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

* Unused

* fix: add TRAE_FILE to update_all_existing_agents() for auto-detect support

Add missing update_if_new call for TRAE_FILE in the bash
update-agent-context.sh script's update_all_existing_agents()
function, matching the PowerShell implementation.

This ensures running the script without arguments will correctly
auto-detect and update existing Trae agent files.

* Add configuration for 'trae' in agents.py

* Potential fix for pull request finding

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

* Refactor trae configuration test for clarity

* Potential fix for pull request finding

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

* Update update-agent-context.sh

* Fix formatting in update-agent-context.sh

---------

Co-authored-by: root <root@g340-cd52-700-60f9-5561-211-6c32.byted.org>
Co-authored-by: root <root@g340-cd52-700-c3d1-c735-796-4b9e.byted.org>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 10:03:54 -05:00
Hamilton Snow
c12b8c1763 feat(cli): polite deep merge for settings.json and support JSONC (#1874)
* feat(cli): polite deep merge for settings.json with json5 and safe atomic write

* fix(cli): prevent temp fd leak and align merge-policy docs
2026-03-17 09:51:13 -05:00
Michal Bachorik
d2ecf6560d feat(extensions,presets): add priority-based resolution ordering (#1855)
* feat(extensions,presets): add priority-based resolution ordering

Add priority field to extension and preset registries for deterministic
template resolution when multiple sources provide the same template.

Extensions:
- Add `list_by_priority()` method to ExtensionRegistry
- Add `--priority` option to `extension add` command
- Add `extension set-priority` command
- Show priority in `extension list` and `extension info`
- Preserve priority during `extension update`
- Update RFC documentation

Presets:
- Add `preset set-priority` command
- Show priority in `preset info` output
- Use priority ordering in PresetResolver for extensions

Both systems:
- Lower priority number = higher precedence (default: 10)
- Backwards compatible with legacy entries (missing priority defaults to 10)
- Comprehensive test coverage including backwards compatibility

Closes #1845
Closes #1854

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review feedback

- list_by_priority(): add secondary sort by ID for deterministic ordering,
  return deep copies to prevent mutation
- install_from_directory/zip: validate priority >= 1 early
- extension add CLI: validate --priority >= 1 before install
- PresetRegistry.update(): preserve installed_at timestamp
- Test assertions: use exact source string instead of substring match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address additional review feedback

- PresetResolver: add fallback to directory scanning when registry is
  empty/corrupted for robustness and backwards compatibility
- PresetRegistry.update(): add guard to prevent injecting installed_at
  when absent in existing entry (mirrors ExtensionRegistry behavior)
- RFC: update extension list example to match actual CLI output format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore defensive code and RFC descriptions lost in rebase

- Restore defensive code in list_by_priority() with .get() and isinstance check
- Restore detailed --from URL and --dev option descriptions in RFC

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add defensive code to presets list_by_priority()

- Add .get() and isinstance check for corrupted/empty registry
- Move copy import to module level (remove local import)
- Matches defensive pattern used in extensions.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address reviewer feedback on priority resolution

- Rename _normalize_priority to normalize_priority (public API)
- Add comprehensive tests for normalize_priority function (9 tests)
- Filter non-dict metadata entries in list_by_priority() methods
- Fix extension priority resolution to merge registered and unregistered
  extensions into unified sorted list (unregistered get implicit priority 10)
- Add tests for extension priority resolution ordering (4 tests)

The key fix ensures unregistered extensions with implicit priority 10
correctly beat registered extensions with priority > 10, and vice versa.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: DRY refactor and strengthen test assertions

- Extract _get_all_extensions_by_priority() helper in PresetResolver
  to eliminate duplicated extension list construction
- Add priority=10 assertion to test_legacy_extension_without_priority_field
- Add priority=10 assertion to test_legacy_preset_without_priority_field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add isinstance(dict) checks for corrupted registry entries

Add defensive checks throughout CLI commands and manager methods
to handle cases where registry entries may be corrupted (non-dict
values). This prevents AttributeError when calling .get() on
non-dict metadata.

Locations fixed:
- __init__.py: preset/extension info, set-priority, enable/disable,
  upgrade commands
- extensions.py: list_installed()
- presets.py: list_installed()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: normalize priority display to match resolution behavior

Use normalize_priority() for all priority display in CLI commands
to ensure displayed values match actual resolution behavior when
registry data is corrupted/hand-edited.

Locations fixed:
- extensions.py: list_installed()
- presets.py: list_installed(), PresetResolver
- __init__.py: preset info, extension info, set-priority commands

Also added GraphQL query for unresolved PR comments to CLAUDE.md.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: repair corrupted priority values in set-priority commands

Changed set-priority commands to check if the raw stored value is
already a valid int equal to the requested priority before skipping.
This ensures corrupted values (e.g., "high") get repaired even when
setting to the default priority (10).

Also removed CLAUDE.md that was accidentally added to the repo.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: harden registry update methods against corrupted entries

- Normalize priority when restoring during extension update to prevent
  propagating corrupted values (e.g., "high", 0, negative)
- Add isinstance(dict) checks in ExtensionRegistry.update() and
  PresetRegistry.update() to handle corrupted entries (string/list)
  that would cause TypeError on merge

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use safe fallback for version in list_installed()

When registry entry is corrupted (non-dict), metadata becomes {} after
the isinstance check. Use metadata.get("version", manifest.version)
instead of metadata["version"] to avoid KeyError.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:44:34 -05:00
Seiya Kojima
7a5762fe6a fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)
In multi-remote environments, `git fetch --all` outputs messages like
"Fetching origin" to stdout. Since `check_existing_branches()` only
redirected stderr (`2>/dev/null`), the stdout output was captured by
the `$(...)` command substitution calling this function, contaminating
the branch number return value and causing arithmetic errors like
`$((10#Fetching...))`.

Fix: redirect both stdout and stderr to /dev/null (`>/dev/null 2>&1`).
2026-03-17 09:22:34 -05:00
Pierluigi Lenoci
9c0c1446ec fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)
* fix(scripts): harden bash scripts with escape, compat, and cleanup fixes

- common.sh: complete RFC 8259 JSON escape (\b, \f, strip control chars)
- common.sh: distinguish python3 success-empty vs failure in resolve_template
- check-prerequisites.sh: escape doc names through json_escape in fallback path
- create-new-feature.sh: remove duplicate json_escape (already in common.sh)
- create-new-feature.sh: warn on stderr when spec template is not found
- update-agent-context.sh: move nested function to top-level for bash 3.2 compat

* fix(scripts): explicit resolve_template return code and best-effort agent updates

- common.sh: resolve_template now returns 1 when no template is found,
  making the "not found" case explicit instead of relying on empty stdout
- setup-plan.sh, create-new-feature.sh: add || true to resolve_template
  calls so set -e does not abort on missing templates (non-fatal)
- update-agent-context.sh: accumulate errors in update_all_existing_agents
  instead of silently discarding them — all agents are attempted and the
  composite result is returned, matching the PowerShell equivalent behavior

* style(scripts): add clarifying comment in resolve_template preset branch

* fix(scripts): wrap python3 call in if-condition to prevent set -e abort

Move the python3 command substitution in resolve_template into an
if-condition so that a non-zero exit (e.g. invalid .registry JSON)
does not abort the function under set -e. The fallback directory
scan now executes as intended regardless of caller errexit settings.

* fix(scripts): track agent file existence before update and avoid top-level globals

- _update_if_new now records the path and sets _found_agent before calling
  update_agent_file, so that failures do not cause duplicate attempts on
  aliased paths (AMP/KIRO/BOB -> AGENTS_FILE) or false "no agent files
  found" fallback triggers
- Remove top-level initialisation of _updated_paths and _found_agent;
  they are now created exclusively inside update_all_existing_agents,
  keeping the script side-effect free when sourced
2026-03-16 17:51:47 -05:00
LADISLAV BIHARI
82b8ce4295 Add cognitive-squad to community extension catalog (#1870)
- Extension ID: cognitive-squad
- Version: 0.1.0
- Author: Testimonial
- 19-function cognitive agent squad for autonomous pre-code analysis
- 7 core agents, 7 specialists, 4 learning functions, feedback loop
- Requires: spec-kit >=0.3.0, optionally understanding >=3.4.0

Repository: https://github.com/Testimonial/cognitive-squad

Co-authored-by: Ladislav Bihari <ladislav.bihari@statsperform.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:10:37 -05:00
Manfred Riem
2cf332db1b docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)
Closes #1390
2026-03-16 13:57:44 -05:00
Ricardo Accioly
b1650f884d chore: update DocGuard extension to v0.9.8 (#1859)
* chore: update DocGuard to v0.9.7

* docs: update DocGuard description in extensions table

* chore: update DocGuard to v0.9.8

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-03-16 12:14:49 -05:00
KhawarHabibKhan
23bd645054 Feature: add specify status command (#1837)
* feat: add specify status command with project info, agent detection, and feature detection

* feat: add SDD artifacts check and task progress parsing to specify status

* feat: add workflow phase detection and extensions summary to specify status

* Revert "feat: add workflow phase detection and extensions summary to specify status"

This reverts commit 1afe3c52af.

* Revert "feat: add SDD artifacts check and task progress parsing to specify status"

This reverts commit 3be36f8759.

* Revert "feat: add specify status command with project info, agent detection, and feature detection"

This reverts commit 681dc46af9.

* feat: add spec-kit-status extension to community catalog

* 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>

* Potential fix for pull request finding

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

* Revert "Potential fix for pull request finding"

This reverts commit 040447be03.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-03-16 12:08:43 -05:00
Michal Bachorik
bef9c2cb59 fix(extensions): show extension ID in list output (#1843)
Display the extension ID below the name in `specify extension list` output.
This allows users to easily copy the ID when disambiguation is needed.

Fixes #1832

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-16 08:41:10 -05:00
Stanislav Deviatov
4f81fc298f feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
* feat(extensions): add reconcile and archive to community catalog

* Update extension link text and add changelogs

Normalize extension link text in extensions/README.md (replace `[@stn1slv]` with `spec-kit-archive` and `spec-kit-reconcile`) and add CHANGELOG URLs to the corresponding entries in extensions/catalog.community.json for the Archive and Reconcile extensions.

---------

Co-authored-by: Stanislav Deviatov <stn1slv@users.noreply.github.com>
2026-03-16 07:46:06 -05:00
Ricardo Accioly
4a3234496e feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
* feat: add DocGuard CDD enforcement extension to community catalog

DocGuard is a Canonical-Driven Development enforcement tool that generates,
validates, scores, and traces project documentation against 51 automated checks.

Provides 6 commands:
- guard: 51-check validation with quality labels
- diagnose: AI-ready fix prompts
- score: CDD maturity scoring (0-100)
- trace: ISO 29119 traceability matrix
- generate: Reverse-engineer docs from codebase
- init: Initialize CDD with compliance profiles

Features:
- Zero dependencies (pure Node.js)
- Config-aware traceability (respects .docguard.json)
- Orphan file detection
- Research-backed (AITPG/TRACE, IEEE TSE/TMLCN 2026)

npm: https://www.npmjs.com/package/docguard-cli
GitHub: https://github.com/raccioly/docguard

* fix: use release asset URL for download_url

The source archive URL nests files under a subdirectory, so the
Spec Kit installer cannot find extension.yml at the archive root.
Switch to a release asset ZIP built from the extension directory.

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

* docs: add DocGuard to community extensions README table

* chore: update DocGuard entry to v0.8.0 (92 checks)

* chore: update DocGuard description (51→92 checks)

---------

Co-authored-by: Ricardo Accioly <ricardoaccioly@RAccioly-J0CWDQ4MXV.attlocal.net>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 17:41:55 -05:00
Manfred Riem
f92d81bbec chore: bump version to 0.3.0 (#1839)
* chore: bump version to 0.3.0

* Potential fix for pull request finding

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 15:17:19 -05:00
Copilot
69ee7a836e feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
* Initial plan

* feat(templates): add pluggable template system with packs, catalog, resolver, and CLI commands

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* test(templates): add comprehensive unit tests for template pack system

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* feat(presets): pluggable preset system with template/command overrides, catalog, and resolver

- Rename 'template packs' to 'presets' to avoid naming collision with core templates
- PresetManifest, PresetRegistry, PresetManager, PresetCatalog, PresetResolver in presets.py
- Extract CommandRegistrar to agents.py as shared infrastructure
- CLI: specify preset list/add/remove/search/resolve/info
- CLI: specify preset catalog list/add/remove
- --preset option on specify init
- Priority-based preset stacking (--priority, lower = higher precedence)
- Command overrides registered into all detected agent directories (17+ agents)
- Extension command safety: skip registration if target extension not installed
- Multi-catalog support: env var, project config, user config, built-in defaults
- resolve_template() / Resolve-Template in bash/PowerShell scripts
- Self-test preset: overrides all 6 core templates + 1 command
- Scaffold with 4 examples: core/extension template and command overrides
- Preset catalog (catalog.json, catalog.community.json)
- Documentation: README.md, ARCHITECTURE.md, PUBLISHING.md
- 110 preset tests, 253 total tests passing

* feat(presets): propagate command overrides to skills via init-options

- Add save_init_options() / load_init_options() helpers that persist
  CLI flags from 'specify init' to .specify/init-options.json
- PresetManager._register_skills() overwrites SKILL.md files when
  --ai-skills was used during init and corresponding skill dirs exist
- PresetManager._unregister_skills() restores core template content
  on preset removal
- registered_skills stored in preset registry metadata
- 8 new tests covering skill override, skip conditions, and restore

* fix: address PR check failures (ruff F541, CodeQL URL substring)

- Remove extraneous f-prefix from two f-strings without placeholders
- Replace substring URL check in test with startswith/endswith assertions
  to satisfy CodeQL incomplete URL substring sanitization rule

* fix: address Copilot PR review comments

- Move save_init_options() before preset install so skills propagation
  works during 'specify init --preset --ai-skills'
- Clean up downloaded ZIP after successful preset install during init
- Validate --from URL scheme (require HTTPS, HTTP only for localhost)
- Expose unregister_commands() on extensions.py CommandRegistrar wrapper
  instead of reaching into private _registrar field
- Use _get_merged_packs() for search() and get_pack_info() so all
  active catalogs are searched, not just the highest-priority one
- Fix fetch_catalog() cache to verify cached URL matches current URL
- Fix PresetResolver: script resolution uses .sh extension, consistent
  file extensions throughout resolve(), and resolve_with_source()
  delegates to resolve() to honor template_type parameter
- Fix bash common.sh: fall through to directory scan when python3
  returns empty preset list
- Fix PowerShell Resolve-Template: filter out dot-folders and sort
  extensions deterministically

* fix: narrow empty except blocks and add explanatory comments

* fix: address Copilot PR review comments (round 2)

- Fix init --preset error masking: distinguish "not found" from real errors
- Fix bash resolve_template: skip hidden dirs in extensions (match Python/PS)
- Fix temp dir leaks in tests: use temp_dir fixture instead of mkdtemp
- Fix self-test catalog entry: add note that it's local-only (no download_url)
- Fix Windows path issue in resolve_with_source: use Path.relative_to()
- Fix skill restore path: use project's .specify/templates/commands/ not source tree
- Add encoding="utf-8" to all file read/write in agents.py
- Update test to set up core command templates for skill restoration

* fix: remove self-test from catalog.json (local-only preset)

* fix: address Copilot PR review comments (round 3)

- Fix PS Resolve-Template fallback to skip dot-prefixed dirs (.cache)
- Rename _catalog to _catalog_name for consistency with extension system
- Enforce install_allowed policy in CLI preset add and download_pack()
- Fix shell injection: pass registry path via env var instead of string interpolation

* fix: correct PresetError docstring from template to preset

* Removed CHANGELOG requirement

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

* Applying review recommendations

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
2026-03-13 15:09:14 -05:00
47 changed files with 7480 additions and 547 deletions

View File

@@ -58,6 +58,8 @@ gh release create "$VERSION" \
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
--title "Spec Kit Templates - $VERSION_NO_V" \

View File

@@ -14,7 +14,7 @@
.PARAMETER Agents
Comma or space separated subset of agents to build (default: all)
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, generic
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, generic
.PARAMETER Scripts
Comma or space separated subset of script types to build (default: both)
@@ -454,6 +454,11 @@ function Build-Variant {
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
}
'trae' {
$rulesDir = Join-Path $baseDir ".trae/rules"
New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null
Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script
}
'generic' {
$cmdDir = Join-Path $baseDir ".speckit/commands"
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
@@ -470,7 +475,7 @@ function Build-Variant {
}
# Define all agents and scripts
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic')
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'generic')
$AllScripts = @('sh', 'ps')
function Normalize-List {

View File

@@ -6,7 +6,7 @@ set -euo pipefail
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
# Version argument should include leading 'v'.
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic (default: all)
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic (default: all)
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
# Examples:
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
@@ -291,6 +291,9 @@ build_variant() {
kimi)
mkdir -p "$base_dir/.kimi/skills"
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
trae)
mkdir -p "$base_dir/.trae/rules"
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
generic)
mkdir -p "$base_dir/.speckit/commands"
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
@@ -300,7 +303,7 @@ build_variant() {
}
# Determine agent list
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic)
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic)
ALL_SCRIPTS=(sh ps)
norm_list() {

View File

@@ -10,10 +10,6 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
---
## General practices
- Any changes to `__init__.py` for the Specify CLI require a version rev in `pyproject.toml` and addition of entries to `CHANGELOG.md`.
## Adding New Agent Support
This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow.
@@ -50,6 +46,7 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
### Step-by-Step Integration Guide

View File

@@ -7,6 +7,123 @@ Recent changes to the Specify CLI and templates are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.1] - 2026-03-17
### Changed
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
- fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
- feat: add Trae IDE support as a new agent (#1817)
- feat(cli): polite deep merge for settings.json and support JSONC (#1874)
- feat(extensions,presets): add priority-based resolution ordering (#1855)
- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)
- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)
- Add cognitive-squad to community extension catalog (#1870)
- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)
- chore: update DocGuard extension to v0.9.8 (#1859)
- Feature: add specify status command (#1837)
- fix(extensions): show extension ID in list output (#1843)
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
- chore: bump version to 0.3.0 (#1839)
- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
- fix: match 'Last updated' timestamp with or without bold markers (#1836)
- Add specify doctor command for project health diagnostics (#1828)
- fix: harden bash scripts against shell injection and improve robustness (#1809)
- fix: clean up command templates (specify, analyze) (#1810)
- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
- fix(cli): deprecate explicit command support for agy (#1798) (#1808)
- Add /selftest.extension core extension to test other extensions (#1758)
- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
- Add Java brownfield walkthrough to community walkthroughs (#1820)
- chore: bump version to 0.2.1 (#1813)
- Added February 2026 newsletter (#1812)
- feat: add Kimi Code CLI agent support (#1790)
- docs: fix broken links in quickstart guide (#1759) (#1797)
- docs: add catalog cli help documentation (#1793) (#1794)
- fix: use quiet checkout to avoid exception on git checkout (#1792)
- feat(extensions): support .extensionignore to exclude files during install (#1781)
- feat: add Codex support for extension command registration (#1767)
- chore: bump version to 0.2.0 (#1786)
- fix: sync agent list comments with actual supported agents (#1785)
- feat(extensions): support multiple active catalogs simultaneously (#1720)
- Pavel/add tabnine cli support (#1503)
- Add Understanding extension to community catalog (#1778)
- Add ralph extension to community catalog (#1780)
- Update README with project initialization instructions (#1772)
- feat: add review extension to community catalog (#1775)
- Add fleet extension to community catalog (#1771)
- Integration of Mistral vibe support into speckit (#1725)
- fix: Remove duplicate options in specify.md (#1765)
- fix: use global branch numbering instead of per-short-name detection (#1757)
- Add Community Walkthroughs section to README (#1766)
- feat(extensions): add Jira Integration to community catalog (#1764)
- Add Azure DevOps Integration extension to community catalog (#1734)
- Fix docs: update Antigravity link and add initialization example (#1748)
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
- make c ignores consistent with c++ (#1747)
- chore: bump version to 0.1.13 (#1746)
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
- feat: add verify extension to community catalog (#1726)
- Add Retrospective Extension to community catalog README table (#1741)
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
- fix: correct Copilot extension command registration (#1724)
- fix(implement): remove Makefile from C ignore patterns (#1558)
- Add sync extension to community catalog (#1728)
- fix(checklist): clarify file handling behavior for append vs create (#1556)
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
- chore: bump version to 0.1.12 (#1737)
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
## [0.3.0] - 2026-03-13
### Changed
- No changes have been documented for this release yet.
<!-- Entries for 0.2.x and earlier releases are documented in their respective sections below. -->
- make c ignores consistent with c++ (#1747)
- chore: bump version to 0.1.13 (#1746)
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
- feat: add verify extension to community catalog (#1726)
- Add Retrospective Extension to community catalog README table (#1741)
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
- fix: correct Copilot extension command registration (#1724)
- fix(implement): remove Makefile from C ignore patterns (#1558)
- Add sync extension to community catalog (#1728)
- fix(checklist): clarify file handling behavior for append vs create (#1556)
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
- chore: bump version to 0.1.12 (#1737)
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
## [Unreleased]
### Added
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
- feat(presets): Pluggable preset system with preset catalog and template resolver
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
- CLI commands: `specify preset search`, `specify preset add`, `specify preset list`, `specify preset remove`, `specify preset resolve`, `specify preset info`
- CLI commands: `specify preset catalog list`, `specify preset catalog add`, `specify preset catalog remove` for multi-catalog management
- `PresetCatalogEntry` dataclass and multi-catalog support mirroring the extension catalog system
- `--preset` option for `specify init` to install presets during initialization
- Priority-based preset resolution: presets with lower priority number win (`--priority` flag)
- `resolve_template()` / `Resolve-Template` helpers in bash and PowerShell common scripts
- Template resolution priority stack: overrides → presets → extensions → core
- Preset catalog files (`presets/catalog.json`, `presets/catalog.community.json`)
- Preset scaffold directory (`presets/scaffold/`)
- Scripts updated to use template resolution instead of hardcoded paths
- feat(presets): Preset command overrides now propagate to agent skills when `--ai-skills` was used during init
- feat: `specify init` persists CLI options to `.specify/init-options.json` for downstream operations
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
## [0.2.1] - 2026-03-11
### Changed
@@ -51,13 +168,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
## [Unreleased]
### Added
- feat(extensions): support `.extensionignore` to exclude files/folders during `specify extension add` (#1781)
## [0.2.0] - 2026-03-09
### Changed

View File

@@ -158,6 +158,10 @@ See Spec-Driven Development in action across different scenarios with these comm
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
## 🤖 Supported AI Agents
| Agent | Support | Notes |
@@ -184,6 +188,7 @@ See Spec-Driven Development in action across different scenarios with these comm
| [Kimi Code](https://code.kimi.com/) | ✅ | |
| [Windsurf](https://windsurf.com/) | ✅ | |
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
| [Trae](https://www.trae.ai/) | ✅ | |
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
## 🔧 Specify CLI Reference

View File

@@ -72,12 +72,17 @@ The following community-contributed extensions are available in [`catalog.commun
| Extension | Purpose | URL |
|-----------|---------|-----|
| Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |

View File

@@ -359,12 +359,15 @@ specify extension add jira
"installed_at": "2026-01-28T14:30:00Z",
"source": "catalog",
"manifest_hash": "sha256:abc123...",
"enabled": true
"enabled": true,
"priority": 10
}
}
}
```
**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.
### 3. Configuration
```bash
@@ -1084,11 +1087,15 @@ List installed extensions in current project.
$ specify extension list
Installed Extensions:
jira (v1.0.0) - Jira Integration
Commands: 3 | Hooks: 2 | Status: Enabled
✓ Jira Integration (v1.0.0)
jira
Create Jira issues from spec-kit artifacts
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
linear (v0.9.0) - Linear Integration
Commands: 1 | Hooks: 1 | Status: Enabled
✓ Linear Integration (v0.9.0)
linear
Create Linear issues from spec-kit artifacts
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
```
**Options:**
@@ -1196,10 +1203,9 @@ Next steps:
**Options:**
- `--from URL`: Install from custom URL or Git repo
- `--version VERSION`: Install specific version
- `--dev PATH`: Install from local path (development mode)
- `--no-register`: Skip command registration (manual setup)
- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.
- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).
- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)
#### `specify extension remove NAME`
@@ -1280,6 +1286,29 @@ $ specify extension disable jira
To re-enable: specify extension enable jira
```
#### `specify extension set-priority NAME PRIORITY`
Change the resolution priority of an installed extension.
```bash
$ specify extension set-priority jira 5
✓ Extension 'Jira Integration' priority changed: 10 → 5
Lower priority = higher precedence in template resolution
```
**Priority Values:**
- Lower numbers = higher precedence (checked first in resolution)
- Default priority is 10
- Must be a positive integer (1 or higher)
**Use Cases:**
- Ensure a critical extension's templates take precedence
- Override default resolution order when multiple extensions provide similar templates
---
## Compatibility & Versioning

View File

@@ -1,8 +1,39 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-13T12:00:00Z",
"updated_at": "2026-03-16T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"archive": {
"name": "Archive Extension",
"id": "archive",
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
"author": "Stanislav Deviatov",
"version": "1.0.0",
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-archive",
"homepage": "https://github.com/stn1slv/spec-kit-archive",
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
"changelog": "https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"archive",
"memory",
"merge",
"changelog"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
},
"azure-devops": {
"name": "Azure DevOps Integration",
"id": "azure-devops",
@@ -74,6 +105,92 @@
"created_at": "2026-02-22T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z"
},
"cognitive-squad": {
"name": "Cognitive Squad",
"id": "cognitive-squad",
"description": "19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop",
"author": "Testimonial",
"version": "0.1.0",
"download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/Testimonial/cognitive-squad",
"homepage": "https://github.com/Testimonial/cognitive-squad",
"documentation": "https://github.com/Testimonial/cognitive-squad/blob/main/README.md",
"changelog": "https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.3.0",
"tools": [
{
"name": "understanding",
"version": ">=3.4.0",
"required": false
},
{
"name": "spec-kit-reverse-eng",
"version": ">=1.0.0",
"required": false
}
]
},
"provides": {
"commands": 7,
"hooks": 1
},
"tags": [
"ai-agents",
"cognitive",
"pre-code",
"analysis",
"multi-agent"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"docguard": {
"name": "DocGuard \u2014 CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies.",
"author": "raccioly",
"version": "0.9.8",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.8/spec-kit-docguard-v0.9.8.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
"changelog": "https://github.com/raccioly/docguard/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "node",
"version": ">=18.0.0",
"required": true
}
]
},
"provides": {
"commands": 6,
"hooks": 3
},
"tags": [
"documentation",
"validation",
"quality",
"cdd",
"traceability",
"ai-agents",
"enforcement",
"spec-kit"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-15T20:00:00Z"
},
"doctor": {
"name": "Project Health Check",
"id": "doctor",
@@ -124,7 +241,12 @@
"commands": 2,
"hooks": 1
},
"tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"],
"tags": [
"orchestration",
"workflow",
"human-in-the-loop",
"parallel"
],
"verified": false,
"downloads": 0,
"stars": 0,
@@ -191,13 +313,49 @@
"commands": 2,
"hooks": 1
},
"tags": ["implementation", "automation", "loop", "copilot"],
"tags": [
"implementation",
"automation",
"loop",
"copilot"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-03-09T00:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",
"id": "reconcile",
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
"author": "Stanislav Deviatov",
"version": "1.0.0",
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
"changelog": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"reconcile",
"drift",
"tasks",
"remediation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-14T00:00:00Z",
"updated_at": "2026-03-14T00:00:00Z"
},
"retrospective": {
"name": "Retrospective Extension",
"id": "retrospective",
@@ -249,7 +407,15 @@
"commands": 7,
"hooks": 1
},
"tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"],
"tags": [
"code-review",
"quality",
"review",
"testing",
"error-handling",
"type-design",
"simplification"
],
"verified": false,
"downloads": 0,
"stars": 0,
@@ -291,7 +457,7 @@
"understanding": {
"name": "Understanding",
"id": "understanding",
"description": "Automated requirements quality analysis validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
"description": "Automated requirements quality analysis \u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
"author": "Ladislav Bihari",
"version": "3.4.0",
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
@@ -329,6 +495,38 @@
"created_at": "2026-03-07T00:00:00Z",
"updated_at": "2026-03-07T00:00:00Z"
},
"status": {
"name": "Project Status",
"id": "status",
"description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.",
"author": "KhawarHabibKhan",
"version": "1.0.0",
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"status",
"workflow",
"progress",
"feature-tracking",
"task-progress"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",

157
presets/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,157 @@
# Preset System Architecture
This document describes the internal architecture of the preset system — how template resolution, command registration, and catalog management work under the hood.
For usage instructions, see [README.md](README.md).
## Template Resolution
When Spec Kit needs a template (e.g. `spec-template`), the `PresetResolver` walks a priority stack and returns the first match:
```mermaid
flowchart TD
A["resolve_template('spec-template')"] --> B{Override exists?}
B -- Yes --> C[".specify/templates/overrides/spec-template.md"]
B -- No --> D{Preset provides it?}
D -- Yes --> E[".specify/presets/preset-id/templates/spec-template.md"]
D -- No --> F{Extension provides it?}
F -- Yes --> G[".specify/extensions/ext-id/templates/spec-template.md"]
F -- No --> H[".specify/templates/spec-template.md"]
E -- "multiple presets?" --> I["lowest priority number wins"]
I --> E
style C fill:#4caf50,color:#fff
style E fill:#2196f3,color:#fff
style G fill:#ff9800,color:#fff
style H fill:#9e9e9e,color:#fff
```
| Priority | Source | Path | Use case |
|----------|--------|------|----------|
| 1 (highest) | Override | `.specify/templates/overrides/` | One-off project-local tweaks |
| 2 | Preset | `.specify/presets/<id>/templates/` | Shareable, stackable customizations |
| 3 | Extension | `.specify/extensions/<id>/templates/` | Extension-provided templates |
| 4 (lowest) | Core | `.specify/templates/` | Shipped defaults |
When multiple presets are installed, they're sorted by their `priority` field (lower number = higher precedence). This is set via `--priority` on `specify preset add`.
The resolution is implemented three times to ensure consistency:
- **Python**: `PresetResolver` in `src/specify_cli/presets.py`
- **Bash**: `resolve_template()` in `scripts/bash/common.sh`
- **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1`
## Command Registration
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
```mermaid
flowchart TD
A["specify preset add my-preset"] --> B{Preset has type: command?}
B -- No --> Z["done (templates only)"]
B -- Yes --> C{Extension command?}
C -- "speckit.myext.cmd\n(3+ dot segments)" --> D{Extension installed?}
D -- No --> E["skip (extension not active)"]
D -- Yes --> F["register command"]
C -- "speckit.specify\n(core command)" --> F
F --> G["detect agent directories"]
G --> H[".claude/commands/"]
G --> I[".gemini/commands/"]
G --> J[".github/agents/"]
G --> K["... (17+ agents)"]
H --> L["write .md (Markdown format)"]
I --> M["write .toml (TOML format)"]
J --> N["write .agent.md + .prompt.md"]
style E fill:#ff5722,color:#fff
style L fill:#4caf50,color:#fff
style M fill:#4caf50,color:#fff
style N fill:#4caf50,color:#fff
```
### Extension safety check
Command names follow the pattern `speckit.<ext-id>.<cmd-name>`. When a command has 3+ dot segments, the system extracts the extension ID and checks if `.specify/extensions/<ext-id>/` exists. If the extension isn't installed, the command is skipped — preventing orphan files referencing non-existent extensions.
Core commands (e.g. `speckit.specify`, with only 2 segments) are always registered.
### Agent format rendering
The `CommandRegistrar` renders commands differently per agent:
| Agent | Format | Extension | Arg placeholder |
|-------|--------|-----------|-----------------|
| Claude, Cursor, opencode, Windsurf, etc. | Markdown | `.md` | `$ARGUMENTS` |
| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |
| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |
### Cleanup on removal
When `specify preset remove` is called, the registered commands are read from the registry metadata and the corresponding files are deleted from each agent directory, including Copilot companion `.prompt.md` files.
## Catalog System
```mermaid
flowchart TD
A["specify preset search"] --> B["PresetCatalog.get_active_catalogs()"]
B --> C{SPECKIT_PRESET_CATALOG_URL set?}
C -- Yes --> D["single custom catalog"]
C -- No --> E{.specify/preset-catalogs.yml exists?}
E -- Yes --> F["project-level catalog stack"]
E -- No --> G{"~/.specify/preset-catalogs.yml exists?"}
G -- Yes --> H["user-level catalog stack"]
G -- No --> I["built-in defaults"]
I --> J["default (install allowed)"]
I --> K["community (discovery only)"]
style D fill:#ff9800,color:#fff
style F fill:#2196f3,color:#fff
style H fill:#2196f3,color:#fff
style J fill:#4caf50,color:#fff
style K fill:#9e9e9e,color:#fff
```
Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag.
## Repository Layout
```
presets/
├── ARCHITECTURE.md # This file
├── PUBLISHING.md # Guide for submitting presets to the catalog
├── README.md # User guide
├── catalog.json # Official preset catalog
├── catalog.community.json # Community preset catalog
├── scaffold/ # Scaffold for creating new presets
│ ├── preset.yml # Example manifest
│ ├── README.md # Guide for customizing the scaffold
│ ├── commands/
│ │ ├── speckit.specify.md # Core command override example
│ │ └── speckit.myext.myextcmd.md # Extension command override example
│ └── templates/
│ ├── spec-template.md # Core template override example
│ └── myext-template.md # Extension template override example
└── self-test/ # Self-test preset (overrides all core templates)
├── preset.yml
├── commands/
│ └── speckit.specify.md
└── templates/
├── spec-template.md
├── plan-template.md
├── tasks-template.md
├── checklist-template.md
├── constitution-template.md
└── agent-file-template.md
```
## Module Structure
```
src/specify_cli/
├── agents.py # CommandRegistrar — shared infrastructure for writing
│ # command files to agent directories
├── presets.py # PresetManifest, PresetRegistry, PresetManager,
│ # PresetCatalog, PresetCatalogEntry, PresetResolver
└── __init__.py # CLI commands: specify preset list/add/remove/search/
# resolve/info, specify preset catalog list/add/remove
```

295
presets/PUBLISHING.md Normal file
View File

@@ -0,0 +1,295 @@
# Preset Publishing Guide
This guide explains how to publish your preset to the Spec Kit preset catalog, making it discoverable by `specify preset search`.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Prepare Your Preset](#prepare-your-preset)
3. [Submit to Catalog](#submit-to-catalog)
4. [Verification Process](#verification-process)
5. [Release Workflow](#release-workflow)
6. [Best Practices](#best-practices)
---
## Prerequisites
Before publishing a preset, ensure you have:
1. **Valid Preset**: A working preset with a valid `preset.yml` manifest
2. **Git Repository**: Preset hosted on GitHub (or other public git hosting)
3. **Documentation**: README.md with description and usage instructions
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
5. **Versioning**: Semantic versioning (e.g., 1.0.0)
6. **Testing**: Preset tested on real projects with `specify preset add --dev`
---
## Prepare Your Preset
### 1. Preset Structure
Ensure your preset follows the standard structure:
```text
your-preset/
├── preset.yml # Required: Preset manifest
├── README.md # Required: Documentation
├── LICENSE # Required: License file
├── CHANGELOG.md # Recommended: Version history
├── templates/ # Template overrides
│ ├── spec-template.md
│ ├── plan-template.md
│ └── ...
└── commands/ # Command overrides (optional)
└── speckit.specify.md
```
Start from the [scaffold](scaffold/) if you're creating a new preset.
### 2. preset.yml Validation
Verify your manifest is valid:
```yaml
schema_version: "1.0"
preset:
id: "your-preset" # Unique lowercase-hyphenated ID
name: "Your Preset Name" # Human-readable name
version: "1.0.0" # Semantic version
description: "Brief description (one sentence)"
author: "Your Name or Organization"
repository: "https://github.com/your-org/spec-kit-preset-your-preset"
license: "MIT"
requires:
speckit_version: ">=0.1.0" # Required spec-kit version
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
description: "Custom spec template"
replaces: "spec-template"
tags: # 2-5 relevant tags
- "category"
- "workflow"
```
**Validation Checklist**:
-`id` is lowercase with hyphens only (no underscores, spaces, or special characters)
-`version` follows semantic versioning (X.Y.Z)
-`description` is concise (under 200 characters)
-`repository` URL is valid and public
- ✅ All template and command files exist in the preset directory
- ✅ Template names are lowercase with hyphens only
- ✅ Command names use dot notation (e.g. `speckit.specify`)
- ✅ Tags are lowercase and descriptive
### 3. Test Locally
```bash
# Install from local directory
specify preset add --dev /path/to/your-preset
# Verify templates resolve from your preset
specify preset resolve spec-template
# Verify preset info
specify preset info your-preset
# List installed presets
specify preset list
# Remove when done testing
specify preset remove your-preset
```
If your preset includes command overrides, verify they appear in the agent directories:
```bash
# Check Claude commands (if using Claude)
ls .claude/commands/speckit.*.md
# Check Copilot commands (if using Copilot)
ls .github/agents/speckit.*.agent.md
# Check Gemini commands (if using Gemini)
ls .gemini/commands/speckit.*.toml
```
### 4. Create GitHub Release
Create a GitHub release for your preset version:
```bash
# Tag the release
git tag v1.0.0
git push origin v1.0.0
```
The release archive URL will be:
```text
https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
```
### 5. Test Installation from Archive
```bash
specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
```
---
## Submit to Catalog
### Understanding the Catalogs
Spec Kit uses a dual-catalog system:
- **`catalog.json`** — Official, verified presets (install allowed by default)
- **`catalog.community.json`** — Community-contributed presets (discovery only by default)
All community presets should be submitted to `catalog.community.json`.
### 1. Fork the spec-kit Repository
```bash
git clone https://github.com/YOUR-USERNAME/spec-kit.git
cd spec-kit
```
### 2. Add Preset to Community Catalog
Edit `presets/catalog.community.json` and add your preset.
> **⚠️ Entries must be sorted alphabetically by preset ID.** Insert your preset in the correct position within the `"presets"` object.
```json
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"your-preset": {
"name": "Your Preset Name",
"description": "Brief description of what your preset provides",
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"templates": 3,
"commands": 1
},
"tags": [
"category",
"workflow"
],
"created_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-03-10T00:00:00Z"
}
}
}
```
### 3. Submit Pull Request
```bash
git checkout -b add-your-preset
git add presets/catalog.community.json
git commit -m "Add your-preset to community catalog
- Preset ID: your-preset
- Version: 1.0.0
- Author: Your Name
- Description: Brief description
"
git push origin add-your-preset
```
**Pull Request Checklist**:
```markdown
## Preset Submission
**Preset Name**: Your Preset Name
**Preset ID**: your-preset
**Version**: 1.0.0
**Repository**: https://github.com/your-org/spec-kit-preset-your-preset
### Checklist
- [ ] Valid preset.yml manifest
- [ ] README.md with description and usage
- [ ] LICENSE file included
- [ ] GitHub release created
- [ ] Preset tested with `specify preset add --dev`
- [ ] Templates resolve correctly (`specify preset resolve`)
- [ ] Commands register to agent directories (if applicable)
- [ ] Commands match template sections (command + template are coherent)
- [ ] Added to presets/catalog.community.json
```
---
## Verification Process
After submission, maintainers will review:
1. **Manifest validation** — valid `preset.yml`, all files exist
2. **Template quality** — templates are useful and well-structured
3. **Command coherence** — commands reference sections that exist in templates
4. **Security** — no malicious content, safe file operations
5. **Documentation** — clear README explaining what the preset does
Once verified, `verified: true` is set and the preset appears in `specify preset search`.
---
## Release Workflow
When releasing a new version:
1. Update `version` in `preset.yml`
2. Update CHANGELOG.md
3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0`
4. Submit PR to update `version` and `download_url` in `presets/catalog.community.json`
---
## Best Practices
### Template Design
- **Keep sections clear** — use headings and placeholder text the LLM can replace
- **Match commands to templates** — if your preset overrides a command, make sure it references the sections in your template
- **Document customization points** — use HTML comments to guide users on what to change
### Naming
- Preset IDs should be descriptive: `healthcare-compliance`, `enterprise-safe`, `startup-lean`
- Avoid generic names: `my-preset`, `custom`, `test`
### Stacking
- Design presets to work well when stacked with others
- Only override templates you need to change
- Document which templates and commands your preset modifies
### Command Overrides
- Only override commands when the workflow needs to change, not just the output format
- If you only need different template sections, a template override is sufficient
- Test command overrides with multiple agents (Claude, Gemini, Copilot)

115
presets/README.md Normal file
View File

@@ -0,0 +1,115 @@
# Presets
Presets are stackable, priority-ordered collections of template and command overrides for Spec Kit. They let you customize both the artifacts produced by the Spec-Driven Development workflow (specs, plans, tasks, checklists, constitutions) and the commands that guide the LLM in creating them — without forking or modifying core files.
## How It Works
When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution stack:
1. `.specify/templates/overrides/` — project-local one-off overrides
2. `.specify/presets/<preset-id>/templates/` — installed presets (sorted by priority)
3. `.specify/extensions/<ext-id>/templates/` — extension-provided templates
4. `.specify/templates/` — core templates shipped with Spec Kit
If no preset is installed, core templates are used — exactly the same behavior as before presets existed.
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Command Overrides
Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions).
When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.
## Quick Start
```bash
# Search available presets
specify preset search
# Install a preset from the catalog
specify preset add healthcare-compliance
# Install from a local directory (for development)
specify preset add --dev ./my-preset
# Install with a specific priority (lower = higher precedence)
specify preset add healthcare-compliance --priority 5
# List installed presets
specify preset list
# See which template a name resolves to
specify preset resolve spec-template
# Get detailed info about a preset
specify preset info healthcare-compliance
# Remove a preset
specify preset remove healthcare-compliance
```
## Stacking Presets
Multiple presets can be installed simultaneously. The `--priority` flag controls which one wins when two presets provide the same template (lower number = higher precedence):
```bash
specify preset add enterprise-safe --priority 10 # base layer
specify preset add healthcare-compliance --priority 5 # overrides enterprise-safe
specify preset add pm-workflow --priority 1 # overrides everything
```
Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely.
## Catalog Management
Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs:
```bash
# List active catalogs
specify preset catalog list
# Add a custom catalog
specify preset catalog add https://example.com/catalog.json --name my-org --install-allowed
# Remove a catalog
specify preset catalog remove my-org
```
## Creating a Preset
See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset.
1. Copy `scaffold/` to a new directory
2. Edit `preset.yml` with your preset's metadata
3. Add or replace templates in `templates/`
4. Test locally with `specify preset add --dev .`
5. Verify with `specify preset resolve spec-template`
## Environment Variables
| Variable | Description |
|----------|-------------|
| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) |
## Configuration Files
| File | Scope | Description |
|------|-------|-------------|
| `.specify/preset-catalogs.yml` | Project | Custom catalog stack for this project |
| `~/.specify/preset-catalogs.yml` | User | Custom catalog stack for all projects |
## Future Considerations
The following enhancements are under consideration for future releases:
- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`:
| Type | `replace` | `prepend` | `append` | `wrap` |
|------|-----------|-----------|----------|--------|
| **template** | ✓ (default) | ✓ | ✓ | ✓ |
| **command** | ✓ (default) | ✓ | ✓ | ✓ |
| **script** | ✓ (default) | — | — | ✓ |
For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable.
- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it.

View File

@@ -0,0 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-09T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {}
}

6
presets/catalog.json Normal file
View File

@@ -0,0 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {}
}

View File

@@ -0,0 +1,46 @@
# My Preset
A custom preset for Spec Kit. Copy this directory and customize it to create your own.
## Templates Included
| Template | Type | Description |
|----------|------|-------------|
| `spec-template` | template | Custom feature specification template (overrides core and extensions) |
| `myext-template` | template | Override of the myext extension's report template |
| `speckit.specify` | command | Custom specification command (overrides core) |
| `speckit.myext.myextcmd` | command | Override of the myext extension's myextcmd command |
## Development
1. Copy this directory: `cp -r presets/scaffold my-preset`
2. Edit `preset.yml` — set your preset's ID, name, description, and templates
3. Add or modify templates in `templates/`
4. Test locally: `specify preset add --dev ./my-preset`
5. Verify resolution: `specify preset resolve spec-template`
6. Remove when done testing: `specify preset remove my-preset`
## Manifest Reference (`preset.yml`)
Required fields:
- `schema_version` — always `"1.0"`
- `preset.id` — lowercase alphanumeric with hyphens
- `preset.name` — human-readable name
- `preset.version` — semantic version (e.g. `1.0.0`)
- `preset.description` — brief description
- `requires.speckit_version` — version constraint (e.g. `>=0.1.0`)
- `provides.templates` — list of templates with `type`, `name`, and `file`
## Template Types
- **template** — Document scaffolds (spec-template.md, plan-template.md, tasks-template.md, etc.)
- **command** — AI agent workflow prompts (e.g. speckit.specify, speckit.plan)
- **script** — Custom scripts (reserved for future use)
## Publishing
See the [Preset Publishing Guide](../PUBLISHING.md) for details on submitting to the catalog.
## License
MIT

View File

@@ -0,0 +1,20 @@
---
description: "Override of the myext extension's myextcmd command"
---
<!-- Preset override for speckit.myext.myextcmd -->
You are following a customized version of the myext extension's myextcmd command.
When executing this command:
1. Read the user's input from $ARGUMENTS
2. Follow the standard myextcmd workflow
3. Additionally, apply the following customizations from this preset:
- Add compliance checks before proceeding
- Include audit trail entries in the output
> CUSTOMIZE: Replace the instructions above with your own.
> This file overrides the command that the "myext" extension provides.
> When this preset is installed, all agents (Claude, Gemini, Copilot, etc.)
> will use this version instead of the extension's original.

View File

@@ -0,0 +1,23 @@
---
description: "Create a feature specification (preset override)"
scripts:
sh: scripts/bash/create-new-feature.sh "{ARGS}"
ps: scripts/powershell/create-new-feature.ps1 "{ARGS}"
---
## User Input
```text
$ARGUMENTS
```
Given the feature description above:
1. **Create the feature branch** by running the script:
- Bash: `{SCRIPT} --json --short-name "<short-name>" "<description>"`
- The JSON output contains BRANCH_NAME and SPEC_FILE paths.
2. **Read the spec-template** to see the sections you need to fill.
3. **Write the specification** to SPEC_FILE, replacing the placeholders in each section
(Overview, Requirements, Acceptance Criteria) with details from the user's description.

View File

@@ -0,0 +1,91 @@
schema_version: "1.0"
preset:
# CUSTOMIZE: Change 'my-preset' to your preset ID (lowercase, hyphen-separated)
id: "my-preset"
# CUSTOMIZE: Human-readable name for your preset
name: "My Preset"
# CUSTOMIZE: Update version when releasing (semantic versioning: X.Y.Z)
version: "1.0.0"
# CUSTOMIZE: Brief description (under 200 characters)
description: "Brief description of what your preset provides"
# CUSTOMIZE: Your name or organization name
author: "Your Name"
# CUSTOMIZE: GitHub repository URL (create before publishing)
repository: "https://github.com/your-org/spec-kit-preset-my-preset"
# REVIEW: License (MIT is recommended for open source)
license: "MIT"
# Requirements for this preset
requires:
# CUSTOMIZE: Minimum spec-kit version required
speckit_version: ">=0.1.0"
# Templates provided by this preset
provides:
templates:
# CUSTOMIZE: Define your template overrides
# Templates are document scaffolds (spec-template.md, plan-template.md, etc.)
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
description: "Custom feature specification template"
replaces: "spec-template" # Which core template this overrides (optional)
# ADD MORE TEMPLATES: Copy this block for each template
# - type: "template"
# name: "plan-template"
# file: "templates/plan-template.md"
# description: "Custom plan template"
# replaces: "plan-template"
# OVERRIDE EXTENSION TEMPLATES:
# Presets sit above extensions in the resolution stack, so you can
# override templates provided by any installed extension.
# For example, if the "myext" extension provides a spec-template,
# the preset's version above will take priority automatically.
# Override a template provided by the "myext" extension:
- type: "template"
name: "myext-template"
file: "templates/myext-template.md"
description: "Override myext's report template"
replaces: "myext-template"
# Command overrides (AI agent workflow prompts)
# Presets can override both core and extension commands.
# Commands are automatically registered into all detected agent
# directories (.claude/commands/, .gemini/commands/, etc.)
# Override a core command:
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Custom specification command"
replaces: "speckit.specify"
# Override an extension command (e.g. from the "myext" extension):
- type: "command"
name: "speckit.myext.myextcmd"
file: "commands/speckit.myext.myextcmd.md"
description: "Override myext's myextcmd command with custom workflow"
replaces: "speckit.myext.myextcmd"
# Script templates (reserved for future use)
# - type: "script"
# name: "create-new-feature"
# file: "scripts/bash/create-new-feature.sh"
# description: "Custom feature creation script"
# replaces: "create-new-feature"
# CUSTOMIZE: Add relevant tags (2-5 recommended)
# Used for discovery in catalog
tags:
- "example"
- "preset"

View File

@@ -0,0 +1,24 @@
# MyExt Report
> This template overrides the one provided by the "myext" extension.
> Customize it to match your needs.
## Summary
Brief summary of the report.
## Details
- Detail 1
- Detail 2
## Actions
- [ ] Action 1
- [ ] Action 2
<!--
CUSTOMIZE: This template takes priority over the myext extension's
version of myext-template. The extension's original is still available
if you remove this preset.
-->

View File

@@ -0,0 +1,18 @@
# Feature Specification: [FEATURE NAME]
**Created**: [DATE]
**Status**: Draft
## Overview
[Brief description of the feature]
## Requirements
- [ ] Requirement 1
- [ ] Requirement 2
## Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2

View File

@@ -0,0 +1,15 @@
---
description: "Self-test override of the specify command"
---
<!-- preset:self-test -->
You are following the self-test preset's version of the specify command.
When creating a specification, follow this process:
1. Read the user's requirements from $ARGUMENTS
2. Create a specification document using the spec-template
3. Include all standard sections plus the self-test marker
> This command is provided by the self-test preset.

View File

@@ -0,0 +1,61 @@
schema_version: "1.0"
preset:
id: "self-test"
name: "Self-Test Preset"
version: "1.0.0"
description: "A preset that overrides all core templates for testing purposes"
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
speckit_version: ">=0.1.0"
provides:
templates:
- type: "template"
name: "spec-template"
file: "templates/spec-template.md"
description: "Self-test spec template"
replaces: "spec-template"
- type: "template"
name: "plan-template"
file: "templates/plan-template.md"
description: "Self-test plan template"
replaces: "plan-template"
- type: "template"
name: "tasks-template"
file: "templates/tasks-template.md"
description: "Self-test tasks template"
replaces: "tasks-template"
- type: "template"
name: "checklist-template"
file: "templates/checklist-template.md"
description: "Self-test checklist template"
replaces: "checklist-template"
- type: "template"
name: "constitution-template"
file: "templates/constitution-template.md"
description: "Self-test constitution template"
replaces: "constitution-template"
- type: "template"
name: "agent-file-template"
file: "templates/agent-file-template.md"
description: "Self-test agent file template"
replaces: "agent-file-template"
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Self-test override of the specify command"
replaces: "speckit.specify"
tags:
- "testing"
- "self-test"

View File

@@ -0,0 +1,9 @@
# Agent File (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Agent Instructions
Follow these guidelines when working on this project.

View File

@@ -0,0 +1,15 @@
# Checklist (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Pre-Implementation
- [ ] Spec reviewed
- [ ] Plan approved
## Post-Implementation
- [ ] Tests passing
- [ ] Documentation updated

View File

@@ -0,0 +1,15 @@
# Constitution (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Principles
1. Principle 1
2. Principle 2
## Guidelines
- Guideline 1
- Guideline 2

View File

@@ -0,0 +1,22 @@
# Implementation Plan (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Approach
Describe the implementation approach.
## Steps
1. Step 1
2. Step 2
## Dependencies
- Dependency 1
## Risks
- Risk 1

View File

@@ -0,0 +1,23 @@
# Feature Specification (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Overview
Brief description of the feature.
## Requirements
- Requirement 1
- Requirement 2
## Design
Describe the design approach.
## Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2

View File

@@ -0,0 +1,17 @@
# Tasks (Self-Test Preset)
<!-- preset:self-test -->
> This template is provided by the self-test preset.
## Task List
- [ ] Task 1
- [ ] Task 2
## Estimation
| Task | Estimate |
|------|----------|
| Task 1 | TBD |
| Task 2 | TBD |

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.2.1"
version = "0.3.1"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [
@@ -14,6 +14,7 @@ dependencies = [
"pyyaml>=6.0",
"packaging>=23.0",
"pathspec>=0.12.0",
"json5>=0.13.0",
]
[project.scripts]

View File

@@ -168,7 +168,7 @@ if $JSON_MODE; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '"%s",' "${docs[@]}")
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"

View File

@@ -161,7 +161,7 @@ has_jq() {
}
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
# Handles backslash, double-quote, and control characters (newline, tab, carriage return).
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
@@ -169,9 +169,95 @@ json_escape() {
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
s="${s//$'\b'/\\b}"
s="${s//$'\f'/\\f}"
# Strip remaining control characters (U+0000U+001F) not individually escaped above
s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037')
printf '%s' "$s"
}
check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; }
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo "$2" || echo "$2"; }
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
resolve_template() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Priority 1: Project overrides
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
# Read preset IDs sorted by priority (lower number = higher precedence).
# The python3 call is wrapped in an if-condition so that set -e does not
# abort the function when python3 exits non-zero (e.g. invalid JSON).
local sorted_presets=""
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
# python3 succeeded and returned preset IDs — search in priority order
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
else
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
else
# Fallback: alphabetical directory order (no python3 available)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
fi
# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
# Skip hidden directories (e.g. .backup, .cache)
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
# Priority 4: Core templates
local core="$base/${template_name}.md"
[ -f "$core" ] && echo "$core" && return 0
# Template not found in any location.
# Return 1 so callers can distinguish "not found" from "found".
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
return 1
}

View File

@@ -138,7 +138,7 @@ check_existing_branches() {
local specs_dir="$1"
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune 2>/dev/null || true
git fetch --all --prune >/dev/null 2>&1 || true
# Get highest number from ALL branches (not just matching short name)
local highest_branch=$(get_highest_from_branches)
@@ -162,21 +162,11 @@ clean_branch_name() {
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
printf '%s' "$s"
}
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialised with --no-git.
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
if git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
@@ -307,9 +297,14 @@ fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
TEMPLATE="$REPO_ROOT/.specify/templates/spec-template.md"
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2

View File

@@ -39,12 +39,12 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
mkdir -p "$FEATURE_DIR"
# Copy plan template if it exists
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
if [[ -f "$TEMPLATE" ]]; then
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN"
else
echo "Warning: Plan template not found at $TEMPLATE"
echo "Warning: Plan template not found"
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi

View File

@@ -35,7 +35,7 @@
# - Creates default Claude file if no agent files exist
#
# Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic
# Leave empty to update all existing agent files
set -e
@@ -83,6 +83,7 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
BOB_FILE="$AGENTS_FILE"
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
KIMI_FILE="$REPO_ROOT/KIMI.md"
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
# Template file
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
@@ -675,67 +676,82 @@ update_specific_agent() {
kimi)
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
;;
trae)
update_agent_file "$TRAE_FILE" "Trae" || return 1
;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*)
log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic"
exit 1
;;
esac
}
# Helper: skip non-existent files and files already updated (dedup by
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
# Uses a linear array instead of associative array for bash 3.2 compatibility.
# Note: defined at top level because bash 3.2 does not support true
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
# initialised exclusively inside update_all_existing_agents so that
# sourcing this script has no side effects on the caller's environment.
_update_if_new() {
local file="$1" name="$2"
[[ -f "$file" ]] || return 0
local real_path
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
local p
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
for p in "${_updated_paths[@]}"; do
[[ "$p" == "$real_path" ]] && return 0
done
fi
# Record the file as seen before attempting the update so that:
# (a) aliases pointing to the same path are not retried on failure
# (b) _found_agent reflects file existence, not update success
_updated_paths+=("$real_path")
_found_agent=true
update_agent_file "$file" "$name"
}
update_all_existing_agents() {
local found_agent=false
local _updated_paths=()
_found_agent=false
_updated_paths=()
local _all_ok=true
# Helper: skip non-existent files and files already updated (dedup by
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
# Uses a linear array instead of associative array for bash 3.2 compatibility.
update_if_new() {
local file="$1" name="$2"
[[ -f "$file" ]] || return 0
local real_path
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
local p
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
for p in "${_updated_paths[@]}"; do
[[ "$p" == "$real_path" ]] && return 0
done
fi
update_agent_file "$file" "$name" || return 1
_updated_paths+=("$real_path")
found_agent=true
}
update_if_new "$CLAUDE_FILE" "Claude Code"
update_if_new "$GEMINI_FILE" "Gemini CLI"
update_if_new "$COPILOT_FILE" "GitHub Copilot"
update_if_new "$CURSOR_FILE" "Cursor IDE"
update_if_new "$QWEN_FILE" "Qwen Code"
update_if_new "$AGENTS_FILE" "Codex/opencode"
update_if_new "$AMP_FILE" "Amp"
update_if_new "$KIRO_FILE" "Kiro CLI"
update_if_new "$BOB_FILE" "IBM Bob"
update_if_new "$WINDSURF_FILE" "Windsurf"
update_if_new "$KILOCODE_FILE" "Kilo Code"
update_if_new "$AUGGIE_FILE" "Auggie CLI"
update_if_new "$ROO_FILE" "Roo Code"
update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI"
update_if_new "$SHAI_FILE" "SHAI"
update_if_new "$TABNINE_FILE" "Tabnine CLI"
update_if_new "$QODER_FILE" "Qoder CLI"
update_if_new "$AGY_FILE" "Antigravity"
update_if_new "$VIBE_FILE" "Mistral Vibe"
update_if_new "$KIMI_FILE" "Kimi Code"
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
# If no agent files exist, create a default Claude file
if [[ "$found_agent" == false ]]; then
if [[ "$_found_agent" == false ]]; then
log_info "No existing agent files found, creating default Claude file..."
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
fi
[[ "$_all_ok" == true ]]
}
print_summary() {
echo
@@ -754,7 +770,7 @@ print_summary() {
fi
echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]"
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic]"
}
#==============================================================================

View File

@@ -135,3 +135,70 @@ function Test-DirHasFiles {
}
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
$presets = $registryData.presets
if ($presets) {
$sortedPresets = $presets.PSObject.Properties |
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
ForEach-Object { $_.Name }
}
} catch {
# Fallback: alphabetical directory order
$sortedPresets = @()
}
}
if ($sortedPresets.Count -gt 0) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) { return $core }
return $null
}

View File

@@ -141,6 +141,9 @@ if (-not $fallbackRoot) {
exit 1
}
# Load common functions (includes Resolve-Template)
. "$PSScriptRoot/common.ps1"
try {
$repoRoot = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) {
@@ -276,9 +279,9 @@ if ($hasGit) {
$featureDir = Join-Path $specsDir $branchName
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
$template = Join-Path $repoRoot '.specify/templates/spec-template.md'
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
$specFile = Join-Path $featureDir 'spec.md'
if (Test-Path $template) {
if ($template -and (Test-Path $template)) {
Copy-Item $template $specFile -Force
} else {
New-Item -ItemType File -Path $specFile | Out-Null

View File

@@ -32,12 +32,12 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GI
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if it exists, otherwise note it or create empty file
$template = Join-Path $paths.REPO_ROOT '.specify/templates/plan-template.md'
if (Test-Path $template) {
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($template -and (Test-Path $template)) {
Copy-Item $template $paths.IMPL_PLAN -Force
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
} else {
Write-Warning "Plan template not found at $template"
Write-Warning "Plan template not found"
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, generic)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','generic')]
[string]$AgentType
)
@@ -64,6 +64,7 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -408,8 +409,9 @@ function Update-SpecificAgent {
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic'; return $false }
}
}
@@ -435,6 +437,7 @@ function Update-AllExistingAgents {
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...'
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }

File diff suppressed because it is too large Load Diff

428
src/specify_cli/agents.py Normal file
View File

@@ -0,0 +1,428 @@
"""
Agent Command Registrar for Spec Kit
Shared infrastructure for registering commands with AI agents.
Used by both the extension system and the preset system to write
command files into agent-specific directories in the correct format.
"""
from pathlib import Path
from typing import Dict, List, Any
import yaml
class CommandRegistrar:
"""Handles registration of commands with AI agents.
Supports writing command files in Markdown or TOML format to the
appropriate agent directory, with correct argument placeholders
and companion files (e.g. Copilot .prompt.md).
"""
# Agent configurations with directory, format, and argument placeholder
AGENT_CONFIGS = {
"claude": {
"dir": ".claude/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"gemini": {
"dir": ".gemini/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"copilot": {
"dir": ".github/agents",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".agent.md"
},
"cursor": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qwen": {
"dir": ".qwen/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"opencode": {
"dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kilocode": {
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"auggie": {
"dir": ".augment/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"roo": {
"dir": ".roo/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codebuddy": {
"dir": ".codebuddy/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qodercli": {
"dir": ".qoder/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kiro-cli": {
"dir": ".kiro/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"amp": {
"dir": ".agents/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"shai": {
"dir": ".shai/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"tabnine": {
"dir": ".tabnine/agent/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"bob": {
"dir": ".bob/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kimi": {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md"
},
"trae": {
"dir": ".trae/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
}
}
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Args:
content: Markdown content with YAML frontmatter
Returns:
Tuple of (frontmatter_dict, body_content)
"""
if not content.startswith("---"):
return {}, content
# Find second ---
end_marker = content.find("---", 3)
if end_marker == -1:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3:].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
except yaml.YAMLError:
frontmatter = {}
return frontmatter, body
@staticmethod
def render_frontmatter(fm: dict) -> str:
"""Render frontmatter dictionary as YAML.
Args:
fm: Frontmatter dictionary
Returns:
YAML-formatted frontmatter with delimiters
"""
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Adjust script paths from extension-relative to repo-relative.
Args:
frontmatter: Frontmatter dictionary
Returns:
Modified frontmatter with adjusted paths
"""
if "scripts" in frontmatter:
for key in frontmatter["scripts"]:
script_path = frontmatter["scripts"][key]
if script_path.startswith("../../scripts/"):
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
return frontmatter
def render_markdown_command(
self,
frontmatter: dict,
body: str,
source_id: str,
context_note: str = None
) -> str:
"""Render command in Markdown format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
context_note: Custom context comment (default: <!-- Source: {source_id} -->)
Returns:
Formatted Markdown command file content
"""
if context_note is None:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(
self,
frontmatter: dict,
body: str,
source_id: str
) -> str:
"""Render command in TOML format.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
Returns:
Formatted TOML command file content
"""
toml_lines = []
if "description" in frontmatter:
desc = frontmatter["description"].replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
return "\n".join(toml_lines)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format.
Args:
content: Command content
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
to_placeholder: Target placeholder (e.g., "{{args}}")
Returns:
Content with converted placeholders
"""
return content.replace(from_placeholder, to_placeholder)
def register_commands(
self,
agent_name: str,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
) -> List[str]:
"""Register commands for a specific agent.
Args:
agent_name: Agent name (claude, gemini, copilot, etc.)
commands: List of command info dicts with 'name', 'file', and optional 'aliases'
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
List of registered command names
Raises:
ValueError: If agent is not supported
"""
if agent_name not in self.AGENT_CONFIGS:
raise ValueError(f"Unsupported agent: {agent_name}")
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
for cmd_info in commands:
cmd_name = cmd_info["name"]
cmd_file = cmd_info["file"]
source_file = source_dir / cmd_file
if not source_file.exists():
continue
content = source_file.read_text(encoding="utf-8")
frontmatter, body = self.parse_frontmatter(content)
frontmatter = self._adjust_script_paths(frontmatter)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
if agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, cmd_name)
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(output, encoding="utf-8")
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
registered.append(alias)
return registered
@staticmethod
def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
"""Generate a companion .prompt.md file for a Copilot agent command.
Args:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
def register_commands_for_all_agents(
self,
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
Args:
commands: List of command info dicts
source_id: Identifier of the source (extension or preset ID)
source_dir: Directory containing command source files
project_root: Path to project root
context_note: Custom context comment for markdown output
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"].split("/")[0]
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id, source_dir, project_root,
context_note=context_note
)
if registered:
results[agent_name] = registered
except ValueError:
continue
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
Args:
registered_commands: Dict mapping agent names to command name lists
project_root: Path to project root
"""
for agent_name, cmd_names in registered_commands.items():
if agent_name not in self.AGENT_CONFIGS:
continue
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
prompt_file.unlink()

View File

@@ -41,6 +41,26 @@ class CompatibilityError(ExtensionError):
pass
def normalize_priority(value: Any, default: int = 10) -> int:
"""Normalize a stored priority value for sorting and display.
Corrupted registry data may contain missing, non-numeric, or non-positive
values. In those cases, fall back to the default priority.
Args:
value: Priority value to normalize (may be int, str, None, etc.)
default: Default priority to use for invalid values (default: 10)
Returns:
Normalized priority as positive integer (>= 1)
"""
try:
priority = int(value)
except (TypeError, ValueError):
return default
return priority if priority >= 1 else default
@dataclass
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
@@ -251,6 +271,9 @@ class ExtensionRegistry:
raise KeyError(f"Extension '{extension_id}' is not installed")
# Merge new metadata with existing, preserving original installed_at
existing = self.data["extensions"][extension_id]
# Handle corrupted registry entries (e.g., string/list instead of dict)
if not isinstance(existing, dict):
existing = {}
# Merge: existing fields preserved, new fields override
merged = {**existing, **metadata}
# Always preserve original installed_at based on key existence, not truthiness,
@@ -324,6 +347,32 @@ class ExtensionRegistry:
"""
return extension_id in self.data["extensions"]
def list_by_priority(self) -> List[tuple]:
"""Get all installed extensions sorted by priority.
Lower priority number = higher precedence (checked first).
Extensions with equal priority are sorted alphabetically by ID
for deterministic ordering.
Returns:
List of (extension_id, metadata_copy) tuples sorted by priority.
Metadata is deep-copied to prevent accidental mutation.
"""
extensions = self.data.get("extensions", {}) or {}
if not isinstance(extensions, dict):
extensions = {}
sortable_extensions = []
for ext_id, meta in extensions.items():
if not isinstance(meta, dict):
continue
metadata_copy = copy.deepcopy(meta)
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
sortable_extensions.append((ext_id, metadata_copy))
return sorted(
sortable_extensions,
key=lambda item: (item[1]["priority"], item[0]),
)
class ExtensionManager:
"""Manages extension lifecycle: installation, removal, updates."""
@@ -440,7 +489,8 @@ class ExtensionManager:
self,
source_dir: Path,
speckit_version: str,
register_commands: bool = True
register_commands: bool = True,
priority: int = 10,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -448,14 +498,19 @@ class ExtensionManager:
source_dir: Path to extension directory
speckit_version: Current spec-kit version
register_commands: If True, register commands with AI agents
priority: Resolution priority (lower = higher precedence, default 10)
Returns:
Installed extension manifest
Raises:
ValidationError: If manifest is invalid
ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible
"""
# Validate priority
if priority < 1:
raise ValidationError("Priority must be a positive integer (1 or higher)")
# Load and validate manifest
manifest_path = source_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path)
@@ -497,6 +552,7 @@ class ExtensionManager:
"source": "local",
"manifest_hash": manifest.get_hash(),
"enabled": True,
"priority": priority,
"registered_commands": registered_commands
})
@@ -505,21 +561,27 @@ class ExtensionManager:
def install_from_zip(
self,
zip_path: Path,
speckit_version: str
speckit_version: str,
priority: int = 10,
) -> ExtensionManifest:
"""Install extension from ZIP file.
Args:
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
Returns:
Installed extension manifest
Raises:
ValidationError: If manifest is invalid
ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible
"""
# Validate priority early
if priority < 1:
raise ValidationError("Priority must be a positive integer (1 or higher)")
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
@@ -554,7 +616,7 @@ class ExtensionManager:
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version)
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -578,23 +640,7 @@ class ExtensionManager:
# Unregister commands from all AI agents
if registered_commands:
registrar = CommandRegistrar()
for agent_name, cmd_names in registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
agent_config = registrar.AGENT_CONFIGS[agent_name]
commands_dir = self.project_root / agent_config["dir"]
for cmd_name in cmd_names:
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
# Also remove companion .prompt.md for Copilot
if agent_name == "copilot":
prompt_file = self.project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
if prompt_file.exists():
prompt_file.unlink()
registrar.unregister_commands(registered_commands, self.project_root)
if keep_config:
# Preserve config files, only remove non-config files
@@ -648,6 +694,9 @@ class ExtensionManager:
result = []
for ext_id, metadata in self.registry.list().items():
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
if not isinstance(metadata, dict):
metadata = {}
ext_dir = self.extensions_dir / ext_id
manifest_path = ext_dir / "extension.yml"
@@ -659,6 +708,7 @@ class ExtensionManager:
"version": metadata.get("version", "unknown"),
"description": manifest.description,
"enabled": metadata.get("enabled", True),
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"),
"command_count": len(manifest.commands),
"hook_count": len(manifest.hooks)
@@ -671,6 +721,7 @@ class ExtensionManager:
"version": metadata.get("version", "unknown"),
"description": "⚠️ Corrupted extension",
"enabled": False,
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"),
"command_count": 0,
"hook_count": 0
@@ -718,255 +769,47 @@ def version_satisfies(current: str, required: str) -> bool:
class CommandRegistrar:
"""Handles registration of extension commands with AI agents."""
"""Handles registration of extension commands with AI agents.
# Agent configurations with directory, format, and argument placeholder
AGENT_CONFIGS = {
"claude": {
"dir": ".claude/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"gemini": {
"dir": ".gemini/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"copilot": {
"dir": ".github/agents",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".agent.md"
},
"cursor": {
"dir": ".cursor/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qwen": {
"dir": ".qwen/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"opencode": {
"dir": ".opencode/command",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codex": {
"dir": ".codex/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kilocode": {
"dir": ".kilocode/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"auggie": {
"dir": ".augment/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"roo": {
"dir": ".roo/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"codebuddy": {
"dir": ".codebuddy/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"qodercli": {
"dir": ".qoder/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kiro-cli": {
"dir": ".kiro/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"amp": {
"dir": ".agents/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"shai": {
"dir": ".shai/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"tabnine": {
"dir": ".tabnine/agent/commands",
"format": "toml",
"args": "{{args}}",
"extension": ".toml"
},
"bob": {
"dir": ".bob/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"kimi": {
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md"
}
}
This is a backward-compatible wrapper around the shared CommandRegistrar
in agents.py. Extension-specific methods accept ExtensionManifest objects
and delegate to the generic API.
"""
# Re-export AGENT_CONFIGS at class level for direct attribute access
from .agents import CommandRegistrar as _AgentRegistrar
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
def __init__(self):
from .agents import CommandRegistrar as _Registrar
self._registrar = _Registrar()
# Delegate static/utility methods
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter from Markdown content.
Args:
content: Markdown content with YAML frontmatter
Returns:
Tuple of (frontmatter_dict, body_content)
"""
if not content.startswith("---"):
return {}, content
# Find second ---
end_marker = content.find("---", 3)
if end_marker == -1:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3:].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
except yaml.YAMLError:
frontmatter = {}
return frontmatter, body
from .agents import CommandRegistrar as _Registrar
return _Registrar.parse_frontmatter(content)
@staticmethod
def render_frontmatter(fm: dict) -> str:
"""Render frontmatter dictionary as YAML.
from .agents import CommandRegistrar as _Registrar
return _Registrar.render_frontmatter(fm)
Args:
fm: Frontmatter dictionary
@staticmethod
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
from .agents import CommandRegistrar as _Registrar
_Registrar.write_copilot_prompt(project_root, cmd_name)
Returns:
YAML-formatted frontmatter with delimiters
"""
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
"""Adjust script paths from extension-relative to repo-relative.
Args:
frontmatter: Frontmatter dictionary
Returns:
Modified frontmatter with adjusted paths
"""
if "scripts" in frontmatter:
for key in frontmatter["scripts"]:
script_path = frontmatter["scripts"][key]
if script_path.startswith("../../scripts/"):
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
return frontmatter
def _render_markdown_command(
self,
frontmatter: dict,
body: str,
ext_id: str
) -> str:
"""Render command in Markdown format.
Args:
frontmatter: Command frontmatter
body: Command body content
ext_id: Extension ID
Returns:
Formatted Markdown command file content
"""
def _render_markdown_command(self, frontmatter, body, ext_id):
# Preserve extension-specific comment format for backward compatibility
context_note = f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
return self._registrar.render_frontmatter(frontmatter) + "\n" + context_note + body
def _render_toml_command(
self,
frontmatter: dict,
body: str,
ext_id: str
) -> str:
"""Render command in TOML format.
Args:
frontmatter: Command frontmatter
body: Command body content
ext_id: Extension ID
Returns:
Formatted TOML command file content
"""
# TOML format for Gemini/Qwen
toml_lines = []
# Add description if present
if "description" in frontmatter:
# Escape quotes in description
desc = frontmatter["description"].replace('"', '\\"')
toml_lines.append(f'description = "{desc}"')
toml_lines.append("")
# Add extension context as comments
toml_lines.append(f"# Extension: {ext_id}")
toml_lines.append(f"# Config: .specify/extensions/{ext_id}/")
toml_lines.append("")
# Add prompt content
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
return "\n".join(toml_lines)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format.
Args:
content: Command content
from_placeholder: Source placeholder (e.g., "$ARGUMENTS")
to_placeholder: Target placeholder (e.g., "{{args}}")
Returns:
Content with converted placeholders
"""
return content.replace(from_placeholder, to_placeholder)
def _render_toml_command(self, frontmatter, body, ext_id):
# Preserve extension-specific context comments for backward compatibility
base = self._registrar.render_toml_command(frontmatter, body, ext_id)
context_lines = f"# Extension: {ext_id}\n# Config: .specify/extensions/{ext_id}/\n"
return base.rstrip("\n") + "\n" + context_lines
def register_commands_for_agent(
self,
@@ -975,96 +818,14 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> List[str]:
"""Register extension commands for a specific agent.
Args:
agent_name: Agent name (claude, gemini, copilot, etc.)
manifest: Extension manifest
extension_dir: Path to extension directory
project_root: Path to project root
Returns:
List of registered command names
Raises:
ExtensionError: If agent is not supported
"""
"""Register extension commands for a specific agent."""
if agent_name not in self.AGENT_CONFIGS:
raise ExtensionError(f"Unsupported agent: {agent_name}")
agent_config = self.AGENT_CONFIGS[agent_name]
commands_dir = project_root / agent_config["dir"]
commands_dir.mkdir(parents=True, exist_ok=True)
registered = []
for cmd_info in manifest.commands:
cmd_name = cmd_info["name"]
cmd_file = cmd_info["file"]
# Read source command file
source_file = extension_dir / cmd_file
if not source_file.exists():
continue
content = source_file.read_text()
frontmatter, body = self.parse_frontmatter(content)
# Adjust script paths
frontmatter = self._adjust_script_paths(frontmatter)
# Convert argument placeholders
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
)
# Render in agent-specific format
if agent_config["format"] == "markdown":
output = self._render_markdown_command(frontmatter, body, manifest.id)
elif agent_config["format"] == "toml":
output = self._render_toml_command(frontmatter, body, manifest.id)
else:
raise ExtensionError(f"Unsupported format: {agent_config['format']}")
# Write command file
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}"
dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output)
# Generate companion .prompt.md for Copilot agents
if agent_name == "copilot":
self._write_copilot_prompt(project_root, cmd_name)
registered.append(cmd_name)
# Register aliases
for alias in cmd_info.get("aliases", []):
alias_file = commands_dir / f"{alias}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(output)
# Generate companion .prompt.md for alias too
if agent_name == "copilot":
self._write_copilot_prompt(project_root, alias)
registered.append(alias)
return registered
@staticmethod
def _write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
"""Generate a companion .prompt.md file for a Copilot agent command.
Copilot requires a .prompt.md file in .github/prompts/ that references
the corresponding .agent.md file in .github/agents/ via an ``agent:``
frontmatter field.
Args:
project_root: Path to project root
cmd_name: Command name (used as the file stem, e.g. 'speckit.my-ext.example')
"""
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n")
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
return self._registrar.register_commands(
agent_name, manifest.commands, manifest.id, extension_dir, project_root,
context_note=context_note
)
def register_commands_for_all_agents(
self,
@@ -1072,35 +833,20 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents.
"""Register extension commands for all detected agents."""
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
return self._registrar.register_commands_for_all_agents(
manifest.commands, manifest.id, extension_dir, project_root,
context_note=context_note
)
Args:
manifest: Extension manifest
extension_dir: Path to extension directory
project_root: Path to project root
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
# Detect which agents are present in the project
for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"].split("/")[0]
# Register if agent directory exists
if agent_dir.exists():
try:
registered = self.register_commands_for_agent(
agent_name, manifest, extension_dir, project_root
)
if registered:
results[agent_name] = registered
except ExtensionError:
# Skip agent on error
continue
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
) -> None:
"""Remove previously registered command files from agent directories."""
self._registrar.unregister_commands(registered_commands, project_root)
def register_commands_for_claude(
self,
@@ -1108,16 +854,7 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path
) -> List[str]:
"""Register extension commands for Claude Code agent.
Args:
manifest: Extension manifest
extension_dir: Path to extension directory
project_root: Path to project root
Returns:
List of registered command names
"""
"""Register extension commands for Claude Code agent."""
return self.register_commands_for_agent("claude", manifest, extension_dir, project_root)

1630
src/specify_cli/presets.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -233,3 +233,79 @@ class TestAgentConfigConsistency:
def test_ai_help_includes_kimi(self):
"""CLI help text for --ai should include kimi."""
assert "kimi" in AI_ASSISTANT_HELP
# --- Trae IDE consistency checks ---
def test_trae_in_agent_config(self):
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
assert "trae" in AGENT_CONFIG
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules"
assert AGENT_CONFIG["trae"]["requires_cli"] is False
assert AGENT_CONFIG["trae"]["install_url"] is None
def test_trae_in_extension_registrar(self):
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "trae" in cfg
trae_cfg = cfg["trae"]
assert trae_cfg["format"] == "markdown"
assert trae_cfg["args"] == "$ARGUMENTS"
assert trae_cfg["extension"] == ".md"
def test_trae_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include trae in agent lists."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
assert sh_match is not None
sh_agents = sh_match.group(1).split()
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
assert ps_match is not None
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
assert "trae" in sh_agents
assert "trae" in ps_agents
def test_trae_in_release_scripts_generate_commands(self):
"""Release scripts should generate markdown commands for trae in .trae/rules."""
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
assert ".trae/rules" in sh_text
assert ".trae/rules" in ps_text
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
def test_trae_in_github_release_output(self):
"""GitHub release script should include trae template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-trae-sh-" in gh_release_text
assert "spec-kit-template-trae-ps-" in gh_release_text
def test_trae_in_agent_context_scripts(self):
"""Agent context scripts should support trae agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
assert "trae" in bash_text
assert "TRAE_FILE" in bash_text
assert "trae" in pwsh_text
assert "TRAE_FILE" in pwsh_text
def test_trae_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
assert "trae" in validate_set_values
def test_ai_help_includes_trae(self):
"""CLI help text for --ai should include trae."""
assert "trae" in AI_ASSISTANT_HELP

View File

@@ -62,7 +62,7 @@ def templates_dir(project_dir):
tpl_root.mkdir(parents=True, exist_ok=True)
# Template with valid YAML frontmatter
(tpl_root / "specify.md").write_text(
(tpl_root / "speckit.specify.md").write_text(
"---\n"
"description: Create or update the feature specification.\n"
"handoffs:\n"
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
)
# Template with minimal frontmatter
(tpl_root / "plan.md").write_text(
(tpl_root / "speckit.plan.md").write_text(
"---\n"
"description: Generate implementation plan.\n"
"---\n"
@@ -91,7 +91,7 @@ def templates_dir(project_dir):
)
# Template with no frontmatter
(tpl_root / "tasks.md").write_text(
(tpl_root / "speckit.tasks.md").write_text(
"# Tasks Command\n"
"\n"
"Body without frontmatter.\n",
@@ -99,7 +99,7 @@ def templates_dir(project_dir):
)
# Template with empty YAML frontmatter (yaml.safe_load returns None)
(tpl_root / "empty_fm.md").write_text(
(tpl_root / "speckit.empty_fm.md").write_text(
"---\n"
"---\n"
"\n"
@@ -337,7 +337,7 @@ class TestInstallAiSkills:
cmds_dir = project_dir / ".claude" / "commands"
cmds_dir.mkdir(parents=True)
(cmds_dir / "broken.md").write_text(
(cmds_dir / "speckit.broken.md").write_text(
"---\n"
"description: [unclosed bracket\n"
" invalid: yaml: content: here\n"
@@ -430,9 +430,12 @@ class TestInstallAiSkills:
# Place .md templates in the agent's commands directory
agent_folder = AGENT_CONFIG[agent_key]["folder"]
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
cmds_dir.mkdir(parents=True)
(cmds_dir / "specify.md").write_text(
# Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md
fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md"
(cmds_dir / fname).write_text(
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
)
@@ -448,7 +451,100 @@ class TestInstallAiSkills:
assert expected_skill_name in skill_dirs
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
def test_copilot_ignores_non_speckit_agents(self, project_dir):
"""Non-speckit markdown in .github/agents/ must not produce skills."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
(agents_dir / "speckit.plan.agent.md").write_text(
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
)
(agents_dir / "my-custom-agent.agent.md").write_text(
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
)
result = install_ai_skills(project_dir, "copilot")
assert result is True
skills_dir = _get_skills_dir(project_dir, "copilot")
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert "speckit-plan" in skill_dirs
assert "speckit-my-custom-agent.agent" not in skill_dirs
assert "speckit-my-custom-agent" not in skill_dirs
@pytest.mark.parametrize("agent_key,custom_file", [
("claude", "review.md"),
("cursor-agent", "deploy.md"),
("qwen", "my-workflow.md"),
])
def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):
"""User-authored command files must not produce skills for any agent."""
proj = temp_dir / f"proj-{agent_key}"
proj.mkdir()
agent_folder = AGENT_CONFIG[agent_key]["folder"]
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
cmds_dir.mkdir(parents=True)
(cmds_dir / "speckit.specify.md").write_text(
"---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n"
)
(cmds_dir / custom_file).write_text(
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
)
result = install_ai_skills(proj, agent_key)
assert result is True
skills_dir = _get_skills_dir(proj, agent_key)
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert "speckit-specify" in skill_dirs
custom_stem = Path(custom_file).stem
assert f"speckit-{custom_stem}" not in skill_dirs
def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):
"""Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files."""
agents_dir = project_dir / ".github" / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
# Only a user-authored agent, no speckit.* templates
(agents_dir / "my-custom-agent.agent.md").write_text(
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
)
result = install_ai_skills(project_dir, "copilot")
# Should succeed via fallback to templates/commands/
assert result is True
skills_dir = _get_skills_dir(project_dir, "copilot")
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
# Should have skills from fallback templates, not from the custom agent
assert "speckit-plan" in skill_dirs
assert not any("my-custom" in d for d in skill_dirs)
@pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"])
def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):
"""Fallback to templates/commands/ when agent dir has no speckit.*.md files."""
proj = temp_dir / f"proj-{agent_key}"
proj.mkdir()
agent_folder = AGENT_CONFIG[agent_key]["folder"]
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
cmds_dir.mkdir(parents=True)
# Only a user-authored command, no speckit.* templates
(cmds_dir / "my-custom-command.md").write_text(
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
)
result = install_ai_skills(proj, agent_key)
# Should succeed via fallback to templates/commands/
assert result is True
skills_dir = _get_skills_dir(proj, agent_key)
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert not any("my-custom" in d for d in skill_dirs)
class TestCommandCoexistence:
"""Verify install_ai_skills never touches command files.
@@ -460,14 +556,16 @@ class TestCommandCoexistence:
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
# Verify commands exist before
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
# Verify commands exist before (templates_dir adds 4 speckit.* files,
# commands_dir_claude overlaps with 3 of them)
before = list(commands_dir_claude.glob("speckit.*"))
assert len(before) >= 3
install_ai_skills(project_dir, "claude")
# Commands must still be there — install_ai_skills never touches them
remaining = list(commands_dir_claude.glob("speckit.*"))
assert len(remaining) == 3
assert len(remaining) == len(before)
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""

View File

@@ -26,6 +26,7 @@ from specify_cli.extensions import (
ExtensionError,
ValidationError,
CompatibilityError,
normalize_priority,
version_satisfies,
)
@@ -121,6 +122,57 @@ def project_dir(temp_dir):
return proj_dir
# ===== normalize_priority Tests =====
class TestNormalizePriority:
"""Test normalize_priority helper function."""
def test_valid_integer(self):
"""Test with valid integer priority."""
assert normalize_priority(5) == 5
assert normalize_priority(1) == 1
assert normalize_priority(100) == 100
def test_valid_string_number(self):
"""Test with string that can be converted to int."""
assert normalize_priority("5") == 5
assert normalize_priority("10") == 10
def test_zero_returns_default(self):
"""Test that zero priority returns default."""
assert normalize_priority(0) == 10
assert normalize_priority(0, default=5) == 5
def test_negative_returns_default(self):
"""Test that negative priority returns default."""
assert normalize_priority(-1) == 10
assert normalize_priority(-100, default=5) == 5
def test_none_returns_default(self):
"""Test that None returns default."""
assert normalize_priority(None) == 10
assert normalize_priority(None, default=5) == 5
def test_invalid_string_returns_default(self):
"""Test that non-numeric string returns default."""
assert normalize_priority("invalid") == 10
assert normalize_priority("abc", default=5) == 5
def test_float_truncates(self):
"""Test that float is truncated to int."""
assert normalize_priority(5.9) == 5
assert normalize_priority(3.1) == 3
def test_empty_string_returns_default(self):
"""Test that empty string returns default."""
assert normalize_priority("") == 10
def test_custom_default(self):
"""Test custom default value."""
assert normalize_priority(None, default=20) == 20
assert normalize_priority("invalid", default=1) == 1
# ===== ExtensionManifest Tests =====
class TestExtensionManifest:
@@ -2337,3 +2389,404 @@ class TestExtensionUpdateCLI:
for cmd_file in command_files:
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
class TestExtensionListCLI:
"""Test extension list CLI output format."""
def test_list_shows_extension_id(self, extension_dir, project_dir):
"""extension list should display the extension ID."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install the extension using the manager
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
# Verify the extension ID is shown in the output
assert "test-ext" in result.output
# Verify name and version are also shown
assert "Test Extension" in result.output
assert "1.0.0" in result.output
class TestExtensionPriority:
"""Test extension priority-based resolution."""
def test_list_by_priority_empty(self, temp_dir):
"""Test list_by_priority on empty registry."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
result = registry.list_by_priority()
assert result == []
def test_list_by_priority_single(self, temp_dir):
"""Test list_by_priority with single extension."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0", "priority": 5})
result = registry.list_by_priority()
assert len(result) == 1
assert result[0][0] == "test-ext"
assert result[0][1]["priority"] == 5
def test_list_by_priority_ordering(self, temp_dir):
"""Test list_by_priority returns extensions sorted by priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add in non-priority order
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.add("ext-mid", {"version": "1.0.0", "priority": 10})
result = registry.list_by_priority()
assert len(result) == 3
# Lower priority number = higher precedence (first)
assert result[0][0] == "ext-high"
assert result[1][0] == "ext-mid"
assert result[2][0] == "ext-low"
def test_list_by_priority_default(self, temp_dir):
"""Test list_by_priority uses default priority of 10."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add without explicit priority
registry.add("ext-default", {"version": "1.0.0"})
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
result = registry.list_by_priority()
assert len(result) == 3
# ext-high (1), ext-default (10), ext-low (20)
assert result[0][0] == "ext-high"
assert result[1][0] == "ext-default"
assert result[2][0] == "ext-low"
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
"""Malformed priority values fall back to the default priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
registry.data["extensions"]["ext-invalid"] = {
"version": "1.0.0",
"priority": "high",
}
registry._save()
result = registry.list_by_priority()
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
assert result[1][1]["priority"] == 10
def test_install_with_priority(self, extension_dir, project_dir):
"""Test that install_from_directory stores priority."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 5
def test_install_default_priority(self, extension_dir, project_dir):
"""Test that install_from_directory uses default priority of 10."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 10
def test_list_installed_includes_priority(self, extension_dir, project_dir):
"""Test that list_installed includes priority in returned data."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=3)
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 3
def test_priority_preserved_on_update(self, temp_dir):
"""Test that registry update preserves priority."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("test-ext", {"version": "1.0.0", "priority": 5, "enabled": True})
# Update with new metadata (no priority specified)
registry.update("test-ext", {"enabled": False})
updated = registry.get("test-ext")
assert updated["priority"] == 5 # Preserved
assert updated["enabled"] is False # Updated
def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir):
"""Resolution scans unregistered extension dirs after valid registry entries."""
extensions_dir = project_dir / ".specify" / "extensions"
valid_dir = extensions_dir / "valid-ext" / "templates"
valid_dir.mkdir(parents=True)
(valid_dir / "other-template.md").write_text("# Valid\n")
broken_dir = extensions_dir / "broken-ext" / "templates"
broken_dir.mkdir(parents=True)
(broken_dir / "target-template.md").write_text("# Broken Target\n")
registry = ExtensionRegistry(extensions_dir)
registry.add("valid-ext", {"version": "1.0.0", "priority": 10})
registry.data["extensions"]["broken-ext"] = "corrupted"
registry._save()
from specify_cli.presets import PresetResolver
resolver = PresetResolver(project_dir)
resolved = resolver.resolve("target-template")
sourced = resolver.resolve_with_source("target-template")
assert resolved is not None
assert resolved.name == "target-template.md"
assert "Broken Target" in resolved.read_text()
assert sourced is not None
assert sourced["source"] == "extension:broken-ext (unregistered)"
class TestExtensionPriorityCLI:
"""Test extension priority CLI integration."""
def test_add_with_priority_option(self, extension_dir, project_dir):
"""Test extension add command with --priority option."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, [
"extension", "add", str(extension_dir), "--dev", "--priority", "3"
])
assert result.exit_code == 0, result.output
manager = ExtensionManager(project_dir)
metadata = manager.registry.get("test-ext")
assert metadata["priority"] == 3
def test_list_shows_priority(self, extension_dir, project_dir):
"""Test extension list shows priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with priority
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "list"])
assert result.exit_code == 0, result.output
assert "Priority: 7" in result.output
def test_set_priority_changes_priority(self, extension_dir, project_dir):
"""Test set-priority command changes extension priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with default priority
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Verify default priority
assert manager.registry.get("test-ext")["priority"] == 10
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
assert manager2.registry.get("test-ext")["priority"] == 5
def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
"""Test set-priority with same value shows already set message."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension with priority 5
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
def test_set_priority_invalid_value(self, extension_dir, project_dir):
"""Test set-priority rejects invalid priority values."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "0"])
assert result.exit_code == 1, result.output
assert "Priority must be a positive integer" in result.output
def test_set_priority_not_installed(self, project_dir):
"""Test set-priority fails for non-installed extension."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Ensure .specify exists
(project_dir / ".specify").mkdir(parents=True, exist_ok=True)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower()
def test_set_priority_by_display_name(self, extension_dir, project_dir):
"""Test set-priority works with extension display name."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install extension
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Use display name "Test Extension" instead of ID "test-ext"
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"])
assert result.exit_code == 0, result.output
assert "priority changed" in result.output
# Reload registry to see updated value
manager2 = ExtensionManager(project_dir)
assert manager2.registry.get("test-ext")["priority"] == 3
class TestExtensionPriorityBackwardsCompatibility:
"""Test backwards compatibility for extensions installed before priority feature."""
def test_legacy_extension_without_priority_field(self, temp_dir):
"""Extensions installed before priority feature should default to 10."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
# Simulate legacy registry entry without priority field
registry = ExtensionRegistry(extensions_dir)
registry.data["extensions"]["legacy-ext"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
"installed_at": "2025-01-01T00:00:00Z",
# No "priority" field - simulates pre-feature extension
}
registry._save()
# Reload registry
registry2 = ExtensionRegistry(extensions_dir)
# list_by_priority should use default of 10
result = registry2.list_by_priority()
assert len(result) == 1
assert result[0][0] == "legacy-ext"
# Priority defaults to 10 and is normalized in returned metadata
assert result[0][1]["priority"] == 10
def test_legacy_extension_in_list_installed(self, extension_dir, project_dir):
"""list_installed returns priority=10 for legacy extensions without priority field."""
manager = ExtensionManager(project_dir)
# Install extension normally
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Manually remove priority to simulate legacy extension
ext_data = manager.registry.data["extensions"]["test-ext"]
del ext_data["priority"]
manager.registry._save()
# list_installed should still return priority=10
installed = manager.list_installed()
assert len(installed) == 1
assert installed[0]["priority"] == 10
def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):
"""Legacy extensions (no priority) sort with default=10 among prioritized extensions."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Add extension with explicit priority=5
registry.add("ext-with-priority", {"version": "1.0.0", "priority": 5})
# Add legacy extension without priority (manually)
registry.data["extensions"]["legacy-ext"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
# No priority field
}
registry._save()
# Add extension with priority=15
registry.add("ext-low-priority", {"version": "1.0.0", "priority": 15})
# Reload and check ordering
registry2 = ExtensionRegistry(extensions_dir)
result = registry2.list_by_priority()
assert len(result) == 3
# Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15)
assert result[0][0] == "ext-with-priority"
assert result[1][0] == "legacy-ext"
assert result[2][0] == "ext-low-priority"

190
tests/test_merge.py Normal file
View File

@@ -0,0 +1,190 @@
import stat
from specify_cli import merge_json_files
from specify_cli import handle_vscode_settings
# --- Dimension 2: Polite Deep Merge Strategy ---
def test_merge_json_files_type_mismatch_preservation(tmp_path):
"""If user has a string but template wants a dict, PRESERVE user's string."""
existing_file = tmp_path / "settings.json"
# User might have overridden a setting with a simple string or different type
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
# Template might expect a dict for the same key (hypothetically)
new_settings = {
"chat.editor.fontFamily": {"font": "TemplateFont"}
}
merged = merge_json_files(existing_file, new_settings)
# Result is None because user settings were preserved and nothing else changed
assert merged is None
def test_merge_json_files_deep_nesting(tmp_path):
"""Verify deep recursive merging of new keys."""
existing_file = tmp_path / "settings.json"
existing_file.write_text("""
{
"a": {
"b": {
"c": 1
}
}
}
""")
new_settings = {
"a": {
"b": {
"d": 2 # New nested key
},
"e": 3 # New mid-level key
}
}
merged = merge_json_files(existing_file, new_settings)
assert merged["a"]["b"]["c"] == 1
assert merged["a"]["b"]["d"] == 2
assert merged["a"]["e"] == 3
def test_merge_json_files_empty_existing(tmp_path):
"""Merging into an empty/new file."""
existing_file = tmp_path / "empty.json"
existing_file.write_text("{}")
new_settings = {"a": 1}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1}
# --- Dimension 3: Real-world Simulation ---
def test_merge_vscode_realistic_scenario(tmp_path):
"""A realistic VSCode settings.json with many existing preferences, comments, and trailing commas."""
existing_file = tmp_path / "vscode_settings.json"
existing_file.write_text("""
{
"editor.fontSize": 12,
"editor.formatOnSave": true, /* block comment */
"files.exclude": {
"**/.git": true,
"**/node_modules": true,
},
"chat.promptFilesRecommendations": {
"existing.tool": true,
} // User comment
}
""")
template_settings = {
"chat.promptFilesRecommendations": {
"speckit.specify": True,
"speckit.plan": True
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": True
}
}
merged = merge_json_files(existing_file, template_settings)
# Check preservation
assert merged["editor.fontSize"] == 12
assert merged["files.exclude"]["**/.git"] is True
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
# Check additions
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
# --- Dimension 4: Error Handling & Robustness ---
def test_merge_json_files_with_bom(tmp_path):
"""Test files with UTF-8 BOM (sometimes created on Windows)."""
existing_file = tmp_path / "bom.json"
content = '{"a": 1}'
# Prepend UTF-8 BOM
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
new_settings = {"b": 2}
merged = merge_json_files(existing_file, new_settings)
assert merged == {"a": 1, "b": 2}
def test_merge_json_files_not_a_dictionary_template(tmp_path):
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
existing_file = tmp_path / "ok.json"
existing_file.write_text('{"a": 1}')
# Secure fallback: return None to skip writing and avoid clobbering
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
def test_merge_json_files_unparseable_existing(tmp_path):
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
bad_file = tmp_path / "bad.json"
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
assert merge_json_files(bad_file, {"b": 2}) is None
def test_merge_json_files_list_preservation(tmp_path):
"""Verify that existing list values are preserved and NOT merged or overwritten."""
existing_file = tmp_path / "list.json"
existing_file.write_text('{"my.list": ["user_item"]}')
template_settings = {
"my.list": ["template_item"]
}
merged = merge_json_files(existing_file, template_settings)
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
# Since nothing changed, it returns None.
assert merged is None
def test_merge_json_files_no_changes(tmp_path):
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
existing_file = tmp_path / "no_change.json"
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
template_settings = {
"a": 1, # Already exists
"b": {"c": 2} # Already exists nested
}
# Should return None because result == existing
assert merge_json_files(existing_file, template_settings) is None
def test_merge_json_files_type_mismatch_no_op(tmp_path):
"""If a key exists with different type and we preserve it, it might still result in no change."""
existing_file = tmp_path / "mismatch_no_op.json"
existing_file.write_text('{"a": "user_string"}')
template_settings = {
"a": {"key": "template_dict"} # Mismatch, will be ignored
}
# Should return None because we preserved the user's string and nothing else changed
assert merge_json_files(existing_file, template_settings) is None
def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
"""Atomic rewrite should preserve existing file mode bits."""
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir()
dest_file = vscode_dir / "settings.json"
template_file = tmp_path / "template_settings.json"
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
dest_file.chmod(0o640)
before_mode = stat.S_IMODE(dest_file.stat().st_mode)
template_file.write_text('{"b": 2}\n', encoding="utf-8")
handle_vscode_settings(
template_file,
dest_file,
"settings.json",
verbose=False,
tracker=None,
)
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
assert after_mode == before_mode

2003
tests/test_presets.py Normal file

File diff suppressed because it is too large Load Diff