mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 19:03:08 +00:00
Compare commits
19 Commits
v0.3.1
...
13a46dd8b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a46dd8b2 | ||
|
|
1a0f8b17ea | ||
|
|
db66637f8d | ||
|
|
f7fbda53d2 | ||
|
|
7259652c9e | ||
|
|
d8bc72f1cf | ||
|
|
f5f8311415 | ||
|
|
6da1375396 | ||
|
|
1c143e64b1 | ||
|
|
da6e7d2283 | ||
|
|
3ffef55954 | ||
|
|
52f137ce84 | ||
|
|
445eefe5ba | ||
|
|
35ced30747 | ||
|
|
914a06a89f | ||
|
|
abf4aebdb3 | ||
|
|
6003a232d8 | ||
|
|
2e8a4d6432 | ||
|
|
65ecaa9fe4 |
@@ -58,8 +58,6 @@ gh release create "$VERSION" \
|
||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
||||
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
||||
--title "Spec Kit Templates - $VERSION_NO_V" \
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
.PARAMETER Agents
|
||||
Comma or space separated subset of agents to build (default: all)
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, generic
|
||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, generic
|
||||
|
||||
.PARAMETER Scripts
|
||||
Comma or space separated subset of script types to build (default: both)
|
||||
@@ -454,11 +454,6 @@ function Build-Variant {
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
||||
}
|
||||
'trae' {
|
||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||
New-Item -ItemType Directory -Force -Path $rulesDir | Out-Null
|
||||
Generate-Commands -Agent 'trae' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $rulesDir -ScriptVariant $Script
|
||||
}
|
||||
'generic' {
|
||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||
@@ -475,7 +470,7 @@ function Build-Variant {
|
||||
}
|
||||
|
||||
# Define all agents and scripts
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'generic')
|
||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic')
|
||||
$AllScripts = @('sh', 'ps')
|
||||
|
||||
function Normalize-List {
|
||||
|
||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||
# Version argument should include leading 'v'.
|
||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic (default: all)
|
||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic (default: all)
|
||||
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||
# Examples:
|
||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||
@@ -291,9 +291,6 @@ build_variant() {
|
||||
kimi)
|
||||
mkdir -p "$base_dir/.kimi/skills"
|
||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
||||
trae)
|
||||
mkdir -p "$base_dir/.trae/rules"
|
||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||
generic)
|
||||
mkdir -p "$base_dir/.speckit/commands"
|
||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||
@@ -303,7 +300,7 @@ build_variant() {
|
||||
}
|
||||
|
||||
# Determine agent list
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae generic)
|
||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic)
|
||||
ALL_SCRIPTS=(sh ps)
|
||||
|
||||
norm_list() {
|
||||
|
||||
@@ -46,7 +46,6 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
||||
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
||||
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
|
||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||
|
||||
### Step-by-Step Integration Guide
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -7,106 +7,10 @@ Recent changes to the Specify CLI and templates are documented here.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.1] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skill… (#1867)
|
||||
- feat: add Trae IDE support as a new agent (#1817)
|
||||
- feat(cli): polite deep merge for settings.json and support JSONC (#1874)
|
||||
- feat(extensions,presets): add priority-based resolution ordering (#1855)
|
||||
- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)
|
||||
- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)
|
||||
- Add cognitive-squad to community extension catalog (#1870)
|
||||
- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)
|
||||
- chore: update DocGuard extension to v0.9.8 (#1859)
|
||||
- Feature: add specify status command (#1837)
|
||||
- fix(extensions): show extension ID in list output (#1843)
|
||||
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
||||
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
||||
- chore: bump version to 0.3.0 (#1839)
|
||||
- feat(presets): Pluggable preset system with catalog, resolver, and skills propagation (#1787)
|
||||
- fix: match 'Last updated' timestamp with or without bold markers (#1836)
|
||||
- Add specify doctor command for project health diagnostics (#1828)
|
||||
- fix: harden bash scripts against shell injection and improve robustness (#1809)
|
||||
- fix: clean up command templates (specify, analyze) (#1810)
|
||||
- fix: migrate Qwen Code CLI from TOML to Markdown format (#1589) (#1730)
|
||||
- fix(cli): deprecate explicit command support for agy (#1798) (#1808)
|
||||
- Add /selftest.extension core extension to test other extensions (#1758)
|
||||
- feat(extensions): Quality of life improvements for RFC-aligned catalog integration (#1776)
|
||||
- Add Java brownfield walkthrough to community walkthroughs (#1820)
|
||||
- chore: bump version to 0.2.1 (#1813)
|
||||
- Added February 2026 newsletter (#1812)
|
||||
- feat: add Kimi Code CLI agent support (#1790)
|
||||
- docs: fix broken links in quickstart guide (#1759) (#1797)
|
||||
- docs: add catalog cli help documentation (#1793) (#1794)
|
||||
- fix: use quiet checkout to avoid exception on git checkout (#1792)
|
||||
- feat(extensions): support .extensionignore to exclude files during install (#1781)
|
||||
- feat: add Codex support for extension command registration (#1767)
|
||||
- chore: bump version to 0.2.0 (#1786)
|
||||
- fix: sync agent list comments with actual supported agents (#1785)
|
||||
- feat(extensions): support multiple active catalogs simultaneously (#1720)
|
||||
- Pavel/add tabnine cli support (#1503)
|
||||
- Add Understanding extension to community catalog (#1778)
|
||||
- Add ralph extension to community catalog (#1780)
|
||||
- Update README with project initialization instructions (#1772)
|
||||
- feat: add review extension to community catalog (#1775)
|
||||
- Add fleet extension to community catalog (#1771)
|
||||
- Integration of Mistral vibe support into speckit (#1725)
|
||||
- fix: Remove duplicate options in specify.md (#1765)
|
||||
- fix: use global branch numbering instead of per-short-name detection (#1757)
|
||||
- Add Community Walkthroughs section to README (#1766)
|
||||
- feat(extensions): add Jira Integration to community catalog (#1764)
|
||||
- Add Azure DevOps Integration extension to community catalog (#1734)
|
||||
- Fix docs: update Antigravity link and add initialization example (#1748)
|
||||
- fix: wire after_tasks and after_implement hook events into command templates (#1702)
|
||||
- make c ignores consistent with c++ (#1747)
|
||||
- chore: bump version to 0.1.13 (#1746)
|
||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
||||
- feat: add verify extension to community catalog (#1726)
|
||||
- Add Retrospective Extension to community catalog README table (#1741)
|
||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
||||
- fix: correct Copilot extension command registration (#1724)
|
||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
||||
- Add sync extension to community catalog (#1728)
|
||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
||||
- chore: bump version to 0.1.12 (#1737)
|
||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||
|
||||
|
||||
## [0.3.0] - 2026-03-13
|
||||
|
||||
### Changed
|
||||
|
||||
- No changes have been documented for this release yet.
|
||||
|
||||
<!-- Entries for 0.2.x and earlier releases are documented in their respective sections below. -->
|
||||
- make c ignores consistent with c++ (#1747)
|
||||
- chore: bump version to 0.1.13 (#1746)
|
||||
- feat: add kiro-cli and AGENT_CONFIG consistency coverage (#1690)
|
||||
- feat: add verify extension to community catalog (#1726)
|
||||
- Add Retrospective Extension to community catalog README table (#1741)
|
||||
- fix(scripts): add empty description validation and branch checkout error handling (#1559)
|
||||
- fix: correct Copilot extension command registration (#1724)
|
||||
- fix(implement): remove Makefile from C ignore patterns (#1558)
|
||||
- Add sync extension to community catalog (#1728)
|
||||
- fix(checklist): clarify file handling behavior for append vs create (#1556)
|
||||
- fix(clarify): correct conflicting question limit from 10 to 5 (#1557)
|
||||
- chore: bump version to 0.1.12 (#1737)
|
||||
- fix: use RELEASE_PAT so tag push triggers release workflow (#1736)
|
||||
- fix: release-trigger uses release branch + PR instead of direct push to main (#1733)
|
||||
- fix: Split release process to sync pyproject.toml version with git tags (#1732)
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
|
||||
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
||||
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
||||
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
||||
|
||||
@@ -158,10 +158,6 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
|
||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||
|
||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
||||
|
||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
|
||||
| Agent | Support | Notes |
|
||||
@@ -188,7 +184,6 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
||||
| [Trae](https://www.trae.ai/) | ✅ | |
|
||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||
|
||||
## 🔧 Specify CLI Reference
|
||||
|
||||
@@ -72,17 +72,11 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|
||||
| Extension | Purpose | URL |
|
||||
|-----------|---------|-----|
|
||||
| Archive Extension | Archive merged features into main project memory. | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Cognitive Squad | 19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies. | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
|
||||
@@ -359,15 +359,12 @@ specify extension add jira
|
||||
"installed_at": "2026-01-28T14:30:00Z",
|
||||
"source": "catalog",
|
||||
"manifest_hash": "sha256:abc123...",
|
||||
"enabled": true,
|
||||
"priority": 10
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.
|
||||
|
||||
### 3. Configuration
|
||||
|
||||
```bash
|
||||
@@ -1087,15 +1084,11 @@ List installed extensions in current project.
|
||||
$ specify extension list
|
||||
|
||||
Installed Extensions:
|
||||
✓ Jira Integration (v1.0.0)
|
||||
jira
|
||||
Create Jira issues from spec-kit artifacts
|
||||
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
|
||||
✓ jira (v1.0.0) - Jira Integration
|
||||
Commands: 3 | Hooks: 2 | Status: Enabled
|
||||
|
||||
✓ Linear Integration (v0.9.0)
|
||||
linear
|
||||
Create Linear issues from spec-kit artifacts
|
||||
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
|
||||
✓ linear (v0.9.0) - Linear Integration
|
||||
Commands: 1 | Hooks: 1 | Status: Enabled
|
||||
```
|
||||
|
||||
**Options:**
|
||||
@@ -1203,9 +1196,10 @@ Next steps:
|
||||
|
||||
**Options:**
|
||||
|
||||
- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.
|
||||
- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).
|
||||
- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)
|
||||
- `--from URL`: Install from custom URL or Git repo
|
||||
- `--version VERSION`: Install specific version
|
||||
- `--dev PATH`: Install from local path (development mode)
|
||||
- `--no-register`: Skip command registration (manual setup)
|
||||
|
||||
#### `specify extension remove NAME`
|
||||
|
||||
@@ -1286,29 +1280,6 @@ $ specify extension disable jira
|
||||
To re-enable: specify extension enable jira
|
||||
```
|
||||
|
||||
#### `specify extension set-priority NAME PRIORITY`
|
||||
|
||||
Change the resolution priority of an installed extension.
|
||||
|
||||
```bash
|
||||
$ specify extension set-priority jira 5
|
||||
|
||||
✓ Extension 'Jira Integration' priority changed: 10 → 5
|
||||
|
||||
Lower priority = higher precedence in template resolution
|
||||
```
|
||||
|
||||
**Priority Values:**
|
||||
|
||||
- Lower numbers = higher precedence (checked first in resolution)
|
||||
- Default priority is 10
|
||||
- Must be a positive integer (1 or higher)
|
||||
|
||||
**Use Cases:**
|
||||
|
||||
- Ensure a critical extension's templates take precedence
|
||||
- Override default resolution order when multiple extensions provide similar templates
|
||||
|
||||
---
|
||||
|
||||
## Compatibility & Versioning
|
||||
|
||||
@@ -1,39 +1,8 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
"id": "archive",
|
||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"homepage": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
|
||||
"changelog": "https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"archive",
|
||||
"memory",
|
||||
"merge",
|
||||
"changelog"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
"id": "azure-devops",
|
||||
@@ -105,123 +74,6 @@
|
||||
"created_at": "2026-02-22T00:00:00Z",
|
||||
"updated_at": "2026-02-22T00:00:00Z"
|
||||
},
|
||||
"cognitive-squad": {
|
||||
"name": "Cognitive Squad",
|
||||
"id": "cognitive-squad",
|
||||
"description": "19-function cognitive agent squad for autonomous pre-code analysis — 7 core agents, 7 specialists, 4 learning functions with feedback loop",
|
||||
"author": "Testimonial",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/Testimonial/cognitive-squad/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/Testimonial/cognitive-squad",
|
||||
"homepage": "https://github.com/Testimonial/cognitive-squad",
|
||||
"documentation": "https://github.com/Testimonial/cognitive-squad/blob/main/README.md",
|
||||
"changelog": "https://github.com/Testimonial/cognitive-squad/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.3.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "understanding",
|
||||
"version": ">=3.4.0",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "spec-kit-reverse-eng",
|
||||
"version": ">=1.0.0",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"ai-agents",
|
||||
"cognitive",
|
||||
"pre-code",
|
||||
"analysis",
|
||||
"multi-agent"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
},
|
||||
"docguard": {
|
||||
"name": "DocGuard \u2014 CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero dependencies.",
|
||||
"author": "raccioly",
|
||||
"version": "0.9.8",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.9.8/spec-kit-docguard-v0.9.8.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
"changelog": "https://github.com/raccioly/docguard/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "node",
|
||||
"version": ">=18.0.0",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"documentation",
|
||||
"validation",
|
||||
"quality",
|
||||
"cdd",
|
||||
"traceability",
|
||||
"ai-agents",
|
||||
"enforcement",
|
||||
"spec-kit"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-15T20:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
"id": "doctor",
|
||||
"description": "Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git.",
|
||||
"author": "KhawarHabibKhan",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-doctor/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/KhawarHabibKhan/spec-kit-doctor",
|
||||
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-doctor",
|
||||
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/README.md",
|
||||
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-doctor/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"diagnostics",
|
||||
"health-check",
|
||||
"validation",
|
||||
"project-structure"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-13T00:00:00Z"
|
||||
},
|
||||
"fleet": {
|
||||
"name": "Fleet Orchestrator",
|
||||
"id": "fleet",
|
||||
@@ -241,12 +93,7 @@
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"orchestration",
|
||||
"workflow",
|
||||
"human-in-the-loop",
|
||||
"parallel"
|
||||
],
|
||||
"tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
@@ -313,49 +160,13 @@
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"implementation",
|
||||
"automation",
|
||||
"loop",
|
||||
"copilot"
|
||||
],
|
||||
"tags": ["implementation", "automation", "loop", "copilot"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z"
|
||||
},
|
||||
"reconcile": {
|
||||
"name": "Reconcile Extension",
|
||||
"id": "reconcile",
|
||||
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
|
||||
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
|
||||
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
|
||||
"changelog": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"reconcile",
|
||||
"drift",
|
||||
"tasks",
|
||||
"remediation"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"retrospective": {
|
||||
"name": "Retrospective Extension",
|
||||
"id": "retrospective",
|
||||
@@ -407,15 +218,7 @@
|
||||
"commands": 7,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"code-review",
|
||||
"quality",
|
||||
"review",
|
||||
"testing",
|
||||
"error-handling",
|
||||
"type-design",
|
||||
"simplification"
|
||||
],
|
||||
"tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
@@ -457,7 +260,7 @@
|
||||
"understanding": {
|
||||
"name": "Understanding",
|
||||
"id": "understanding",
|
||||
"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.",
|
||||
"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.",
|
||||
"author": "Ladislav Bihari",
|
||||
"version": "3.4.0",
|
||||
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
||||
@@ -495,38 +298,6 @@
|
||||
"created_at": "2026-03-07T00:00:00Z",
|
||||
"updated_at": "2026-03-07T00:00:00Z"
|
||||
},
|
||||
"status": {
|
||||
"name": "Project Status",
|
||||
"id": "status",
|
||||
"description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.",
|
||||
"author": "KhawarHabibKhan",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
||||
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
||||
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
|
||||
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"status",
|
||||
"workflow",
|
||||
"progress",
|
||||
"feature-tracking",
|
||||
"task-progress"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"updated_at": "2026-03-16T00:00:00Z"
|
||||
},
|
||||
"v-model": {
|
||||
"name": "V-Model Extension Pack",
|
||||
"id": "v-model",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.2.1"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -14,7 +14,6 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"packaging>=23.0",
|
||||
"pathspec>=0.12.0",
|
||||
"json5>=0.13.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -79,28 +79,15 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get feature paths and validate branch
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
eval $(get_feature_paths)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
|
||||
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||
if $PATHS_ONLY; then
|
||||
if $JSON_MODE; then
|
||||
# Minimal JSON paths payload (no validation performed)
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg repo_root "$REPO_ROOT" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg tasks "$TASKS" \
|
||||
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
|
||||
else
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
|
||||
fi
|
||||
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||
"$REPO_ROOT" "$CURRENT_BRANCH" "$FEATURE_DIR" "$FEATURE_SPEC" "$IMPL_PLAN" "$TASKS"
|
||||
else
|
||||
echo "REPO_ROOT: $REPO_ROOT"
|
||||
echo "BRANCH: $CURRENT_BRANCH"
|
||||
@@ -154,25 +141,14 @@ fi
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
if has_jq; then
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
else
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
json_docs=$(printf '"%s",' "${docs[@]}")
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$FEATURE_DIR" "$json_docs"
|
||||
else
|
||||
# Text output
|
||||
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||
|
||||
@@ -120,7 +120,7 @@ find_feature_dir_by_prefix() {
|
||||
# Multiple matches - this shouldn't happen with proper naming convention
|
||||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
||||
return 1
|
||||
echo "$specs_dir/$branch_name" # Return something to avoid breaking the script
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -134,46 +134,21 @@ get_feature_paths() {
|
||||
fi
|
||||
|
||||
# Use prefix-based lookup to support multiple branches per spec
|
||||
local feature_dir
|
||||
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||
echo "ERROR: Failed to resolve feature directory" >&2
|
||||
return 1
|
||||
fi
|
||||
local feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch")
|
||||
|
||||
# Use printf '%q' to safely quote values, preventing shell injection
|
||||
# via crafted branch names or paths containing special characters
|
||||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||||
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||||
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||||
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||||
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||||
}
|
||||
|
||||
# Check if jq is available for safe JSON construction
|
||||
has_jq() {
|
||||
command -v jq >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\b'/\\b}"
|
||||
s="${s//$'\f'/\\f}"
|
||||
# Strip remaining control characters (U+0000–U+001F) not individually escaped above
|
||||
s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037')
|
||||
printf '%s' "$s"
|
||||
cat <<EOF
|
||||
REPO_ROOT='$repo_root'
|
||||
CURRENT_BRANCH='$current_branch'
|
||||
HAS_GIT='$has_git_repo'
|
||||
FEATURE_DIR='$feature_dir'
|
||||
FEATURE_SPEC='$feature_dir/spec.md'
|
||||
IMPL_PLAN='$feature_dir/plan.md'
|
||||
TASKS='$feature_dir/tasks.md'
|
||||
RESEARCH='$feature_dir/research.md'
|
||||
DATA_MODEL='$feature_dir/data-model.md'
|
||||
QUICKSTART='$feature_dir/quickstart.md'
|
||||
CONTRACTS_DIR='$feature_dir/contracts'
|
||||
EOF
|
||||
}
|
||||
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
@@ -198,11 +173,9 @@ resolve_template() {
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||
local sorted_presets=""
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence)
|
||||
local sorted_presets
|
||||
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
@@ -212,17 +185,14 @@ try:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
# python3 succeeded and returned preset IDs — search in priority order
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
# python3 succeeded but registry has no presets — nothing to search
|
||||
" 2>/dev/null)
|
||||
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_presets"
|
||||
else
|
||||
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||
# python3 returned empty list — fall through to directory scan
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
@@ -255,9 +225,8 @@ except Exception:
|
||||
local core="$base/${template_name}.md"
|
||||
[ -f "$core" ] && echo "$core" && return 0
|
||||
|
||||
# Template not found in any location.
|
||||
# Return 1 so callers can distinguish "not found" from "found".
|
||||
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||
return 1
|
||||
# Return success with empty output so callers using set -e don't abort;
|
||||
# callers check [ -n "$TEMPLATE" ] to detect "not found".
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
|
||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
git fetch --all --prune 2>/dev/null || true
|
||||
|
||||
# Get highest number from ALL branches (not just matching short name)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
@@ -297,31 +297,18 @@ fi
|
||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||
cp "$TEMPLATE" "$SPEC_FILE"
|
||||
else
|
||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||
touch "$SPEC_FILE"
|
||||
fi
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||
|
||||
# Inform the user how to persist the feature variable in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
||||
export SPECIFY_FEATURE="$BRANCH_NAME"
|
||||
|
||||
if $JSON_MODE; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg spec_file "$SPEC_FILE" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||
fi
|
||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$BRANCH_NAME" "$SPEC_FILE" "$FEATURE_NUM"
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
echo "SPECIFY_FEATURE environment variable set to: $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
@@ -28,9 +28,7 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
eval $(get_feature_paths)
|
||||
|
||||
# Check if we're on a proper feature branch (only for git repos)
|
||||
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
@@ -39,7 +37,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
# Copy plan template if it exists
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
@@ -51,18 +49,8 @@ fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg feature_spec "$FEATURE_SPEC" \
|
||||
--arg impl_plan "$IMPL_PLAN" \
|
||||
--arg specs_dir "$FEATURE_DIR" \
|
||||
--arg branch "$CURRENT_BRANCH" \
|
||||
--arg has_git "$HAS_GIT" \
|
||||
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||
else
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||
fi
|
||||
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||
"$FEATURE_SPEC" "$IMPL_PLAN" "$FEATURE_DIR" "$CURRENT_BRANCH" "$HAS_GIT"
|
||||
else
|
||||
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
# - Creates default Claude file if no agent files exist
|
||||
#
|
||||
# Usage: ./update-agent-context.sh [agent_type]
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic
|
||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic
|
||||
# Leave empty to update all existing agent files
|
||||
|
||||
set -e
|
||||
@@ -53,9 +53,7 @@ SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||
eval "$_paths_output"
|
||||
unset _paths_output
|
||||
eval $(get_feature_paths)
|
||||
|
||||
NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code
|
||||
AGENT_TYPE="${1:-}"
|
||||
@@ -73,17 +71,14 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||
# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid
|
||||
# updating the same file multiple times.
|
||||
AMP_FILE="$AGENTS_FILE"
|
||||
AMP_FILE="$REPO_ROOT/AGENTS.md"
|
||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||
TABNINE_FILE="$REPO_ROOT/TABNINE.md"
|
||||
KIRO_FILE="$AGENTS_FILE"
|
||||
KIRO_FILE="$REPO_ROOT/AGENTS.md"
|
||||
AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
||||
BOB_FILE="$AGENTS_FILE"
|
||||
BOB_FILE="$REPO_ROOT/AGENTS.md"
|
||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
|
||||
|
||||
# Template file
|
||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||
@@ -117,8 +112,6 @@ log_warning() {
|
||||
# Cleanup function for temporary files
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
# Disarm traps to prevent re-entrant loop
|
||||
trap - EXIT INT TERM
|
||||
rm -f /tmp/agent_update_*_$$
|
||||
rm -f /tmp/manual_additions_$$
|
||||
exit $exit_code
|
||||
@@ -483,7 +476,7 @@ update_existing_agent_file() {
|
||||
fi
|
||||
|
||||
# Update timestamp
|
||||
if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
|
||||
if [[ "$line" =~ \*\*Last\ updated\*\*:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then
|
||||
echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file"
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
@@ -614,144 +607,182 @@ update_specific_agent() {
|
||||
|
||||
case "$agent_type" in
|
||||
claude)
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
;;
|
||||
gemini)
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
;;
|
||||
copilot)
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
;;
|
||||
cursor-agent)
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
;;
|
||||
qwen)
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code" || return 1
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
;;
|
||||
opencode)
|
||||
update_agent_file "$AGENTS_FILE" "opencode" || return 1
|
||||
update_agent_file "$AGENTS_FILE" "opencode"
|
||||
;;
|
||||
codex)
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1
|
||||
update_agent_file "$AGENTS_FILE" "Codex CLI"
|
||||
;;
|
||||
windsurf)
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
;;
|
||||
kilocode)
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
;;
|
||||
auggie)
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
;;
|
||||
roo)
|
||||
update_agent_file "$ROO_FILE" "Roo Code" || return 1
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
;;
|
||||
codebuddy)
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
;;
|
||||
qodercli)
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI" || return 1
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
;;
|
||||
amp)
|
||||
update_agent_file "$AMP_FILE" "Amp" || return 1
|
||||
update_agent_file "$AMP_FILE" "Amp"
|
||||
;;
|
||||
shai)
|
||||
update_agent_file "$SHAI_FILE" "SHAI" || return 1
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
;;
|
||||
tabnine)
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI"
|
||||
;;
|
||||
kiro-cli)
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
;;
|
||||
agy)
|
||||
update_agent_file "$AGY_FILE" "Antigravity" || return 1
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
;;
|
||||
bob)
|
||||
update_agent_file "$BOB_FILE" "IBM Bob" || return 1
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
;;
|
||||
vibe)
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe"
|
||||
;;
|
||||
kimi)
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
||||
;;
|
||||
trae)
|
||||
update_agent_file "$TRAE_FILE" "Trae" || return 1
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code"
|
||||
;;
|
||||
generic)
|
||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown agent type '$agent_type'"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic"
|
||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Helper: skip non-existent files and files already updated (dedup by
|
||||
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
||||
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
||||
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
||||
# Note: defined at top level because bash 3.2 does not support true
|
||||
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
|
||||
# initialised exclusively inside update_all_existing_agents so that
|
||||
# sourcing this script has no side effects on the caller's environment.
|
||||
|
||||
_update_if_new() {
|
||||
local file="$1" name="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
local real_path
|
||||
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
||||
local p
|
||||
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
||||
for p in "${_updated_paths[@]}"; do
|
||||
[[ "$p" == "$real_path" ]] && return 0
|
||||
done
|
||||
fi
|
||||
# Record the file as seen before attempting the update so that:
|
||||
# (a) aliases pointing to the same path are not retried on failure
|
||||
# (b) _found_agent reflects file existence, not update success
|
||||
_updated_paths+=("$real_path")
|
||||
_found_agent=true
|
||||
update_agent_file "$file" "$name"
|
||||
}
|
||||
|
||||
update_all_existing_agents() {
|
||||
_found_agent=false
|
||||
_updated_paths=()
|
||||
local _all_ok=true
|
||||
|
||||
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
|
||||
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
|
||||
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
|
||||
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
|
||||
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
|
||||
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
|
||||
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
|
||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
||||
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
||||
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
||||
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
||||
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
|
||||
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
|
||||
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
|
||||
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
|
||||
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
|
||||
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
|
||||
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
|
||||
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$_found_agent" == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
||||
local found_agent=false
|
||||
|
||||
# Check each possible agent file and update if it exists
|
||||
if [[ -f "$CLAUDE_FILE" ]]; then
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$GEMINI_FILE" ]]; then
|
||||
update_agent_file "$GEMINI_FILE" "Gemini CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$COPILOT_FILE" ]]; then
|
||||
update_agent_file "$COPILOT_FILE" "GitHub Copilot"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CURSOR_FILE" ]]; then
|
||||
update_agent_file "$CURSOR_FILE" "Cursor IDE"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QWEN_FILE" ]]; then
|
||||
update_agent_file "$QWEN_FILE" "Qwen Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGENTS_FILE" ]]; then
|
||||
update_agent_file "$AGENTS_FILE" "Codex/opencode"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$WINDSURF_FILE" ]]; then
|
||||
update_agent_file "$WINDSURF_FILE" "Windsurf"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KILOCODE_FILE" ]]; then
|
||||
update_agent_file "$KILOCODE_FILE" "Kilo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
[[ "$_all_ok" == true ]]
|
||||
if [[ -f "$AUGGIE_FILE" ]]; then
|
||||
update_agent_file "$AUGGIE_FILE" "Auggie CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$ROO_FILE" ]]; then
|
||||
update_agent_file "$ROO_FILE" "Roo Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$CODEBUDDY_FILE" ]]; then
|
||||
update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$SHAI_FILE" ]]; then
|
||||
update_agent_file "$SHAI_FILE" "SHAI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$TABNINE_FILE" ]]; then
|
||||
update_agent_file "$TABNINE_FILE" "Tabnine CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$QODER_FILE" ]]; then
|
||||
update_agent_file "$QODER_FILE" "Qoder CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KIRO_FILE" ]]; then
|
||||
update_agent_file "$KIRO_FILE" "Kiro CLI"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$AGY_FILE" ]]; then
|
||||
update_agent_file "$AGY_FILE" "Antigravity"
|
||||
found_agent=true
|
||||
fi
|
||||
if [[ -f "$BOB_FILE" ]]; then
|
||||
update_agent_file "$BOB_FILE" "IBM Bob"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$VIBE_FILE" ]]; then
|
||||
update_agent_file "$VIBE_FILE" "Mistral Vibe"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
if [[ -f "$KIMI_FILE" ]]; then
|
||||
update_agent_file "$KIMI_FILE" "Kimi Code"
|
||||
found_agent=true
|
||||
fi
|
||||
|
||||
# If no agent files exist, create a default Claude file
|
||||
if [[ "$found_agent" == false ]]; then
|
||||
log_info "No existing agent files found, creating default Claude file..."
|
||||
update_agent_file "$CLAUDE_FILE" "Claude Code"
|
||||
fi
|
||||
}
|
||||
print_summary() {
|
||||
echo
|
||||
@@ -770,7 +801,7 @@ print_summary() {
|
||||
fi
|
||||
|
||||
echo
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic]"
|
||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]"
|
||||
}
|
||||
|
||||
#==============================================================================
|
||||
|
||||
@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
|
||||
2. Plan Data Extraction
|
||||
3. Agent File Management (create from template or update existing)
|
||||
4. Content Generation (technology stack, recent changes, timestamp)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, generic)
|
||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, generic)
|
||||
|
||||
.PARAMETER AgentType
|
||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
||||
#>
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','generic')]
|
||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')]
|
||||
[string]$AgentType
|
||||
)
|
||||
|
||||
@@ -64,7 +64,6 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
||||
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
||||
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
|
||||
|
||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||
|
||||
@@ -332,7 +331,7 @@ function Update-ExistingAgentFile {
|
||||
if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ }
|
||||
continue
|
||||
}
|
||||
if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') {
|
||||
if ($line -match '\*\*Last updated\*\*: .*\d{4}-\d{2}-\d{2}') {
|
||||
$output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd')))
|
||||
continue
|
||||
}
|
||||
@@ -409,9 +408,8 @@ function Update-SpecificAgent {
|
||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
||||
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
|
||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|generic'; return $false }
|
||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +435,6 @@ function Update-AllExistingAgents {
|
||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
|
||||
if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
|
||||
if (-not $found) {
|
||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
# "platformdirs",
|
||||
# "readchar",
|
||||
# "httpx",
|
||||
# "json5",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
@@ -33,8 +32,6 @@ import tempfile
|
||||
import shutil
|
||||
import shlex
|
||||
import json
|
||||
import json5
|
||||
import stat
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Tuple
|
||||
@@ -275,13 +272,6 @@ AGENT_CONFIG = {
|
||||
"install_url": "https://code.kimi.com/",
|
||||
"requires_cli": True,
|
||||
},
|
||||
"trae": {
|
||||
"name": "Trae",
|
||||
"folder": ".trae/",
|
||||
"commands_subdir": "rules", # Trae uses .trae/rules/ for project rules
|
||||
"install_url": None, # IDE-based
|
||||
"requires_cli": False,
|
||||
},
|
||||
"generic": {
|
||||
"name": "Generic (bring your own agent)",
|
||||
"folder": None, # Set dynamically via --ai-commands-dir
|
||||
@@ -664,82 +654,37 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||
"""Handle merging or copying of .vscode/settings.json files.
|
||||
|
||||
Note: when merge produces changes, rewritten output is normalized JSON and
|
||||
existing JSONC comments/trailing commas are not preserved.
|
||||
"""
|
||||
"""Handle merging or copying of .vscode/settings.json files."""
|
||||
def log(message, color="green"):
|
||||
if verbose and not tracker:
|
||||
console.print(f"[{color}]{message}[/] {rel_path}")
|
||||
|
||||
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
|
||||
"""Atomically write JSON while preserving existing mode bits when possible."""
|
||||
temp_path: Optional[Path] = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
encoding='utf-8',
|
||||
dir=target_file.parent,
|
||||
prefix=f"{target_file.name}.",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as f:
|
||||
temp_path = Path(f.name)
|
||||
json.dump(payload, f, indent=4)
|
||||
f.write('\n')
|
||||
|
||||
if target_file.exists():
|
||||
try:
|
||||
existing_stat = target_file.stat()
|
||||
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
|
||||
if hasattr(os, "chown"):
|
||||
try:
|
||||
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
|
||||
except PermissionError:
|
||||
# Best-effort owner/group preservation without requiring elevated privileges.
|
||||
pass
|
||||
except OSError:
|
||||
# Best-effort metadata preservation; data safety is prioritized.
|
||||
pass
|
||||
|
||||
os.replace(temp_path, target_file)
|
||||
except Exception:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(sub_item, 'r', encoding='utf-8') as f:
|
||||
# json5 natively supports comments and trailing commas (JSONC)
|
||||
new_settings = json5.load(f)
|
||||
new_settings = json.load(f)
|
||||
|
||||
if dest_file.exists():
|
||||
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
||||
if merged is not None:
|
||||
atomic_write_json(dest_file, merged)
|
||||
log("Merged:", "green")
|
||||
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
|
||||
else:
|
||||
log("Skipped merge (preserved existing settings)", "yellow")
|
||||
with open(dest_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(merged, f, indent=4)
|
||||
f.write('\n')
|
||||
log("Merged:", "green")
|
||||
else:
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
log("Copied (no existing settings.json):", "blue")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
|
||||
|
||||
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
|
||||
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
|
||||
"""Merge new JSON content into existing JSON file.
|
||||
|
||||
Performs a polite deep merge where:
|
||||
Performs a deep merge where:
|
||||
- New keys are added
|
||||
- Existing keys are preserved (not overwritten) unless both values are dictionaries
|
||||
- Nested dictionaries are merged recursively only when both sides are dictionaries
|
||||
- Lists and other values are preserved from base if they exist
|
||||
- Existing keys are preserved unless overwritten by new content
|
||||
- Nested dictionaries are merged recursively
|
||||
- Lists and other values are replaced (not merged)
|
||||
|
||||
Args:
|
||||
existing_path: Path to existing JSON file
|
||||
@@ -747,64 +692,28 @@ def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = Fals
|
||||
verbose: Whether to print merge details
|
||||
|
||||
Returns:
|
||||
Merged JSON content as dict, or None if the existing file should be left untouched.
|
||||
Merged JSON content as dict
|
||||
"""
|
||||
# Load existing content first to have a safe fallback
|
||||
existing_content = None
|
||||
exists = existing_path.exists()
|
||||
|
||||
if exists:
|
||||
try:
|
||||
with open(existing_path, 'r', encoding='utf-8') as f:
|
||||
# Handle comments (JSONC) natively with json5
|
||||
# Note: json5 handles BOM automatically
|
||||
existing_content = json5.load(f)
|
||||
except FileNotFoundError:
|
||||
# Handle race condition where file is deleted after exists() check
|
||||
exists = False
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
|
||||
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
|
||||
return None
|
||||
|
||||
# Validate template content
|
||||
if not isinstance(new_content, dict):
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
|
||||
return None
|
||||
|
||||
if not exists:
|
||||
try:
|
||||
with open(existing_path, 'r', encoding='utf-8') as f:
|
||||
existing_content = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
# If file doesn't exist or is invalid, just use new content
|
||||
return new_content
|
||||
|
||||
# If existing content parsed but is not a dict, skip merge to avoid data loss
|
||||
if not isinstance(existing_content, dict):
|
||||
if verbose:
|
||||
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
|
||||
return None
|
||||
|
||||
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge update dict into base dict, preserving base values."""
|
||||
def deep_merge(base: dict, update: dict) -> dict:
|
||||
"""Recursively merge update dict into base dict."""
|
||||
result = base.copy()
|
||||
for key, value in update.items():
|
||||
if key not in result:
|
||||
# Add new key
|
||||
result[key] = value
|
||||
elif isinstance(result[key], dict) and isinstance(value, dict):
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
# Recursively merge nested dictionaries
|
||||
result[key] = deep_merge_polite(result[key], value)
|
||||
result[key] = deep_merge(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
|
||||
# Add new key or replace existing value
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
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
|
||||
merged = deep_merge(existing_content, new_content)
|
||||
|
||||
if verbose:
|
||||
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
||||
@@ -1256,12 +1165,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
else:
|
||||
templates_dir = project_path / commands_subdir
|
||||
|
||||
# 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)):
|
||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||
# Fallback: try the repo-relative path (for running from source checkout)
|
||||
# This also covers agents whose extracted commands are in a different
|
||||
# format (e.g. gemini/tabnine use .toml, not .md).
|
||||
@@ -1269,16 +1173,15 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
fallback_dir = script_dir / "templates" / "commands"
|
||||
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
||||
templates_dir = fallback_dir
|
||||
template_glob = "*.md"
|
||||
|
||||
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
|
||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||
if tracker:
|
||||
tracker.error("ai-skills", "command templates not found")
|
||||
else:
|
||||
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
||||
return False
|
||||
|
||||
command_files = sorted(templates_dir.glob(template_glob))
|
||||
command_files = sorted(templates_dir.glob("*.md"))
|
||||
if not command_files:
|
||||
if tracker:
|
||||
tracker.skip("ai-skills", "no command templates found")
|
||||
@@ -1317,14 +1220,11 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
body = content
|
||||
|
||||
command_name = command_file.stem
|
||||
# Normalize: extracted commands may be named "speckit.<cmd>.md"
|
||||
# or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
|
||||
# any trailing ".agent" suffix so skill names stay clean and
|
||||
# Normalize: extracted commands may be named "speckit.<cmd>.md";
|
||||
# strip the "speckit." prefix so skill names stay clean and
|
||||
# SKILL_DESCRIPTIONS lookups work.
|
||||
if command_name.startswith("speckit."):
|
||||
command_name = command_name[len("speckit."):]
|
||||
if command_name.endswith(".agent"):
|
||||
command_name = command_name[:-len(".agent")]
|
||||
# Kimi CLI discovers skills by directory name and invokes them as
|
||||
# /skill:<name> — use dot separator to match packaging convention.
|
||||
if selected_ai == "kimi":
|
||||
@@ -1349,8 +1249,6 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
||||
source_name = command_file.name
|
||||
if source_name.startswith("speckit."):
|
||||
source_name = source_name[len("speckit."):]
|
||||
if source_name.endswith(".agent.md"):
|
||||
source_name = source_name[:-len(".agent.md")] + ".md"
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
@@ -2102,11 +2000,6 @@ def preset_add(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
@@ -2284,7 +2177,6 @@ def preset_info(
|
||||
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||
):
|
||||
"""Show detailed information about a preset."""
|
||||
from .extensions import normalize_priority
|
||||
from .presets import PresetCatalog, PresetManager, PresetError
|
||||
|
||||
project_root = Path.cwd()
|
||||
@@ -2318,10 +2210,6 @@ def preset_info(
|
||||
if license_val:
|
||||
console.print(f" License: {license_val}")
|
||||
console.print("\n [green]Status: installed[/green]")
|
||||
# Get priority from registry
|
||||
pack_metadata = manager.registry.get(pack_id)
|
||||
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
|
||||
console.print(f" [dim]Priority:[/dim] {priority}")
|
||||
console.print()
|
||||
return
|
||||
|
||||
@@ -2353,58 +2241,6 @@ def preset_info(
|
||||
console.print()
|
||||
|
||||
|
||||
@preset_app.command("set-priority")
|
||||
def preset_set_priority(
|
||||
pack_id: str = typer.Argument(help="Preset ID"),
|
||||
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
||||
):
|
||||
"""Set the resolution priority of an installed preset."""
|
||||
from .presets import PresetManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
# Check if we're in a spec-kit project
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = PresetManager(project_root)
|
||||
|
||||
# Check if preset is installed
|
||||
if not manager.registry.is_installed(pack_id):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get current metadata
|
||||
metadata = manager.registry.get(pack_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from .extensions import normalize_priority
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
old_priority = normalize_priority(raw_priority)
|
||||
|
||||
# Update priority
|
||||
manager.registry.update(pack_id, {"priority": priority})
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}")
|
||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
||||
|
||||
|
||||
# ===== Preset Catalog Commands =====
|
||||
|
||||
|
||||
@@ -2740,9 +2576,8 @@ def extension_list(
|
||||
status_color = "green" if ext["enabled"] else "red"
|
||||
|
||||
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
||||
console.print(f" [dim]{ext['id']}[/dim]")
|
||||
console.print(f" {ext['description']}")
|
||||
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
||||
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
||||
console.print()
|
||||
|
||||
if available or all_extensions:
|
||||
@@ -2930,7 +2765,6 @@ def extension_add(
|
||||
extension: str = typer.Argument(help="Extension name or path"),
|
||||
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
||||
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
"""Install an extension."""
|
||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
||||
@@ -2944,11 +2778,6 @@ def extension_add(
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = ExtensionManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
@@ -2965,7 +2794,7 @@ def extension_add(
|
||||
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
|
||||
manifest = manager.install_from_directory(source_path, speckit_version)
|
||||
|
||||
elif from_url:
|
||||
# Install from URL (ZIP file)
|
||||
@@ -2998,7 +2827,7 @@ def extension_add(
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -3042,7 +2871,7 @@ def extension_add(
|
||||
|
||||
try:
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
@@ -3218,7 +3047,7 @@ def extension_info(
|
||||
extension: str = typer.Argument(help="Extension ID or name"),
|
||||
):
|
||||
"""Show detailed information about an extension."""
|
||||
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
|
||||
from .extensions import ExtensionCatalog, ExtensionManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
@@ -3255,15 +3084,8 @@ def extension_info(
|
||||
# Get local manifest info
|
||||
ext_manifest = manager.get_extension(resolved_installed_id)
|
||||
metadata = manager.registry.get(resolved_installed_id)
|
||||
metadata_is_dict = isinstance(metadata, dict)
|
||||
if not metadata_is_dict:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
|
||||
"some information may be unavailable."
|
||||
)
|
||||
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
|
||||
|
||||
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
|
||||
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
|
||||
console.print(f"ID: {resolved_installed_id}")
|
||||
console.print()
|
||||
|
||||
@@ -3291,8 +3113,6 @@ def extension_info(
|
||||
|
||||
console.print()
|
||||
console.print("[green]✓ Installed[/green]")
|
||||
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
|
||||
console.print(f"[dim]Priority:[/dim] {priority}")
|
||||
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
||||
return
|
||||
|
||||
@@ -3308,8 +3128,6 @@ def extension_info(
|
||||
|
||||
def _print_extension_info(ext_info: dict, manager):
|
||||
"""Print formatted extension info from catalog data."""
|
||||
from .extensions import normalize_priority
|
||||
|
||||
# Header
|
||||
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
||||
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
||||
@@ -3388,9 +3206,6 @@ def _print_extension_info(ext_info: dict, manager):
|
||||
install_allowed = ext_info.get("_install_allowed", True)
|
||||
if is_installed:
|
||||
console.print("[green]✓ Installed[/green]")
|
||||
metadata = manager.registry.get(ext_info['id'])
|
||||
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
|
||||
console.print(f"[dim]Priority:[/dim] {priority}")
|
||||
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
||||
elif install_allowed:
|
||||
console.print("[yellow]Not installed[/yellow]")
|
||||
@@ -3417,7 +3232,6 @@ def extension_update(
|
||||
ValidationError,
|
||||
CommandRegistrar,
|
||||
HookExecutor,
|
||||
normalize_priority,
|
||||
)
|
||||
from packaging import version as pkg_version
|
||||
import shutil
|
||||
@@ -3457,7 +3271,7 @@ def extension_update(
|
||||
for ext_id in extensions_to_update:
|
||||
# Get installed version
|
||||
metadata = manager.registry.get(ext_id)
|
||||
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
|
||||
if metadata is None or "version" not in metadata:
|
||||
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
||||
continue
|
||||
try:
|
||||
@@ -3642,13 +3456,13 @@ def extension_update(
|
||||
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
||||
|
||||
# 9. Restore metadata from backup (installed_at, enabled state)
|
||||
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
||||
if backup_registry_entry:
|
||||
# Copy current registry entry to avoid mutating internal
|
||||
# registry state before explicit restore().
|
||||
current_metadata = manager.registry.get(extension_id)
|
||||
if current_metadata is None or not isinstance(current_metadata, dict):
|
||||
if current_metadata is None:
|
||||
raise RuntimeError(
|
||||
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
|
||||
f"Registry entry for '{extension_id}' missing after install — update incomplete"
|
||||
)
|
||||
new_metadata = dict(current_metadata)
|
||||
|
||||
@@ -3656,10 +3470,6 @@ def extension_update(
|
||||
if "installed_at" in backup_registry_entry:
|
||||
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
||||
|
||||
# Preserve the original priority (normalized to handle corruption)
|
||||
if "priority" in backup_registry_entry:
|
||||
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
|
||||
|
||||
# If extension was disabled before update, disable it again
|
||||
if not backup_registry_entry.get("enabled", True):
|
||||
new_metadata["enabled"] = False
|
||||
@@ -3713,7 +3523,7 @@ def extension_update(
|
||||
# (files that weren't in the original backup)
|
||||
try:
|
||||
new_registry_entry = manager.registry.get(extension_id)
|
||||
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
||||
if new_registry_entry is None:
|
||||
new_registered_commands = {}
|
||||
else:
|
||||
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||
@@ -3833,10 +3643,10 @@ def extension_enable(
|
||||
|
||||
# Update registry
|
||||
metadata = manager.registry.get(extension_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
if metadata is None:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
if metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
@@ -3881,10 +3691,10 @@ def extension_disable(
|
||||
|
||||
# Update registry
|
||||
metadata = manager.registry.get(extension_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
if metadata is None:
|
||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
if not metadata.get("enabled", True):
|
||||
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
@@ -3906,57 +3716,6 @@ def extension_disable(
|
||||
console.print(f"To re-enable: specify extension enable {extension_id}")
|
||||
|
||||
|
||||
@extension_app.command("set-priority")
|
||||
def extension_set_priority(
|
||||
extension: str = typer.Argument(help="Extension ID or name"),
|
||||
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
||||
):
|
||||
"""Set the resolution priority of an installed extension."""
|
||||
from .extensions import ExtensionManager
|
||||
|
||||
project_root = Path.cwd()
|
||||
|
||||
# Check if we're in a spec-kit project
|
||||
specify_dir = project_root / ".specify"
|
||||
if not specify_dir.exists():
|
||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||
console.print("Run this command from a spec-kit project root")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
manager = ExtensionManager(project_root)
|
||||
|
||||
# Resolve extension ID from argument (handles ambiguous names)
|
||||
installed = manager.list_installed()
|
||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
|
||||
|
||||
# Get current metadata
|
||||
metadata = manager.registry.get(extension_id)
|
||||
if metadata is None or not isinstance(metadata, dict):
|
||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from .extensions import normalize_priority
|
||||
raw_priority = metadata.get("priority")
|
||||
# Only skip if the stored value is already a valid int equal to requested priority
|
||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
old_priority = normalize_priority(raw_priority)
|
||||
|
||||
# Update priority
|
||||
manager.registry.update(extension_id, {"priority": priority})
|
||||
|
||||
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
|
||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
@@ -135,12 +135,6 @@ class CommandRegistrar:
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md"
|
||||
},
|
||||
"trae": {
|
||||
"dir": ".trae/rules",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,26 +41,6 @@ class CompatibilityError(ExtensionError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
"""Normalize a stored priority value for sorting and display.
|
||||
|
||||
Corrupted registry data may contain missing, non-numeric, or non-positive
|
||||
values. In those cases, fall back to the default priority.
|
||||
|
||||
Args:
|
||||
value: Priority value to normalize (may be int, str, None, etc.)
|
||||
default: Default priority to use for invalid values (default: 10)
|
||||
|
||||
Returns:
|
||||
Normalized priority as positive integer (>= 1)
|
||||
"""
|
||||
try:
|
||||
priority = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return priority if priority >= 1 else default
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry:
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
@@ -271,9 +251,6 @@ class ExtensionRegistry:
|
||||
raise KeyError(f"Extension '{extension_id}' is not installed")
|
||||
# Merge new metadata with existing, preserving original installed_at
|
||||
existing = self.data["extensions"][extension_id]
|
||||
# Handle corrupted registry entries (e.g., string/list instead of dict)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
# Merge: existing fields preserved, new fields override
|
||||
merged = {**existing, **metadata}
|
||||
# Always preserve original installed_at based on key existence, not truthiness,
|
||||
@@ -347,32 +324,6 @@ class ExtensionRegistry:
|
||||
"""
|
||||
return extension_id in self.data["extensions"]
|
||||
|
||||
def list_by_priority(self) -> List[tuple]:
|
||||
"""Get all installed extensions sorted by priority.
|
||||
|
||||
Lower priority number = higher precedence (checked first).
|
||||
Extensions with equal priority are sorted alphabetically by ID
|
||||
for deterministic ordering.
|
||||
|
||||
Returns:
|
||||
List of (extension_id, metadata_copy) tuples sorted by priority.
|
||||
Metadata is deep-copied to prevent accidental mutation.
|
||||
"""
|
||||
extensions = self.data.get("extensions", {}) or {}
|
||||
if not isinstance(extensions, dict):
|
||||
extensions = {}
|
||||
sortable_extensions = []
|
||||
for ext_id, meta in extensions.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
metadata_copy = copy.deepcopy(meta)
|
||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||
sortable_extensions.append((ext_id, metadata_copy))
|
||||
return sorted(
|
||||
sortable_extensions,
|
||||
key=lambda item: (item[1]["priority"], item[0]),
|
||||
)
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
"""Manages extension lifecycle: installation, removal, updates."""
|
||||
@@ -489,8 +440,7 @@ class ExtensionManager:
|
||||
self,
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
register_commands: bool = True,
|
||||
priority: int = 10,
|
||||
register_commands: bool = True
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from a local directory.
|
||||
|
||||
@@ -498,19 +448,14 @@ class ExtensionManager:
|
||||
source_dir: Path to extension directory
|
||||
speckit_version: Current spec-kit version
|
||||
register_commands: If True, register commands with AI agents
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
|
||||
Raises:
|
||||
ValidationError: If manifest is invalid or priority is invalid
|
||||
ValidationError: If manifest is invalid
|
||||
CompatibilityError: If extension is incompatible
|
||||
"""
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
||||
|
||||
# Load and validate manifest
|
||||
manifest_path = source_dir / "extension.yml"
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
@@ -552,7 +497,6 @@ class ExtensionManager:
|
||||
"source": "local",
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
"priority": priority,
|
||||
"registered_commands": registered_commands
|
||||
})
|
||||
|
||||
@@ -561,27 +505,21 @@ class ExtensionManager:
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
speckit_version: str
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from ZIP file.
|
||||
|
||||
Args:
|
||||
zip_path: Path to extension ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
|
||||
Raises:
|
||||
ValidationError: If manifest is invalid or priority is invalid
|
||||
ValidationError: If manifest is invalid
|
||||
CompatibilityError: If extension is incompatible
|
||||
"""
|
||||
# Validate priority early
|
||||
if priority < 1:
|
||||
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
@@ -616,7 +554,7 @@ class ExtensionManager:
|
||||
raise ValidationError("No extension.yml found in ZIP file")
|
||||
|
||||
# Install from extracted directory
|
||||
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
|
||||
return self.install_from_directory(extension_dir, speckit_version)
|
||||
|
||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||
"""Remove an installed extension.
|
||||
@@ -694,9 +632,6 @@ class ExtensionManager:
|
||||
result = []
|
||||
|
||||
for ext_id, metadata in self.registry.list().items():
|
||||
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
manifest_path = ext_dir / "extension.yml"
|
||||
|
||||
@@ -708,7 +643,6 @@ class ExtensionManager:
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": len(manifest.commands),
|
||||
"hook_count": len(manifest.hooks)
|
||||
@@ -721,7 +655,6 @@ class ExtensionManager:
|
||||
"version": metadata.get("version", "unknown"),
|
||||
"description": "⚠️ Corrupted extension",
|
||||
"enabled": False,
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"command_count": 0,
|
||||
"hook_count": 0
|
||||
|
||||
@@ -7,7 +7,6 @@ Presets are self-contained, versioned collections of templates
|
||||
customize the Spec-Driven Development workflow.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
@@ -24,8 +23,6 @@ import yaml
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .extensions import ExtensionRegistry, normalize_priority
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresetCatalogEntry:
|
||||
@@ -274,38 +271,6 @@ class PresetRegistry:
|
||||
del self.data["presets"][pack_id]
|
||||
self._save()
|
||||
|
||||
def update(self, pack_id: str, updates: dict):
|
||||
"""Update preset metadata in registry.
|
||||
|
||||
Merges the provided updates with the existing entry, preserving any
|
||||
fields not specified. The installed_at timestamp is always preserved
|
||||
from the original entry.
|
||||
|
||||
Args:
|
||||
pack_id: Preset ID
|
||||
updates: Partial metadata to merge into existing metadata
|
||||
|
||||
Raises:
|
||||
KeyError: If preset is not installed
|
||||
"""
|
||||
if pack_id not in self.data["presets"]:
|
||||
raise KeyError(f"Preset '{pack_id}' not found in registry")
|
||||
existing = self.data["presets"][pack_id]
|
||||
# Handle corrupted registry entries (e.g., string/list instead of dict)
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
# Merge: existing fields preserved, new fields override
|
||||
merged = {**existing, **updates}
|
||||
# Always preserve original installed_at based on key existence, not truthiness,
|
||||
# to handle cases where the field exists but may be falsy (legacy/corruption)
|
||||
if "installed_at" in existing:
|
||||
merged["installed_at"] = existing["installed_at"]
|
||||
else:
|
||||
# If not present in existing, explicitly remove from merged if caller provided it
|
||||
merged.pop("installed_at", None)
|
||||
self.data["presets"][pack_id] = merged
|
||||
self._save()
|
||||
|
||||
def get(self, pack_id: str) -> Optional[dict]:
|
||||
"""Get preset metadata from registry.
|
||||
|
||||
@@ -329,26 +294,14 @@ class PresetRegistry:
|
||||
"""Get all installed presets sorted by priority.
|
||||
|
||||
Lower priority number = higher precedence (checked first).
|
||||
Presets with equal priority are sorted alphabetically by ID
|
||||
for deterministic ordering.
|
||||
|
||||
Returns:
|
||||
List of (pack_id, metadata_copy) tuples sorted by priority.
|
||||
Metadata is deep-copied to prevent accidental mutation.
|
||||
List of (pack_id, metadata) tuples sorted by priority
|
||||
"""
|
||||
packs = self.data.get("presets", {}) or {}
|
||||
if not isinstance(packs, dict):
|
||||
packs = {}
|
||||
sortable_packs = []
|
||||
for pack_id, meta in packs.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
metadata_copy = copy.deepcopy(meta)
|
||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||
sortable_packs.append((pack_id, metadata_copy))
|
||||
packs = self.data["presets"]
|
||||
return sorted(
|
||||
sortable_packs,
|
||||
key=lambda item: (item[1]["priority"], item[0]),
|
||||
packs.items(),
|
||||
key=lambda item: item[1].get("priority", 10),
|
||||
)
|
||||
|
||||
def is_installed(self, pack_id: str) -> bool:
|
||||
@@ -727,13 +680,9 @@ class PresetManager:
|
||||
Installed preset manifest
|
||||
|
||||
Raises:
|
||||
PresetValidationError: If manifest is invalid or priority is invalid
|
||||
PresetValidationError: If manifest is invalid
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
# Validate priority
|
||||
if priority < 1:
|
||||
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
||||
|
||||
manifest_path = source_dir / "preset.yml"
|
||||
manifest = PresetManifest(manifest_path)
|
||||
|
||||
@@ -780,19 +729,14 @@ class PresetManager:
|
||||
Args:
|
||||
zip_path: Path to preset ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
|
||||
Returns:
|
||||
Installed preset manifest
|
||||
|
||||
Raises:
|
||||
PresetValidationError: If manifest is invalid or priority is invalid
|
||||
PresetValidationError: If manifest is invalid
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
# Validate priority early
|
||||
if priority < 1:
|
||||
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
@@ -864,9 +808,6 @@ class PresetManager:
|
||||
result = []
|
||||
|
||||
for pack_id, metadata in self.registry.list().items():
|
||||
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
|
||||
@@ -875,13 +816,13 @@ class PresetManager:
|
||||
result.append({
|
||||
"id": pack_id,
|
||||
"name": manifest.name,
|
||||
"version": metadata.get("version", manifest.version),
|
||||
"version": metadata["version"],
|
||||
"description": manifest.description,
|
||||
"enabled": metadata.get("enabled", True),
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": len(manifest.templates),
|
||||
"tags": manifest.tags,
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"priority": metadata.get("priority", 10),
|
||||
})
|
||||
except PresetValidationError:
|
||||
result.append({
|
||||
@@ -893,7 +834,7 @@ class PresetManager:
|
||||
"installed_at": metadata.get("installed_at"),
|
||||
"template_count": 0,
|
||||
"tags": [],
|
||||
"priority": normalize_priority(metadata.get("priority")),
|
||||
"priority": metadata.get("priority", 10),
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -1452,40 +1393,6 @@ class PresetResolver:
|
||||
self.overrides_dir = self.templates_dir / "overrides"
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
Registered extensions use their stored priority; unregistered directories
|
||||
get implicit priority=10. Results are sorted by (priority, ext_id) for
|
||||
deterministic ordering.
|
||||
|
||||
Returns:
|
||||
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
|
||||
"""
|
||||
if not self.extensions_dir.exists():
|
||||
return []
|
||||
|
||||
registry = ExtensionRegistry(self.extensions_dir)
|
||||
registered_extensions = registry.list_by_priority()
|
||||
registered_extension_ids = {ext_id for ext_id, _ in registered_extensions}
|
||||
|
||||
all_extensions: list[tuple[int, str, dict | None]] = []
|
||||
|
||||
for ext_id, metadata in registered_extensions:
|
||||
priority = normalize_priority(metadata.get("priority") if metadata else None)
|
||||
all_extensions.append((priority, ext_id, metadata))
|
||||
|
||||
# Add unregistered directories with implicit priority=10
|
||||
for ext_dir in self.extensions_dir.iterdir():
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
if ext_dir.name not in registered_extension_ids:
|
||||
all_extensions.append((10, ext_dir.name, None))
|
||||
|
||||
# Sort by (priority, ext_id) for deterministic ordering
|
||||
all_extensions.sort(key=lambda x: (x[0], x[1]))
|
||||
return all_extensions
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
template_name: str,
|
||||
@@ -1538,18 +1445,18 @@ class PresetResolver:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
|
||||
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||
else:
|
||||
candidate = ext_dir / f"{template_name}{ext}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Priority 3: Extension-provided templates
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||
else:
|
||||
candidate = ext_dir / "templates" / f"{template_name}{ext}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
# Priority 4: Core templates
|
||||
if template_type == "template":
|
||||
@@ -1607,24 +1514,17 @@ class PresetResolver:
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
try:
|
||||
resolved.relative_to(ext_dir)
|
||||
if ext_meta:
|
||||
version = ext_meta.get("version", "?")
|
||||
if self.extensions_dir.exists():
|
||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
resolved.relative_to(ext_dir)
|
||||
return {
|
||||
"path": resolved_str,
|
||||
"source": f"extension:{ext_id} v{version}",
|
||||
"source": f"extension:{ext_dir.name}",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"path": resolved_str,
|
||||
"source": f"extension:{ext_id} (unregistered)",
|
||||
}
|
||||
except ValueError:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return {"path": resolved_str, "source": "core"}
|
||||
|
||||
@@ -233,79 +233,3 @@ class TestAgentConfigConsistency:
|
||||
def test_ai_help_includes_kimi(self):
|
||||
"""CLI help text for --ai should include kimi."""
|
||||
assert "kimi" in AI_ASSISTANT_HELP
|
||||
|
||||
# --- Trae IDE consistency checks ---
|
||||
|
||||
def test_trae_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
|
||||
assert "trae" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
|
||||
assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules"
|
||||
assert AGENT_CONFIG["trae"]["requires_cli"] is False
|
||||
assert AGENT_CONFIG["trae"]["install_url"] is None
|
||||
|
||||
def test_trae_in_extension_registrar(self):
|
||||
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "trae" in cfg
|
||||
trae_cfg = cfg["trae"]
|
||||
assert trae_cfg["format"] == "markdown"
|
||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||
assert trae_cfg["extension"] == ".md"
|
||||
|
||||
def test_trae_in_release_agent_lists(self):
|
||||
"""Bash and PowerShell release scripts should include trae in agent lists."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
||||
assert sh_match is not None
|
||||
sh_agents = sh_match.group(1).split()
|
||||
|
||||
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
||||
assert ps_match is not None
|
||||
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
||||
|
||||
assert "trae" in sh_agents
|
||||
assert "trae" in ps_agents
|
||||
|
||||
def test_trae_in_release_scripts_generate_commands(self):
|
||||
"""Release scripts should generate markdown commands for trae in .trae/rules."""
|
||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert ".trae/rules" in sh_text
|
||||
assert ".trae/rules" in ps_text
|
||||
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
|
||||
|
||||
def test_trae_in_github_release_output(self):
|
||||
"""GitHub release script should include trae template packages."""
|
||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert "spec-kit-template-trae-sh-" in gh_release_text
|
||||
assert "spec-kit-template-trae-ps-" in gh_release_text
|
||||
|
||||
def test_trae_in_agent_context_scripts(self):
|
||||
"""Agent context scripts should support trae agent type."""
|
||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
assert "trae" in bash_text
|
||||
assert "TRAE_FILE" in bash_text
|
||||
assert "trae" in pwsh_text
|
||||
assert "TRAE_FILE" in pwsh_text
|
||||
|
||||
def test_trae_in_powershell_validate_set(self):
|
||||
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
|
||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||
|
||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||
assert validate_set_match is not None
|
||||
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
|
||||
|
||||
assert "trae" in validate_set_values
|
||||
|
||||
def test_ai_help_includes_trae(self):
|
||||
"""CLI help text for --ai should include trae."""
|
||||
assert "trae" in AI_ASSISTANT_HELP
|
||||
|
||||
@@ -62,7 +62,7 @@ def templates_dir(project_dir):
|
||||
tpl_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Template with valid YAML frontmatter
|
||||
(tpl_root / "speckit.specify.md").write_text(
|
||||
(tpl_root / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Create or update the feature specification.\n"
|
||||
"handoffs:\n"
|
||||
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
|
||||
)
|
||||
|
||||
# Template with minimal frontmatter
|
||||
(tpl_root / "speckit.plan.md").write_text(
|
||||
(tpl_root / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Generate implementation plan.\n"
|
||||
"---\n"
|
||||
@@ -91,7 +91,7 @@ def templates_dir(project_dir):
|
||||
)
|
||||
|
||||
# Template with no frontmatter
|
||||
(tpl_root / "speckit.tasks.md").write_text(
|
||||
(tpl_root / "tasks.md").write_text(
|
||||
"# Tasks Command\n"
|
||||
"\n"
|
||||
"Body without frontmatter.\n",
|
||||
@@ -99,7 +99,7 @@ def templates_dir(project_dir):
|
||||
)
|
||||
|
||||
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
||||
(tpl_root / "speckit.empty_fm.md").write_text(
|
||||
(tpl_root / "empty_fm.md").write_text(
|
||||
"---\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
@@ -337,7 +337,7 @@ class TestInstallAiSkills:
|
||||
cmds_dir = project_dir / ".claude" / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
|
||||
(cmds_dir / "speckit.broken.md").write_text(
|
||||
(cmds_dir / "broken.md").write_text(
|
||||
"---\n"
|
||||
"description: [unclosed bracket\n"
|
||||
" invalid: yaml: content: here\n"
|
||||
@@ -430,12 +430,9 @@ class TestInstallAiSkills:
|
||||
|
||||
# Place .md templates in the agent's commands directory
|
||||
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 = proj / agent_folder.rstrip("/") / "commands"
|
||||
cmds_dir.mkdir(parents=True)
|
||||
# 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(
|
||||
(cmds_dir / "specify.md").write_text(
|
||||
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
||||
)
|
||||
|
||||
@@ -451,100 +448,7 @@ class TestInstallAiSkills:
|
||||
assert expected_skill_name in skill_dirs
|
||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||
|
||||
def test_copilot_ignores_non_speckit_agents(self, project_dir):
|
||||
"""Non-speckit markdown in .github/agents/ must not produce skills."""
|
||||
agents_dir = project_dir / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
||||
)
|
||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "copilot")
|
||||
|
||||
assert result is True
|
||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert "speckit-plan" in skill_dirs
|
||||
assert "speckit-my-custom-agent.agent" not in skill_dirs
|
||||
assert "speckit-my-custom-agent" not in skill_dirs
|
||||
|
||||
@pytest.mark.parametrize("agent_key,custom_file", [
|
||||
("claude", "review.md"),
|
||||
("cursor-agent", "deploy.md"),
|
||||
("qwen", "my-workflow.md"),
|
||||
])
|
||||
def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):
|
||||
"""User-authored command files must not produce skills for any agent."""
|
||||
proj = temp_dir / f"proj-{agent_key}"
|
||||
proj.mkdir()
|
||||
|
||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
||||
cmds_dir.mkdir(parents=True)
|
||||
(cmds_dir / "speckit.specify.md").write_text(
|
||||
"---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n"
|
||||
)
|
||||
(cmds_dir / custom_file).write_text(
|
||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(proj, agent_key)
|
||||
|
||||
assert result is True
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert "speckit-specify" in skill_dirs
|
||||
custom_stem = Path(custom_file).stem
|
||||
assert f"speckit-{custom_stem}" not in skill_dirs
|
||||
|
||||
def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):
|
||||
"""Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files."""
|
||||
agents_dir = project_dir / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Only a user-authored agent, no speckit.* templates
|
||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(project_dir, "copilot")
|
||||
|
||||
# Should succeed via fallback to templates/commands/
|
||||
assert result is True
|
||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
# Should have skills from fallback templates, not from the custom agent
|
||||
assert "speckit-plan" in skill_dirs
|
||||
assert not any("my-custom" in d for d in skill_dirs)
|
||||
|
||||
@pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"])
|
||||
def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):
|
||||
"""Fallback to templates/commands/ when agent dir has no speckit.*.md files."""
|
||||
proj = temp_dir / f"proj-{agent_key}"
|
||||
proj.mkdir()
|
||||
|
||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
||||
cmds_dir.mkdir(parents=True)
|
||||
# Only a user-authored command, no speckit.* templates
|
||||
(cmds_dir / "my-custom-command.md").write_text(
|
||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
||||
)
|
||||
|
||||
result = install_ai_skills(proj, agent_key)
|
||||
|
||||
# Should succeed via fallback to templates/commands/
|
||||
assert result is True
|
||||
skills_dir = _get_skills_dir(proj, agent_key)
|
||||
assert skills_dir.exists()
|
||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||
assert not any("my-custom" in d for d in skill_dirs)
|
||||
|
||||
class TestCommandCoexistence:
|
||||
"""Verify install_ai_skills never touches command files.
|
||||
@@ -556,16 +460,14 @@ class TestCommandCoexistence:
|
||||
|
||||
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
||||
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
||||
# Verify commands exist before (templates_dir adds 4 speckit.* files,
|
||||
# commands_dir_claude overlaps with 3 of them)
|
||||
before = list(commands_dir_claude.glob("speckit.*"))
|
||||
assert len(before) >= 3
|
||||
# Verify commands exist before
|
||||
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
|
||||
|
||||
install_ai_skills(project_dir, "claude")
|
||||
|
||||
# Commands must still be there — install_ai_skills never touches them
|
||||
remaining = list(commands_dir_claude.glob("speckit.*"))
|
||||
assert len(remaining) == len(before)
|
||||
assert len(remaining) == 3
|
||||
|
||||
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."""
|
||||
|
||||
@@ -26,7 +26,6 @@ from specify_cli.extensions import (
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
CompatibilityError,
|
||||
normalize_priority,
|
||||
version_satisfies,
|
||||
)
|
||||
|
||||
@@ -122,57 +121,6 @@ def project_dir(temp_dir):
|
||||
return proj_dir
|
||||
|
||||
|
||||
# ===== normalize_priority Tests =====
|
||||
|
||||
class TestNormalizePriority:
|
||||
"""Test normalize_priority helper function."""
|
||||
|
||||
def test_valid_integer(self):
|
||||
"""Test with valid integer priority."""
|
||||
assert normalize_priority(5) == 5
|
||||
assert normalize_priority(1) == 1
|
||||
assert normalize_priority(100) == 100
|
||||
|
||||
def test_valid_string_number(self):
|
||||
"""Test with string that can be converted to int."""
|
||||
assert normalize_priority("5") == 5
|
||||
assert normalize_priority("10") == 10
|
||||
|
||||
def test_zero_returns_default(self):
|
||||
"""Test that zero priority returns default."""
|
||||
assert normalize_priority(0) == 10
|
||||
assert normalize_priority(0, default=5) == 5
|
||||
|
||||
def test_negative_returns_default(self):
|
||||
"""Test that negative priority returns default."""
|
||||
assert normalize_priority(-1) == 10
|
||||
assert normalize_priority(-100, default=5) == 5
|
||||
|
||||
def test_none_returns_default(self):
|
||||
"""Test that None returns default."""
|
||||
assert normalize_priority(None) == 10
|
||||
assert normalize_priority(None, default=5) == 5
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
"""Test that non-numeric string returns default."""
|
||||
assert normalize_priority("invalid") == 10
|
||||
assert normalize_priority("abc", default=5) == 5
|
||||
|
||||
def test_float_truncates(self):
|
||||
"""Test that float is truncated to int."""
|
||||
assert normalize_priority(5.9) == 5
|
||||
assert normalize_priority(3.1) == 3
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
"""Test that empty string returns default."""
|
||||
assert normalize_priority("") == 10
|
||||
|
||||
def test_custom_default(self):
|
||||
"""Test custom default value."""
|
||||
assert normalize_priority(None, default=20) == 20
|
||||
assert normalize_priority("invalid", default=1) == 1
|
||||
|
||||
|
||||
# ===== ExtensionManifest Tests =====
|
||||
|
||||
class TestExtensionManifest:
|
||||
@@ -2389,404 +2337,3 @@ class TestExtensionUpdateCLI:
|
||||
|
||||
for cmd_file in command_files:
|
||||
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
|
||||
|
||||
|
||||
class TestExtensionListCLI:
|
||||
"""Test extension list CLI output format."""
|
||||
|
||||
def test_list_shows_extension_id(self, extension_dir, project_dir):
|
||||
"""extension list should display the extension ID."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install the extension using the manager
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Verify the extension ID is shown in the output
|
||||
assert "test-ext" in result.output
|
||||
# Verify name and version are also shown
|
||||
assert "Test Extension" in result.output
|
||||
assert "1.0.0" in result.output
|
||||
|
||||
|
||||
class TestExtensionPriority:
|
||||
"""Test extension priority-based resolution."""
|
||||
|
||||
def test_list_by_priority_empty(self, temp_dir):
|
||||
"""Test list_by_priority on empty registry."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_list_by_priority_single(self, temp_dir):
|
||||
"""Test list_by_priority with single extension."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "test-ext"
|
||||
assert result[0][1]["priority"] == 5
|
||||
|
||||
def test_list_by_priority_ordering(self, temp_dir):
|
||||
"""Test list_by_priority returns extensions sorted by priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
# Add in non-priority order
|
||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.add("ext-mid", {"version": "1.0.0", "priority": 10})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# Lower priority number = higher precedence (first)
|
||||
assert result[0][0] == "ext-high"
|
||||
assert result[1][0] == "ext-mid"
|
||||
assert result[2][0] == "ext-low"
|
||||
|
||||
def test_list_by_priority_default(self, temp_dir):
|
||||
"""Test list_by_priority uses default priority of 10."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
# Add without explicit priority
|
||||
registry.add("ext-default", {"version": "1.0.0"})
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# ext-high (1), ext-default (10), ext-low (20)
|
||||
assert result[0][0] == "ext-high"
|
||||
assert result[1][0] == "ext-default"
|
||||
assert result[2][0] == "ext-low"
|
||||
|
||||
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
||||
"""Malformed priority values fall back to the default priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.data["extensions"]["ext-invalid"] = {
|
||||
"version": "1.0.0",
|
||||
"priority": "high",
|
||||
}
|
||||
registry._save()
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
|
||||
assert result[1][1]["priority"] == 10
|
||||
|
||||
def test_install_with_priority(self, extension_dir, project_dir):
|
||||
"""Test that install_from_directory stores priority."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
|
||||
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 5
|
||||
|
||||
def test_install_default_priority(self, extension_dir, project_dir):
|
||||
"""Test that install_from_directory uses default priority of 10."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 10
|
||||
|
||||
def test_list_installed_includes_priority(self, extension_dir, project_dir):
|
||||
"""Test that list_installed includes priority in returned data."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=3)
|
||||
|
||||
installed = manager.list_installed()
|
||||
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["priority"] == 3
|
||||
|
||||
def test_priority_preserved_on_update(self, temp_dir):
|
||||
"""Test that registry update preserves priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0", "priority": 5, "enabled": True})
|
||||
|
||||
# Update with new metadata (no priority specified)
|
||||
registry.update("test-ext", {"enabled": False})
|
||||
|
||||
updated = registry.get("test-ext")
|
||||
assert updated["priority"] == 5 # Preserved
|
||||
assert updated["enabled"] is False # Updated
|
||||
|
||||
def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir):
|
||||
"""Resolution scans unregistered extension dirs after valid registry entries."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
|
||||
valid_dir = extensions_dir / "valid-ext" / "templates"
|
||||
valid_dir.mkdir(parents=True)
|
||||
(valid_dir / "other-template.md").write_text("# Valid\n")
|
||||
|
||||
broken_dir = extensions_dir / "broken-ext" / "templates"
|
||||
broken_dir.mkdir(parents=True)
|
||||
(broken_dir / "target-template.md").write_text("# Broken Target\n")
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("valid-ext", {"version": "1.0.0", "priority": 10})
|
||||
registry.data["extensions"]["broken-ext"] = "corrupted"
|
||||
registry._save()
|
||||
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
resolved = resolver.resolve("target-template")
|
||||
sourced = resolver.resolve_with_source("target-template")
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.name == "target-template.md"
|
||||
assert "Broken Target" in resolved.read_text()
|
||||
assert sourced is not None
|
||||
assert sourced["source"] == "extension:broken-ext (unregistered)"
|
||||
|
||||
|
||||
class TestExtensionPriorityCLI:
|
||||
"""Test extension priority CLI integration."""
|
||||
|
||||
def test_add_with_priority_option(self, extension_dir, project_dir):
|
||||
"""Test extension add command with --priority option."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, [
|
||||
"extension", "add", str(extension_dir), "--dev", "--priority", "3"
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 3
|
||||
|
||||
def test_list_shows_priority(self, extension_dir, project_dir):
|
||||
"""Test extension list shows priority."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with priority
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Priority: 7" in result.output
|
||||
|
||||
def test_set_priority_changes_priority(self, extension_dir, project_dir):
|
||||
"""Test set-priority command changes extension priority."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with default priority
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Verify default priority
|
||||
assert manager.registry.get("test-ext")["priority"] == 10
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed: 10 → 5" in result.output
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = ExtensionManager(project_dir)
|
||||
assert manager2.registry.get("test-ext")["priority"] == 5
|
||||
|
||||
def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
|
||||
"""Test set-priority with same value shows already set message."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with priority 5
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already has priority 5" in result.output
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "0"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Priority must be a positive integer" in result.output
|
||||
|
||||
def test_set_priority_not_installed(self, project_dir):
|
||||
"""Test set-priority fails for non-installed extension."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Ensure .specify exists
|
||||
(project_dir / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower()
|
||||
|
||||
def test_set_priority_by_display_name(self, extension_dir, project_dir):
|
||||
"""Test set-priority works with extension display name."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Use display name "Test Extension" instead of ID "test-ext"
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed" in result.output
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = ExtensionManager(project_dir)
|
||||
assert manager2.registry.get("test-ext")["priority"] == 3
|
||||
|
||||
|
||||
class TestExtensionPriorityBackwardsCompatibility:
|
||||
"""Test backwards compatibility for extensions installed before priority feature."""
|
||||
|
||||
def test_legacy_extension_without_priority_field(self, temp_dir):
|
||||
"""Extensions installed before priority feature should default to 10."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
# Simulate legacy registry entry without priority field
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.data["extensions"]["legacy-ext"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
"installed_at": "2025-01-01T00:00:00Z",
|
||||
# No "priority" field - simulates pre-feature extension
|
||||
}
|
||||
registry._save()
|
||||
|
||||
# Reload registry
|
||||
registry2 = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# list_by_priority should use default of 10
|
||||
result = registry2.list_by_priority()
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "legacy-ext"
|
||||
# Priority defaults to 10 and is normalized in returned metadata
|
||||
assert result[0][1]["priority"] == 10
|
||||
|
||||
def test_legacy_extension_in_list_installed(self, extension_dir, project_dir):
|
||||
"""list_installed returns priority=10 for legacy extensions without priority field."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
# Install extension normally
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Manually remove priority to simulate legacy extension
|
||||
ext_data = manager.registry.data["extensions"]["test-ext"]
|
||||
del ext_data["priority"]
|
||||
manager.registry._save()
|
||||
|
||||
# list_installed should still return priority=10
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["priority"] == 10
|
||||
|
||||
def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):
|
||||
"""Legacy extensions (no priority) sort with default=10 among prioritized extensions."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# Add extension with explicit priority=5
|
||||
registry.add("ext-with-priority", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
# Add legacy extension without priority (manually)
|
||||
registry.data["extensions"]["legacy-ext"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
# No priority field
|
||||
}
|
||||
registry._save()
|
||||
|
||||
# Add extension with priority=15
|
||||
registry.add("ext-low-priority", {"version": "1.0.0", "priority": 15})
|
||||
|
||||
# Reload and check ordering
|
||||
registry2 = ExtensionRegistry(extensions_dir)
|
||||
result = registry2.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15)
|
||||
assert result[0][0] == "ext-with-priority"
|
||||
assert result[1][0] == "legacy-ext"
|
||||
assert result[2][0] == "ext-low-priority"
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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
|
||||
@@ -32,7 +32,6 @@ from specify_cli.presets import (
|
||||
PresetCompatibilityError,
|
||||
VALID_PRESET_TEMPLATE_TYPES,
|
||||
)
|
||||
from specify_cli.extensions import ExtensionRegistry
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
@@ -574,24 +573,6 @@ class TestRegistryPriority:
|
||||
assert sorted_packs[0][0] == "pack-b"
|
||||
assert sorted_packs[1][0] == "pack-a"
|
||||
|
||||
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
||||
"""Malformed priority values fall back to the default priority."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.data["presets"]["pack-invalid"] = {
|
||||
"version": "1.0.0",
|
||||
"priority": "high",
|
||||
}
|
||||
registry._save()
|
||||
|
||||
sorted_packs = registry.list_by_priority()
|
||||
|
||||
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
|
||||
assert sorted_packs[1][1]["priority"] == 10
|
||||
|
||||
|
||||
# ===== PresetResolver Tests =====
|
||||
|
||||
@@ -697,11 +678,6 @@ class TestPresetResolver:
|
||||
ext_template = ext_templates_dir / "custom-template.md"
|
||||
ext_template.write_text("# Extension Custom Template\n")
|
||||
|
||||
# Register extension in registry
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("custom-template")
|
||||
assert result is not None
|
||||
@@ -765,15 +741,10 @@ class TestPresetResolver:
|
||||
ext_template = ext_templates_dir / "unique-template.md"
|
||||
ext_template.write_text("# Unique\n")
|
||||
|
||||
# Register extension in registry
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve_with_source("unique-template")
|
||||
assert result is not None
|
||||
assert result["source"] == "extension:my-ext v1.0.0"
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
def test_resolve_with_source_not_found(self, project_dir):
|
||||
"""Test resolve_with_source for nonexistent template."""
|
||||
@@ -794,104 +765,6 @@ class TestPresetResolver:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtensionPriorityResolution:
|
||||
"""Test extension priority resolution with registered and unregistered extensions."""
|
||||
|
||||
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
|
||||
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create registered extension with priority 20 (lower precedence than 10)
|
||||
registered_dir = extensions_dir / "registered-ext"
|
||||
(registered_dir / "templates").mkdir(parents=True)
|
||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
||||
|
||||
# Create unregistered extension directory (implicit priority 10)
|
||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||
(unregistered_dir / "templates").mkdir(parents=True)
|
||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||
|
||||
# Unregistered (priority 10) should beat registered (priority 20)
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("test-template")
|
||||
assert result is not None
|
||||
assert "From Unregistered" in result.read_text()
|
||||
|
||||
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
|
||||
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create registered extension with priority 5 (higher precedence than 10)
|
||||
registered_dir = extensions_dir / "registered-ext"
|
||||
(registered_dir / "templates").mkdir(parents=True)
|
||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
# Create unregistered extension directory (implicit priority 10)
|
||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||
(unregistered_dir / "templates").mkdir(parents=True)
|
||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||
|
||||
# Registered (priority 5) should beat unregistered (priority 10)
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("test-template")
|
||||
assert result is not None
|
||||
assert "From Registered" in result.read_text()
|
||||
|
||||
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
|
||||
"""Test resolve_with_source correctly attributes unregistered extension."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create registered extension with priority 20
|
||||
registered_dir = extensions_dir / "registered-ext"
|
||||
(registered_dir / "templates").mkdir(parents=True)
|
||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
||||
|
||||
# Create unregistered extension (implicit priority 10)
|
||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||
(unregistered_dir / "templates").mkdir(parents=True)
|
||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||
|
||||
# Attribution should show unregistered extension
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve_with_source("test-template")
|
||||
assert result is not None
|
||||
assert "unregistered-ext" in result["source"]
|
||||
assert "(unregistered)" in result["source"]
|
||||
|
||||
def test_same_priority_sorted_alphabetically(self, project_dir):
|
||||
"""Extensions with same priority are sorted alphabetically by ID."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create two unregistered extensions (both implicit priority 10)
|
||||
# "aaa-ext" should come before "zzz-ext" alphabetically
|
||||
zzz_dir = extensions_dir / "zzz-ext"
|
||||
(zzz_dir / "templates").mkdir(parents=True)
|
||||
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
|
||||
|
||||
aaa_dir = extensions_dir / "aaa-ext"
|
||||
(aaa_dir / "templates").mkdir(parents=True)
|
||||
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
|
||||
|
||||
# AAA should win due to alphabetical ordering at same priority
|
||||
resolver = PresetResolver(project_dir)
|
||||
result = resolver.resolve("test-template")
|
||||
assert result is not None
|
||||
assert "From AAA" in result.read_text()
|
||||
|
||||
|
||||
# ===== PresetCatalog Tests =====
|
||||
|
||||
|
||||
@@ -1106,13 +979,8 @@ class TestIntegration:
|
||||
ext_templates_dir.mkdir(parents=True)
|
||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||
|
||||
# Register extension in registry
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
ext_registry = ExtensionRegistry(extensions_dir)
|
||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
||||
|
||||
result = resolver.resolve_with_source("spec-template")
|
||||
assert result["source"] == "extension:my-ext v1.0.0"
|
||||
assert result["source"] == "extension:my-ext"
|
||||
|
||||
# Install pack — should win over extension
|
||||
manager = PresetManager(project_dir)
|
||||
@@ -1842,162 +1710,3 @@ class TestPresetSkills:
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata.get("registered_skills", []) == []
|
||||
|
||||
|
||||
class TestPresetSetPriority:
|
||||
"""Test preset set-priority CLI command."""
|
||||
|
||||
def test_set_priority_changes_priority(self, project_dir, pack_dir):
|
||||
"""Test set-priority command changes preset priority."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset with default priority
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Verify default priority
|
||||
assert manager.registry.get("test-pack")["priority"] == 10
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed: 10 → 5" in result.output
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = PresetManager(project_dir)
|
||||
assert manager2.registry.get("test-pack")["priority"] == 5
|
||||
|
||||
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
|
||||
"""Test set-priority with same value shows already set message."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset with priority 5
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already has priority 5" in result.output
|
||||
|
||||
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install preset
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Priority must be a positive integer" in result.output
|
||||
|
||||
def test_set_priority_not_installed(self, project_dir):
|
||||
"""Test set-priority fails for non-installed preset."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "not installed" in result.output.lower()
|
||||
|
||||
|
||||
class TestPresetPriorityBackwardsCompatibility:
|
||||
"""Test backwards compatibility for presets installed before priority feature."""
|
||||
|
||||
def test_legacy_preset_without_priority_field(self, temp_dir):
|
||||
"""Presets installed before priority feature should default to 10."""
|
||||
presets_dir = temp_dir / ".specify" / "presets"
|
||||
presets_dir.mkdir(parents=True)
|
||||
|
||||
# Simulate legacy registry entry without priority field
|
||||
registry = PresetRegistry(presets_dir)
|
||||
registry.data["presets"]["legacy-pack"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
"installed_at": "2025-01-01T00:00:00Z",
|
||||
# No "priority" field - simulates pre-feature preset
|
||||
}
|
||||
registry._save()
|
||||
|
||||
# Reload registry
|
||||
registry2 = PresetRegistry(presets_dir)
|
||||
|
||||
# list_by_priority should use default of 10
|
||||
result = registry2.list_by_priority()
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "legacy-pack"
|
||||
# Priority defaults to 10 and is normalized in returned metadata
|
||||
assert result[0][1]["priority"] == 10
|
||||
|
||||
def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):
|
||||
"""list_installed returns priority=10 for legacy presets without priority field."""
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
# Install preset normally
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
# Manually remove priority to simulate legacy preset
|
||||
pack_data = manager.registry.data["presets"]["test-pack"]
|
||||
del pack_data["priority"]
|
||||
manager.registry._save()
|
||||
|
||||
# list_installed should still return priority=10
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["priority"] == 10
|
||||
|
||||
def test_mixed_legacy_and_new_presets_ordering(self, temp_dir):
|
||||
"""Legacy presets (no priority) sort with default=10 among prioritized presets."""
|
||||
presets_dir = temp_dir / ".specify" / "presets"
|
||||
presets_dir.mkdir(parents=True)
|
||||
|
||||
registry = PresetRegistry(presets_dir)
|
||||
|
||||
# Add preset with explicit priority=5
|
||||
registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
# Add legacy preset without priority (manually)
|
||||
registry.data["presets"]["legacy-pack"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
# No priority field
|
||||
}
|
||||
|
||||
# Add another preset with priority=15
|
||||
registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15})
|
||||
registry._save()
|
||||
|
||||
# Reload and check ordering
|
||||
registry2 = PresetRegistry(presets_dir)
|
||||
sorted_presets = registry2.list_by_priority()
|
||||
|
||||
# Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)
|
||||
assert [p[0] for p in sorted_presets] == [
|
||||
"pack-with-priority",
|
||||
"legacy-pack",
|
||||
"low-priority-pack",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user