Compare commits

...

34 Commits

Author SHA1 Message Date
Vianca M.
d2559d7025 docs: add Extension Comparison Guide for community extensions (#1897)
* docs: add Extension Comparison Guide for community extensions

* docs: delete addt. doc and just add columns to readme
2026-03-19 14:33:47 -05:00
Manfred Riem
f85944aafe docs: update SUPPORT.md, fix issue templates, add preset submission template (#1910)
* docs: update SUPPORT.md, fix issue templates, add preset submission template

- SUPPORT.md: simplify structure, add Discussions link, soften response commitment
- config.yml: fix broken Extension Development Guide URL (was manfredseee → github)
- agent_request.yml: update agent list with Tabnine, Vibe, Kimi, Trae, Pi, iFlow
- preset_submission.yml: new issue template for preset catalog submissions

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 11:57:32 -05:00
Irina Chichikova
34171efcef Add support for Junie (#1831)
* Add support for Junie

* Add Junie agent configuration to specify-cli

* Add support for iflow agent in context update scripts
2026-03-19 11:54:42 -05:00
Hamilton Snow
c8af730b14 feat: migrate Codex/agy init to native skills workflow (#1906)
* feat: migrate codex and agy to native skills flow

* fix: harden codex skill frontmatter and script fallback

* fix: clarify skills separator default expansion

* fix: rewrite agent_scripts paths for codex skills

* fix: align kimi guidance and platform-aware codex fallback
2026-03-19 09:00:41 -05:00
Manfred Riem
a4b60aca7f chore: bump version to 0.3.2 (#1909)
* chore: bump version to 0.3.2

* fix: correct changelog generation — use tag sort instead of git describe, remove duplicate entries

- Replace git describe --tags --abbrev=0 with git tag --sort=-version:refname
  to find the correct previous tag (git describe misses tags on unmerged
  release branches)
- Change changelog section heading from '### Changed' to '### Changes'
- Remove duplicate entries from 0.3.2 that belonged to prior releases
- Clean up changelog preamble and stale entries

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-19 08:52:09 -05:00
Li-Xian Chen
2f25e2d575 Add conduct extension to community catalog (#1908)
- Extension ID: conduct
- Version: 1.0.0
- Author: twbrandon7
- Description: Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.
2026-03-19 08:12:29 -05:00
davesharpe13
7484eb521a feat(extensions): add verify-tasks extension to community catalog (#1871)
* feat(extensions): add verify-tasks extension to community catalog

- Extension ID: verify-tasks
- Version: 1.0.0
- Detects phantom completions: tasks marked [X] in tasks.md with no real implementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Replace email with name in verify-tasks catalog entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:11:31 -05:00
Michal Bachorik
2bf655e261 feat(presets): add enable/disable toggle and update semantics (#1891)
* feat(presets): add enable/disable toggle and update semantics

Add preset enable/disable CLI commands and update semantics to match
the extension system capabilities.

Changes:
- Add `preset enable` and `preset disable` CLI commands
- Add `restore()` method to PresetRegistry for rollback scenarios
- Update `get()` and `list()` to return deep copies (prevents mutation)
- Update `list_by_priority()` to filter disabled presets by default
- Add input validation to `restore()` for defensive programming
- Add 16 new tests covering all functionality and edge cases

Closes #1851
Closes #1852

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

* fix: address PR review - deep copy and error message accuracy

- Fix error message in restore() to match actual validation ("dict" not "non-empty dict")
- Use copy.deepcopy() in restore() to prevent caller mutation
- Apply same fixes to ExtensionRegistry for parity
- Add /defensive-check command for pre-PR validation
- Add tests for restore() validation and deep copy behavior

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

* revert: remove defensive-check command from PR

* fix: address PR review - clarify messaging and add parity

- Add note to enable/disable output clarifying commands/skills remain active
- Add include_disabled parameter to ExtensionRegistry.list_by_priority for parity
- Add tests for extension disabled filtering

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

* fix: address PR review - disabled extension resolution and corrupted entries

- Fix _get_all_extensions_by_priority to use include_disabled=True for tracking
  registered IDs, preventing disabled extensions from being picked up as
  unregistered directories
- Add corrupted entry handling to get() - returns None for non-dict entries
- Add integration tests for disabled extension template resolution
- Add tests for get() corrupted entry handling in both registries

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

* fix: handle corrupted registry in list() methods

- Add defensive handling to list() when presets/extensions is not a dict
- Return empty dict instead of crashing on corrupted registry
- Apply same fix to both PresetRegistry and ExtensionRegistry for parity
- Add tests for corrupted registry handling

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

* fix: validate top-level registry structure in get() and restore()

- get() now validates self.data["presets/extensions"] is a dict before accessing
- restore() ensures presets/extensions dict exists before writing
- Prevents crashes when registry JSON is parseable but has corrupted structure
- Applied same fixes to both PresetRegistry and ExtensionRegistry for parity

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

* fix: validate root-level JSON structure in _load() and is_installed()

- _load() now validates json.load() result is a dict before returning
- is_installed() validates presets/extensions is a dict before checking membership
- Prevents crashes when registry file is valid JSON but wrong type (e.g., array)
- Applied same fixes to both registries for parity

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

* fix: normalize presets/extensions field in _load()

- _load() now normalizes the presets/extensions field to {} if not a dict
- Makes corrupted registries recoverable for add/update/remove operations
- Applied same fix to both registries for parity

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

* fix: use raw registry keys to track corrupted extensions

- Use registry.list().keys() instead of list_by_priority() for tracking
- Corrupted entries are now treated as tracked, not picked up as unregistered
- Tighten test assertion for disabled preset resolution
- Update test to match new expected behavior for corrupted entries

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

* fix: handle None metadata in ExtensionManager.remove()

- Add defensive check for corrupted metadata in remove()
- Match existing pattern in PresetManager.remove()

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

* fix: add keys() method and filter corrupted entries in list()

- Add lightweight keys() method that returns IDs without deep copy
- Update list() to filter out non-dict entries (match type contract)
- Use keys() instead of list().keys() for performance
- Fix comment to reflect actual behavior

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

* fix: address defensive-check findings - deep copy, corruption guards, parity

- Extension enable/disable: use delta pattern matching presets
- add(): use copy.deepcopy(metadata) in both registries
- remove(): guard outer field for corruption in both registries
- update(): guard outer field for corruption in both registries

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

* fix: deep copy updates in update() to prevent caller mutation

Both PresetRegistry.update() and ExtensionRegistry.update() now deep
copy the input updates/metadata dict to prevent callers from mutating
nested objects after the call.

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-19 07:48:48 -05:00
fuyongde
f6794685b6 feat: add iFlow CLI support (#1875)
Add `iflow` as a supported AI agent (the key users pass to --ai) across
all relevant configuration files, release scripts, agent context
scripts, and README. Includes consistency tests following the same
pattern as kimi/tabnine additions.

- README: describe `check` generically (git + all AGENT_CONFIG CLI agents)
- README: describe `--ai` with reference to AGENT_CONFIG for full list

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 06:44:22 -05:00
Matt Van Horn
333a76535b feat(commands): wire before/after hook events into specify and plan templates (#1886)
* feat(commands): wire before/after hook events into specify and plan templates

Replicates the hook evaluation pattern from tasks.md and implement.md
(introduced in PR #1702) into the specify and plan command templates.
This completes the hook lifecycle across all SDD phases.

Changes:
- specify.md: Add before_specify/after_specify hook blocks
- plan.md: Add before_plan/after_plan hook blocks
- EXTENSION-API-REFERENCE.md: Document new hook events
- EXTENSION-USER-GUIDE.md: List all available hook events

Fixes #1788

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

* Mark before_commit/after_commit as planned in extension docs

These hook events are defined in the API reference but not yet wired
into any core command template. Marking them as planned rather than
removing them, since the infrastructure supports them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix hook enablement to default true when field is absent

Matches HookExecutor.get_hooks_for_event() semantics where
hooks without an explicit enabled field are treated as enabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(docs): mark commit hooks as planned in user guide config example

The yaml config comment listed before_commit/after_commit as
"Available events" but they are not yet wired into core templates.
Moved them to a separate "Planned" line, consistent with the
API reference.

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

* fix(commands): align enabled-filtering semantics across all hook templates

tasks.md and implement.md previously said "Filter to only hooks where
enabled: true", which would skip hooks that omit the enabled field.
Updated to match specify.md/plan.md and HookExecutor's h.get('enabled', True)
behavior: filter out only hooks where enabled is explicitly false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 06:37:03 -05:00
Matt Van Horn
6d0b84ab5b docs(catalog): add speckit-utils to community catalog (#1896)
* docs(catalog): add speckit-utils to community catalog

Adds SDD Utilities extension (resume, doctor, validate) to the
community catalog and README table. Hosted at mvanhorn/speckit-utils.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Bump catalog updated_at to current date

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-03-18 14:27:27 -05:00
Manfred Riem
497b5885e1 docs: Add Extensions & Presets section to README (#1898)
* docs: add Extensions & Presets section to README

Add a new 'Making Spec Kit Your Own: Extensions & Presets' section that covers:
- Layering diagram (Mermaid) showing resolution order
- Extensions: what they are, when to use, examples
- Presets: what they are, when to use, examples
- When-to-use-which comparison table
- Links to extensions/README.md and presets/README.md

* docs: clarify project-local overrides in layering diagram

Address review feedback: explain the project-local overrides layer
shown in the diagram, and adjust the intro to acknowledge it as a
third customization mechanism alongside extensions and presets.

* docs: Clarify template vs command resolution in README

- Separate template resolution (top-down, first-match-wins stack) from
  command registration (written directly into agent directories)
- Update Mermaid diagram paths to use <preset-id> and <ext-id>
  placeholders consistent with existing documentation

Addresses PR review feedback on #1898.

* docs: Clarify install-time vs runtime resolution for commands and templates

- README: label templates as runtime-resolved (stack walk) and commands
  as install-time (copied into agent directories, last-installed wins)
- presets/README: add runtime note to template resolution, contrast with
  install-time command registration

* docs: Address review — fix template copy wording, tighten command override description

- presets/README: clarify that preset files are copied at install but
  template resolution still walks the stack at runtime
- README: describe priority-based command resolution and automatic
  restoration on removal instead of vague 'replacing whatever was there'
2026-03-18 14:21:20 -05:00
Ricardo Accioly
33c83a6162 chore: update DocGuard extension to v0.9.11 (#1899)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 14:07:44 -05:00
LADISLAV BIHARI
f97c8e95a6 Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884)
Updated description to version-independent wording:
"Multi-agent cognitive system with Triadic Model: understanding,
internalization, application — with quality gates, backpropagation
verification, and self-healing"

Changes:
- description: version-independent (no counts)
- provides.commands: 7 → 10
- tags: pre-code,analysis → full-lifecycle,verification
- updated_at: bumped to 2026-03-18

Co-authored-by: Ladislav Bihari <ladislav.bihari@statsperform.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:50:36 -05:00
Vianca M.
cfd99ad499 feat: register spec-kit-iterate extension (#1887)
* feat: register spec-kit-iterate extension

* fix: copilot review

* Potential fix for pull request finding

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 08:50:19 -05:00
Matt Van Horn
96712e1cdf fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885)
The $Number (Int32) parameter was implicitly receiving positional
arguments intended for $FeatureDescription, causing a
ParameterBindingArgumentTransformationException when AI agents
called the script with positional strings.

Add [Parameter(Position = 0)] to $FeatureDescription so it binds
first, and mark $Number with [Parameter()] (no Position) so it
only binds by name (-Number N).

Fixes #1879

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:00:16 -05:00
Pierluigi Lenoci
2e55bdd3f2 fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872)
* fix(scripts): encode residual control chars as \uXXXX instead of stripping

json_escape() was silently deleting control characters (U+0000-U+001F)
that were not individually handled (\n, \t, \r, \b, \f). Per RFC 8259,
these must be encoded as \uXXXX sequences to preserve data integrity.

Replace the tr -d strip with a char-by-char loop that emits proper
\uXXXX escapes for any remaining control characters.

* fix(scripts): address Copilot review on json_escape control char loop

- Set LC_ALL=C for the entire loop (not just printf) so that ${#s} and
  ${s:$i:1} operate on bytes deterministically across locales
- Fix comment: U+0000 (NUL) cannot exist in bash strings, range is
  U+0001-U+001F; adjust code guard accordingly (code >= 1)
- Emit directly to stdout instead of accumulating in a variable,
  avoiding quadratic string concatenation on longer inputs

* perf(scripts): use printf -v to avoid subshell in json_escape loop

Replace code=$(printf ...) with printf -v code to assign the character
code without spawning a subshell on every byte, reducing overhead for
longer inputs.
2026-03-18 07:58:34 -05:00
Ricardo Accioly
eecb723663 chore: update DocGuard extension to v0.9.10 (#1890)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 07:43:42 -05:00
Greazly
1a21bdef01 Feature/spec kit add pi coding agent pullrequest (#1853)
* feat(ai): add native support for Pi coding agent by pi+gpt 5.4

* docs(pi): document MCP limitations for Pi agent

* fix: unitended kimi agent mention added to update-agent-context.ps1

* fix: address reviewer feedback

* Apply suggestions from code review

Changes in AGENTS.md weren't part of my PR, but the Copilot feedback seems to be correct is correct. I've doublechecked it with contents of test_agent_config_consistency.py and create-release-packages scripts

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 14:50:18 -05:00
Vianca M.
f21eb71990 feat: register spec-kit-learn extension (#1883)
* feat: register spec-kit-learn extension

* Potential fix for pull request finding

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

resolve copilot review

Potential fix for pull request finding

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

Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-17 13:31:00 -05:00
Manfred Riem
b471b5e6f3 chore: bump version to 0.3.1 (#1880)
* chore: bump version to 0.3.1

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-03-17 12:41:29 -05: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
41 changed files with 4409 additions and 445 deletions

View File

@@ -51,6 +51,14 @@ echo -e "\n🤖 Installing OpenCode CLI..."
run_command "npm install -g opencode-ai@latest" run_command "npm install -g opencode-ai@latest"
echo "✅ Done" echo "✅ Done"
echo -e "\n🤖 Installing Junie CLI..."
run_command "npm install -g @jetbrains/junie-cli@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Pi Coding Agent..."
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Kiro CLI..." echo -e "\n🤖 Installing Kiro CLI..."
# https://kiro.dev/docs/cli/ # https://kiro.dev/docs/cli/
KIRO_INSTALLER_URL="https://kiro.dev/install.sh" KIRO_INSTALLER_URL="https://kiro.dev/install.sh"

View File

@@ -8,7 +8,7 @@ body:
value: | value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, IBM Bob, Antigravity **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI
- type: input - type: input
id: agent-name id: agent-name

View File

@@ -7,7 +7,7 @@ contact_links:
url: https://github.com/github/spec-kit/blob/main/README.md url: https://github.com/github/spec-kit/blob/main/README.md
about: Read the Spec Kit documentation and guides about: Read the Spec Kit documentation and guides
- name: 🛠️ Extension Development Guide - name: 🛠️ Extension Development Guide
url: https://github.com/manfredseee/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md url: https://github.com/github/spec-kit/blob/main/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
about: Learn how to develop and publish Spec Kit extensions about: Learn how to develop and publish Spec Kit extensions
- name: 🤝 Contributing Guide - name: 🤝 Contributing Guide
url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md url: https://github.com/github/spec-kit/blob/main/CONTRIBUTING.md

View File

@@ -0,0 +1,169 @@
name: Preset Submission
description: Submit your preset to the Spec Kit preset catalog
title: "[Preset]: Add "
labels: ["preset-submission", "enhancement", "needs-triage"]
body:
- type: markdown
attributes:
value: |
Thanks for contributing a preset! This template helps you submit your preset to the community catalog.
**Before submitting:**
- Review the [Preset Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md)
- Ensure your preset has a valid `preset.yml` manifest
- Create a GitHub release with a version tag (e.g., v1.0.0)
- Test installation from the release archive: `specify preset add --from <download-url>`
- type: input
id: preset-id
attributes:
label: Preset ID
description: Unique preset identifier (lowercase with hyphens only)
placeholder: "e.g., healthcare-compliance"
validations:
required: true
- type: input
id: preset-name
attributes:
label: Preset Name
description: Human-readable preset name
placeholder: "e.g., Healthcare Compliance"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Semantic version number
placeholder: "e.g., 1.0.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: Brief description of what your preset does (under 200 characters)
placeholder: Enforces HIPAA-compliant spec workflows with audit templates and compliance checklists
validations:
required: true
- type: input
id: author
attributes:
label: Author
description: Your name or organization
placeholder: "e.g., John Doe or Acme Corp"
validations:
required: true
- type: input
id: repository
attributes:
label: Repository URL
description: GitHub repository URL for your preset
placeholder: "https://github.com/your-org/spec-kit-your-preset"
validations:
required: true
- type: input
id: download-url
attributes:
label: Download URL
description: URL to the GitHub release archive for your preset (e.g., https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip)
placeholder: "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip"
validations:
required: true
- type: input
id: license
attributes:
label: License
description: Open source license type
placeholder: "e.g., MIT, Apache-2.0"
validations:
required: true
- type: input
id: speckit-version
attributes:
label: Required Spec Kit Version
description: Minimum Spec Kit version required
placeholder: "e.g., >=0.3.0"
validations:
required: true
- type: textarea
id: templates-provided
attributes:
label: Templates Provided
description: List the template overrides your preset provides
placeholder: |
- spec-template.md — adds compliance section
- plan-template.md — includes audit checkpoints
- checklist-template.md — HIPAA compliance checklist
validations:
required: true
- type: textarea
id: commands-provided
attributes:
label: Commands Provided (optional)
description: List any command overrides your preset provides
placeholder: |
- speckit.specify.md — customized for compliance workflows
- type: textarea
id: tags
attributes:
label: Tags
description: 2-5 relevant tags (lowercase, separated by commas)
placeholder: "compliance, healthcare, hipaa, audit"
validations:
required: true
- type: textarea
id: features
attributes:
label: Key Features
description: List the main features and capabilities of your preset
placeholder: |
- HIPAA-compliant spec templates
- Audit trail checklists
- Compliance review workflow
validations:
required: true
- type: checkboxes
id: testing
attributes:
label: Testing Checklist
description: Confirm that your preset has been tested
options:
- label: Preset installs successfully via `specify preset add`
required: true
- label: Template resolution works correctly after installation
required: true
- label: Documentation is complete and accurate
required: true
- label: Tested on at least one real project
required: true
- type: checkboxes
id: requirements
attributes:
label: Submission Requirements
description: Verify your preset meets all requirements
options:
- label: Valid `preset.yml` manifest included
required: true
- label: README.md with description and usage instructions
required: true
- label: LICENSE file included
required: true
- label: GitHub release created with version tag
required: true
- label: Preset ID follows naming conventions (lowercase-with-hyphens)
required: true

View File

@@ -86,8 +86,10 @@ jobs:
if [ -f "CHANGELOG.md" ]; then if [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d) DATE=$(date +%Y-%m-%d)
# Get the previous tag to compare commits # Get the previous tag by sorting all version tags numerically
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") # (git describe --tags only finds tags reachable from HEAD,
# which misses tags on unmerged release branches)
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | head -n 1)
echo "Generating changelog from commits..." echo "Generating changelog from commits..."
if [[ -n "$PREVIOUS_TAG" ]]; then if [[ -n "$PREVIOUS_TAG" ]]; then
@@ -104,7 +106,7 @@ jobs:
echo "" echo ""
echo "## [${{ steps.version.outputs.version }}] - $DATE" echo "## [${{ steps.version.outputs.version }}] - $DATE"
echo "" echo ""
echo "### Changed" echo "### Changes"
echo "" echo ""
echo "$COMMITS" echo "$COMMITS"
echo "" echo ""

View File

@@ -30,6 +30,8 @@ gh release create "$VERSION" \
.genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \ .genreleases/spec-kit-template-qwen-ps-"$VERSION".zip \
.genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \
.genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \
.genreleases/spec-kit-template-junie-sh-"$VERSION".zip \
.genreleases/spec-kit-template-junie-ps-"$VERSION".zip \
.genreleases/spec-kit-template-codex-sh-"$VERSION".zip \ .genreleases/spec-kit-template-codex-sh-"$VERSION".zip \
.genreleases/spec-kit-template-codex-ps-"$VERSION".zip \ .genreleases/spec-kit-template-codex-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \ .genreleases/spec-kit-template-kilocode-sh-"$VERSION".zip \
@@ -58,6 +60,12 @@ gh release create "$VERSION" \
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \ .genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \ .genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
.genreleases/spec-kit-template-kimi-ps-"$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-pi-sh-"$VERSION".zip \
.genreleases/spec-kit-template-pi-ps-"$VERSION".zip \
.genreleases/spec-kit-template-iflow-sh-"$VERSION".zip \
.genreleases/spec-kit-template-iflow-ps-"$VERSION".zip \
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \ .genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \ .genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
--title "Spec Kit Templates - $VERSION_NO_V" \ --title "Spec Kit Templates - $VERSION_NO_V" \

View File

@@ -14,7 +14,7 @@
.PARAMETER Agents .PARAMETER Agents
Comma or space separated subset of agents to build (default: all) 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, junie, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, iflow, generic
.PARAMETER Scripts .PARAMETER Scripts
Comma or space separated subset of script types to build (default: both) Comma or space separated subset of script types to build (default: both)
@@ -201,20 +201,22 @@ agent: $basename
} }
} }
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format. # Create skills in <skills_dir>\<name>\SKILL.md format.
# Kimi CLI discovers skills as directories containing a SKILL.md file, # Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
# invoked with /skill:<name> (e.g. /skill:speckit.specify). # current dotted-name exception (e.g. speckit.plan).
function New-KimiSkills { function New-Skills {
param( param(
[string]$SkillsDir, [string]$SkillsDir,
[string]$ScriptVariant [string]$ScriptVariant,
[string]$AgentName,
[string]$Separator = '-'
) )
$templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue $templates = Get-ChildItem -Path "templates/commands/*.md" -File -ErrorAction SilentlyContinue
foreach ($template in $templates) { foreach ($template in $templates) {
$name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name) $name = [System.IO.Path]::GetFileNameWithoutExtension($template.Name)
$skillName = "speckit.$name" $skillName = "speckit${Separator}$name"
$skillDir = Join-Path $SkillsDir $skillName $skillDir = Join-Path $SkillsDir $skillName
New-Item -ItemType Directory -Force -Path $skillDir | Out-Null New-Item -ItemType Directory -Force -Path $skillDir | Out-Null
@@ -267,7 +269,7 @@ function New-KimiSkills {
$body = $outputLines -join "`n" $body = $outputLines -join "`n"
$body = $body -replace '\{ARGS\}', '$ARGUMENTS' $body = $body -replace '\{ARGS\}', '$ARGUMENTS'
$body = $body -replace '__AGENT__', 'kimi' $body = $body -replace '__AGENT__', $AgentName
$body = Rewrite-Paths -Content $body $body = Rewrite-Paths -Content $body
# Strip existing frontmatter, keep only body # Strip existing frontmatter, keep only body
@@ -395,9 +397,14 @@ function Build-Variant {
$cmdDir = Join-Path $baseDir ".windsurf/workflows" $cmdDir = Join-Path $baseDir ".windsurf/workflows"
Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script Generate-Commands -Agent 'windsurf' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
} }
'junie' {
$cmdDir = Join-Path $baseDir ".junie/commands"
Generate-Commands -Agent 'junie' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
}
'codex' { 'codex' {
$cmdDir = Join-Path $baseDir ".codex/prompts" $skillsDir = Join-Path $baseDir ".agents/skills"
Generate-Commands -Agent 'codex' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'codex' -Separator '-'
} }
'kilocode' { 'kilocode' {
$cmdDir = Join-Path $baseDir ".kilocode/workflows" $cmdDir = Join-Path $baseDir ".kilocode/workflows"
@@ -452,7 +459,20 @@ function Build-Variant {
'kimi' { 'kimi' {
$skillsDir = Join-Path $baseDir ".kimi/skills" $skillsDir = Join-Path $baseDir ".kimi/skills"
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
}
'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
}
'pi' {
$cmdDir = Join-Path $baseDir ".pi/prompts"
Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
}
'iflow' {
$cmdDir = Join-Path $baseDir ".iflow/commands"
Generate-Commands -Agent 'iflow' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
} }
'generic' { 'generic' {
$cmdDir = Join-Path $baseDir ".speckit/commands" $cmdDir = Join-Path $baseDir ".speckit/commands"
@@ -470,7 +490,7 @@ function Build-Variant {
} }
# Define all agents and scripts # 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', 'junie', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', 'iflow', 'generic')
$AllScripts = @('sh', 'ps') $AllScripts = @('sh', 'ps')
function Normalize-List { function Normalize-List {

View File

@@ -6,7 +6,7 @@ set -euo pipefail
# Usage: .github/workflows/scripts/create-release-packages.sh <version> # Usage: .github/workflows/scripts/create-release-packages.sh <version>
# Version argument should include leading 'v'. # Version argument should include leading 'v'.
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built. # 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 junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic (default: all)
# SCRIPTS : space or comma separated subset of: sh ps (default: both) # SCRIPTS : space or comma separated subset of: sh ps (default: both)
# Examples: # Examples:
# AGENTS=claude SCRIPTS=sh $0 v0.2.0 # AGENTS=claude SCRIPTS=sh $0 v0.2.0
@@ -121,18 +121,20 @@ EOF
done done
} }
# Create Kimi Code skills in .kimi/skills/<name>/SKILL.md format. # Create skills in <skills_dir>/<name>/SKILL.md format.
# Kimi CLI discovers skills as directories containing a SKILL.md file, # Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
# invoked with /skill:<name> (e.g. /skill:speckit.specify). # current dotted-name exception (e.g. speckit.plan).
create_kimi_skills() { create_skills() {
local skills_dir="$1" local skills_dir="$1"
local script_variant="$2" local script_variant="$2"
local agent_name="$3"
local separator="${4:-"-"}"
for template in templates/commands/*.md; do for template in templates/commands/*.md; do
[[ -f "$template" ]] || continue [[ -f "$template" ]] || continue
local name local name
name=$(basename "$template" .md) name=$(basename "$template" .md)
local skill_name="speckit.${name}" local skill_name="speckit${separator}${name}"
local skill_dir="${skills_dir}/${skill_name}" local skill_dir="${skills_dir}/${skill_name}"
mkdir -p "$skill_dir" mkdir -p "$skill_dir"
@@ -175,9 +177,9 @@ create_kimi_skills() {
in_frontmatter && skip_scripts && /^[[:space:]]/ { next } in_frontmatter && skip_scripts && /^[[:space:]]/ { next }
{ print } { print }
') ')
body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed 's/__AGENT__/kimi/g' | rewrite_paths) body=$(printf '%s\n' "$body" | sed 's/{ARGS}/\$ARGUMENTS/g' | sed "s/__AGENT__/$agent_name/g" | rewrite_paths)
# Strip existing frontmatter and prepend Kimi frontmatter # Strip existing frontmatter and prepend skills frontmatter.
local template_body local template_body
template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found') template_body=$(printf '%s\n' "$body" | awk '/^---/{p++; if(p==2){found=1; next}} found')
@@ -248,9 +250,12 @@ build_variant() {
windsurf) windsurf)
mkdir -p "$base_dir/.windsurf/workflows" mkdir -p "$base_dir/.windsurf/workflows"
generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;;
junie)
mkdir -p "$base_dir/.junie/commands"
generate_commands junie md "\$ARGUMENTS" "$base_dir/.junie/commands" "$script" ;;
codex) codex)
mkdir -p "$base_dir/.codex/prompts" mkdir -p "$base_dir/.agents/skills"
generate_commands codex md "\$ARGUMENTS" "$base_dir/.codex/prompts" "$script" ;; create_skills "$base_dir/.agents/skills" "$script" "codex" "-" ;;
kilocode) kilocode)
mkdir -p "$base_dir/.kilocode/workflows" mkdir -p "$base_dir/.kilocode/workflows"
generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;; generate_commands kilocode md "\$ARGUMENTS" "$base_dir/.kilocode/workflows" "$script" ;;
@@ -290,7 +295,16 @@ build_variant() {
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;; generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
kimi) kimi)
mkdir -p "$base_dir/.kimi/skills" mkdir -p "$base_dir/.kimi/skills"
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;; create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
trae)
mkdir -p "$base_dir/.trae/rules"
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
pi)
mkdir -p "$base_dir/.pi/prompts"
generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;;
iflow)
mkdir -p "$base_dir/.iflow/commands"
generate_commands iflow md "\$ARGUMENTS" "$base_dir/.iflow/commands" "$script" ;;
generic) generic)
mkdir -p "$base_dir/.speckit/commands" mkdir -p "$base_dir/.speckit/commands"
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;; generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
@@ -300,7 +314,7 @@ build_variant() {
} }
# Determine agent list # 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 junie codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi iflow generic)
ALL_SCRIPTS=(sh ps) ALL_SCRIPTS=(sh ps)
norm_list() { norm_list() {

View File

@@ -33,11 +33,12 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI | | **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
| **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI | | **Codex CLI** | `.codex/prompts/` | Markdown | `codex` | Codex CLI |
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | | **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
| **Kilo Code** | `.kilocode/rules/` | Markdown | N/A (IDE-based) | Kilo Code IDE | | **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains |
| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI | | **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE | | **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | | **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | | **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | | **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
@@ -45,7 +46,10 @@ Specify supports multiple AI agents by generating agent-specific command files a
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | | **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | | **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | | **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) |
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | | **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 | | **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
### Step-by-Step Integration Guide ### Step-by-Step Integration Guide
@@ -84,7 +88,7 @@ This eliminates the need for special-case mappings throughout the codebase.
- `folder`: Directory where agent-specific files are stored (relative to project root) - `folder`: Directory where agent-specific files are stored (relative to project root)
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
- Most agents use `"commands"` (e.g., `.claude/commands/`) - Most agents use `"commands"` (e.g., `.claude/commands/`)
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular) - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular)
- This field enables `--ai-skills` to locate command templates correctly for skill generation - This field enables `--ai-skills` to locate command templates correctly for skill generation
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents) - `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
- `requires_cli`: Whether the agent requires a CLI tool check during initialization - `requires_cli`: Whether the agent requires a CLI tool check during initialization
@@ -315,6 +319,7 @@ Require a command-line tool to be installed:
- **Cursor**: `cursor-agent` CLI - **Cursor**: `cursor-agent` CLI
- **Qwen Code**: `qwen` CLI - **Qwen Code**: `qwen` CLI
- **opencode**: `opencode` CLI - **opencode**: `opencode` CLI
- **Junie**: `junie` CLI
- **Kiro CLI**: `kiro-cli` CLI - **Kiro CLI**: `kiro-cli` CLI
- **CodeBuddy CLI**: `codebuddy` CLI - **CodeBuddy CLI**: `codebuddy` CLI
- **Qoder CLI**: `qodercli` CLI - **Qoder CLI**: `qodercli` CLI
@@ -322,6 +327,7 @@ Require a command-line tool to be installed:
- **SHAI**: `shai` CLI - **SHAI**: `shai` CLI
- **Tabnine CLI**: `tabnine` CLI - **Tabnine CLI**: `tabnine` CLI
- **Kimi Code**: `kimi` CLI - **Kimi Code**: `kimi` CLI
- **Pi Coding Agent**: `pi` CLI
### IDE-Based Agents ### IDE-Based Agents
@@ -335,7 +341,7 @@ Work within integrated development environments:
### Markdown Format ### Markdown Format
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen Used by: Claude, Cursor, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
**Standard format:** **Standard format:**
@@ -373,6 +379,10 @@ Command content with {SCRIPT} and {{args}} placeholders.
## Directory Conventions ## Directory Conventions
- **CLI agents**: Usually `.<agent-name>/commands/` - **CLI agents**: Usually `.<agent-name>/commands/`
- **Common prompt-based exceptions**:
- Codex: `.codex/prompts/`
- Kiro CLI: `.kiro/prompts/`
- Pi: `.pi/prompts/`
- **IDE agents**: Follow IDE-specific patterns: - **IDE agents**: Follow IDE-specific patterns:
- Copilot: `.github/agents/` - Copilot: `.github/agents/`
- Cursor: `.cursor/commands/` - Cursor: `.cursor/commands/`

View File

@@ -1,11 +1,45 @@
# Changelog # Changelog
<!-- markdownlint-disable MD024 --> ## [0.3.2] - 2026-03-19
Recent changes to the Specify CLI and templates are documented here. ### Changes
- Add conduct extension to community catalog (#1908)
- feat(extensions): add verify-tasks extension to community catalog (#1871)
- feat(presets): add enable/disable toggle and update semantics (#1891)
- feat: add iFlow CLI support (#1875)
- feat(commands): wire before/after hook events into specify and plan templates (#1886)
- docs(catalog): add speckit-utils to community catalog (#1896)
- docs: Add Extensions & Presets section to README (#1898)
- chore: update DocGuard extension to v0.9.11 (#1899)
- Update cognitive-squad catalog entry — Triadic Model, full lifecycle (#1884)
- feat: register spec-kit-iterate extension (#1887)
- fix(scripts): add explicit positional binding to PowerShell create-new-feature params (#1885)
- fix(scripts): encode residual JSON control chars as \uXXXX instead of stripping (#1872)
- chore: update DocGuard extension to v0.9.10 (#1890)
- Feature/spec kit add pi coding agent pullrequest (#1853)
- feat: register spec-kit-learn extension (#1883)
## [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)
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.0] - 2026-03-13 ## [0.3.0] - 2026-03-13
@@ -35,6 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - feat(presets): Pluggable preset system with preset catalog and template resolver
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types - Preset manifest (`preset.yml`) with validation for artifact, command, and script types
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py` - `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
@@ -286,19 +321,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- chore(deps): bump actions/stale from 9 to 10 (#1623) - chore(deps): bump actions/stale from 9 to 10 (#1623)
- feat: add dependabot configuration for pip and GitHub Actions updates (#1622) - feat: add dependabot configuration for pip and GitHub Actions updates (#1622)
## [0.0.97] - 2026-02-18
- Remove Maintainers section from README.md (#1618)
## [0.0.96] - 2026-02-17
- fix: typo in plan-template.md (#1446)
## [0.0.95] - 2026-02-12
- Feat: add a new agent: Google Anti Gravity (#1220)
## [0.0.94] - 2026-02-11
- Add stale workflow for 180-day inactive issues and PRs (#1594)

127
README.md
View File

@@ -25,6 +25,7 @@
- [🚶 Community Walkthroughs](#-community-walkthroughs) - [🚶 Community Walkthroughs](#-community-walkthroughs)
- [🤖 Supported AI Agents](#-supported-ai-agents) - [🤖 Supported AI Agents](#-supported-ai-agents)
- [🔧 Specify CLI Reference](#-specify-cli-reference) - [🔧 Specify CLI Reference](#-specify-cli-reference)
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
- [📚 Core Philosophy](#-core-philosophy) - [📚 Core Philosophy](#-core-philosophy)
- [🌟 Development Phases](#-development-phases) - [🌟 Development Phases](#-development-phases)
- [🎯 Experimental Goals](#-experimental-goals) - [🎯 Experimental Goals](#-experimental-goals)
@@ -98,7 +99,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init --here --ai c
### 2. Establish project principles ### 2. Establish project principles
Launch your AI assistant in the project directory. The `/speckit.*` commands are available in the assistant. Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
@@ -158,6 +159,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 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 ## 🤖 Supported AI Agents
| Agent | Support | Notes | | Agent | Support | Notes |
@@ -168,7 +173,7 @@ See Spec-Driven Development in action across different scenarios with these comm
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | | [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | |
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | | | [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | | [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | |
| [Codex CLI](https://github.com/openai/codex) | ✅ | | | [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-<command>`. |
| [Cursor](https://cursor.sh/) | ✅ | | | [Cursor](https://cursor.sh/) | ✅ | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | | [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
@@ -176,14 +181,18 @@ See Spec-Driven Development in action across different scenarios with these comm
| [Jules](https://jules.google.com/) | ✅ | | | [Jules](https://jules.google.com/) | ✅ | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | | | [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | |
| [opencode](https://opencode.ai/) | ✅ | | | [opencode](https://opencode.ai/) | ✅ | |
| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | | [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
| [Roo Code](https://roocode.com/) | ✅ | | | [Roo Code](https://roocode.com/) | ✅ | |
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | | [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | | [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | |
| [Kimi Code](https://code.kimi.com/) | ✅ | | | [Kimi Code](https://code.kimi.com/) | ✅ | |
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | |
| [Windsurf](https://windsurf.com/) | ✅ | | | [Windsurf](https://windsurf.com/) | ✅ | |
| [Junie](https://junie.jetbrains.com/) | ✅ | |
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | | [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 | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
## 🔧 Specify CLI Reference ## 🔧 Specify CLI Reference
@@ -192,27 +201,27 @@ The `specify` command supports the following options:
### Commands ### Commands
| Command | Description | | Command | Description |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `init` | Initialize a new Specify project from the latest template | | `init` | Initialize a new Specify project from the latest template |
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`) | | `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, etc.) |
### `specify init` Arguments & Options ### `specify init` Arguments & Options
| Argument/Option | Type | Description | | Argument/Option | Type | Description |
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | | `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, or `generic` (requires `--ai-commands-dir`) | | `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, or `generic` (requires `--ai-commands-dir`) |
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | | `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
| `--no-git` | Flag | Skip git repository initialization | | `--no-git` | Flag | Skip git repository initialization |
| `--here` | Flag | Initialize project in the current directory instead of creating a new one | | `--here` | Flag | Initialize project in the current directory instead of creating a new one |
| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) | | `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) |
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
| `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--debug` | Flag | Enable detailed debug output for troubleshooting |
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | | `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) | | `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
### Examples ### Examples
@@ -247,6 +256,12 @@ specify init my-project --ai vibe
# Initialize with IBM Bob support # Initialize with IBM Bob support
specify init my-project --ai bob specify init my-project --ai bob
# Initialize with Pi Coding Agent support
specify init my-project --ai pi
# Initialize with Codex CLI support
specify init my-project --ai codex --ai-skills
# Initialize with Antigravity support # Initialize with Antigravity support
specify init my-project --ai agy --ai-skills specify init my-project --ai agy --ai-skills
@@ -287,7 +302,9 @@ specify check
### Available Slash Commands ### Available Slash Commands
After running `specify init`, your AI coding agent will have access to these slash commands for structured development: After running `specify init`, your AI coding agent will have access to these slash commands for structured development.
For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`.
#### Core Commands #### Core Commands
@@ -317,6 +334,68 @@ Additional commands for enhanced quality and validation:
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.<br/>\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | | `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.<br/>\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. |
## 🧩 Making Spec Kit Your Own: Extensions & Presets
Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments:
```mermaid
block-beta
columns 1
overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"]
presets["Presets — Customize core & extensions\n.specify/presets/<preset-id>/templates/"]
extensions["Extensions — Add new capabilities\n.specify/extensions/<ext-id>/templates/"]
core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"]
style overrides fill:transparent,stroke:#999
style presets fill:transparent,stroke:#4a9eda
style extensions fill:transparent,stroke:#4a9e4a
style core fill:transparent,stroke:#e6a817
```
**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults.
### Extensions — Add New Capabilities
Use **extensions** when you need functionality that goes beyond Spec Kit's core. Extensions introduce new commands and templates — for example, adding domain-specific workflows that are not covered by the built-in SDD commands, integrating with external tools, or adding entirely new development phases. They expand *what Spec Kit can do*.
```bash
# Search available extensions
specify extension search
# Install an extension
specify extension add <extension-name>
```
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
See the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own.
### Presets — Customize Existing Workflows
Use **presets** when you want to change *how* Spec Kit works without adding new capabilities. Presets override the templates and commands that ship with the core *and* with installed extensions — for example, enforcing a compliance-oriented spec format, using domain-specific terminology, or applying organizational standards to plans and tasks. They customize the artifacts and instructions that Spec Kit and its extensions produce.
```bash
# Search available presets
specify preset search
# Install a preset
specify preset add <preset-name>
```
For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering.
See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own.
### When to Use Which
| Goal | Use |
| --- | --- |
| Add a brand-new command or workflow | Extension |
| Customize the format of specs, plans, or tasks | Preset |
| Integrate an external tool or service | Extension |
| Enforce organizational or regulatory standards | Preset |
| Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands |
## 📚 Core Philosophy ## 📚 Core Philosophy
Spec-Driven Development is a structured process that emphasizes: Spec-Driven Development is a structured process that emphasizes:
@@ -411,11 +490,11 @@ specify init <project_name> --ai copilot
# Or in current directory: # Or in current directory:
specify init . --ai claude specify init . --ai claude
specify init . --ai codex specify init . --ai codex --ai-skills
# or use --here flag # or use --here flag
specify init --here --ai claude specify init --here --ai claude
specify init --here --ai codex specify init --here --ai codex --ai-skills
# Force merge into a non-empty current directory # Force merge into a non-empty current directory
specify init . --force --ai claude specify init . --force --ai claude
@@ -424,7 +503,7 @@ specify init . --force --ai claude
specify init --here --force --ai claude specify init --here --force --ai claude
``` ```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash ```bash
specify init <project_name> --ai claude --ignore-agent-tools specify init <project_name> --ai claude --ignore-agent-tools

View File

@@ -1,18 +1,17 @@
# Support # Support
## How to file issues and get help ## How to get help
This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. Please search existing [issues](https://github.com/github/spec-kit/issues) and [discussions](https://github.com/github/spec-kit/discussions) before creating new ones to avoid duplicates.
For help or questions about using this project, please:
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports, feature requests, or questions about the Spec-Driven Development methodology
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
- Review the [README](./README.md) for getting started instructions and troubleshooting tips - Review the [README](./README.md) for getting started instructions and troubleshooting tips
- Check the [comprehensive guide](./spec-driven.md) for detailed documentation on the Spec-Driven Development process
- Ask in [GitHub Discussions](https://github.com/github/spec-kit/discussions) for questions about using Spec Kit or the Spec-Driven Development methodology
- Open a [GitHub issue](https://github.com/github/spec-kit/issues/new) for bug reports and feature requests
## Project Status ## Project Status
**Spec Kit** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner. **Spec Kit** is under active development and maintained by GitHub staff and the community. We will do our best to respond to support, feature requests, and community questions as time permits.
## GitHub Support Policy ## GitHub Support Policy

View File

@@ -3,7 +3,7 @@
## Prerequisites ## Prerequisites
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli) or [Gemini CLI](https://github.com/google-gemini/gemini-cli) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management - [uv](https://docs.astral.sh/uv/) for package management
- [Python 3.11+](https://www.python.org/downloads/) - [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
@@ -35,6 +35,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <project_name
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai pi
``` ```
### Specify Script Type (Shell vs PowerShell) ### Specify Script Type (Shell vs PowerShell)

View File

@@ -289,8 +289,9 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
```bash ```bash
ls -la .claude/commands/ # Claude Code ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini ls -la .gemini/commands/ # Gemini
ls -la .cursor/commands/ # Cursor ls -la .cursor/commands/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
``` ```
3. **Check agent-specific setup:** 3. **Check agent-specific setup:**
@@ -398,7 +399,7 @@ The `specify` CLI tool is used for:
- **Upgrades:** `specify init --here --force` to update templates and commands - **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation - **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
**If your agent isn't recognizing slash commands:** **If your agent isn't recognizing slash commands:**
@@ -410,6 +411,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
# For Claude # For Claude
ls -la .claude/commands/ ls -la .claude/commands/
# For Pi
ls -la .pi/prompts/
``` ```
2. **Restart your IDE/editor completely** (not just reload window) 2. **Restart your IDE/editor completely** (not just reload window)

View File

@@ -53,7 +53,7 @@ provides:
required: boolean # Default: false required: boolean # Default: false
hooks: # Optional, event hooks hooks: # Optional, event hooks
event_name: # e.g., "after_tasks", "after_implement" event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
command: string # Command to execute command: string # Command to execute
optional: boolean # Default: true optional: boolean # Default: true
prompt: string # Prompt text for optional hooks prompt: string # Prompt text for optional hooks
@@ -108,7 +108,7 @@ defaults: # Optional, default configuration values
#### `hooks` #### `hooks`
- **Type**: object - **Type**: object
- **Keys**: Event names (e.g., `after_tasks`, `after_implement`, `before_commit`) - **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_commit`)
- **Description**: Hooks that execute at lifecycle events - **Description**: Hooks that execute at lifecycle events
- **Events**: Defined by core spec-kit commands - **Events**: Defined by core spec-kit commands
@@ -551,10 +551,16 @@ hooks:
Standard events (defined by core): Standard events (defined by core):
- `before_specify` - Before specification generation
- `after_specify` - After specification generation
- `before_plan` - Before implementation planning
- `after_plan` - After implementation planning
- `before_tasks` - Before task generation
- `after_tasks` - After task generation - `after_tasks` - After task generation
- `before_implement` - Before implementation
- `after_implement` - After implementation - `after_implement` - After implementation
- `before_commit` - Before git commit - `before_commit` - Before git commit *(planned - not yet wired into core templates)*
- `after_commit` - After git commit - `after_commit` - After git commit *(planned - not yet wired into core templates)*
### Hook Configuration ### Hook Configuration

View File

@@ -387,6 +387,9 @@ settings:
auto_execute_hooks: true auto_execute_hooks: true
# Hook configuration # Hook configuration
# Available events: before_specify, after_specify, before_plan, after_plan,
# before_tasks, after_tasks, before_implement, after_implement
# Planned (not yet wired into core templates): before_commit, after_commit
hooks: hooks:
after_tasks: after_tasks:
- extension: jira - extension: jira

View File

@@ -70,21 +70,34 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json): The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
| Extension | Purpose | URL | **Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress
|-----------|---------|-----|
| 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) | **Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs
| 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) |
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks with config-aware traceability, quality labels, and AI-ready fix prompts. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extension | Purpose | Category | Effect | URL |
| 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) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| 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) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) | | 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 | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| 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) | | Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
| 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) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
| 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) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | [understanding](https://github.com/Testimonial/understanding) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [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 | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [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 | `docs` | Read+Write | [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 | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
## Adding Your Extension ## Adding Your Extension

View File

@@ -359,12 +359,15 @@ specify extension add jira
"installed_at": "2026-01-28T14:30:00Z", "installed_at": "2026-01-28T14:30:00Z",
"source": "catalog", "source": "catalog",
"manifest_hash": "sha256:abc123...", "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 ### 3. Configuration
```bash ```bash
@@ -1084,11 +1087,15 @@ List installed extensions in current project.
$ specify extension list $ specify extension list
Installed Extensions: Installed Extensions:
jira (v1.0.0) - Jira Integration ✓ Jira Integration (v1.0.0)
Commands: 3 | Hooks: 2 | Status: Enabled jira
Create Jira issues from spec-kit artifacts
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
linear (v0.9.0) - Linear Integration ✓ Linear Integration (v0.9.0)
Commands: 1 | Hooks: 1 | Status: Enabled linear
Create Linear issues from spec-kit artifacts
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
``` ```
**Options:** **Options:**
@@ -1196,10 +1203,9 @@ Next steps:
**Options:** **Options:**
- `--from URL`: Install from custom URL or Git repo - `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.
- `--version VERSION`: Install specific version - `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).
- `--dev PATH`: Install from local path (development mode) - `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)
- `--no-register`: Skip command registration (manual setup)
#### `specify extension remove NAME` #### `specify extension remove NAME`
@@ -1280,6 +1286,29 @@ $ specify extension disable jira
To re-enable: specify extension enable 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 ## Compatibility & Versioning

View File

@@ -1,8 +1,39 @@
{ {
"schema_version": "1.0", "schema_version": "1.0",
"updated_at": "2026-03-13T12:00:00Z", "updated_at": "2026-03-19T12:08:20Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": { "extensions": {
"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": { "azure-devops": {
"name": "Azure DevOps Integration", "name": "Azure DevOps Integration",
"id": "azure-devops", "id": "azure-devops",
@@ -74,13 +105,87 @@
"created_at": "2026-02-22T00:00:00Z", "created_at": "2026-02-22T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z" "updated_at": "2026-02-22T00:00:00Z"
}, },
"cognitive-squad": {
"name": "Cognitive Squad",
"id": "cognitive-squad",
"description": "Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing",
"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": 10,
"hooks": 1
},
"tags": [
"ai-agents",
"cognitive",
"full-lifecycle",
"verification",
"multi-agent"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"conduct": {
"name": "Conduct Extension",
"id": "conduct",
"description": "Executes a single spec-kit phase via sub-agent delegation to reduce context pollution.",
"author": "twbrandon7",
"version": "1.0.0",
"download_url": "https://github.com/twbrandon7/spec-kit-conduct-ext/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/twbrandon7/spec-kit-conduct-ext",
"homepage": "https://github.com/twbrandon7/spec-kit-conduct-ext",
"documentation": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/README.md",
"changelog": "https://github.com/twbrandon7/spec-kit-conduct-ext/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.3.1"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"conduct",
"workflow",
"automation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-19T12:08:20Z",
"updated_at": "2026-03-19T12:08:20Z"
},
"docguard": { "docguard": {
"name": "DocGuard CDD Enforcement", "name": "DocGuard \u2014 CDD Enforcement",
"id": "docguard", "id": "docguard",
"description": "Canonical-Driven Development enforcement. Generates, validates, scores, and traces project documentation against 92 automated checks. Zero dependencies.", "description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies.",
"author": "raccioly", "author": "raccioly",
"version": "0.8.0", "version": "0.9.11",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.8.0/spec-kit-docguard-v0.8.0.zip", "download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.11/spec-kit-docguard-v0.9.11.zip",
"repository": "https://github.com/raccioly/docguard", "repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli", "homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md", "documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -98,7 +203,7 @@
}, },
"provides": { "provides": {
"commands": 6, "commands": 6,
"hooks": 1 "hooks": 3
}, },
"tags": [ "tags": [
"documentation", "documentation",
@@ -108,13 +213,13 @@
"traceability", "traceability",
"ai-agents", "ai-agents",
"enforcement", "enforcement",
"scoring" "spec-kit"
], ],
"verified": false, "verified": false,
"downloads": 0, "downloads": 0,
"stars": 0, "stars": 0,
"created_at": "2026-03-13T00:00:00Z", "created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-03-13T00:00:00Z" "updated_at": "2026-03-18T18:53:31Z"
}, },
"doctor": { "doctor": {
"name": "Project Health Check", "name": "Project Health Check",
@@ -178,6 +283,36 @@
"created_at": "2026-03-06T00:00:00Z", "created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-06T00:00:00Z" "updated_at": "2026-03-06T00:00:00Z"
}, },
"iterate": {
"name": "Iterate",
"id": "iterate",
"description": "Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building",
"author": "Vianca Martinez",
"version": "2.0.0",
"download_url": "https://github.com/imviancagrace/spec-kit-iterate/archive/refs/tags/v2.0.0.zip",
"repository": "https://github.com/imviancagrace/spec-kit-iterate",
"homepage": "https://github.com/imviancagrace/spec-kit-iterate",
"documentation": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/README.md",
"changelog": "https://github.com/imviancagrace/spec-kit-iterate/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2,
"hooks": 0
},
"tags": [
"iteration",
"change-management",
"spec-maintenance"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"jira": { "jira": {
"name": "Jira Integration", "name": "Jira Integration",
"id": "jira", "id": "jira",
@@ -250,6 +385,37 @@
"created_at": "2026-03-09T00:00:00Z", "created_at": "2026-03-09T00:00:00Z",
"updated_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": { "retrospective": {
"name": "Retrospective Extension", "name": "Retrospective Extension",
"id": "retrospective", "id": "retrospective",
@@ -316,6 +482,38 @@
"created_at": "2026-03-06T00:00:00Z", "created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-06T00:00:00Z" "updated_at": "2026-03-06T00:00:00Z"
}, },
"speckit-utils": {
"name": "SDD Utilities",
"id": "speckit-utils",
"description": "Resume interrupted workflows, validate project health, and verify spec-to-task traceability.",
"author": "mvanhorn",
"version": "1.0.0",
"download_url": "https://github.com/mvanhorn/speckit-utils/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/mvanhorn/speckit-utils",
"homepage": "https://github.com/mvanhorn/speckit-utils",
"documentation": "https://github.com/mvanhorn/speckit-utils/blob/main/README.md",
"changelog": "https://github.com/mvanhorn/speckit-utils/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"hooks": 2
},
"tags": [
"resume",
"doctor",
"validate",
"workflow",
"health-check"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"sync": { "sync": {
"name": "Spec Sync", "name": "Spec Sync",
"id": "sync", "id": "sync",
@@ -351,7 +549,7 @@
"understanding": { "understanding": {
"name": "Understanding", "name": "Understanding",
"id": "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", "author": "Ladislav Bihari",
"version": "3.4.0", "version": "3.4.0",
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip", "download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
@@ -389,6 +587,38 @@
"created_at": "2026-03-07T00:00:00Z", "created_at": "2026-03-07T00:00:00Z",
"updated_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": { "v-model": {
"name": "V-Model Extension Pack", "name": "V-Model Extension Pack",
"id": "v-model", "id": "v-model",
@@ -421,6 +651,37 @@
"created_at": "2026-02-20T00:00:00Z", "created_at": "2026-02-20T00:00:00Z",
"updated_at": "2026-02-22T00:00:00Z" "updated_at": "2026-02-22T00:00:00Z"
}, },
"learn": {
"name": "Learning Extension",
"id": "learn",
"description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
"author": "Vianca Martinez",
"version": "1.0.0",
"download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/imviancagrace/spec-kit-learn",
"homepage": "https://github.com/imviancagrace/spec-kit-learn",
"documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
"changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2,
"hooks": 1
},
"tags": [
"learning",
"education",
"mentoring",
"knowledge-transfer"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-17T00:00:00Z",
"updated_at": "2026-03-17T00:00:00Z"
},
"verify": { "verify": {
"name": "Verify Extension", "name": "Verify Extension",
"id": "verify", "id": "verify",
@@ -452,6 +713,37 @@
"stars": 0, "stars": 0,
"created_at": "2026-03-03T00:00:00Z", "created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-03-03T00:00:00Z" "updated_at": "2026-03-03T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
"id": "verify-tasks",
"description": "Detect phantom completions: tasks marked [X] in tasks.md with no real implementation.",
"author": "Dave Sharpe",
"version": "1.0.0",
"download_url": "https://github.com/datastone-inc/spec-kit-verify-tasks/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/datastone-inc/spec-kit-verify-tasks",
"homepage": "https://github.com/datastone-inc/spec-kit-verify-tasks",
"documentation": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/README.md",
"changelog": "https://github.com/datastone-inc/spec-kit-verify-tasks/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 1
},
"tags": [
"verification",
"quality",
"phantom-completion",
"tasks"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
} }
} }
} }

View File

@@ -13,13 +13,15 @@ When Spec Kit needs a template (e.g. `spec-template`), it walks a resolution sta
If no preset is installed, core templates are used — exactly the same behavior as before presets existed. If no preset is installed, core templates are used — exactly the same behavior as before presets existed.
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md). For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Command Overrides ## Command Overrides
Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions). Presets can also override the commands that guide the SDD workflow. Templates define *what* gets produced (specs, plans, constitutions); commands define *how* the LLM produces them (the step-by-step instructions).
When a preset includes `type: "command"` entries, the commands are automatically registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up. Unlike templates, command overrides are applied **at install time**. When a preset includes `type: "command"` entries, the commands are registered into all detected agent directories (`.claude/commands/`, `.gemini/commands/`, etc.) in the correct format (Markdown or TOML with appropriate argument placeholders). When the preset is removed, the registered commands are cleaned up.
## Quick Start ## Quick Start

View File

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

View File

@@ -168,7 +168,7 @@ if $JSON_MODE; then
if [[ ${#docs[@]} -eq 0 ]]; then if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]" json_docs="[]"
else else
json_docs=$(printf '"%s",' "${docs[@]}") json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]" json_docs="[${json_docs%,}]"
fi fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs" 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). # 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() { json_escape() {
local s="$1" local s="$1"
s="${s//\\/\\\\}" s="${s//\\/\\\\}"
@@ -169,7 +169,23 @@ json_escape() {
s="${s//$'\n'/\\n}" s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}" s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}" s="${s//$'\r'/\\r}"
printf '%s' "$s" s="${s//$'\b'/\\b}"
s="${s//$'\f'/\\f}"
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
# (U+0000/NUL cannot appear in bash strings and is excluded.)
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
local LC_ALL=C
local i char code
for (( i=0; i<${#s}; i++ )); do
char="${s:$i:1}"
printf -v code '%d' "'$char" 2>/dev/null || code=256
if (( code >= 1 && code <= 31 )); then
printf '\\u%04x' "$code"
else
printf '%s' "$char"
fi
done
} }
check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; } check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; }
@@ -194,9 +210,11 @@ resolve_template() {
if [ -d "$presets_dir" ]; then if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry" local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
# Read preset IDs sorted by priority (lower number = higher precedence) # Read preset IDs sorted by priority (lower number = higher precedence).
local sorted_presets # The python3 call is wrapped in an if-condition so that set -e does not
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " # 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 import json, sys, os
try: try:
with open(os.environ['SPECKIT_REGISTRY']) as f: with open(os.environ['SPECKIT_REGISTRY']) as f:
@@ -206,14 +224,17 @@ try:
print(pid) print(pid)
except Exception: except Exception:
sys.exit(1) sys.exit(1)
" 2>/dev/null) " 2>/dev/null); then
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then if [ -n "$sorted_presets" ]; then
while IFS= read -r preset_id; do # python3 succeeded and returned preset IDs — search in priority order
local candidate="$presets_dir/$preset_id/templates/${template_name}.md" while IFS= read -r preset_id; do
[ -f "$candidate" ] && echo "$candidate" && return 0 local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
done <<< "$sorted_presets" [ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
else 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 for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue [ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md" local candidate="$preset/templates/${template_name}.md"
@@ -246,8 +267,9 @@ except Exception:
local core="$base/${template_name}.md" local core="$base/${template_name}.md"
[ -f "$core" ] && echo "$core" && return 0 [ -f "$core" ] && echo "$core" && return 0
# Return success with empty output so callers using set -e don't abort; # Template not found in any location.
# callers check [ -n "$TEMPLATE" ] to detect "not found". # Return 1 so callers can distinguish "not found" from "found".
return 0 # 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" local specs_dir="$1"
# Fetch all remotes to get latest branch info (suppress errors if no remotes) # 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) # Get highest number from ALL branches (not just matching short name)
local highest_branch=$(get_highest_from_branches) 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/-$//' 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 # Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that # to searching for repository markers so the workflow still functions in repositories that
# were initialised with --no-git. # were initialised with --no-git.
@@ -308,9 +297,14 @@ fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
mkdir -p "$FEATURE_DIR" 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" 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 # Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2

View File

@@ -39,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
mkdir -p "$FEATURE_DIR" mkdir -p "$FEATURE_DIR"
# Copy plan template if it exists # 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 if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN" cp "$TEMPLATE" "$IMPL_PLAN"
echo "Copied plan template to $IMPL_PLAN" echo "Copied plan template to $IMPL_PLAN"

View File

@@ -30,12 +30,12 @@
# #
# 5. Multi-Agent Support # 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions # - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Antigravity or Generic # - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Antigravity or Generic
# - Can update single agents or all existing agent files # - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist # - Creates default Claude file if no agent files exist
# #
# Usage: ./update-agent-context.sh [agent_type] # 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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic
# Leave empty to update all existing agent files # Leave empty to update all existing agent files
set -e set -e
@@ -68,12 +68,13 @@ CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc"
QWEN_FILE="$REPO_ROOT/QWEN.md" QWEN_FILE="$REPO_ROOT/QWEN.md"
AGENTS_FILE="$REPO_ROOT/AGENTS.md" AGENTS_FILE="$REPO_ROOT/AGENTS.md"
WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md"
JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md"
KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md"
AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
QODER_FILE="$REPO_ROOT/QODER.md" QODER_FILE="$REPO_ROOT/QODER.md"
# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid # Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
# updating the same file multiple times. # updating the same file multiple times.
AMP_FILE="$AGENTS_FILE" AMP_FILE="$AGENTS_FILE"
SHAI_FILE="$REPO_ROOT/SHAI.md" SHAI_FILE="$REPO_ROOT/SHAI.md"
@@ -83,6 +84,8 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
BOB_FILE="$AGENTS_FILE" BOB_FILE="$AGENTS_FILE"
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
KIMI_FILE="$REPO_ROOT/KIMI.md" KIMI_FILE="$REPO_ROOT/KIMI.md"
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
IFLOW_FILE="$REPO_ROOT/IFLOW.md"
# Template file # Template file
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
@@ -636,6 +639,9 @@ update_specific_agent() {
windsurf) windsurf)
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
;; ;;
junie)
update_agent_file "$JUNIE_FILE" "Junie" || return 1
;;
kilocode) kilocode)
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
;; ;;
@@ -675,67 +681,90 @@ update_specific_agent() {
kimi) kimi)
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
;; ;;
trae)
update_agent_file "$TRAE_FILE" "Trae" || return 1
;;
pi)
update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1
;;
iflow)
update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1
;;
generic) generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." 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 "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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic"
exit 1 exit 1
;; ;;
esac 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() { update_all_existing_agents() {
local found_agent=false _found_agent=false
local _updated_paths=() _updated_paths=()
local _all_ok=true
# Helper: skip non-existent files and files already updated (dedup by _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
# realpath so that variables pointing to the same file — e.g. AMP_FILE, _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
# Uses a linear array instead of associative array for bash 3.2 compatibility. _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
update_if_new() { _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
local file="$1" name="$2" _update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
[[ -f "$file" ]] || return 0 _update_if_new "$AMP_FILE" "Amp" || _all_ok=false
local real_path _update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
real_path=$(realpath "$file" 2>/dev/null || echo "$file") _update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
local p _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
if [[ ${#_updated_paths[@]} -gt 0 ]]; then _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
for p in "${_updated_paths[@]}"; do _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
[[ "$p" == "$real_path" ]] && return 0 _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
done _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
fi _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
update_agent_file "$file" "$name" || return 1 _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
_updated_paths+=("$real_path") _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
found_agent=true _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 "$CLAUDE_FILE" "Claude Code" _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
update_if_new "$GEMINI_FILE" "Gemini CLI" _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
update_if_new "$COPILOT_FILE" "GitHub Copilot" _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false
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"
# If no agent files exist, create a default Claude file # 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..." log_info "No existing agent files found, creating default Claude file..."
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
fi fi
[[ "$_all_ok" == true ]]
} }
print_summary() { print_summary() {
echo echo
@@ -754,7 +783,7 @@ print_summary() {
fi fi
echo 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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]"
} }
#============================================================================== #==============================================================================

View File

@@ -4,9 +4,10 @@
param( param(
[switch]$Json, [switch]$Json,
[string]$ShortName, [string]$ShortName,
[Parameter()]
[int]$Number = 0, [int]$Number = 0,
[switch]$Help, [switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)] [Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription [string[]]$FeatureDescription
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction 2. Plan Data Extraction
3. Agent File Management (create from template or update existing) 3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp) 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, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, generic)
.PARAMETER AgentType .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). 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( param(
[Parameter(Position=0)] [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','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','iflow','generic')]
[string]$AgentType [string]$AgentType
) )
@@ -51,6 +51,7 @@ $CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc'
$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' $QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md'
$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' $WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md'
$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md'
$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' $KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md'
$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' $AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md'
$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' $ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md'
@@ -64,6 +65,8 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' $VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -395,6 +398,7 @@ function Update-SpecificAgent {
'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' }
'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' }
'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' }
'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' }
'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' }
'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' }
'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' }
@@ -408,8 +412,11 @@ function Update-SpecificAgent {
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } '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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic'; return $false }
} }
} }
@@ -423,6 +430,7 @@ function Update-AllExistingAgents {
if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true } if (Test-Path $QWEN_FILE) { if (-not (Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }; $found = $true }
if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true } if (Test-Path $AGENTS_FILE) { if (-not (Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex/opencode')) { $ok = $false }; $found = $true }
if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true } if (Test-Path $WINDSURF_FILE) { if (-not (Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }; $found = $true }
if (Test-Path $JUNIE_FILE) { if (-not (Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }; $found = $true }
if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true } if (Test-Path $KILOCODE_FILE) { if (-not (Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }; $found = $true }
if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true } if (Test-Path $AUGGIE_FILE) { if (-not (Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false }; $found = $true }
if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true } if (Test-Path $ROO_FILE) { if (-not (Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code')) { $ok = $false }; $found = $true }
@@ -435,6 +443,8 @@ 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 $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 $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 $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 (Test-Path $IFLOW_FILE) { if (-not (Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false }; $found = $true }
if (-not $found) { if (-not $found) {
Write-Info 'No existing agent files found, creating default Claude file...' Write-Info 'No existing agent files found, creating default Claude file...'
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
@@ -449,7 +459,7 @@ function Print-Summary {
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host '' Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]' Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|generic]'
} }
function Main { function Main {

View File

@@ -7,6 +7,7 @@
# "platformdirs", # "platformdirs",
# "readchar", # "readchar",
# "httpx", # "httpx",
# "json5",
# ] # ]
# /// # ///
""" """
@@ -30,8 +31,9 @@ import sys
import zipfile import zipfile
import tempfile import tempfile
import shutil import shutil
import shlex
import json import json
import json5
import stat
import yaml import yaml
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
@@ -169,8 +171,8 @@ AGENT_CONFIG = {
}, },
"codex": { "codex": {
"name": "Codex CLI", "name": "Codex CLI",
"folder": ".codex/", "folder": ".agents/",
"commands_subdir": "prompts", # Special: uses prompts/ not commands/ "commands_subdir": "skills", # Codex now uses project skills directly
"install_url": "https://github.com/openai/codex", "install_url": "https://github.com/openai/codex",
"requires_cli": True, "requires_cli": True,
}, },
@@ -181,6 +183,13 @@ AGENT_CONFIG = {
"install_url": None, # IDE-based "install_url": None, # IDE-based
"requires_cli": False, "requires_cli": False,
}, },
"junie": {
"name": "Junie",
"folder": ".junie/",
"commands_subdir": "commands",
"install_url": "https://junie.jetbrains.com/",
"requires_cli": True,
},
"kilocode": { "kilocode": {
"name": "Kilo Code", "name": "Kilo Code",
"folder": ".kilocode/", "folder": ".kilocode/",
@@ -272,6 +281,27 @@ AGENT_CONFIG = {
"install_url": "https://code.kimi.com/", "install_url": "https://code.kimi.com/",
"requires_cli": True, "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,
},
"pi": {
"name": "Pi Coding Agent",
"folder": ".pi/",
"commands_subdir": "prompts",
"install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
"requires_cli": True,
},
"iflow": {
"name": "iFlow CLI",
"folder": ".iflow/",
"commands_subdir": "commands",
"install_url": "https://docs.iflow.cn/en/cli/quickstart",
"requires_cli": True,
},
"generic": { "generic": {
"name": "Generic (bring your own agent)", "name": "Generic (bring your own agent)",
"folder": None, # Set dynamically via --ai-commands-dir "folder": None, # Set dynamically via --ai-commands-dir
@@ -654,37 +684,82 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
os.chdir(original_cwd) os.chdir(original_cwd)
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
"""Handle merging or copying of .vscode/settings.json files.""" """Handle merging or copying of .vscode/settings.json files.
Note: when merge produces changes, rewritten output is normalized JSON and
existing JSONC comments/trailing commas are not preserved.
"""
def log(message, color="green"): def log(message, color="green"):
if verbose and not tracker: if verbose and not tracker:
console.print(f"[{color}]{message}[/] {rel_path}") 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: try:
with open(sub_item, 'r', encoding='utf-8') as f: 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(): if dest_file.exists():
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker) merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
with open(dest_file, 'w', encoding='utf-8') as f: if merged is not None:
json.dump(merged, f, indent=4) atomic_write_json(dest_file, merged)
f.write('\n') log("Merged:", "green")
log("Merged:", "green") log("Note: comments/trailing commas are normalized when rewritten", "yellow")
else:
log("Skipped merge (preserved existing settings)", "yellow")
else: else:
shutil.copy2(sub_item, dest_file) shutil.copy2(sub_item, dest_file)
log("Copied (no existing settings.json):", "blue") log("Copied (no existing settings.json):", "blue")
except Exception as e: except Exception as e:
log(f"Warning: Could not merge, copying instead: {e}", "yellow") log(f"Warning: Could not merge settings: {e}", "yellow")
shutil.copy2(sub_item, dest_file) 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. """Merge new JSON content into existing JSON file.
Performs a deep merge where: Performs a polite deep merge where:
- New keys are added - New keys are added
- Existing keys are preserved unless overwritten by new content - Existing keys are preserved (not overwritten) unless both values are dictionaries
- Nested dictionaries are merged recursively - Nested dictionaries are merged recursively only when both sides are dictionaries
- Lists and other values are replaced (not merged) - Lists and other values are preserved from base if they exist
Args: Args:
existing_path: Path to existing JSON file existing_path: Path to existing JSON file
@@ -692,28 +767,64 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal
verbose: Whether to print merge details verbose: Whether to print merge details
Returns: Returns:
Merged JSON content as dict Merged JSON content as dict, or None if the existing file should be left untouched.
""" """
try: # Load existing content first to have a safe fallback
with open(existing_path, 'r', encoding='utf-8') as f: existing_content = None
existing_content = json.load(f) exists = existing_path.exists()
except (FileNotFoundError, json.JSONDecodeError):
# If file doesn't exist or is invalid, just use new content 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 return new_content
def deep_merge(base: dict, update: dict) -> dict: # If existing content parsed but is not a dict, skip merge to avoid data loss
"""Recursively merge update dict into base dict.""" 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() result = base.copy()
for key, value in update.items(): for key, value in update.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict): if key not in result:
# Recursively merge nested dictionaries # Add new key
result[key] = deep_merge(result[key], value)
else:
# Add new key or replace existing value
result[key] = value 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 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: if verbose:
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}") console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
@@ -1106,6 +1217,9 @@ AGENT_SKILLS_DIR_OVERRIDES = {
# Default skills directory for agents not in AGENT_CONFIG # Default skills directory for agents not in AGENT_CONFIG
DEFAULT_SKILLS_DIR = ".agents/skills" DEFAULT_SKILLS_DIR = ".agents/skills"
# Agents whose downloaded template already contains skills in the final layout.
NATIVE_SKILLS_AGENTS = {"codex", "kimi"}
# Enhanced descriptions for each spec-kit command skill # Enhanced descriptions for each spec-kit command skill
SKILL_DESCRIPTIONS = { SKILL_DESCRIPTIONS = {
"specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.", "specify": "Create or update feature specifications from natural language descriptions. Use when starting new features or refining requirements. Generates spec.md with user stories, functional requirements, and acceptance criteria following spec-driven development methodology.",
@@ -1165,7 +1279,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
else: else:
templates_dir = project_path / commands_subdir 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) # Fallback: try the repo-relative path (for running from source checkout)
# This also covers agents whose extracted commands are in a different # This also covers agents whose extracted commands are in a different
# format (e.g. gemini/tabnine use .toml, not .md). # format (e.g. gemini/tabnine use .toml, not .md).
@@ -1173,15 +1292,16 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
fallback_dir = script_dir / "templates" / "commands" fallback_dir = script_dir / "templates" / "commands"
if fallback_dir.exists() and any(fallback_dir.glob("*.md")): if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
templates_dir = fallback_dir 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: if tracker:
tracker.error("ai-skills", "command templates not found") tracker.error("ai-skills", "command templates not found")
else: else:
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]") console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
return False return False
command_files = sorted(templates_dir.glob("*.md")) command_files = sorted(templates_dir.glob(template_glob))
if not command_files: if not command_files:
if tracker: if tracker:
tracker.skip("ai-skills", "no command templates found") tracker.skip("ai-skills", "no command templates found")
@@ -1220,13 +1340,14 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
body = content body = content
command_name = command_file.stem command_name = command_file.stem
# Normalize: extracted commands may be named "speckit.<cmd>.md"; # Normalize: extracted commands may be named "speckit.<cmd>.md"
# strip the "speckit." prefix so skill names stay clean and # or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
# any trailing ".agent" suffix so skill names stay clean and
# SKILL_DESCRIPTIONS lookups work. # SKILL_DESCRIPTIONS lookups work.
if command_name.startswith("speckit."): if command_name.startswith("speckit."):
command_name = command_name[len("speckit."):] command_name = command_name[len("speckit."):]
# Kimi CLI discovers skills by directory name and invokes them as if command_name.endswith(".agent"):
# /skill:<name> — use dot separator to match packaging convention. command_name = command_name[:-len(".agent")]
if selected_ai == "kimi": if selected_ai == "kimi":
skill_name = f"speckit.{command_name}" skill_name = f"speckit.{command_name}"
else: else:
@@ -1249,6 +1370,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
source_name = command_file.name source_name = command_file.name
if source_name.startswith("speckit."): if source_name.startswith("speckit."):
source_name = source_name[len("speckit."):] source_name = source_name[len("speckit."):]
if source_name.endswith(".agent.md"):
source_name = source_name[:-len(".agent.md")] + ".md"
frontmatter_data = { frontmatter_data = {
"name": skill_name, "name": skill_name,
@@ -1300,20 +1423,45 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
return installed_count > 0 or skipped_count > 0 return installed_count > 0 or skipped_count > 0
def _handle_agy_deprecation(console: Console) -> None: def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
""" """Return True when a native-skills agent has spec-kit bundled skills."""
Print the deprecation error for the Antigravity (agy) agent and exit. skills_dir = _get_skills_dir(project_path, selected_ai)
if not skills_dir.is_dir():
return False
Context for future AI reviewers (e.g., GitHub Copilot): pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
- Prior to Antigravity v1.20.5, users could rely on explicit agent command definitions generated by this tool. return any(skills_dir.glob(pattern))
- v1.20.5 removed this "command support" in favor of "Agent Skills".
- `specify_cli` handles this by throwing a fail-fast deprecation error if `--ai agy` is
invoked without the accompanying `--ai-skills` flag. Users are instructed to add AGENT_SKILLS_MIGRATIONS = {
the skills flag to generate agent skills templates instead. "agy": {
""" "error": "Explicit command support was deprecated in Antigravity version 1.20.5.",
console.print("\n[red]Error:[/red] Explicit command support was deprecated in Antigravity version 1.20.5.") "usage": "specify init <project> --ai agy --ai-skills",
"interactive_note": (
"'agy' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
"automatically for compatibility (explicit .agent/commands usage is deprecated)."
),
},
"codex": {
"error": (
"Custom prompt-based spec-kit initialization is deprecated for Codex CLI; "
"use agent skills instead."
),
"usage": "specify init <project> --ai codex --ai-skills",
"interactive_note": (
"'codex' was selected interactively; enabling [cyan]--ai-skills[/cyan] "
"automatically for compatibility (.agents/skills is the recommended Codex layout)."
),
},
}
def _handle_agent_skills_migration(console: Console, agent_key: str) -> None:
"""Print a fail-fast migration error for agents that now require skills."""
migration = AGENT_SKILLS_MIGRATIONS[agent_key]
console.print(f"\n[red]Error:[/red] {migration['error']}")
console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.") console.print("Please use [cyan]--ai-skills[/cyan] when initializing to install templates as agent skills instead.")
console.print("[yellow]Usage:[/yellow] specify init <project> --ai agy --ai-skills") console.print(f"[yellow]Usage:[/yellow] {migration['usage']}")
raise typer.Exit(1) raise typer.Exit(1)
@app.command() @app.command()
@@ -1351,7 +1499,7 @@ def init(
specify init . --ai claude # Initialize in current directory specify init . --ai claude # Initialize in current directory
specify init . # Initialize in current directory (interactive AI selection) specify init . # Initialize in current directory (interactive AI selection)
specify init --here --ai claude # Alternative syntax for current directory specify init --here --ai claude # Alternative syntax for current directory
specify init --here --ai codex specify init --here --ai codex --ai-skills
specify init --here --ai codebuddy specify init --here --ai codebuddy
specify init --here --ai vibe # Initialize with Mistral Vibe support specify init --here --ai vibe # Initialize with Mistral Vibe support
specify init --here specify init --here
@@ -1441,24 +1589,16 @@ def init(
"copilot" "copilot"
) )
# [DEPRECATION NOTICE: Antigravity (agy)] # Agents that have moved from explicit commands/prompts to agent skills.
# As of Antigravity v1.20.5, traditional CLI "command" support was fully removed if selected_ai in AGENT_SKILLS_MIGRATIONS and not ai_skills:
# in favor of "Agent Skills" (SKILL.md files under <agent_folder>/skills/<skill_name>/). # If selected interactively (no --ai provided), automatically enable
# Because 'specify_cli' historically populated .agent/commands/, we now must explicitly
# enforce the `--ai-skills` flag for `agy` to ensure valid template generation.
if selected_ai == "agy" and not ai_skills:
# If agy was selected interactively (no --ai provided), automatically enable
# ai_skills so the agent remains usable without requiring an extra flag. # ai_skills so the agent remains usable without requiring an extra flag.
# Preserve deprecation behavior only for explicit '--ai agy' without skills. # Preserve fail-fast behavior only for explicit '--ai <agent>' without skills.
if ai_assistant: if ai_assistant:
_handle_agy_deprecation(console) _handle_agent_skills_migration(console, selected_ai)
else: else:
ai_skills = True ai_skills = True
console.print( console.print(f"\n[yellow]Note:[/yellow] {AGENT_SKILLS_MIGRATIONS[selected_ai]['interactive_note']}")
"\n[yellow]Note:[/yellow] 'agy' was selected interactively; "
"enabling [cyan]--ai-skills[/cyan] automatically for compatibility "
"(explicit .agent/commands usage is deprecated)."
)
# Validate --ai-commands-dir usage # Validate --ai-commands-dir usage
if selected_ai == "generic": if selected_ai == "generic":
@@ -1582,28 +1722,41 @@ def init(
ensure_constitution_from_template(project_path, tracker=tracker) ensure_constitution_from_template(project_path, tracker=tracker)
if ai_skills: if ai_skills:
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker) if selected_ai in NATIVE_SKILLS_AGENTS:
skills_dir = _get_skills_dir(project_path, selected_ai)
if not _has_bundled_skills(project_path, selected_ai):
raise RuntimeError(
f"Expected bundled agent skills in {skills_dir.relative_to(project_path)}, "
"but none were found. Re-run with an up-to-date template."
)
if tracker:
tracker.start("ai-skills")
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
else:
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
else:
skills_ok = install_ai_skills(project_path, selected_ai, tracker=tracker)
# When --ai-skills is used on a NEW project and skills were # When --ai-skills is used on a NEW project and skills were
# successfully installed, remove the command files that the # successfully installed, remove the command files that the
# template archive just created. Skills replace commands, so # template archive just created. Skills replace commands, so
# keeping both would be confusing. For --here on an existing # keeping both would be confusing. For --here on an existing
# repo we leave pre-existing commands untouched to avoid a # repo we leave pre-existing commands untouched to avoid a
# breaking change. We only delete AFTER skills succeed so the # breaking change. We only delete AFTER skills succeed so the
# project always has at least one of {commands, skills}. # project always has at least one of {commands, skills}.
if skills_ok and not here: if skills_ok and not here:
agent_cfg = AGENT_CONFIG.get(selected_ai, {}) agent_cfg = AGENT_CONFIG.get(selected_ai, {})
agent_folder = agent_cfg.get("folder", "") agent_folder = agent_cfg.get("folder", "")
commands_subdir = agent_cfg.get("commands_subdir", "commands") commands_subdir = agent_cfg.get("commands_subdir", "commands")
if agent_folder: if agent_folder:
cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir cmds_dir = project_path / agent_folder.rstrip("/") / commands_subdir
if cmds_dir.exists(): if cmds_dir.exists():
try: try:
shutil.rmtree(cmds_dir) shutil.rmtree(cmds_dir)
except OSError: except OSError:
# Best-effort cleanup: skills are already installed, # Best-effort cleanup: skills are already installed,
# so leaving stale commands is non-fatal. # so leaving stale commands is non-fatal.
console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]") console.print("[yellow]Warning: could not remove extracted commands directory[/yellow]")
if not no_git: if not no_git:
tracker.start("git") tracker.start("git")
@@ -1727,38 +1880,48 @@ def init(
steps_lines.append("1. You're already in the project directory!") steps_lines.append("1. You're already in the project directory!")
step_num = 2 step_num = 2
# Add Codex-specific setup step if needed if selected_ai == "codex" and ai_skills:
if selected_ai == "codex": steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
codex_path = project_path / ".codex"
quoted_path = shlex.quote(str(codex_path))
if os.name == "nt": # Windows
cmd = f"setx CODEX_HOME {quoted_path}"
else: # Unix-like systems
cmd = f"export CODEX_HOME={quoted_path}"
steps_lines.append(f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]")
step_num += 1 step_num += 1
steps_lines.append(f"{step_num}. Start using slash commands with your AI agent:") codex_skill_mode = selected_ai == "codex" and ai_skills
kimi_skill_mode = selected_ai == "kimi"
native_skill_mode = codex_skill_mode or kimi_skill_mode
usage_label = "skills" if native_skill_mode else "slash commands"
steps_lines.append(" 2.1 [cyan]/speckit.constitution[/] - Establish project principles") def _display_cmd(name: str) -> str:
steps_lines.append(" 2.2 [cyan]/speckit.specify[/] - Create baseline specification") if codex_skill_mode:
steps_lines.append(" 2.3 [cyan]/speckit.plan[/] - Create implementation plan") return f"$speckit-{name}"
steps_lines.append(" 2.4 [cyan]/speckit.tasks[/] - Generate actionable tasks") if kimi_skill_mode:
steps_lines.append(" 2.5 [cyan]/speckit.implement[/] - Execute implementation") return f"/skill:speckit.{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles")
steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification")
steps_lines.append(f" {step_num}.3 [cyan]{_display_cmd('plan')}[/] - Create implementation plan")
steps_lines.append(f" {step_num}.4 [cyan]{_display_cmd('tasks')}[/] - Generate actionable tasks")
steps_lines.append(f" {step_num}.5 [cyan]{_display_cmd('implement')}[/] - Execute implementation")
steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2)) steps_panel = Panel("\n".join(steps_lines), title="Next Steps", border_style="cyan", padding=(1,2))
console.print() console.print()
console.print(steps_panel) console.print(steps_panel)
enhancement_intro = (
"Optional skills that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
if native_skill_mode
else "Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]"
)
enhancement_lines = [ enhancement_lines = [
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]", enhancement_intro,
"", "",
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)", f"○ [cyan]{_display_cmd('clarify')}[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]{_display_cmd('plan')}[/] if used)",
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])", f"○ [cyan]{_display_cmd('analyze')}[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]{_display_cmd('tasks')}[/], before [cyan]{_display_cmd('implement')}[/])",
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])" f"○ [cyan]{_display_cmd('checklist')}[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]{_display_cmd('plan')}[/])"
] ]
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2)) enhancements_title = "Enhancement Skills" if native_skill_mode else "Enhancement Commands"
enhancements_panel = Panel("\n".join(enhancement_lines), title=enhancements_title, border_style="cyan", padding=(1,2))
console.print() console.print()
console.print(enhancements_panel) console.print(enhancements_panel)
@@ -2000,6 +2163,11 @@ def preset_add(
console.print("Run this command from a spec-kit project root") console.print("Run this command from a spec-kit project root")
raise typer.Exit(1) 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) manager = PresetManager(project_root)
speckit_version = get_speckit_version() speckit_version = get_speckit_version()
@@ -2177,6 +2345,7 @@ def preset_info(
pack_id: str = typer.Argument(..., help="Preset ID to get info about"), pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
): ):
"""Show detailed information about a preset.""" """Show detailed information about a preset."""
from .extensions import normalize_priority
from .presets import PresetCatalog, PresetManager, PresetError from .presets import PresetCatalog, PresetManager, PresetError
project_root = Path.cwd() project_root = Path.cwd()
@@ -2210,6 +2379,10 @@ def preset_info(
if license_val: if license_val:
console.print(f" License: {license_val}") console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]") 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() console.print()
return return
@@ -2241,6 +2414,141 @@ def preset_info(
console.print() 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_app.command("enable")
def preset_enable(
pack_id: str = typer.Argument(help="Preset ID to enable"),
):
"""Enable a disabled 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)
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)
if metadata.get("enabled", True):
console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]")
raise typer.Exit(0)
# Enable the preset
manager.registry.update(pack_id, {"enabled": True})
console.print(f"[green]✓[/green] Preset '{pack_id}' enabled")
console.print("\nTemplates from this preset will now be included in resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]")
@preset_app.command("disable")
def preset_disable(
pack_id: str = typer.Argument(help="Preset ID to disable"),
):
"""Disable a preset without removing it."""
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)
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)
if not metadata.get("enabled", True):
console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]")
raise typer.Exit(0)
# Disable the preset
manager.registry.update(pack_id, {"enabled": False})
console.print(f"[green]✓[/green] Preset '{pack_id}' disabled")
console.print("\nTemplates from this preset will be skipped during resolution.")
console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]")
console.print(f"To re-enable: specify preset enable {pack_id}")
# ===== Preset Catalog Commands ===== # ===== Preset Catalog Commands =====
@@ -2576,8 +2884,9 @@ def extension_list(
status_color = "green" if ext["enabled"] else "red" 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" [{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" {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() console.print()
if available or all_extensions: if available or all_extensions:
@@ -2765,6 +3074,7 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"), extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"), dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), 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.""" """Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
@@ -2778,6 +3088,11 @@ def extension_add(
console.print("Run this command from a spec-kit project root") console.print("Run this command from a spec-kit project root")
raise typer.Exit(1) 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) manager = ExtensionManager(project_root)
speckit_version = get_speckit_version() speckit_version = get_speckit_version()
@@ -2794,7 +3109,7 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}") console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1) 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: elif from_url:
# Install from URL (ZIP file) # Install from URL (ZIP file)
@@ -2827,7 +3142,7 @@ def extension_add(
zip_path.write_bytes(zip_data) zip_path.write_bytes(zip_data)
# Install from downloaded ZIP # 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: except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}") console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
raise typer.Exit(1) raise typer.Exit(1)
@@ -2871,7 +3186,7 @@ def extension_add(
try: try:
# Install from downloaded ZIP # 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: finally:
# Clean up downloaded ZIP # Clean up downloaded ZIP
if zip_path.exists(): if zip_path.exists():
@@ -3047,7 +3362,7 @@ def extension_info(
extension: str = typer.Argument(help="Extension ID or name"), extension: str = typer.Argument(help="Extension ID or name"),
): ):
"""Show detailed information about an extension.""" """Show detailed information about an extension."""
from .extensions import ExtensionCatalog, ExtensionManager from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
project_root = Path.cwd() project_root = Path.cwd()
@@ -3084,8 +3399,15 @@ def extension_info(
# Get local manifest info # Get local manifest info
ext_manifest = manager.get_extension(resolved_installed_id) ext_manifest = manager.get_extension(resolved_installed_id)
metadata = manager.registry.get(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(f"ID: {resolved_installed_id}")
console.print() console.print()
@@ -3113,6 +3435,8 @@ def extension_info(
console.print() console.print()
console.print("[green]✓ Installed[/green]") 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}") console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
return return
@@ -3128,6 +3452,8 @@ def extension_info(
def _print_extension_info(ext_info: dict, manager): def _print_extension_info(ext_info: dict, manager):
"""Print formatted extension info from catalog data.""" """Print formatted extension info from catalog data."""
from .extensions import normalize_priority
# Header # Header
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else "" 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}") console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
@@ -3206,6 +3532,9 @@ def _print_extension_info(ext_info: dict, manager):
install_allowed = ext_info.get("_install_allowed", True) install_allowed = ext_info.get("_install_allowed", True)
if is_installed: if is_installed:
console.print("[green]✓ Installed[/green]") 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']}") console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
elif install_allowed: elif install_allowed:
console.print("[yellow]Not installed[/yellow]") console.print("[yellow]Not installed[/yellow]")
@@ -3232,6 +3561,7 @@ def extension_update(
ValidationError, ValidationError,
CommandRegistrar, CommandRegistrar,
HookExecutor, HookExecutor,
normalize_priority,
) )
from packaging import version as pkg_version from packaging import version as pkg_version
import shutil import shutil
@@ -3271,7 +3601,7 @@ def extension_update(
for ext_id in extensions_to_update: for ext_id in extensions_to_update:
# Get installed version # Get installed version
metadata = manager.registry.get(ext_id) 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)") console.print(f"{ext_id}: Registry entry corrupted or missing (skipping)")
continue continue
try: try:
@@ -3456,13 +3786,13 @@ def extension_update(
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name) shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
# 9. Restore metadata from backup (installed_at, enabled state) # 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 # Copy current registry entry to avoid mutating internal
# registry state before explicit restore(). # registry state before explicit restore().
current_metadata = manager.registry.get(extension_id) 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( 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) new_metadata = dict(current_metadata)
@@ -3470,6 +3800,10 @@ def extension_update(
if "installed_at" in backup_registry_entry: if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"] 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 extension was disabled before update, disable it again
if not backup_registry_entry.get("enabled", True): if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False new_metadata["enabled"] = False
@@ -3523,7 +3857,7 @@ def extension_update(
# (files that weren't in the original backup) # (files that weren't in the original backup)
try: try:
new_registry_entry = manager.registry.get(extension_id) 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 = {} new_registered_commands = {}
else: else:
new_registered_commands = new_registry_entry.get("registered_commands", {}) new_registered_commands = new_registry_entry.get("registered_commands", {})
@@ -3643,16 +3977,15 @@ def extension_enable(
# Update registry # Update registry
metadata = manager.registry.get(extension_id) 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)") console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1) raise typer.Exit(1)
if metadata.get("enabled", True): if metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]") console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
metadata["enabled"] = True manager.registry.update(extension_id, {"enabled": True})
manager.registry.update(extension_id, metadata)
# Enable hooks in extensions.yml # Enable hooks in extensions.yml
config = hook_executor.get_project_config() config = hook_executor.get_project_config()
@@ -3691,16 +4024,15 @@ def extension_disable(
# Update registry # Update registry
metadata = manager.registry.get(extension_id) 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)") console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1) raise typer.Exit(1)
if not metadata.get("enabled", True): if not metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]") console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
raise typer.Exit(0) raise typer.Exit(0)
metadata["enabled"] = False manager.registry.update(extension_id, {"enabled": False})
manager.registry.update(extension_id, metadata)
# Disable hooks in extensions.yml # Disable hooks in extensions.yml
config = hook_executor.get_project_config() config = hook_executor.get_project_config()
@@ -3716,6 +4048,57 @@ def extension_disable(
console.print(f"To re-enable: specify extension enable {extension_id}") 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(): def main():
app() app()

View File

@@ -9,6 +9,7 @@ command files into agent-specific directories in the correct format.
from pathlib import Path from pathlib import Path
from typing import Dict, List, Any from typing import Dict, List, Any
import platform
import yaml import yaml
@@ -59,13 +60,19 @@ class CommandRegistrar:
"extension": ".md" "extension": ".md"
}, },
"codex": { "codex": {
"dir": ".codex/prompts", "dir": ".agents/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
},
"windsurf": {
"dir": ".windsurf/workflows",
"format": "markdown", "format": "markdown",
"args": "$ARGUMENTS", "args": "$ARGUMENTS",
"extension": ".md" "extension": ".md"
}, },
"windsurf": { "junie": {
"dir": ".windsurf/workflows", "dir": ".junie/commands",
"format": "markdown", "format": "markdown",
"args": "$ARGUMENTS", "args": "$ARGUMENTS",
"extension": ".md" "extension": ".md"
@@ -106,6 +113,12 @@ class CommandRegistrar:
"args": "$ARGUMENTS", "args": "$ARGUMENTS",
"extension": ".md" "extension": ".md"
}, },
"pi": {
"dir": ".pi/prompts",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"amp": { "amp": {
"dir": ".agents/commands", "dir": ".agents/commands",
"format": "markdown", "format": "markdown",
@@ -134,7 +147,19 @@ class CommandRegistrar:
"dir": ".kimi/skills", "dir": ".kimi/skills",
"format": "markdown", "format": "markdown",
"args": "$ARGUMENTS", "args": "$ARGUMENTS",
"extension": "/SKILL.md" "extension": "/SKILL.md",
},
"trae": {
"dir": ".trae/rules",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
},
"iflow": {
"dir": ".iflow/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md"
} }
} }
@@ -164,6 +189,9 @@ class CommandRegistrar:
except yaml.YAMLError: except yaml.YAMLError:
frontmatter = {} frontmatter = {}
if not isinstance(frontmatter, dict):
frontmatter = {}
return frontmatter, body return frontmatter, body
@staticmethod @staticmethod
@@ -191,11 +219,14 @@ class CommandRegistrar:
Returns: Returns:
Modified frontmatter with adjusted paths Modified frontmatter with adjusted paths
""" """
if "scripts" in frontmatter: for script_key in ("scripts", "agent_scripts"):
for key in frontmatter["scripts"]: scripts = frontmatter.get(script_key)
script_path = frontmatter["scripts"][key] if not isinstance(scripts, dict):
if script_path.startswith("../../scripts/"): continue
frontmatter["scripts"][key] = f".specify/scripts/{script_path[14:]}"
for key, script_path in scripts.items():
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
scripts[key] = f".specify/scripts/{script_path[14:]}"
return frontmatter return frontmatter
def render_markdown_command( def render_markdown_command(
@@ -252,6 +283,95 @@ class CommandRegistrar:
return "\n".join(toml_lines) return "\n".join(toml_lines)
def render_skill_command(
self,
agent_name: str,
skill_name: str,
frontmatter: dict,
body: str,
source_id: str,
source_file: str,
project_root: Path,
) -> str:
"""Render a command override as a SKILL.md file.
SKILL-target agents should receive the same skills-oriented
frontmatter shape used elsewhere in the project instead of the
original command frontmatter.
"""
if not isinstance(frontmatter, dict):
frontmatter = {}
if agent_name == "codex":
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
skill_frontmatter = {
"name": skill_name,
"description": description,
"compatibility": "Requires spec-kit project structure with .specify/ directory",
"metadata": {
"author": "github-spec-kit",
"source": f"{source_id}:{source_file}",
},
}
return self.render_frontmatter(skill_frontmatter) + "\n" + body
@staticmethod
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
"""Resolve script placeholders for Codex skill overrides.
This intentionally scopes the fix to Codex, which is the newly
migrated runtime path in this PR. Existing Kimi behavior is left
unchanged for now.
"""
try:
from . import load_init_options
except ImportError:
return body
if not isinstance(frontmatter, dict):
frontmatter = {}
scripts = frontmatter.get("scripts", {}) or {}
agent_scripts = frontmatter.get("agent_scripts", {}) or {}
if not isinstance(scripts, dict):
scripts = {}
if not isinstance(agent_scripts, dict):
agent_scripts = {}
script_variant = load_init_options(project_root).get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts or default_variant in agent_scripts:
fallback_order.append(default_variant)
if secondary_variant in scripts or secondary_variant in agent_scripts:
fallback_order.append(secondary_variant)
for key in scripts:
if key not in fallback_order:
fallback_order.append(key)
for key in agent_scripts:
if key not in fallback_order:
fallback_order.append(key)
script_variant = fallback_order[0] if fallback_order else None
script_command = scripts.get(script_variant) if script_variant else None
if script_command:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
if agent_script_command:
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
"""Convert argument placeholder format. """Convert argument placeholder format.
@@ -265,6 +385,18 @@ class CommandRegistrar:
""" """
return content.replace(from_placeholder, to_placeholder) return content.replace(from_placeholder, to_placeholder)
@staticmethod
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
return cmd_name
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
def register_commands( def register_commands(
self, self,
agent_name: str, agent_name: str,
@@ -316,14 +448,20 @@ class CommandRegistrar:
body, "$ARGUMENTS", agent_config["args"] body, "$ARGUMENTS", agent_config["args"]
) )
if agent_config["format"] == "markdown": output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
)
elif agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note) output = self.render_markdown_command(frontmatter, body, source_id, context_note)
elif agent_config["format"] == "toml": elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id) output = self.render_toml_command(frontmatter, body, source_id)
else: else:
raise ValueError(f"Unsupported format: {agent_config['format']}") raise ValueError(f"Unsupported format: {agent_config['format']}")
dest_file = commands_dir / f"{cmd_name}{agent_config['extension']}" dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
dest_file.parent.mkdir(parents=True, exist_ok=True) dest_file.parent.mkdir(parents=True, exist_ok=True)
dest_file.write_text(output, encoding="utf-8") dest_file.write_text(output, encoding="utf-8")
@@ -333,9 +471,15 @@ class CommandRegistrar:
registered.append(cmd_name) registered.append(cmd_name)
for alias in cmd_info.get("aliases", []): for alias in cmd_info.get("aliases", []):
alias_file = commands_dir / f"{alias}{agent_config['extension']}" alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
)
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(output, encoding="utf-8") alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot": if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias) self.write_copilot_prompt(project_root, alias)
registered.append(alias) registered.append(alias)
@@ -378,7 +522,7 @@ class CommandRegistrar:
results = {} results = {}
for agent_name, agent_config in self.AGENT_CONFIGS.items(): for agent_name, agent_config in self.AGENT_CONFIGS.items():
agent_dir = project_root / agent_config["dir"].split("/")[0] agent_dir = project_root / agent_config["dir"]
if agent_dir.exists(): if agent_dir.exists():
try: try:
@@ -412,7 +556,8 @@ class CommandRegistrar:
commands_dir = project_root / agent_config["dir"] commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names: for cmd_name in cmd_names:
cmd_file = commands_dir / f"{cmd_name}{agent_config['extension']}" output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists(): if cmd_file.exists():
cmd_file.unlink() cmd_file.unlink()

View File

@@ -41,6 +41,26 @@ class CompatibilityError(ExtensionError):
pass 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 @dataclass
class CatalogEntry: class CatalogEntry:
"""Represents a single catalog entry in the catalog stack.""" """Represents a single catalog entry in the catalog stack."""
@@ -202,7 +222,17 @@ class ExtensionRegistry:
try: try:
with open(self.registry_path, 'r') as f: with open(self.registry_path, 'r') as f:
return json.load(f) data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
return {
"schema_version": self.SCHEMA_VERSION,
"extensions": {}
}
# Normalize extensions field (handles corrupted extensions value)
if not isinstance(data.get("extensions"), dict):
data["extensions"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError): except (json.JSONDecodeError, FileNotFoundError):
# Corrupted or missing registry, start fresh # Corrupted or missing registry, start fresh
return { return {
@@ -224,7 +254,7 @@ class ExtensionRegistry:
metadata: Extension metadata (version, source, etc.) metadata: Extension metadata (version, source, etc.)
""" """
self.data["extensions"][extension_id] = { self.data["extensions"][extension_id] = {
**metadata, **copy.deepcopy(metadata),
"installed_at": datetime.now(timezone.utc).isoformat() "installed_at": datetime.now(timezone.utc).isoformat()
} }
self._save() self._save()
@@ -247,12 +277,16 @@ class ExtensionRegistry:
Raises: Raises:
KeyError: If extension is not installed KeyError: If extension is not installed
""" """
if extension_id not in self.data["extensions"]: extensions = self.data.get("extensions")
if not isinstance(extensions, dict) or extension_id not in extensions:
raise KeyError(f"Extension '{extension_id}' is not installed") raise KeyError(f"Extension '{extension_id}' is not installed")
# Merge new metadata with existing, preserving original installed_at # Merge new metadata with existing, preserving original installed_at
existing = self.data["extensions"][extension_id] existing = extensions[extension_id]
# Merge: existing fields preserved, new fields override # Handle corrupted registry entries (e.g., string/list instead of dict)
merged = {**existing, **metadata} if not isinstance(existing, dict):
existing = {}
# Merge: existing fields preserved, new fields override (deep copy to prevent caller mutation)
merged = {**existing, **copy.deepcopy(metadata)}
# Always preserve original installed_at based on key existence, not truthiness, # Always preserve original installed_at based on key existence, not truthiness,
# to handle cases where the field exists but may be falsy (legacy/corruption) # to handle cases where the field exists but may be falsy (legacy/corruption)
if "installed_at" in existing: if "installed_at" in existing:
@@ -260,7 +294,7 @@ class ExtensionRegistry:
else: else:
# If not present in existing, explicitly remove from merged if caller provided it # If not present in existing, explicitly remove from merged if caller provided it
merged.pop("installed_at", None) merged.pop("installed_at", None)
self.data["extensions"][extension_id] = merged extensions[extension_id] = merged
self._save() self._save()
def restore(self, extension_id: str, metadata: dict): def restore(self, extension_id: str, metadata: dict):
@@ -273,8 +307,16 @@ class ExtensionRegistry:
Args: Args:
extension_id: Extension ID extension_id: Extension ID
metadata: Complete extension metadata including installed_at metadata: Complete extension metadata including installed_at
Raises:
ValueError: If metadata is None or not a dict
""" """
self.data["extensions"][extension_id] = dict(metadata) if metadata is None or not isinstance(metadata, dict):
raise ValueError(f"Cannot restore '{extension_id}': metadata must be a dict")
# Ensure extensions dict exists (handle corrupted registry)
if not isinstance(self.data.get("extensions"), dict):
self.data["extensions"] = {}
self.data["extensions"][extension_id] = copy.deepcopy(metadata)
self._save() self._save()
def remove(self, extension_id: str): def remove(self, extension_id: str):
@@ -283,8 +325,11 @@ class ExtensionRegistry:
Args: Args:
extension_id: Extension ID extension_id: Extension ID
""" """
if extension_id in self.data["extensions"]: extensions = self.data.get("extensions")
del self.data["extensions"][extension_id] if not isinstance(extensions, dict):
return
if extension_id in extensions:
del extensions[extension_id]
self._save() self._save()
def get(self, extension_id: str) -> Optional[dict]: def get(self, extension_id: str) -> Optional[dict]:
@@ -297,21 +342,49 @@ class ExtensionRegistry:
extension_id: Extension ID extension_id: Extension ID
Returns: Returns:
Deep copy of extension metadata, or None if not found Deep copy of extension metadata, or None if not found or corrupted
""" """
entry = self.data["extensions"].get(extension_id) extensions = self.data.get("extensions")
return copy.deepcopy(entry) if entry is not None else None if not isinstance(extensions, dict):
return None
entry = extensions.get(extension_id)
# Return None for missing or corrupted (non-dict) entries
if entry is None or not isinstance(entry, dict):
return None
return copy.deepcopy(entry)
def list(self) -> Dict[str, dict]: def list(self) -> Dict[str, dict]:
"""Get all installed extensions. """Get all installed extensions with valid metadata.
Returns a deep copy of the extensions mapping to prevent callers Returns a deep copy of extensions with dict metadata only.
from accidentally mutating nested internal registry state. Corrupted entries (non-dict values) are filtered out.
Returns: Returns:
Dictionary of extension_id -> metadata (deep copies) Dictionary of extension_id -> metadata (deep copies), empty dict if corrupted
""" """
return copy.deepcopy(self.data["extensions"]) extensions = self.data.get("extensions", {}) or {}
if not isinstance(extensions, dict):
return {}
# Filter to only valid dict entries to match type contract
return {
ext_id: copy.deepcopy(meta)
for ext_id, meta in extensions.items()
if isinstance(meta, dict)
}
def keys(self) -> set:
"""Get all extension IDs including corrupted entries.
Lightweight method that returns IDs without deep-copying metadata.
Use this when you only need to check which extensions are tracked.
Returns:
Set of extension IDs (includes corrupted entries)
"""
extensions = self.data.get("extensions", {}) or {}
if not isinstance(extensions, dict):
return set()
return set(extensions.keys())
def is_installed(self, extension_id: str) -> bool: def is_installed(self, extension_id: str) -> bool:
"""Check if extension is installed. """Check if extension is installed.
@@ -320,9 +393,44 @@ class ExtensionRegistry:
extension_id: Extension ID extension_id: Extension ID
Returns: Returns:
True if extension is installed True if extension is installed, False if not or registry corrupted
""" """
return extension_id in self.data["extensions"] extensions = self.data.get("extensions")
if not isinstance(extensions, dict):
return False
return extension_id in extensions
def list_by_priority(self, include_disabled: bool = False) -> 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.
Args:
include_disabled: If True, include disabled extensions. Default False.
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
# Skip disabled extensions unless explicitly requested
if not include_disabled and not meta.get("enabled", True):
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: class ExtensionManager:
@@ -440,7 +548,8 @@ class ExtensionManager:
self, self,
source_dir: Path, source_dir: Path,
speckit_version: str, speckit_version: str,
register_commands: bool = True register_commands: bool = True,
priority: int = 10,
) -> ExtensionManifest: ) -> ExtensionManifest:
"""Install extension from a local directory. """Install extension from a local directory.
@@ -448,14 +557,19 @@ class ExtensionManager:
source_dir: Path to extension directory source_dir: Path to extension directory
speckit_version: Current spec-kit version speckit_version: Current spec-kit version
register_commands: If True, register commands with AI agents register_commands: If True, register commands with AI agents
priority: Resolution priority (lower = higher precedence, default 10)
Returns: Returns:
Installed extension manifest Installed extension manifest
Raises: Raises:
ValidationError: If manifest is invalid ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible 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 # Load and validate manifest
manifest_path = source_dir / "extension.yml" manifest_path = source_dir / "extension.yml"
manifest = ExtensionManifest(manifest_path) manifest = ExtensionManifest(manifest_path)
@@ -497,6 +611,7 @@ class ExtensionManager:
"source": "local", "source": "local",
"manifest_hash": manifest.get_hash(), "manifest_hash": manifest.get_hash(),
"enabled": True, "enabled": True,
"priority": priority,
"registered_commands": registered_commands "registered_commands": registered_commands
}) })
@@ -505,21 +620,27 @@ class ExtensionManager:
def install_from_zip( def install_from_zip(
self, self,
zip_path: Path, zip_path: Path,
speckit_version: str speckit_version: str,
priority: int = 10,
) -> ExtensionManifest: ) -> ExtensionManifest:
"""Install extension from ZIP file. """Install extension from ZIP file.
Args: Args:
zip_path: Path to extension ZIP file zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
Returns: Returns:
Installed extension manifest Installed extension manifest
Raises: Raises:
ValidationError: If manifest is invalid ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible 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: with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) temp_path = Path(tmpdir)
@@ -554,7 +675,7 @@ class ExtensionManager:
raise ValidationError("No extension.yml found in ZIP file") raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory # 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: def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension. """Remove an installed extension.
@@ -571,7 +692,7 @@ class ExtensionManager:
# Get registered commands before removal # Get registered commands before removal
metadata = self.registry.get(extension_id) metadata = self.registry.get(extension_id)
registered_commands = metadata.get("registered_commands", {}) registered_commands = metadata.get("registered_commands", {}) if metadata else {}
extension_dir = self.extensions_dir / extension_id extension_dir = self.extensions_dir / extension_id
@@ -632,6 +753,9 @@ class ExtensionManager:
result = [] result = []
for ext_id, metadata in self.registry.list().items(): 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 ext_dir = self.extensions_dir / ext_id
manifest_path = ext_dir / "extension.yml" manifest_path = ext_dir / "extension.yml"
@@ -643,6 +767,7 @@ class ExtensionManager:
"version": metadata.get("version", "unknown"), "version": metadata.get("version", "unknown"),
"description": manifest.description, "description": manifest.description,
"enabled": metadata.get("enabled", True), "enabled": metadata.get("enabled", True),
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"), "installed_at": metadata.get("installed_at"),
"command_count": len(manifest.commands), "command_count": len(manifest.commands),
"hook_count": len(manifest.hooks) "hook_count": len(manifest.hooks)
@@ -655,6 +780,7 @@ class ExtensionManager:
"version": metadata.get("version", "unknown"), "version": metadata.get("version", "unknown"),
"description": "⚠️ Corrupted extension", "description": "⚠️ Corrupted extension",
"enabled": False, "enabled": False,
"priority": normalize_priority(metadata.get("priority")),
"installed_at": metadata.get("installed_at"), "installed_at": metadata.get("installed_at"),
"command_count": 0, "command_count": 0,
"hook_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. customize the Spec-Driven Development workflow.
""" """
import copy
import json import json
import hashlib import hashlib
import os import os
@@ -23,6 +24,8 @@ import yaml
from packaging import version as pkg_version from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import ExtensionRegistry, normalize_priority
@dataclass @dataclass
class PresetCatalogEntry: class PresetCatalogEntry:
@@ -235,7 +238,17 @@ class PresetRegistry:
try: try:
with open(self.registry_path, 'r') as f: with open(self.registry_path, 'r') as f:
return json.load(f) data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
return {
"schema_version": self.SCHEMA_VERSION,
"presets": {}
}
# Normalize presets field (handles corrupted presets value)
if not isinstance(data.get("presets"), dict):
data["presets"] = {}
return data
except (json.JSONDecodeError, FileNotFoundError): except (json.JSONDecodeError, FileNotFoundError):
return { return {
"schema_version": self.SCHEMA_VERSION, "schema_version": self.SCHEMA_VERSION,
@@ -256,7 +269,7 @@ class PresetRegistry:
metadata: Pack metadata (version, source, etc.) metadata: Pack metadata (version, source, etc.)
""" """
self.data["presets"][pack_id] = { self.data["presets"][pack_id] = {
**metadata, **copy.deepcopy(metadata),
"installed_at": datetime.now(timezone.utc).isoformat() "installed_at": datetime.now(timezone.utc).isoformat()
} }
self._save() self._save()
@@ -267,41 +280,152 @@ class PresetRegistry:
Args: Args:
pack_id: Preset ID pack_id: Preset ID
""" """
if pack_id in self.data["presets"]: packs = self.data.get("presets")
del self.data["presets"][pack_id] if not isinstance(packs, dict):
return
if pack_id in packs:
del packs[pack_id]
self._save() 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
"""
packs = self.data.get("presets")
if not isinstance(packs, dict) or pack_id not in packs:
raise KeyError(f"Preset '{pack_id}' not found in registry")
existing = packs[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 (deep copy to prevent caller mutation)
merged = {**existing, **copy.deepcopy(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)
packs[pack_id] = merged
self._save()
def restore(self, pack_id: str, metadata: dict):
"""Restore preset metadata to registry without modifying timestamps.
Use this method for rollback scenarios where you have a complete backup
of the registry entry (including installed_at) and want to restore it
exactly as it was.
Args:
pack_id: Preset ID
metadata: Complete preset metadata including installed_at
Raises:
ValueError: If metadata is None or not a dict
"""
if metadata is None or not isinstance(metadata, dict):
raise ValueError(f"Cannot restore '{pack_id}': metadata must be a dict")
# Ensure presets dict exists (handle corrupted registry)
if not isinstance(self.data.get("presets"), dict):
self.data["presets"] = {}
self.data["presets"][pack_id] = copy.deepcopy(metadata)
self._save()
def get(self, pack_id: str) -> Optional[dict]: def get(self, pack_id: str) -> Optional[dict]:
"""Get preset metadata from registry. """Get preset metadata from registry.
Returns a deep copy to prevent callers from accidentally mutating
nested internal registry state without going through the write path.
Args: Args:
pack_id: Preset ID pack_id: Preset ID
Returns: Returns:
Pack metadata or None if not found Deep copy of preset metadata, or None if not found or corrupted
""" """
return self.data["presets"].get(pack_id) packs = self.data.get("presets")
if not isinstance(packs, dict):
return None
entry = packs.get(pack_id)
# Return None for missing or corrupted (non-dict) entries
if entry is None or not isinstance(entry, dict):
return None
return copy.deepcopy(entry)
def list(self) -> Dict[str, dict]: def list(self) -> Dict[str, dict]:
"""Get all installed presets. """Get all installed presets with valid metadata.
Returns a deep copy of presets with dict metadata only.
Corrupted entries (non-dict values) are filtered out.
Returns: Returns:
Dictionary of pack_id -> metadata Dictionary of pack_id -> metadata (deep copies), empty dict if corrupted
""" """
return self.data["presets"] packs = self.data.get("presets", {}) or {}
if not isinstance(packs, dict):
return {}
# Filter to only valid dict entries to match type contract
return {
pack_id: copy.deepcopy(meta)
for pack_id, meta in packs.items()
if isinstance(meta, dict)
}
def list_by_priority(self) -> List[tuple]: def keys(self) -> set:
"""Get all preset IDs including corrupted entries.
Lightweight method that returns IDs without deep-copying metadata.
Use this when you only need to check which presets are tracked.
Returns:
Set of preset IDs (includes corrupted entries)
"""
packs = self.data.get("presets", {}) or {}
if not isinstance(packs, dict):
return set()
return set(packs.keys())
def list_by_priority(self, include_disabled: bool = False) -> List[tuple]:
"""Get all installed presets sorted by priority. """Get all installed presets sorted by priority.
Lower priority number = higher precedence (checked first). Lower priority number = higher precedence (checked first).
Presets with equal priority are sorted alphabetically by ID
for deterministic ordering.
Args:
include_disabled: If True, include disabled presets. Default False.
Returns: 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
# Skip disabled presets unless explicitly requested
if not include_disabled and not meta.get("enabled", True):
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( return sorted(
packs.items(), sortable_packs,
key=lambda item: item[1].get("priority", 10), key=lambda item: (item[1]["priority"], item[0]),
) )
def is_installed(self, pack_id: str) -> bool: def is_installed(self, pack_id: str) -> bool:
@@ -311,9 +435,12 @@ class PresetRegistry:
pack_id: Preset ID pack_id: Preset ID
Returns: Returns:
True if pack is installed True if pack is installed, False if not or registry corrupted
""" """
return pack_id in self.data["presets"] packs = self.data.get("presets")
if not isinstance(packs, dict):
return False
return pack_id in packs
class PresetManager: class PresetManager:
@@ -519,8 +646,6 @@ class PresetManager:
short_name = cmd_name short_name = cmd_name
if short_name.startswith("speckit."): if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):] short_name = short_name[len("speckit."):]
# Kimi CLI discovers skills by directory name and invokes them as
# /skill:<name> — use dot separator to match packaging convention.
if selected_ai == "kimi": if selected_ai == "kimi":
skill_name = f"speckit.{short_name}" skill_name = f"speckit.{short_name}"
else: else:
@@ -680,9 +805,13 @@ class PresetManager:
Installed preset manifest Installed preset manifest
Raises: Raises:
PresetValidationError: If manifest is invalid PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible 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_path = source_dir / "preset.yml"
manifest = PresetManifest(manifest_path) manifest = PresetManifest(manifest_path)
@@ -729,14 +858,19 @@ class PresetManager:
Args: Args:
zip_path: Path to preset ZIP file zip_path: Path to preset ZIP file
speckit_version: Current spec-kit version speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
Returns: Returns:
Installed preset manifest Installed preset manifest
Raises: Raises:
PresetValidationError: If manifest is invalid PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible 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: with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir) temp_path = Path(tmpdir)
@@ -808,6 +942,9 @@ class PresetManager:
result = [] result = []
for pack_id, metadata in self.registry.list().items(): 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 pack_dir = self.presets_dir / pack_id
manifest_path = pack_dir / "preset.yml" manifest_path = pack_dir / "preset.yml"
@@ -816,13 +953,13 @@ class PresetManager:
result.append({ result.append({
"id": pack_id, "id": pack_id,
"name": manifest.name, "name": manifest.name,
"version": metadata["version"], "version": metadata.get("version", manifest.version),
"description": manifest.description, "description": manifest.description,
"enabled": metadata.get("enabled", True), "enabled": metadata.get("enabled", True),
"installed_at": metadata.get("installed_at"), "installed_at": metadata.get("installed_at"),
"template_count": len(manifest.templates), "template_count": len(manifest.templates),
"tags": manifest.tags, "tags": manifest.tags,
"priority": metadata.get("priority", 10), "priority": normalize_priority(metadata.get("priority")),
}) })
except PresetValidationError: except PresetValidationError:
result.append({ result.append({
@@ -834,7 +971,7 @@ class PresetManager:
"installed_at": metadata.get("installed_at"), "installed_at": metadata.get("installed_at"),
"template_count": 0, "template_count": 0,
"tags": [], "tags": [],
"priority": metadata.get("priority", 10), "priority": normalize_priority(metadata.get("priority")),
}) })
return result return result
@@ -1393,6 +1530,48 @@ class PresetResolver:
self.overrides_dir = self.templates_dir / "overrides" self.overrides_dir = self.templates_dir / "overrides"
self.extensions_dir = project_root / ".specify" / "extensions" 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)
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
# This prevents corrupted entries from being picked up as "unregistered" dirs
registered_extension_ids = registry.keys()
# Get all registered extensions including disabled; we filter disabled manually below
all_registered = registry.list_by_priority(include_disabled=True)
all_extensions: list[tuple[int, str, dict | None]] = []
# Only include enabled extensions in the result
for ext_id, metadata in all_registered:
# Skip disabled extensions
if not metadata.get("enabled", True):
continue
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( def resolve(
self, self,
template_name: str, template_name: str,
@@ -1445,18 +1624,18 @@ class PresetResolver:
if candidate.exists(): if candidate.exists():
return candidate return candidate
# Priority 3: Extension-provided templates # Priority 3: Extension-provided templates (sorted by priority — lower number wins)
if self.extensions_dir.exists(): for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
for ext_dir in sorted(self.extensions_dir.iterdir()): ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir() or ext_dir.name.startswith("."): if not ext_dir.is_dir():
continue continue
for subdir in subdirs: for subdir in subdirs:
if subdir: if subdir:
candidate = ext_dir / subdir / f"{template_name}{ext}" candidate = ext_dir / subdir / f"{template_name}{ext}"
else: else:
candidate = ext_dir / "templates" / f"{template_name}{ext}" candidate = ext_dir / f"{template_name}{ext}"
if candidate.exists(): if candidate.exists():
return candidate return candidate
# Priority 4: Core templates # Priority 4: Core templates
if template_type == "template": if template_type == "template":
@@ -1514,17 +1693,24 @@ class PresetResolver:
except ValueError: except ValueError:
continue continue
if self.extensions_dir.exists(): for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
for ext_dir in sorted(self.extensions_dir.iterdir()): ext_dir = self.extensions_dir / ext_id
if not ext_dir.is_dir() or ext_dir.name.startswith("."): if not ext_dir.is_dir():
continue continue
try: try:
resolved.relative_to(ext_dir) resolved.relative_to(ext_dir)
if ext_meta:
version = ext_meta.get("version", "?")
return { return {
"path": resolved_str, "path": resolved_str,
"source": f"extension:{ext_dir.name}", "source": f"extension:{ext_id} v{version}",
} }
except ValueError: else:
continue return {
"path": resolved_str,
"source": f"extension:{ext_id} (unregistered)",
}
except ValueError:
continue
return {"path": resolved_str, "source": "core"} return {"path": resolved_str, "source": "core"}

View File

@@ -19,7 +19,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Check if `.specify/extensions.yml` exists in the project root. - Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_implement` key - If it exists, read it and look for entries under the `hooks.before_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter to only hooks where `enabled: true` - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
@@ -174,7 +174,7 @@ Note: This command assumes a complete task breakdown exists in tasks.md. If task
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root. 10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_implement` key - If it exists, read it and look for entries under the `hooks.after_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter to only hooks where `enabled: true` - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation

View File

@@ -24,6 +24,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty). You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before planning)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline ## Outline
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
@@ -41,6 +75,35 @@ You **MUST** consider the user input before proceeding (if not empty).
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. 4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Phases ## Phases
### Phase 0: Outline & Research ### Phase 0: Outline & Research

View File

@@ -21,6 +21,40 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty). You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before specification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline ## Outline
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command. The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
@@ -176,6 +210,35 @@ Given that feature description, do this:
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). 7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. **NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
## Quick Guidelines ## Quick Guidelines

View File

@@ -28,7 +28,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Check if `.specify/extensions.yml` exists in the project root. - Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_tasks` key - If it exists, read it and look for entries under the `hooks.before_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter to only hooks where `enabled: true` - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
@@ -100,7 +100,7 @@ You **MUST** consider the user input before proceeding (if not empty).
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root. 6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_tasks` key - If it exists, read it and look for entries under the `hooks.after_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter to only hooks where `enabled: true` - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions: - For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable - If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation - If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation

View File

@@ -29,11 +29,17 @@ class TestAgentConfigConsistency:
assert "q" not in cfg assert "q" not in cfg
def test_extension_registrar_includes_codex(self): def test_extension_registrar_includes_codex(self):
"""Extension command registrar should include codex targeting .codex/prompts.""" """Extension command registrar should include codex targeting .agents/skills."""
cfg = CommandRegistrar.AGENT_CONFIGS cfg = CommandRegistrar.AGENT_CONFIGS
assert "codex" in cfg assert "codex" in cfg
assert cfg["codex"]["dir"] == ".codex/prompts" assert cfg["codex"]["dir"] == ".agents/skills"
assert cfg["codex"]["extension"] == "/SKILL.md"
def test_runtime_codex_uses_native_skills(self):
"""Codex runtime config should point at .agents/skills."""
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
def test_release_agent_lists_include_kiro_cli_and_exclude_q(self): def test_release_agent_lists_include_kiro_cli_and_exclude_q(self):
"""Bash and PowerShell release scripts should agree on agent key set for Kiro.""" """Bash and PowerShell release scripts should agree on agent key set for Kiro."""
@@ -71,6 +77,16 @@ class TestAgentConfigConsistency:
assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None assert re.search(r"shai\)\s*\n.*?\.shai/commands", sh_text, re.S) is not None
assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None assert re.search(r"agy\)\s*\n.*?\.agent/commands", sh_text, re.S) is not None
def test_release_scripts_generate_codex_skills(self):
"""Release scripts should generate Codex skills in .agents/skills."""
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 ".agents/skills" in sh_text
assert ".agents/skills" in ps_text
assert re.search(r"codex\)\s*\n.*?create_skills.*?\.agents/skills.*?\"-\"", sh_text, re.S) is not None
assert re.search(r"'codex'\s*\{.*?\.agents/skills.*?New-Skills.*?-Separator '-'", ps_text, re.S) is not None
def test_init_ai_help_includes_roo_and_kiro_alias(self): def test_init_ai_help_includes_roo_and_kiro_alias(self):
"""CLI help text for --ai should stay in sync with agent config and alias guidance.""" """CLI help text for --ai should stay in sync with agent config and alias guidance."""
assert "roo" in AI_ASSISTANT_HELP assert "roo" in AI_ASSISTANT_HELP
@@ -233,3 +249,221 @@ class TestAgentConfigConsistency:
def test_ai_help_includes_kimi(self): def test_ai_help_includes_kimi(self):
"""CLI help text for --ai should include kimi.""" """CLI help text for --ai should include kimi."""
assert "kimi" in AI_ASSISTANT_HELP 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
# --- Pi Coding Agent consistency checks ---
def test_pi_in_agent_config(self):
"""AGENT_CONFIG should include pi with correct folder and commands_subdir."""
assert "pi" in AGENT_CONFIG
assert AGENT_CONFIG["pi"]["folder"] == ".pi/"
assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts"
assert AGENT_CONFIG["pi"]["requires_cli"] is True
assert AGENT_CONFIG["pi"]["install_url"] is not None
def test_pi_in_extension_registrar(self):
"""Extension command registrar should include pi using .pi/prompts."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "pi" in cfg
pi_cfg = cfg["pi"]
assert pi_cfg["dir"] == ".pi/prompts"
assert pi_cfg["format"] == "markdown"
assert pi_cfg["args"] == "$ARGUMENTS"
assert pi_cfg["extension"] == ".md"
def test_pi_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include pi 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 "pi" in sh_agents
assert "pi" in ps_agents
def test_release_scripts_generate_pi_prompt_templates(self):
"""Release scripts should generate Markdown prompt templates for pi in .pi/prompts."""
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 ".pi/prompts" in sh_text
assert ".pi/prompts" in ps_text
assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None
assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None
def test_pi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'pi' 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 "pi" in validate_set_values
def test_pi_in_github_release_output(self):
"""GitHub release script should include pi template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-pi-sh-" in gh_release_text
assert "spec-kit-template-pi-ps-" in gh_release_text
def test_agent_context_scripts_include_pi(self):
"""Agent context scripts should support pi 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 "pi" in bash_text
assert "Pi Coding Agent" in bash_text
assert "pi" in pwsh_text
assert "Pi Coding Agent" in pwsh_text
def test_ai_help_includes_pi(self):
"""CLI help text for --ai should include pi."""
assert "pi" in AI_ASSISTANT_HELP
# --- iFlow CLI consistency checks ---
def test_iflow_in_agent_config(self):
"""AGENT_CONFIG should include iflow with correct folder and commands_subdir."""
assert "iflow" in AGENT_CONFIG
assert AGENT_CONFIG["iflow"]["folder"] == ".iflow/"
assert AGENT_CONFIG["iflow"]["commands_subdir"] == "commands"
assert AGENT_CONFIG["iflow"]["requires_cli"] is True
def test_iflow_in_extension_registrar(self):
"""Extension command registrar should include iflow targeting .iflow/commands."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "iflow" in cfg
assert cfg["iflow"]["dir"] == ".iflow/commands"
assert cfg["iflow"]["format"] == "markdown"
assert cfg["iflow"]["args"] == "$ARGUMENTS"
def test_iflow_in_release_agent_lists(self):
"""Bash and PowerShell release scripts should include iflow 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 "iflow" in sh_agents
assert "iflow" in ps_agents
def test_iflow_in_release_scripts_build_variant(self):
"""Release scripts should generate Markdown commands for iflow in .iflow/commands."""
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 ".iflow/commands" in sh_text
assert ".iflow/commands" in ps_text
assert re.search(r"'iflow'\s*\{.*?\.iflow/commands", ps_text, re.S) is not None
def test_iflow_in_github_release_output(self):
"""GitHub release script should include iflow template packages."""
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
assert "spec-kit-template-iflow-sh-" in gh_release_text
assert "spec-kit-template-iflow-ps-" in gh_release_text
def test_iflow_in_agent_context_scripts(self):
"""Agent context scripts should support iflow 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 "iflow" in bash_text
assert "IFLOW_FILE" in bash_text
assert "iflow" in pwsh_text
assert "IFLOW_FILE" in pwsh_text
def test_ai_help_includes_iflow(self):
"""CLI help text for --ai should include iflow."""
assert "iflow" in AI_ASSISTANT_HELP

View File

@@ -62,7 +62,7 @@ def templates_dir(project_dir):
tpl_root.mkdir(parents=True, exist_ok=True) tpl_root.mkdir(parents=True, exist_ok=True)
# Template with valid YAML frontmatter # Template with valid YAML frontmatter
(tpl_root / "specify.md").write_text( (tpl_root / "speckit.specify.md").write_text(
"---\n" "---\n"
"description: Create or update the feature specification.\n" "description: Create or update the feature specification.\n"
"handoffs:\n" "handoffs:\n"
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
) )
# Template with minimal frontmatter # Template with minimal frontmatter
(tpl_root / "plan.md").write_text( (tpl_root / "speckit.plan.md").write_text(
"---\n" "---\n"
"description: Generate implementation plan.\n" "description: Generate implementation plan.\n"
"---\n" "---\n"
@@ -91,7 +91,7 @@ def templates_dir(project_dir):
) )
# Template with no frontmatter # Template with no frontmatter
(tpl_root / "tasks.md").write_text( (tpl_root / "speckit.tasks.md").write_text(
"# Tasks Command\n" "# Tasks Command\n"
"\n" "\n"
"Body without frontmatter.\n", "Body without frontmatter.\n",
@@ -99,7 +99,7 @@ def templates_dir(project_dir):
) )
# Template with empty YAML frontmatter (yaml.safe_load returns None) # 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" "---\n"
"\n" "\n"
@@ -182,6 +182,11 @@ class TestGetSkillsDir:
result = _get_skills_dir(project_dir, "kiro-cli") result = _get_skills_dir(project_dir, "kiro-cli")
assert result == project_dir / ".kiro" / "skills" assert result == project_dir / ".kiro" / "skills"
def test_pi_skills_dir(self, project_dir):
"""Pi should use .pi/skills/."""
result = _get_skills_dir(project_dir, "pi")
assert result == project_dir / ".pi" / "skills"
def test_unknown_agent_uses_default(self, project_dir): def test_unknown_agent_uses_default(self, project_dir):
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR.""" """Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
result = _get_skills_dir(project_dir, "nonexistent-agent") result = _get_skills_dir(project_dir, "nonexistent-agent")
@@ -337,7 +342,7 @@ class TestInstallAiSkills:
cmds_dir = project_dir / ".claude" / "commands" cmds_dir = project_dir / ".claude" / "commands"
cmds_dir.mkdir(parents=True) cmds_dir.mkdir(parents=True)
(cmds_dir / "broken.md").write_text( (cmds_dir / "speckit.broken.md").write_text(
"---\n" "---\n"
"description: [unclosed bracket\n" "description: [unclosed bracket\n"
" invalid: yaml: content: here\n" " invalid: yaml: content: here\n"
@@ -422,6 +427,27 @@ class TestInstallAiSkills:
assert (cmds_dir / "speckit.specify.md").exists() assert (cmds_dir / "speckit.specify.md").exists()
assert (cmds_dir / "speckit.plan.md").exists() assert (cmds_dir / "speckit.plan.md").exists()
def test_pi_prompt_dir_installs_skills(self, project_dir):
"""Pi should install skills directly from .pi/prompts/."""
prompts_dir = project_dir / ".pi" / "prompts"
prompts_dir.mkdir(parents=True)
(prompts_dir / "speckit.specify.md").write_text(
"---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n"
)
(prompts_dir / "speckit.plan.md").write_text(
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
)
result = install_ai_skills(project_dir, "pi")
assert result is True
skills_dir = project_dir / ".pi" / "skills"
assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
assert len(skill_dirs) >= 1
assert (prompts_dir / "speckit.specify.md").exists()
assert (prompts_dir / "speckit.plan.md").exists()
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"]) @pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
def test_skills_install_for_all_agents(self, temp_dir, agent_key): def test_skills_install_for_all_agents(self, temp_dir, agent_key):
"""install_ai_skills should produce skills for every configured agent.""" """install_ai_skills should produce skills for every configured agent."""
@@ -430,9 +456,12 @@ class TestInstallAiSkills:
# Place .md templates in the agent's commands directory # Place .md templates in the agent's commands directory
agent_folder = AGENT_CONFIG[agent_key]["folder"] 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.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" "---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
) )
@@ -442,13 +471,105 @@ class TestInstallAiSkills:
skills_dir = _get_skills_dir(proj, agent_key) skills_dir = _get_skills_dir(proj, agent_key)
assert skills_dir.exists() assert skills_dir.exists()
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()] skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
# Kimi uses dot-separator (speckit.specify) to match /skill:speckit.* invocation; # Kimi uses dotted skill names; other agents use hyphen-separated names.
# all other agents use hyphen-separator (speckit-specify).
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify" expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
assert expected_skill_name in skill_dirs assert expected_skill_name in skill_dirs
assert (skills_dir / expected_skill_name / "SKILL.md").exists() 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: class TestCommandCoexistence:
"""Verify install_ai_skills never touches command files. """Verify install_ai_skills never touches command files.
@@ -460,14 +581,16 @@ class TestCommandCoexistence:
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude): 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.""" """install_ai_skills must NOT remove pre-existing .claude/commands files."""
# Verify commands exist before # Verify commands exist before (templates_dir adds 4 speckit.* files,
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3 # 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") install_ai_skills(project_dir, "claude")
# Commands must still be there — install_ai_skills never touches them # Commands must still be there — install_ai_skills never touches them
remaining = list(commands_dir_claude.glob("speckit.*")) 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): 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.""" """install_ai_skills must NOT remove pre-existing .gemini/commands files."""
@@ -570,6 +693,82 @@ class TestNewProjectCommandSkip:
prompts_dir = target / ".kiro" / "prompts" prompts_dir = target / ".kiro" / "prompts"
assert not prompts_dir.exists() assert not prompts_dir.exists()
def test_codex_native_skills_preserved_without_conversion(self, tmp_path):
"""Codex should keep bundled .agents/skills and skip install_ai_skills conversion."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "new-codex-proj"
def fake_download(project_path, *args, **kwargs):
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills") as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke(
app,
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
)
assert result.exit_code == 0
mock_skills.assert_not_called()
assert (target / ".agents" / "skills" / "speckit-specify" / "SKILL.md").exists()
def test_codex_native_skills_missing_fails_clearly(self, tmp_path):
"""Codex native skills init should fail if bundled skills are missing."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "missing-codex-skills"
with patch("specify_cli.download_and_extract_template", lambda *args, **kwargs: None), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills") as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke(
app,
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
)
assert result.exit_code == 1
mock_skills.assert_not_called()
assert "Expected bundled agent skills" in result.output
def test_codex_native_skills_ignores_non_speckit_skill_dirs(self, tmp_path):
"""Non-spec-kit SKILL.md files should not satisfy Codex bundled-skills validation."""
from typer.testing import CliRunner
runner = CliRunner()
target = tmp_path / "foreign-codex-skills"
def fake_download(project_path, *args, **kwargs):
skill_dir = project_path / ".agents" / "skills" / "other-tool"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: Foreign skill\n---\n\nBody.\n")
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
patch("specify_cli.ensure_executable_scripts"), \
patch("specify_cli.ensure_constitution_from_template"), \
patch("specify_cli.install_ai_skills") as mock_skills, \
patch("specify_cli.is_git_repo", return_value=False), \
patch("specify_cli.shutil.which", return_value="/usr/bin/codex"):
result = runner.invoke(
app,
["init", str(target), "--ai", "codex", "--ai-skills", "--script", "sh", "--no-git"],
)
assert result.exit_code == 1
mock_skills.assert_not_called()
assert "Expected bundled agent skills" in result.output
def test_commands_preserved_when_skills_fail(self, tmp_path): def test_commands_preserved_when_skills_fail(self, tmp_path):
"""If skills fail, commands should NOT be removed (safety net).""" """If skills fail, commands should NOT be removed (safety net)."""
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -713,6 +912,17 @@ class TestCliValidation:
assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output assert "Explicit command support was deprecated in Antigravity version 1.20.5." in result.output
assert "--ai-skills" in result.output assert "--ai-skills" in result.output
def test_codex_without_ai_skills_fails(self):
"""--ai codex without --ai-skills should fail with exit code 1."""
from typer.testing import CliRunner
runner = CliRunner()
result = runner.invoke(app, ["init", "test-proj", "--ai", "codex"])
assert result.exit_code == 1
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" in result.output
assert "--ai-skills" in result.output
def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch): def test_interactive_agy_without_ai_skills_prompts_skills(self, monkeypatch):
"""Interactive selector returning agy without --ai-skills should automatically enable --ai-skills.""" """Interactive selector returning agy without --ai-skills should automatically enable --ai-skills."""
from typer.testing import CliRunner from typer.testing import CliRunner
@@ -755,6 +965,72 @@ class TestCliValidation:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Explicit command support was deprecated" not in result.output assert "Explicit command support was deprecated" not in result.output
def test_interactive_codex_without_ai_skills_enables_skills(self, monkeypatch):
"""Interactive selector returning codex without --ai-skills should automatically enable --ai-skills."""
from typer.testing import CliRunner
def _fake_select_with_arrows(*args, **kwargs):
options = kwargs.get("options")
if options is None and len(args) >= 1:
options = args[0]
if isinstance(options, dict) and "codex" in options:
return "codex"
if isinstance(options, (list, tuple)) and "codex" in options:
return "codex"
if isinstance(options, dict) and options:
return next(iter(options.keys()))
if isinstance(options, (list, tuple)) and options:
return options[0]
return None
monkeypatch.setattr("specify_cli.select_with_arrows", _fake_select_with_arrows)
def _fake_download(*args, **kwargs):
project_path = Path(args[0])
skill_dir = project_path / ".agents" / "skills" / "speckit-specify"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(app, ["init", "test-proj", "--no-git", "--ignore-agent-tools"])
assert result.exit_code == 0
assert "Custom prompt-based spec-kit initialization is deprecated for Codex CLI" not in result.output
assert ".agents/skills" in result.output
assert "$speckit-constitution" in result.output
assert "/speckit.constitution" not in result.output
assert "Optional skills that you can use for your specs" in result.output
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
from typer.testing import CliRunner
def _fake_download(*args, **kwargs):
project_path = Path(args[0])
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
app,
["init", "test-proj", "--ai", "kimi", "--no-git", "--ignore-agent-tools"],
)
assert result.exit_code == 0
assert "/skill:speckit.constitution" in result.output
assert "/speckit.constitution" not in result.output
assert "Optional skills that you can use for your specs" in result.output
def test_ai_skills_flag_appears_in_help(self): def test_ai_skills_flag_appears_in_help(self):
"""--ai-skills should appear in init --help output.""" """--ai-skills should appear in init --help output."""
from typer.testing import CliRunner from typer.testing import CliRunner

View File

@@ -26,6 +26,7 @@ from specify_cli.extensions import (
ExtensionError, ExtensionError,
ValidationError, ValidationError,
CompatibilityError, CompatibilityError,
normalize_priority,
version_satisfies, version_satisfies,
) )
@@ -121,6 +122,57 @@ def project_dir(temp_dir):
return proj_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 ===== # ===== ExtensionManifest Tests =====
class TestExtensionManifest: class TestExtensionManifest:
@@ -368,6 +420,48 @@ class TestExtensionRegistry:
assert registry.is_installed("test-ext") assert registry.is_installed("test-ext")
assert registry.get("test-ext")["version"] == "1.0.0" assert registry.get("test-ext")["version"] == "1.0.0"
def test_restore_rejects_none_metadata(self, temp_dir):
"""Test restore() raises ValueError for None metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", None)
def test_restore_rejects_non_dict_metadata(self, temp_dir):
"""Test restore() raises ValueError for non-dict metadata."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", "not-a-dict")
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-ext", ["list", "not", "dict"])
def test_restore_uses_deep_copy(self, temp_dir):
"""Test restore() deep copies metadata to prevent mutation."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
original_metadata = {
"version": "1.0.0",
"nested": {"key": "original"},
}
registry.restore("test-ext", original_metadata)
# Mutate the original metadata after restore
original_metadata["version"] = "MUTATED"
original_metadata["nested"]["key"] = "MUTATED"
# Registry should have the original values
stored = registry.get("test-ext")
assert stored["version"] == "1.0.0"
assert stored["nested"]["key"] == "original"
def test_get_returns_deep_copy(self, temp_dir): def test_get_returns_deep_copy(self, temp_dir):
"""Test that get() returns deep copies for nested structures.""" """Test that get() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions" extensions_dir = temp_dir / "extensions"
@@ -387,6 +481,26 @@ class TestExtensionRegistry:
internal = registry.data["extensions"]["test-ext"] internal = registry.data["extensions"]["test-ext"]
assert internal["registered_commands"] == {"claude": ["cmd1"]} assert internal["registered_commands"] == {"claude": ["cmd1"]}
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
"""Test that get() returns None for corrupted (non-dict) entries."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Directly corrupt the registry with non-dict entries
registry.data["extensions"]["corrupted-string"] = "not a dict"
registry.data["extensions"]["corrupted-list"] = ["not", "a", "dict"]
registry.data["extensions"]["corrupted-int"] = 42
registry._save()
# All corrupted entries should return None
assert registry.get("corrupted-string") is None
assert registry.get("corrupted-list") is None
assert registry.get("corrupted-int") is None
# Non-existent should also return None
assert registry.get("nonexistent") is None
def test_list_returns_deep_copy(self, temp_dir): def test_list_returns_deep_copy(self, temp_dir):
"""Test that list() returns deep copies for nested structures.""" """Test that list() returns deep copies for nested structures."""
extensions_dir = temp_dir / "extensions" extensions_dir = temp_dir / "extensions"
@@ -406,6 +520,20 @@ class TestExtensionRegistry:
internal = registry.data["extensions"]["test-ext"] internal = registry.data["extensions"]["test-ext"]
assert internal["registered_commands"] == {"claude": ["cmd1"]} assert internal["registered_commands"] == {"claude": ["cmd1"]}
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
"""Test that list() returns empty dict when extensions is not a dict."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
# Corrupt the registry - extensions is a list instead of dict
registry.data["extensions"] = ["not", "a", "dict"]
registry._save()
# list() should return empty dict, not crash
result = registry.list()
assert result == {}
# ===== ExtensionManager Tests ===== # ===== ExtensionManager Tests =====
@@ -537,9 +665,19 @@ class TestCommandRegistrar:
assert "q" not in CommandRegistrar.AGENT_CONFIGS assert "q" not in CommandRegistrar.AGENT_CONFIGS
def test_codex_agent_config_present(self): def test_codex_agent_config_present(self):
"""Codex should be mapped to .codex/prompts.""" """Codex should be mapped to .agents/skills."""
assert "codex" in CommandRegistrar.AGENT_CONFIGS assert "codex" in CommandRegistrar.AGENT_CONFIGS
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts" assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".agents/skills"
assert CommandRegistrar.AGENT_CONFIGS["codex"]["extension"] == "/SKILL.md"
def test_pi_agent_config_present(self):
"""Pi should be mapped to .pi/prompts."""
assert "pi" in CommandRegistrar.AGENT_CONFIGS
cfg = CommandRegistrar.AGENT_CONFIGS["pi"]
assert cfg["dir"] == ".pi/prompts"
assert cfg["format"] == "markdown"
assert cfg["args"] == "$ARGUMENTS"
assert cfg["extension"] == ".md"
def test_qwen_agent_config_is_markdown(self): def test_qwen_agent_config_is_markdown(self):
"""Qwen should use Markdown format with $ARGUMENTS (not TOML).""" """Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
@@ -580,6 +718,21 @@ $ARGUMENTS
assert frontmatter == {} assert frontmatter == {}
assert body == content assert body == content
def test_parse_frontmatter_non_mapping_returns_empty_dict(self):
"""Non-mapping YAML frontmatter should not crash downstream renderers."""
content = """---
- item1
- item2
---
# Command body
"""
registrar = CommandRegistrar()
frontmatter, body = registrar.parse_frontmatter(content)
assert frontmatter == {}
assert "Command body" in body
def test_render_frontmatter(self): def test_render_frontmatter(self):
"""Test rendering frontmatter to YAML.""" """Test rendering frontmatter to YAML."""
frontmatter = { frontmatter = {
@@ -671,6 +824,299 @@ $ARGUMENTS
assert (claude_dir / "speckit.alias.cmd.md").exists() assert (claude_dir / "speckit.alias.cmd.md").exists()
assert (claude_dir / "speckit.shortcut.md").exists() assert (claude_dir / "speckit.shortcut.md").exists()
def test_unregister_commands_for_codex_skills_uses_mapped_names(self, project_dir):
"""Codex skill cleanup should use the same mapped names as registration."""
skills_dir = project_dir / ".agents" / "skills"
(skills_dir / "speckit-specify").mkdir(parents=True)
(skills_dir / "speckit-specify" / "SKILL.md").write_text("body")
(skills_dir / "speckit-shortcut").mkdir(parents=True)
(skills_dir / "speckit-shortcut" / "SKILL.md").write_text("body")
registrar = CommandRegistrar()
registrar.unregister_commands(
{"codex": ["speckit.specify", "speckit.shortcut"]},
project_dir,
)
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
"""A Codex project under .agents/skills should not implicitly activate Amp."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registered = registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert "codex" in registered
assert "amp" not in registered
assert not (project_dir / ".agents" / "commands").exists()
def test_codex_skill_registration_writes_skill_frontmatter(self, extension_dir, project_dir):
"""Codex SKILL.md output should use skills-oriented frontmatter."""
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "name: speckit-test.hello" in content
assert "description: Test hello command" in content
assert "compatibility:" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content
assert "<!-- Extension:" not in content
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
"""Codex SKILL.md overrides should resolve script placeholders."""
import yaml
ext_dir = temp_dir / "ext-scripted"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-scripted",
"name": "Scripted Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.test.plan",
"file": "commands/plan.md",
"description": "Scripted command",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
agent_scripts:
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
---
Run {SCRIPT}
Then {AGENT_SCRIPT}
Agent __AGENT__
"""
)
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text('{"ai":"codex","ai_skills":true,"script":"sh"}')
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "{SCRIPT}" not in content
assert "{AGENT_SCRIPT}" not in content
assert "__AGENT__" not in content
assert "{ARGS}" not in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir):
"""Codex alias skills should render their own matching `name:` frontmatter."""
import yaml
ext_dir = temp_dir / "ext-alias-skill"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-alias-skill",
"name": "Alias Skill Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.alias.cmd",
"file": "commands/cmd.md",
"aliases": ["speckit.shortcut"],
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Alias skill\n---\n\nBody\n")
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
assert primary.exists()
assert alias.exists()
assert "name: speckit-alias.cmd" in primary.read_text()
assert "name: speckit-shortcut" in alias.read_text()
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
self, project_dir, temp_dir
):
"""Codex placeholder substitution should still work without init-options.json."""
import yaml
ext_dir = temp_dir / "ext-script-fallback"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-script-fallback",
"name": "Script fallback",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.fallback.plan",
"file": "commands/plan.md",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Fallback scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
agent_scripts:
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
---
Run {SCRIPT}
Then {AGENT_SCRIPT}
"""
)
# Intentionally do NOT create .specify/init-options.json
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "{SCRIPT}" not in content
assert "{AGENT_SCRIPT}" not in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
self, project_dir, temp_dir, monkeypatch
):
"""Without init metadata, Windows fallback should prefer ps scripts over sh."""
import yaml
monkeypatch.setattr("specify_cli.agents.platform.system", lambda: "Windows")
ext_dir = temp_dir / "ext-script-windows-fallback"
ext_dir.mkdir()
(ext_dir / "commands").mkdir()
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": "ext-script-windows-fallback",
"name": "Script fallback windows",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": "speckit.windows.plan",
"file": "commands/plan.md",
}
]
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
(ext_dir / "commands" / "plan.md").write_text(
"""---
description: "Windows fallback scripted command"
scripts:
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
ps: ../../scripts/powershell/setup-plan.ps1 -Json
agent_scripts:
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__
---
Run {SCRIPT}
Then {AGENT_SCRIPT}
"""
)
skills_dir = project_dir / ".agents" / "skills"
skills_dir.mkdir(parents=True)
manifest = ExtensionManifest(ext_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content
assert ".specify/scripts/bash/setup-plan.sh" not in content
def test_register_commands_for_copilot(self, extension_dir, project_dir): def test_register_commands_for_copilot(self, extension_dir, project_dir):
"""Test registering commands for Copilot agent with .agent.md extension.""" """Test registering commands for Copilot agent with .agent.md extension."""
# Create .github/agents directory (Copilot project) # Create .github/agents directory (Copilot project)
@@ -2337,3 +2783,439 @@ class TestExtensionUpdateCLI:
for cmd_file in command_files: for cmd_file in command_files:
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}" 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_list_by_priority_excludes_disabled(self, temp_dir):
"""Test that list_by_priority excludes disabled extensions by default."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
registry.add("ext-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
# Default: exclude disabled
by_priority = registry.list_by_priority()
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-default" in ext_ids
assert "ext-disabled" not in ext_ids
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
"""Test that list_by_priority includes disabled extensions when requested."""
extensions_dir = temp_dir / "extensions"
extensions_dir.mkdir()
registry = ExtensionRegistry(extensions_dir)
registry.add("ext-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("ext-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
# Include disabled
by_priority = registry.list_by_priority(include_disabled=True)
ext_ids = [p[0] for p in by_priority]
assert "ext-enabled" in ext_ids
assert "ext-disabled" in ext_ids
# Disabled ext has lower priority number, so it comes first when included
assert ext_ids[0] == "ext-disabled"
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_corrupted_extension_entry_not_picked_up_as_unregistered(self, project_dir):
"""Corrupted registry entries are still tracked and NOT picked up as unregistered."""
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})
# Corrupt the entry - should still be tracked, not picked up as unregistered
registry.data["extensions"]["broken-ext"] = "corrupted"
registry._save()
from specify_cli.presets import PresetResolver
resolver = PresetResolver(project_dir)
# Corrupted extension templates should NOT be resolved
resolved = resolver.resolve("target-template")
assert resolved is None
# Valid extension template should still resolve
valid_resolved = resolver.resolve("other-template")
assert valid_resolved is not None
assert "Valid" in valid_resolved.read_text()
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, PresetCompatibilityError,
VALID_PRESET_TEMPLATE_TYPES, VALID_PRESET_TEMPLATE_TYPES,
) )
from specify_cli.extensions import ExtensionRegistry
# ===== Fixtures ===== # ===== Fixtures =====
@@ -368,6 +369,172 @@ class TestPresetRegistry:
registry = PresetRegistry(packs_dir) registry = PresetRegistry(packs_dir)
assert registry.get("nonexistent") is None assert registry.get("nonexistent") is None
def test_restore(self, temp_dir):
"""Test restore() preserves timestamps exactly."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Create original entry with a specific timestamp
original_metadata = {
"version": "1.0.0",
"source": "local",
"installed_at": "2025-01-15T10:30:00+00:00",
"enabled": True,
}
registry.restore("test-pack", original_metadata)
# Verify exact restoration
restored = registry.get("test-pack")
assert restored["installed_at"] == "2025-01-15T10:30:00+00:00"
assert restored["version"] == "1.0.0"
assert restored["enabled"] is True
def test_restore_rejects_none_metadata(self, temp_dir):
"""Test restore() raises ValueError for None metadata."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", None)
def test_restore_rejects_non_dict_metadata(self, temp_dir):
"""Test restore() raises ValueError for non-dict metadata."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", "not-a-dict")
with pytest.raises(ValueError, match="metadata must be a dict"):
registry.restore("test-pack", ["list", "not", "dict"])
def test_restore_uses_deep_copy(self, temp_dir):
"""Test restore() deep copies metadata to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
original_metadata = {
"version": "1.0.0",
"nested": {"key": "original"},
}
registry.restore("test-pack", original_metadata)
# Mutate the original metadata after restore
original_metadata["version"] = "MUTATED"
original_metadata["nested"]["key"] = "MUTATED"
# Registry should have the original values
stored = registry.get("test-pack")
assert stored["version"] == "1.0.0"
assert stored["nested"]["key"] == "original"
def test_get_returns_deep_copy(self, temp_dir):
"""Test that get() returns a deep copy to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
# Get and mutate the returned copy
metadata = registry.get("test-pack")
metadata["version"] = "MUTATED"
metadata["nested"]["key"] = "MUTATED"
# Original should be unchanged
fresh = registry.get("test-pack")
assert fresh["version"] == "1.0.0"
assert fresh["nested"]["key"] == "original"
def test_get_returns_none_for_corrupted_entry(self, temp_dir):
"""Test that get() returns None for corrupted (non-dict) entries."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Directly corrupt the registry with non-dict entries
registry.data["presets"]["corrupted-string"] = "not a dict"
registry.data["presets"]["corrupted-list"] = ["not", "a", "dict"]
registry.data["presets"]["corrupted-int"] = 42
registry._save()
# All corrupted entries should return None
assert registry.get("corrupted-string") is None
assert registry.get("corrupted-list") is None
assert registry.get("corrupted-int") is None
# Non-existent should also return None
assert registry.get("nonexistent") is None
def test_list_returns_deep_copy(self, temp_dir):
"""Test that list() returns deep copies to prevent mutation."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("test-pack", {"version": "1.0.0", "nested": {"key": "original"}})
# Get list and mutate
all_packs = registry.list()
all_packs["test-pack"]["version"] = "MUTATED"
all_packs["test-pack"]["nested"]["key"] = "MUTATED"
# Original should be unchanged
fresh = registry.get("test-pack")
assert fresh["version"] == "1.0.0"
assert fresh["nested"]["key"] == "original"
def test_list_returns_empty_dict_for_corrupted_registry(self, temp_dir):
"""Test that list() returns empty dict when presets is not a dict."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
# Corrupt the registry - presets is a list instead of dict
registry.data["presets"] = ["not", "a", "dict"]
registry._save()
# list() should return empty dict, not crash
result = registry.list()
assert result == {}
def test_list_by_priority_excludes_disabled(self, temp_dir):
"""Test that list_by_priority excludes disabled presets by default."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
registry.add("pack-default", {"version": "1.0.0", "priority": 10}) # no enabled field = True
# Default: exclude disabled
by_priority = registry.list_by_priority()
pack_ids = [p[0] for p in by_priority]
assert "pack-enabled" in pack_ids
assert "pack-default" in pack_ids
assert "pack-disabled" not in pack_ids
def test_list_by_priority_includes_disabled_when_requested(self, temp_dir):
"""Test that list_by_priority includes disabled presets when requested."""
packs_dir = temp_dir / "packs"
packs_dir.mkdir()
registry = PresetRegistry(packs_dir)
registry.add("pack-enabled", {"version": "1.0.0", "enabled": True, "priority": 5})
registry.add("pack-disabled", {"version": "1.0.0", "enabled": False, "priority": 1})
# Include disabled
by_priority = registry.list_by_priority(include_disabled=True)
pack_ids = [p[0] for p in by_priority]
assert "pack-enabled" in pack_ids
assert "pack-disabled" in pack_ids
# Disabled pack has lower priority number, so it comes first when included
assert pack_ids[0] == "pack-disabled"
# ===== PresetManager Tests ===== # ===== PresetManager Tests =====
@@ -573,6 +740,24 @@ class TestRegistryPriority:
assert sorted_packs[0][0] == "pack-b" assert sorted_packs[0][0] == "pack-b"
assert sorted_packs[1][0] == "pack-a" 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 ===== # ===== PresetResolver Tests =====
@@ -678,11 +863,54 @@ class TestPresetResolver:
ext_template = ext_templates_dir / "custom-template.md" ext_template = ext_templates_dir / "custom-template.md"
ext_template.write_text("# Extension Custom Template\n") 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) resolver = PresetResolver(project_dir)
result = resolver.resolve("custom-template") result = resolver.resolve("custom-template")
assert result is not None assert result is not None
assert "Extension Custom Template" in result.read_text() assert "Extension Custom Template" in result.read_text()
def test_resolve_disabled_extension_templates_skipped(self, project_dir):
"""Test that disabled extension templates are not resolved."""
# Create extension with templates
ext_dir = project_dir / ".specify" / "extensions" / "disabled-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "disabled-template.md"
ext_template.write_text("# Disabled Extension Template\n")
# Register extension as disabled
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("disabled-ext", {"version": "1.0.0", "priority": 1, "enabled": False})
# Template should NOT be resolved because extension is disabled
resolver = PresetResolver(project_dir)
result = resolver.resolve("disabled-template")
assert result is None, "Disabled extension template should not be resolved"
def test_resolve_disabled_extension_not_picked_up_as_unregistered(self, project_dir):
"""Test that disabled extensions are not picked up via unregistered dir scan."""
# Create extension directory with templates
ext_dir = project_dir / ".specify" / "extensions" / "test-disabled-ext"
ext_templates_dir = ext_dir / "templates"
ext_templates_dir.mkdir(parents=True)
ext_template = ext_templates_dir / "unique-disabled-template.md"
ext_template.write_text("# Should Not Resolve\n")
# Register the extension but disable it
extensions_dir = project_dir / ".specify" / "extensions"
ext_registry = ExtensionRegistry(extensions_dir)
ext_registry.add("test-disabled-ext", {"version": "1.0.0", "enabled": False})
# Verify the template is NOT resolved (even though the directory exists)
resolver = PresetResolver(project_dir)
result = resolver.resolve("unique-disabled-template")
assert result is None, "Disabled extension should not be picked up as unregistered"
def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data): def test_resolve_pack_over_extension(self, project_dir, pack_dir, temp_dir, valid_pack_data):
"""Test that pack templates take priority over extension templates.""" """Test that pack templates take priority over extension templates."""
# Create extension with templates # Create extension with templates
@@ -741,10 +969,15 @@ class TestPresetResolver:
ext_template = ext_templates_dir / "unique-template.md" ext_template = ext_templates_dir / "unique-template.md"
ext_template.write_text("# Unique\n") 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) resolver = PresetResolver(project_dir)
result = resolver.resolve_with_source("unique-template") result = resolver.resolve_with_source("unique-template")
assert result is not None 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): def test_resolve_with_source_not_found(self, project_dir):
"""Test resolve_with_source for nonexistent template.""" """Test resolve_with_source for nonexistent template."""
@@ -765,6 +998,104 @@ class TestPresetResolver:
assert result is None 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 ===== # ===== PresetCatalog Tests =====
@@ -979,8 +1310,13 @@ class TestIntegration:
ext_templates_dir.mkdir(parents=True) ext_templates_dir.mkdir(parents=True)
(ext_templates_dir / "spec-template.md").write_text("# Extension\n") (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") 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 # Install pack — should win over extension
manager = PresetManager(project_dir) manager = PresetManager(project_dir)
@@ -1710,3 +2046,348 @@ class TestPresetSkills:
metadata = manager.registry.get("self-test") metadata = manager.registry.get("self-test")
assert metadata.get("registered_skills", []) == [] 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",
]
class TestPresetEnableDisable:
"""Test preset enable/disable CLI commands."""
def test_disable_preset(self, project_dir, pack_dir):
"""Test disable command sets enabled=False."""
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")
# Verify initially enabled
assert manager.registry.get("test-pack").get("enabled", True) is True
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 0, result.output
assert "disabled" in result.output.lower()
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is False
def test_enable_preset(self, project_dir, pack_dir):
"""Test enable command sets enabled=True."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset and disable it
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.update("test-pack", {"enabled": False})
# Verify disabled
assert manager.registry.get("test-pack")["enabled"] is False
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "test-pack"])
assert result.exit_code == 0, result.output
assert "enabled" in result.output.lower()
# Reload registry to see updated value
manager2 = PresetManager(project_dir)
assert manager2.registry.get("test-pack")["enabled"] is True
def test_disable_already_disabled(self, project_dir, pack_dir):
"""Test disable on already disabled preset shows warning."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset and disable it
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.update("test-pack", {"enabled": False})
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 0, result.output
assert "already disabled" in result.output.lower()
def test_enable_already_enabled(self, project_dir, pack_dir):
"""Test enable on already enabled preset shows warning."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset (enabled by default)
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", "enable", "test-pack"])
assert result.exit_code == 0, result.output
assert "already enabled" in result.output.lower()
def test_disable_not_installed(self, project_dir):
"""Test disable 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", "disable", "nonexistent"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
def test_enable_not_installed(self, project_dir):
"""Test enable 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", "enable", "nonexistent"])
assert result.exit_code == 1, result.output
assert "not installed" in result.output.lower()
def test_disabled_preset_excluded_from_resolution(self, project_dir, pack_dir):
"""Test that disabled presets are excluded from template resolution."""
# Install preset with a template
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
# Create a template in the preset directory
preset_template = project_dir / ".specify" / "presets" / "test-pack" / "templates" / "test-template.md"
preset_template.parent.mkdir(parents=True, exist_ok=True)
preset_template.write_text("# Template from test-pack")
resolver = PresetResolver(project_dir)
# Template should be found when enabled
result = resolver.resolve("test-template", "template")
assert result is not None
assert "test-pack" in str(result)
# Disable the preset
manager.registry.update("test-pack", {"enabled": False})
# Template should NOT be found when disabled
resolver2 = PresetResolver(project_dir)
result2 = resolver2.resolve("test-template", "template")
assert result2 is None
def test_enable_corrupted_registry_entry(self, project_dir, pack_dir):
"""Test enable fails gracefully for corrupted registry entry."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset then corrupt the registry entry
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
manager.registry._save()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "enable", "test-pack"])
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()
def test_disable_corrupted_registry_entry(self, project_dir, pack_dir):
"""Test disable fails gracefully for corrupted registry entry."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Install preset then corrupt the registry entry
manager = PresetManager(project_dir)
manager.install_from_directory(pack_dir, "0.1.5")
manager.registry.data["presets"]["test-pack"] = "corrupted-string"
manager.registry._save()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["preset", "disable", "test-pack"])
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()