Compare commits

..

17 Commits

Author SHA1 Message Date
Copilot
3e91d01f15 fix: correct 0.3.1 CHANGELOG.md entries (#1882)
* Initial plan

* fix: correct 0.3.1 CHANGELOG.md entries - fix truncated title and remove duplicates

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

---------

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-17 12:36:43 -05:00
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
25 changed files with 2007 additions and 192 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

@@ -46,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,26 @@ 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 skills (#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)
## [0.3.0] - 2026-03-13
### Changed
@@ -35,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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`

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",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.3.0"
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,6 +169,10 @@ 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"
}
@@ -194,9 +198,11 @@ resolve_template() {
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)
local sorted_presets
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
# 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:
@@ -206,14 +212,17 @@ try:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null)
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
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"
" 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 returned empty list — fall through to directory scan
# 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"
@@ -246,8 +255,9 @@ except Exception:
local core="$base/${template_name}.md"
[ -f "$core" ] && echo "$core" && return 0
# Return success with empty output so callers using set -e don't abort;
# callers check [ -n "$TEMPLATE" ] to detect "not found".
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,17 +162,6 @@ 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.
@@ -308,9 +297,14 @@ fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR"
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else 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,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
mkdir -p "$FEATURE_DIR"
# Copy plan template if it exists
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
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"

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

@@ -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 }

View File

@@ -7,6 +7,7 @@
# "platformdirs",
# "readchar",
# "httpx",
# "json5",
# ]
# ///
"""
@@ -32,6 +33,8 @@ import tempfile
import shutil
import shlex
import json
import json5
import stat
import yaml
from pathlib import Path
from typing import Any, Optional, Tuple
@@ -272,6 +275,13 @@ AGENT_CONFIG = {
"install_url": "https://code.kimi.com/",
"requires_cli": True,
},
"trae": {
"name": "Trae",
"folder": ".trae/",
"commands_subdir": "rules", # Trae uses .trae/rules/ for project rules
"install_url": None, # IDE-based
"requires_cli": False,
},
"generic": {
"name": "Generic (bring your own agent)",
"folder": None, # Set dynamically via --ai-commands-dir
@@ -654,37 +664,82 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files."""
"""Handle merging or copying of .vscode/settings.json files.
Note: when merge produces changes, rewritten output is normalized JSON and
existing JSONC comments/trailing commas are not preserved.
"""
def log(message, color="green"):
if verbose and not tracker:
console.print(f"[{color}]{message}[/] {rel_path}")
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
"""Atomically write JSON while preserving existing mode bits when possible."""
temp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(
mode='w',
encoding='utf-8',
dir=target_file.parent,
prefix=f"{target_file.name}.",
suffix=".tmp",
delete=False,
) as f:
temp_path = Path(f.name)
json.dump(payload, f, indent=4)
f.write('\n')
if target_file.exists():
try:
existing_stat = target_file.stat()
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
if hasattr(os, "chown"):
try:
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
except PermissionError:
# Best-effort owner/group preservation without requiring elevated privileges.
pass
except OSError:
# Best-effort metadata preservation; data safety is prioritized.
pass
os.replace(temp_path, target_file)
except Exception:
if temp_path and temp_path.exists():
temp_path.unlink()
raise
try:
with open(sub_item, 'r', encoding='utf-8') as f:
new_settings = json.load(f)
# json5 natively supports comments and trailing commas (JSONC)
new_settings = json5.load(f)
if dest_file.exists():
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
with open(dest_file, 'w', encoding='utf-8') as f:
json.dump(merged, f, indent=4)
f.write('\n')
log("Merged:", "green")
if merged is not None:
atomic_write_json(dest_file, merged)
log("Merged:", "green")
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
else:
log("Skipped merge (preserved existing settings)", "yellow")
else:
shutil.copy2(sub_item, dest_file)
log("Copied (no existing settings.json):", "blue")
except Exception as e:
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
shutil.copy2(sub_item, dest_file)
log(f"Warning: Could not merge settings: {e}", "yellow")
if not dest_file.exists():
shutil.copy2(sub_item, dest_file)
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
"""Merge new JSON content into existing JSON file.
Performs a deep merge where:
Performs a polite deep merge where:
- New keys are added
- Existing keys are preserved unless overwritten by new content
- Nested dictionaries are merged recursively
- Lists and other values are replaced (not merged)
- Existing keys are preserved (not overwritten) unless both values are dictionaries
- Nested dictionaries are merged recursively only when both sides are dictionaries
- Lists and other values are preserved from base if they exist
Args:
existing_path: Path to existing JSON file
@@ -692,28 +747,64 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal
verbose: Whether to print merge details
Returns:
Merged JSON content as dict
Merged JSON content as dict, or None if the existing file should be left untouched.
"""
try:
with open(existing_path, 'r', encoding='utf-8') as f:
existing_content = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
# If file doesn't exist or is invalid, just use new content
# Load existing content first to have a safe fallback
existing_content = None
exists = existing_path.exists()
if exists:
try:
with open(existing_path, 'r', encoding='utf-8') as f:
# Handle comments (JSONC) natively with json5
# Note: json5 handles BOM automatically
existing_content = json5.load(f)
except FileNotFoundError:
# Handle race condition where file is deleted after exists() check
exists = False
except Exception as e:
if verbose:
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
return None
# Validate template content
if not isinstance(new_content, dict):
if verbose:
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
return None
if not exists:
return new_content
def deep_merge(base: dict, update: dict) -> dict:
"""Recursively merge update dict into base dict."""
# If existing content parsed but is not a dict, skip merge to avoid data loss
if not isinstance(existing_content, dict):
if verbose:
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
return None
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge update dict into base dict, preserving base values."""
result = base.copy()
for key, value in update.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
result[key] = deep_merge(result[key], value)
else:
# Add new key or replace existing value
if key not in result:
# Add new key
result[key] = value
elif isinstance(result[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
result[key] = deep_merge_polite(result[key], value)
else:
# Key already exists and values are not both dicts; preserve existing value.
# This ensures user settings aren't overwritten by template defaults.
pass
return result
merged = deep_merge(existing_content, new_content)
merged = deep_merge_polite(existing_content, new_content)
# Detect if anything actually changed. If not, return None so the caller
# can skip rewriting the file (preserving user's comments/formatting).
if merged == existing_content:
return None
if verbose:
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
@@ -1165,7 +1256,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
else:
templates_dir = project_path / commands_subdir
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
# Only consider speckit.*.md templates so that user-authored command
# files (e.g. custom slash commands, agent files) coexisting in the
# same commands directory are not incorrectly converted into skills.
template_glob = "speckit.*.md"
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
# Fallback: try the repo-relative path (for running from source checkout)
# This also covers agents whose extracted commands are in a different
# format (e.g. gemini/tabnine use .toml, not .md).
@@ -1173,15 +1269,16 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
fallback_dir = script_dir / "templates" / "commands"
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
templates_dir = fallback_dir
template_glob = "*.md"
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
if tracker:
tracker.error("ai-skills", "command templates not found")
else:
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
return False
command_files = sorted(templates_dir.glob("*.md"))
command_files = sorted(templates_dir.glob(template_glob))
if not command_files:
if tracker:
tracker.skip("ai-skills", "no command templates found")
@@ -1220,11 +1317,14 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
body = content
command_name = command_file.stem
# Normalize: extracted commands may be named "speckit.<cmd>.md";
# strip the "speckit." prefix so skill names stay clean and
# Normalize: extracted commands may be named "speckit.<cmd>.md"
# or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
# any trailing ".agent" suffix so skill names stay clean and
# SKILL_DESCRIPTIONS lookups work.
if command_name.startswith("speckit."):
command_name = command_name[len("speckit."):]
if command_name.endswith(".agent"):
command_name = command_name[:-len(".agent")]
# Kimi CLI discovers skills by directory name and invokes them as
# /skill:<name> — use dot separator to match packaging convention.
if selected_ai == "kimi":
@@ -1249,6 +1349,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
source_name = command_file.name
if source_name.startswith("speckit."):
source_name = source_name[len("speckit."):]
if source_name.endswith(".agent.md"):
source_name = source_name[:-len(".agent.md")] + ".md"
frontmatter_data = {
"name": skill_name,
@@ -2000,6 +2102,11 @@ def preset_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
speckit_version = get_speckit_version()
@@ -2177,6 +2284,7 @@ def preset_info(
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
):
"""Show detailed information about a preset."""
from .extensions import normalize_priority
from .presets import PresetCatalog, PresetManager, PresetError
project_root = Path.cwd()
@@ -2210,6 +2318,10 @@ def preset_info(
if license_val:
console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(pack_id)
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
console.print(f" [dim]Priority:[/dim] {priority}")
console.print()
return
@@ -2241,6 +2353,58 @@ def preset_info(
console.print()
@preset_app.command("set-priority")
def preset_set_priority(
pack_id: str = typer.Argument(help="Preset ID"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed preset."""
from .presets import PresetManager
project_root = Path.cwd()
# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
# Check if preset is installed
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
raise typer.Exit(1)
# Get current metadata
metadata = manager.registry.get(pack_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
from .extensions import normalize_priority
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(pack_id, {"priority": priority})
console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
# ===== Preset Catalog Commands =====
@@ -2576,8 +2740,9 @@ def extension_list(
status_color = "green" if ext["enabled"] else "red"
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
console.print(f" [dim]{ext['id']}[/dim]")
console.print(f" {ext['description']}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()
if available or all_extensions:
@@ -2765,6 +2930,7 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
@@ -2778,6 +2944,11 @@ def extension_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
@@ -2794,7 +2965,7 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1)
manifest = manager.install_from_directory(source_path, speckit_version)
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
elif from_url:
# Install from URL (ZIP file)
@@ -2827,7 +2998,7 @@ def extension_add(
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
raise typer.Exit(1)
@@ -2871,7 +3042,7 @@ def extension_add(
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
@@ -3047,7 +3218,7 @@ def extension_info(
extension: str = typer.Argument(help="Extension ID or name"),
):
"""Show detailed information about an extension."""
from .extensions import ExtensionCatalog, ExtensionManager
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
project_root = Path.cwd()
@@ -3084,8 +3255,15 @@ def extension_info(
# Get local manifest info
ext_manifest = manager.get_extension(resolved_installed_id)
metadata = manager.registry.get(resolved_installed_id)
metadata_is_dict = isinstance(metadata, dict)
if not metadata_is_dict:
console.print(
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
"some information may be unavailable."
)
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
console.print(f"ID: {resolved_installed_id}")
console.print()
@@ -3113,6 +3291,8 @@ def extension_info(
console.print()
console.print("[green]✓ Installed[/green]")
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
return
@@ -3128,6 +3308,8 @@ def extension_info(
def _print_extension_info(ext_info: dict, manager):
"""Print formatted extension info from catalog data."""
from .extensions import normalize_priority
# Header
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
@@ -3206,6 +3388,9 @@ def _print_extension_info(ext_info: dict, manager):
install_allowed = ext_info.get("_install_allowed", True)
if is_installed:
console.print("[green]✓ Installed[/green]")
metadata = manager.registry.get(ext_info['id'])
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
@@ -3232,6 +3417,7 @@ def extension_update(
ValidationError,
CommandRegistrar,
HookExecutor,
normalize_priority,
)
from packaging import version as pkg_version
import shutil
@@ -3271,7 +3457,7 @@ def extension_update(
for ext_id in extensions_to_update:
# Get installed version
metadata = manager.registry.get(ext_id)
if metadata is None or "version" not in metadata:
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
console.print(f"{ext_id}: Registry entry corrupted or missing (skipping)")
continue
try:
@@ -3456,13 +3642,13 @@ def extension_update(
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
# 9. Restore metadata from backup (installed_at, enabled state)
if backup_registry_entry:
if backup_registry_entry and isinstance(backup_registry_entry, dict):
# Copy current registry entry to avoid mutating internal
# registry state before explicit restore().
current_metadata = manager.registry.get(extension_id)
if current_metadata is None:
if current_metadata is None or not isinstance(current_metadata, dict):
raise RuntimeError(
f"Registry entry for '{extension_id}' missing after install — update incomplete"
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
)
new_metadata = dict(current_metadata)
@@ -3470,6 +3656,10 @@ def extension_update(
if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
# Preserve the original priority (normalized to handle corruption)
if "priority" in backup_registry_entry:
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
# If extension was disabled before update, disable it again
if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False
@@ -3523,7 +3713,7 @@ def extension_update(
# (files that weren't in the original backup)
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None:
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
@@ -3643,10 +3833,10 @@ def extension_enable(
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None:
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
raise typer.Exit(0)
@@ -3691,10 +3881,10 @@ def extension_disable(
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None:
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if not metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
raise typer.Exit(0)
@@ -3716,6 +3906,57 @@ def extension_disable(
console.print(f"To re-enable: specify extension enable {extension_id}")
@extension_app.command("set-priority")
def extension_set_priority(
extension: str = typer.Argument(help="Extension ID or name"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed extension."""
from .extensions import ExtensionManager
project_root = Path.cwd()
# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
# Get current metadata
metadata = manager.registry.get(extension_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
from .extensions import normalize_priority
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
raise typer.Exit(0)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(extension_id, {"priority": priority})
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
def main():
app()

View File

@@ -135,6 +135,12 @@ class CommandRegistrar:
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md"
},
"trae": {
"dir": ".trae/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
}
}

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.
@@ -632,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"
@@ -643,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)
@@ -655,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

View File

@@ -7,6 +7,7 @@ Presets are self-contained, versioned collections of templates
customize the Spec-Driven Development workflow.
"""
import copy
import json
import hashlib
import os
@@ -23,6 +24,8 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import ExtensionRegistry, normalize_priority
@dataclass
class PresetCatalogEntry:
@@ -271,6 +274,38 @@ class PresetRegistry:
del self.data["presets"][pack_id]
self._save()
def update(self, pack_id: str, updates: dict):
"""Update preset metadata in registry.
Merges the provided updates with the existing entry, preserving any
fields not specified. The installed_at timestamp is always preserved
from the original entry.
Args:
pack_id: Preset ID
updates: Partial metadata to merge into existing metadata
Raises:
KeyError: If preset is not installed
"""
if pack_id not in self.data["presets"]:
raise KeyError(f"Preset '{pack_id}' not found in registry")
existing = self.data["presets"][pack_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, **updates}
# Always preserve original installed_at based on key existence, not truthiness,
# to handle cases where the field exists but may be falsy (legacy/corruption)
if "installed_at" in existing:
merged["installed_at"] = existing["installed_at"]
else:
# If not present in existing, explicitly remove from merged if caller provided it
merged.pop("installed_at", None)
self.data["presets"][pack_id] = merged
self._save()
def get(self, pack_id: str) -> Optional[dict]:
"""Get preset metadata from registry.
@@ -294,14 +329,26 @@ class PresetRegistry:
"""Get all installed presets sorted by priority.
Lower priority number = higher precedence (checked first).
Presets with equal priority are sorted alphabetically by ID
for deterministic ordering.
Returns:
List of (pack_id, metadata) tuples sorted by priority
List of (pack_id, metadata_copy) tuples sorted by priority.
Metadata is deep-copied to prevent accidental mutation.
"""
packs = self.data["presets"]
packs = self.data.get("presets", {}) or {}
if not isinstance(packs, dict):
packs = {}
sortable_packs = []
for pack_id, meta in packs.items():
if not isinstance(meta, dict):
continue
metadata_copy = copy.deepcopy(meta)
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
sortable_packs.append((pack_id, metadata_copy))
return sorted(
packs.items(),
key=lambda item: item[1].get("priority", 10),
sortable_packs,
key=lambda item: (item[1]["priority"], item[0]),
)
def is_installed(self, pack_id: str) -> bool:
@@ -680,9 +727,13 @@ class PresetManager:
Installed preset manifest
Raises:
PresetValidationError: If manifest is invalid
PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible
"""
# Validate priority
if priority < 1:
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
manifest_path = source_dir / "preset.yml"
manifest = PresetManifest(manifest_path)
@@ -729,14 +780,19 @@ class PresetManager:
Args:
zip_path: Path to preset ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
Returns:
Installed preset manifest
Raises:
PresetValidationError: If manifest is invalid
PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible
"""
# Validate priority early
if priority < 1:
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
@@ -808,6 +864,9 @@ class PresetManager:
result = []
for pack_id, metadata in self.registry.list().items():
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
if not isinstance(metadata, dict):
metadata = {}
pack_dir = self.presets_dir / pack_id
manifest_path = pack_dir / "preset.yml"
@@ -816,13 +875,13 @@ class PresetManager:
result.append({
"id": pack_id,
"name": manifest.name,
"version": metadata["version"],
"version": metadata.get("version", manifest.version),
"description": manifest.description,
"enabled": metadata.get("enabled", True),
"installed_at": metadata.get("installed_at"),
"template_count": len(manifest.templates),
"tags": manifest.tags,
"priority": metadata.get("priority", 10),
"priority": normalize_priority(metadata.get("priority")),
})
except PresetValidationError:
result.append({
@@ -834,7 +893,7 @@ class PresetManager:
"installed_at": metadata.get("installed_at"),
"template_count": 0,
"tags": [],
"priority": metadata.get("priority", 10),
"priority": normalize_priority(metadata.get("priority")),
})
return result
@@ -1393,6 +1452,40 @@ class PresetResolver:
self.overrides_dir = self.templates_dir / "overrides"
self.extensions_dir = project_root / ".specify" / "extensions"
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
"""Build unified list of registered and unregistered extensions sorted by priority.
Registered extensions use their stored priority; unregistered directories
get implicit priority=10. Results are sorted by (priority, ext_id) for
deterministic ordering.
Returns:
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
"""
if not self.extensions_dir.exists():
return []
registry = ExtensionRegistry(self.extensions_dir)
registered_extensions = registry.list_by_priority()
registered_extension_ids = {ext_id for ext_id, _ in registered_extensions}
all_extensions: list[tuple[int, str, dict | None]] = []
for ext_id, metadata in registered_extensions:
priority = normalize_priority(metadata.get("priority") if metadata else None)
all_extensions.append((priority, ext_id, metadata))
# Add unregistered directories with implicit priority=10
for ext_dir in self.extensions_dir.iterdir():
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
if ext_dir.name not in registered_extension_ids:
all_extensions.append((10, ext_dir.name, None))
# Sort by (priority, ext_id) for deterministic ordering
all_extensions.sort(key=lambda x: (x[0], x[1]))
return all_extensions
def resolve(
self,
template_name: str,
@@ -1445,18 +1538,18 @@ class PresetResolver:
if candidate.exists():
return candidate
# Priority 3: Extension-provided templates
if self.extensions_dir.exists():
for ext_dir in sorted(self.extensions_dir.iterdir()):
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
for subdir in subdirs:
if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}"
else:
candidate = ext_dir / "templates" / f"{template_name}{ext}"
if candidate.exists():
return candidate
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
for subdir in subdirs:
if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}"
else:
candidate = ext_dir / f"{template_name}{ext}"
if candidate.exists():
return candidate
# Priority 4: Core templates
if template_type == "template":
@@ -1514,17 +1607,24 @@ class PresetResolver:
except ValueError:
continue
if self.extensions_dir.exists():
for ext_dir in sorted(self.extensions_dir.iterdir()):
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
continue
try:
resolved.relative_to(ext_dir)
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir():
continue
try:
resolved.relative_to(ext_dir)
if ext_meta:
version = ext_meta.get("version", "?")
return {
"path": resolved_str,
"source": f"extension:{ext_dir.name}",
"source": f"extension:{ext_id} v{version}",
}
except ValueError:
continue
else:
return {
"path": resolved_str,
"source": f"extension:{ext_id} (unregistered)",
}
except ValueError:
continue
return {"path": resolved_str, "source": "core"}

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

View File

@@ -32,6 +32,7 @@ from specify_cli.presets import (
PresetCompatibilityError,
VALID_PRESET_TEMPLATE_TYPES,
)
from specify_cli.extensions import ExtensionRegistry
# ===== Fixtures =====
@@ -573,6 +574,24 @@ class TestRegistryPriority:
assert sorted_packs[0][0] == "pack-b"
assert sorted_packs[1][0] == "pack-a"
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
"""Malformed priority values fall back to the default priority."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
registry.data["presets"]["pack-invalid"] = {
"version": "1.0.0",
"priority": "high",
}
registry._save()
sorted_packs = registry.list_by_priority()
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
assert sorted_packs[1][1]["priority"] == 10
# ===== PresetResolver Tests =====
@@ -678,6 +697,11 @@ class TestPresetResolver:
ext_template = ext_templates_dir / "custom-template.md"
ext_template.write_text("# Extension Custom Template\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
resolver = PresetResolver(project_dir)
result = resolver.resolve("custom-template")
assert result is not None
@@ -741,10 +765,15 @@ class TestPresetResolver:
ext_template = ext_templates_dir / "unique-template.md"
ext_template.write_text("# Unique\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("unique-template")
assert result is not None
assert result["source"] == "extension:my-ext"
assert result["source"] == "extension:my-ext v1.0.0"
def test_resolve_with_source_not_found(self, project_dir):
"""Test resolve_with_source for nonexistent template."""
@@ -765,6 +794,104 @@ class TestPresetResolver:
assert result is None
class TestExtensionPriorityResolution:
"""Test extension priority resolution with registered and unregistered extensions."""
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 20 (lower precedence than 10)
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
# Create unregistered extension directory (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Unregistered (priority 10) should beat registered (priority 20)
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From Unregistered" in result.read_text()
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 5 (higher precedence than 10)
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
# Create unregistered extension directory (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Registered (priority 5) should beat unregistered (priority 10)
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From Registered" in result.read_text()
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
"""Test resolve_with_source correctly attributes unregistered extension."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create registered extension with priority 20
registered_dir = extensions_dir / "registered-ext"
(registered_dir / "templates").mkdir(parents=True)
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
# Create unregistered extension (implicit priority 10)
unregistered_dir = extensions_dir / "unregistered-ext"
(unregistered_dir / "templates").mkdir(parents=True)
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
# Attribution should show unregistered extension
resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("test-template")
assert result is not None
assert "unregistered-ext" in result["source"]
assert "(unregistered)" in result["source"]
def test_same_priority_sorted_alphabetically(self, project_dir):
"""Extensions with same priority are sorted alphabetically by ID."""
extensions_dir = project_dir / ".specify" / "extensions"
extensions_dir.mkdir(parents=True, exist_ok=True)
# Create two unregistered extensions (both implicit priority 10)
# "aaa-ext" should come before "zzz-ext" alphabetically
zzz_dir = extensions_dir / "zzz-ext"
(zzz_dir / "templates").mkdir(parents=True)
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
aaa_dir = extensions_dir / "aaa-ext"
(aaa_dir / "templates").mkdir(parents=True)
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
# AAA should win due to alphabetical ordering at same priority
resolver = PresetResolver(project_dir)
result = resolver.resolve("test-template")
assert result is not None
assert "From AAA" in result.read_text()
# ===== PresetCatalog Tests =====
@@ -979,8 +1106,13 @@ class TestIntegration:
ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
# Register extension in registry
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
result = resolver.resolve_with_source("spec-template")
assert result["source"] == "extension:my-ext"
assert result["source"] == "extension:my-ext v1.0.0"
# Install pack — should win over extension
manager = PresetManager(project_dir)
@@ -1710,3 +1842,162 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test")
assert metadata.get("registered_skills", []) == []
class TestPresetSetPriority:
"""Test preset set-priority CLI command."""
def test_set_priority_changes_priority(self, project_dir, pack_dir):
"""Test set-priority command changes preset priority."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset with default priority
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Verify default priority
assert manager.registry.get("test-pack")["priority"] == 10
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "priority changed: 10 → 5" in result.output
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["priority"] == 5
def test_set_priority_same_value_no_change(self, project_dir, pack_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 preset with priority 5
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
assert result.exit_code == 0, result.output
assert "already has priority 5" in result.output
def test_set_priority_invalid_value(self, project_dir, pack_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 preset
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "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 preset."""
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, ["preset", "set-priority", "nonexistent", "5"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
class TestPresetPriorityBackwardsCompatibility:
"""Test backwards compatibility for presets installed before priority feature."""
def test_legacy_preset_without_priority_field(self, temp_dir):
"""Presets installed before priority feature should default to 10."""
presets_dir = temp_dir / ".specify" / "presets"
presets_dir.mkdir(parents=True)
# Simulate legacy registry entry without priority field
registry = PresetRegistry(presets_dir)
registry.data["presets"]["legacy-pack"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
"installed_at": "2025-01-01T00:00:00Z",
# No "priority" field - simulates pre-feature preset
}
registry._save()
# Reload registry
registry2 = PresetRegistry(presets_dir)
# list_by_priority should use default of 10
result = registry2.list_by_priority()
assert len(result) == 1
assert result[0][0] == "legacy-pack"
# Priority defaults to 10 and is normalized in returned metadata
assert result[0][1]["priority"] == 10
def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):
"""list_installed returns priority=10 for legacy presets without priority field."""
manager = PresetManager(project_dir)
# Install preset normally
manager.install_from_directory(pack_dir, "0.1.5")
# Manually remove priority to simulate legacy preset
pack_data = manager.registry.data["presets"]["test-pack"]
del pack_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_presets_ordering(self, temp_dir):
"""Legacy presets (no priority) sort with default=10 among prioritized presets."""
presets_dir = temp_dir / ".specify" / "presets"
presets_dir.mkdir(parents=True)
registry = PresetRegistry(presets_dir)
# Add preset with explicit priority=5
registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5})
# Add legacy preset without priority (manually)
registry.data["presets"]["legacy-pack"] = {
"version": "1.0.0",
"source": "local",
"enabled": True,
# No priority field
}
# Add another preset with priority=15
registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15})
registry._save()
# Reload and check ordering
registry2 = PresetRegistry(presets_dir)
sorted_presets = registry2.list_by_priority()
# Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)
assert [p[0] for p in sorted_presets] == [
"pack-with-priority",
"legacy-pack",
"low-priority-pack",
]