mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 19:03:08 +00:00
Compare commits
2 Commits
main
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b10d440b1 | ||
|
|
2656ee1df7 |
@@ -58,8 +58,6 @@ gh release create "$VERSION" \
|
|||||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
|
|
||||||
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
.genreleases/spec-kit-template-generic-sh-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-generic-ps-"$VERSION".zip \
|
||||||
--title "Spec Kit Templates - $VERSION_NO_V" \
|
--title "Spec Kit Templates - $VERSION_NO_V" \
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
.PARAMETER Agents
|
.PARAMETER Agents
|
||||||
Comma or space separated subset of agents to build (default: all)
|
Comma or space separated subset of agents to build (default: all)
|
||||||
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, 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
|
.PARAMETER Scripts
|
||||||
Comma or space separated subset of script types to build (default: both)
|
Comma or space separated subset of script types to build (default: both)
|
||||||
@@ -454,11 +454,6 @@ function Build-Variant {
|
|||||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||||
New-KimiSkills -SkillsDir $skillsDir -ScriptVariant $Script
|
New-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' {
|
'generic' {
|
||||||
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
$cmdDir = Join-Path $baseDir ".speckit/commands"
|
||||||
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
Generate-Commands -Agent 'generic' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -ScriptVariant $Script
|
||||||
@@ -475,7 +470,7 @@ function Build-Variant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Define all agents and scripts
|
# Define all agents and scripts
|
||||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', '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')
|
$AllScripts = @('sh', 'ps')
|
||||||
|
|
||||||
function Normalize-List {
|
function Normalize-List {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ set -euo pipefail
|
|||||||
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
# Usage: .github/workflows/scripts/create-release-packages.sh <version>
|
||||||
# Version argument should include leading 'v'.
|
# Version argument should include leading 'v'.
|
||||||
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
# Optionally set AGENTS and/or SCRIPTS env vars to limit what gets built.
|
||||||
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi 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)
|
# SCRIPTS : space or comma separated subset of: sh ps (default: both)
|
||||||
# Examples:
|
# Examples:
|
||||||
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
# AGENTS=claude SCRIPTS=sh $0 v0.2.0
|
||||||
@@ -291,9 +291,6 @@ build_variant() {
|
|||||||
kimi)
|
kimi)
|
||||||
mkdir -p "$base_dir/.kimi/skills"
|
mkdir -p "$base_dir/.kimi/skills"
|
||||||
create_kimi_skills "$base_dir/.kimi/skills" "$script" ;;
|
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)
|
generic)
|
||||||
mkdir -p "$base_dir/.speckit/commands"
|
mkdir -p "$base_dir/.speckit/commands"
|
||||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||||
@@ -303,7 +300,7 @@ build_variant() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Determine agent list
|
# Determine agent list
|
||||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi 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)
|
ALL_SCRIPTS=(sh ps)
|
||||||
|
|
||||||
norm_list() {
|
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 |
|
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
||||||
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
||||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||||
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
|
|
||||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||||
|
|
||||||
### Step-by-Step Integration Guide
|
### Step-by-Step Integration Guide
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,26 +7,6 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.3.1] - 2026-03-17
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- docs: add greenfield Spring Boot pirate-speak preset demo to README (#1878)
|
|
||||||
- fix(ai-skills): exclude non-speckit copilot agent markdown from skills (#1867)
|
|
||||||
- feat: add Trae IDE support as a new agent (#1817)
|
|
||||||
- feat(cli): polite deep merge for settings.json and support JSONC (#1874)
|
|
||||||
- feat(extensions,presets): add priority-based resolution ordering (#1855)
|
|
||||||
- fix(scripts): suppress stdout from git fetch in create-new-feature.sh (#1876)
|
|
||||||
- fix(scripts): harden bash scripts — escape, compat, and error handling (#1869)
|
|
||||||
- Add cognitive-squad to community extension catalog (#1870)
|
|
||||||
- docs: add Go / React brownfield walkthrough to community walkthroughs (#1868)
|
|
||||||
- chore: update DocGuard extension to v0.9.8 (#1859)
|
|
||||||
- Feature: add specify status command (#1837)
|
|
||||||
- fix(extensions): show extension ID in list output (#1843)
|
|
||||||
- feat(extensions): add Archive and Reconcile extensions to community catalog (#1844)
|
|
||||||
- feat: Add DocGuard CDD enforcement extension to community catalog (#1838)
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-03-13
|
## [0.3.0] - 2026-03-13
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -55,7 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
|
|
||||||
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
||||||
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
||||||
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
||||||
|
|||||||
@@ -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 Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
||||||
|
|
||||||
- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal.
|
|
||||||
|
|
||||||
- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling.
|
|
||||||
|
|
||||||
## 🤖 Supported AI Agents
|
## 🤖 Supported AI Agents
|
||||||
|
|
||||||
| Agent | Support | Notes |
|
| Agent | Support | Notes |
|
||||||
@@ -188,7 +184,6 @@ See Spec-Driven Development in action across different scenarios with these comm
|
|||||||
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
| [Kimi Code](https://code.kimi.com/) | ✅ | |
|
||||||
| [Windsurf](https://windsurf.com/) | ✅ | |
|
| [Windsurf](https://windsurf.com/) | ✅ | |
|
||||||
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` |
|
||||||
| [Trae](https://www.trae.ai/) | ✅ | |
|
|
||||||
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir <path>` for unsupported agents |
|
||||||
|
|
||||||
## 🔧 Specify CLI Reference
|
## 🔧 Specify CLI Reference
|
||||||
|
|||||||
@@ -72,18 +72,12 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
|
|
||||||
| Extension | Purpose | URL |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
|
||||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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",
|
"installed_at": "2026-01-28T14:30:00Z",
|
||||||
"source": "catalog",
|
"source": "catalog",
|
||||||
"manifest_hash": "sha256:abc123...",
|
"manifest_hash": "sha256:abc123...",
|
||||||
"enabled": true,
|
"enabled": true
|
||||||
"priority": 10
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.
|
|
||||||
|
|
||||||
### 3. Configuration
|
### 3. Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1087,15 +1084,11 @@ List installed extensions in current project.
|
|||||||
$ specify extension list
|
$ specify extension list
|
||||||
|
|
||||||
Installed Extensions:
|
Installed Extensions:
|
||||||
✓ Jira Integration (v1.0.0)
|
✓ jira (v1.0.0) - Jira Integration
|
||||||
jira
|
Commands: 3 | Hooks: 2 | Status: Enabled
|
||||||
Create Jira issues from spec-kit artifacts
|
|
||||||
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
|
|
||||||
|
|
||||||
✓ Linear Integration (v0.9.0)
|
✓ linear (v0.9.0) - Linear Integration
|
||||||
linear
|
Commands: 1 | Hooks: 1 | Status: Enabled
|
||||||
Create Linear issues from spec-kit artifacts
|
|
||||||
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
@@ -1203,9 +1196,10 @@ Next steps:
|
|||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
|
|
||||||
- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.
|
- `--from URL`: Install from custom URL or Git repo
|
||||||
- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).
|
- `--version VERSION`: Install specific version
|
||||||
- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)
|
- `--dev PATH`: Install from local path (development mode)
|
||||||
|
- `--no-register`: Skip command registration (manual setup)
|
||||||
|
|
||||||
#### `specify extension remove NAME`
|
#### `specify extension remove NAME`
|
||||||
|
|
||||||
@@ -1286,29 +1280,6 @@ $ specify extension disable jira
|
|||||||
To re-enable: specify extension enable jira
|
To re-enable: specify extension enable jira
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `specify extension set-priority NAME PRIORITY`
|
|
||||||
|
|
||||||
Change the resolution priority of an installed extension.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ specify extension set-priority jira 5
|
|
||||||
|
|
||||||
✓ Extension 'Jira Integration' priority changed: 10 → 5
|
|
||||||
|
|
||||||
Lower priority = higher precedence in template resolution
|
|
||||||
```
|
|
||||||
|
|
||||||
**Priority Values:**
|
|
||||||
|
|
||||||
- Lower numbers = higher precedence (checked first in resolution)
|
|
||||||
- Default priority is 10
|
|
||||||
- Must be a positive integer (1 or higher)
|
|
||||||
|
|
||||||
**Use Cases:**
|
|
||||||
|
|
||||||
- Ensure a critical extension's templates take precedence
|
|
||||||
- Override default resolution order when multiple extensions provide similar templates
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Compatibility & Versioning
|
## Compatibility & Versioning
|
||||||
|
|||||||
@@ -1,39 +1,8 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-03-17T00:00:00Z",
|
"updated_at": "2026-03-13T12:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"archive": {
|
|
||||||
"name": "Archive Extension",
|
|
||||||
"id": "archive",
|
|
||||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
|
||||||
"author": "Stanislav Deviatov",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"repository": "https://github.com/stn1slv/spec-kit-archive",
|
|
||||||
"homepage": "https://github.com/stn1slv/spec-kit-archive",
|
|
||||||
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/stn1slv/spec-kit-archive/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.1.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 1,
|
|
||||||
"hooks": 0
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"archive",
|
|
||||||
"memory",
|
|
||||||
"merge",
|
|
||||||
"changelog"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-03-14T00:00:00Z",
|
|
||||||
"updated_at": "2026-03-14T00:00:00Z"
|
|
||||||
},
|
|
||||||
"azure-devops": {
|
"azure-devops": {
|
||||||
"name": "Azure DevOps Integration",
|
"name": "Azure DevOps Integration",
|
||||||
"id": "azure-devops",
|
"id": "azure-devops",
|
||||||
@@ -105,92 +74,6 @@
|
|||||||
"created_at": "2026-02-22T00:00:00Z",
|
"created_at": "2026-02-22T00:00:00Z",
|
||||||
"updated_at": "2026-02-22T00:00:00Z"
|
"updated_at": "2026-02-22T00:00:00Z"
|
||||||
},
|
},
|
||||||
"cognitive-squad": {
|
|
||||||
"name": "Cognitive Squad",
|
|
||||||
"id": "cognitive-squad",
|
|
||||||
"description": "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": {
|
"doctor": {
|
||||||
"name": "Project Health Check",
|
"name": "Project Health Check",
|
||||||
"id": "doctor",
|
"id": "doctor",
|
||||||
@@ -241,12 +124,7 @@
|
|||||||
"commands": 2,
|
"commands": 2,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"],
|
||||||
"orchestration",
|
|
||||||
"workflow",
|
|
||||||
"human-in-the-loop",
|
|
||||||
"parallel"
|
|
||||||
],
|
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
@@ -313,49 +191,13 @@
|
|||||||
"commands": 2,
|
"commands": 2,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": ["implementation", "automation", "loop", "copilot"],
|
||||||
"implementation",
|
|
||||||
"automation",
|
|
||||||
"loop",
|
|
||||||
"copilot"
|
|
||||||
],
|
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-03-09T00:00:00Z",
|
"created_at": "2026-03-09T00:00:00Z",
|
||||||
"updated_at": "2026-03-09T00:00:00Z"
|
"updated_at": "2026-03-09T00:00:00Z"
|
||||||
},
|
},
|
||||||
"reconcile": {
|
|
||||||
"name": "Reconcile Extension",
|
|
||||||
"id": "reconcile",
|
|
||||||
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
|
|
||||||
"author": "Stanislav Deviatov",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
|
|
||||||
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
|
|
||||||
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.1.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 1,
|
|
||||||
"hooks": 0
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"reconcile",
|
|
||||||
"drift",
|
|
||||||
"tasks",
|
|
||||||
"remediation"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-03-14T00:00:00Z",
|
|
||||||
"updated_at": "2026-03-14T00:00:00Z"
|
|
||||||
},
|
|
||||||
"retrospective": {
|
"retrospective": {
|
||||||
"name": "Retrospective Extension",
|
"name": "Retrospective Extension",
|
||||||
"id": "retrospective",
|
"id": "retrospective",
|
||||||
@@ -407,15 +249,7 @@
|
|||||||
"commands": 7,
|
"commands": 7,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"],
|
||||||
"code-review",
|
|
||||||
"quality",
|
|
||||||
"review",
|
|
||||||
"testing",
|
|
||||||
"error-handling",
|
|
||||||
"type-design",
|
|
||||||
"simplification"
|
|
||||||
],
|
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
@@ -457,7 +291,7 @@
|
|||||||
"understanding": {
|
"understanding": {
|
||||||
"name": "Understanding",
|
"name": "Understanding",
|
||||||
"id": "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",
|
"author": "Ladislav Bihari",
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
||||||
@@ -495,38 +329,6 @@
|
|||||||
"created_at": "2026-03-07T00:00:00Z",
|
"created_at": "2026-03-07T00:00:00Z",
|
||||||
"updated_at": "2026-03-07T00:00:00Z"
|
"updated_at": "2026-03-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
"status": {
|
|
||||||
"name": "Project Status",
|
|
||||||
"id": "status",
|
|
||||||
"description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.",
|
|
||||||
"author": "KhawarHabibKhan",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
|
||||||
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
|
||||||
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.1.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 1,
|
|
||||||
"hooks": 0
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"status",
|
|
||||||
"workflow",
|
|
||||||
"progress",
|
|
||||||
"feature-tracking",
|
|
||||||
"task-progress"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-03-16T00:00:00Z",
|
|
||||||
"updated_at": "2026-03-16T00:00:00Z"
|
|
||||||
},
|
|
||||||
"v-model": {
|
"v-model": {
|
||||||
"name": "V-Model Extension Pack",
|
"name": "V-Model Extension Pack",
|
||||||
"id": "v-model",
|
"id": "v-model",
|
||||||
@@ -559,37 +361,6 @@
|
|||||||
"created_at": "2026-02-20T00:00:00Z",
|
"created_at": "2026-02-20T00:00:00Z",
|
||||||
"updated_at": "2026-02-22T00:00:00Z"
|
"updated_at": "2026-02-22T00:00:00Z"
|
||||||
},
|
},
|
||||||
"learn": {
|
|
||||||
"name": "Learning Extension",
|
|
||||||
"id": "learn",
|
|
||||||
"description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
|
|
||||||
"author": "Vianca Martinez",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
|
|
||||||
"repository": "https://github.com/imviancagrace/spec-kit-learn",
|
|
||||||
"homepage": "https://github.com/imviancagrace/spec-kit-learn",
|
|
||||||
"documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
|
|
||||||
"changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
|
|
||||||
"license": "MIT",
|
|
||||||
"requires": {
|
|
||||||
"speckit_version": ">=0.1.0"
|
|
||||||
},
|
|
||||||
"provides": {
|
|
||||||
"commands": 2,
|
|
||||||
"hooks": 1
|
|
||||||
},
|
|
||||||
"tags": [
|
|
||||||
"learning",
|
|
||||||
"education",
|
|
||||||
"mentoring",
|
|
||||||
"knowledge-transfer"
|
|
||||||
],
|
|
||||||
"verified": false,
|
|
||||||
"downloads": 0,
|
|
||||||
"stars": 0,
|
|
||||||
"created_at": "2026-03-17T00:00:00Z",
|
|
||||||
"updated_at": "2026-03-17T00:00:00Z"
|
|
||||||
},
|
|
||||||
"verify": {
|
"verify": {
|
||||||
"name": "Verify Extension",
|
"name": "Verify Extension",
|
||||||
"id": "verify",
|
"id": "verify",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.3.1"
|
version = "0.3.0"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -14,7 +14,6 @@ dependencies = [
|
|||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"packaging>=23.0",
|
"packaging>=23.0",
|
||||||
"pathspec>=0.12.0",
|
"pathspec>=0.12.0",
|
||||||
"json5>=0.13.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ if $JSON_MODE; then
|
|||||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
json_docs="[]"
|
json_docs="[]"
|
||||||
else
|
else
|
||||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
json_docs=$(printf '"%s",' "${docs[@]}")
|
||||||
json_docs="[${json_docs%,}]"
|
json_docs="[${json_docs%,}]"
|
||||||
fi
|
fi
|
||||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ has_jq() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
# Handles backslash, double-quote, and control characters (newline, tab, carriage return).
|
||||||
json_escape() {
|
json_escape() {
|
||||||
local s="$1"
|
local s="$1"
|
||||||
s="${s//\\/\\\\}"
|
s="${s//\\/\\\\}"
|
||||||
@@ -169,10 +169,6 @@ json_escape() {
|
|||||||
s="${s//$'\n'/\\n}"
|
s="${s//$'\n'/\\n}"
|
||||||
s="${s//$'\t'/\\t}"
|
s="${s//$'\t'/\\t}"
|
||||||
s="${s//$'\r'/\\r}"
|
s="${s//$'\r'/\\r}"
|
||||||
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"
|
printf '%s' "$s"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,11 +194,9 @@ resolve_template() {
|
|||||||
if [ -d "$presets_dir" ]; then
|
if [ -d "$presets_dir" ]; then
|
||||||
local registry_file="$presets_dir/.registry"
|
local registry_file="$presets_dir/.registry"
|
||||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
# Read preset IDs sorted by priority (lower number = higher precedence)
|
||||||
# The python3 call is wrapped in an if-condition so that set -e does not
|
local sorted_presets
|
||||||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
local sorted_presets=""
|
|
||||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
|
||||||
import json, sys, os
|
import json, sys, os
|
||||||
try:
|
try:
|
||||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
@@ -212,17 +206,14 @@ try:
|
|||||||
print(pid)
|
print(pid)
|
||||||
except Exception:
|
except Exception:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
" 2>/dev/null); then
|
" 2>/dev/null)
|
||||||
if [ -n "$sorted_presets" ]; then
|
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
|
||||||
# python3 succeeded and returned preset IDs — search in priority order
|
while IFS= read -r preset_id; do
|
||||||
while IFS= read -r preset_id; do
|
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
done <<< "$sorted_presets"
|
||||||
done <<< "$sorted_presets"
|
|
||||||
fi
|
|
||||||
# python3 succeeded but registry has no presets — nothing to search
|
|
||||||
else
|
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
|
for preset in "$presets_dir"/*/; do
|
||||||
[ -d "$preset" ] || continue
|
[ -d "$preset" ] || continue
|
||||||
local candidate="$preset/templates/${template_name}.md"
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
@@ -255,9 +246,8 @@ except Exception:
|
|||||||
local core="$base/${template_name}.md"
|
local core="$base/${template_name}.md"
|
||||||
[ -f "$core" ] && echo "$core" && return 0
|
[ -f "$core" ] && echo "$core" && return 0
|
||||||
|
|
||||||
# Template not found in any location.
|
# Return success with empty output so callers using set -e don't abort;
|
||||||
# Return 1 so callers can distinguish "not found" from "found".
|
# callers check [ -n "$TEMPLATE" ] to detect "not found".
|
||||||
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
return 0
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ check_existing_branches() {
|
|||||||
local specs_dir="$1"
|
local specs_dir="$1"
|
||||||
|
|
||||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
git fetch --all --prune >/dev/null 2>&1 || true
|
git fetch --all --prune 2>/dev/null || true
|
||||||
|
|
||||||
# Get highest number from ALL branches (not just matching short name)
|
# Get highest number from ALL branches (not just matching short name)
|
||||||
local highest_branch=$(get_highest_from_branches)
|
local highest_branch=$(get_highest_from_branches)
|
||||||
@@ -162,6 +162,17 @@ clean_branch_name() {
|
|||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||||
|
json_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\\/\\\\}"
|
||||||
|
s="${s//\"/\\\"}"
|
||||||
|
s="${s//$'\n'/\\n}"
|
||||||
|
s="${s//$'\t'/\\t}"
|
||||||
|
s="${s//$'\r'/\\r}"
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
# Resolve repository root. Prefer git information when available, but fall back
|
# Resolve repository root. Prefer git information when available, but fall back
|
||||||
# to searching for repository markers so the workflow still functions in repositories that
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
# were initialised with --no-git.
|
# were initialised with --no-git.
|
||||||
@@ -297,14 +308,9 @@ fi
|
|||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
||||||
cp "$TEMPLATE" "$SPEC_FILE"
|
|
||||||
else
|
|
||||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
|
||||||
touch "$SPEC_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Inform the user how to persist the feature variable in their own shell
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
# Copy plan template if it exists
|
# Copy plan template if it exists
|
||||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
||||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
echo "Copied plan template to $IMPL_PLAN"
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
# - Creates default Claude file if no agent files exist
|
# - Creates default Claude file if no agent files exist
|
||||||
#
|
#
|
||||||
# Usage: ./update-agent-context.sh [agent_type]
|
# Usage: ./update-agent-context.sh [agent_type]
|
||||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|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
|
# Leave empty to update all existing agent files
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -83,7 +83,6 @@ AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md"
|
|||||||
BOB_FILE="$AGENTS_FILE"
|
BOB_FILE="$AGENTS_FILE"
|
||||||
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md"
|
||||||
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
KIMI_FILE="$REPO_ROOT/KIMI.md"
|
||||||
TRAE_FILE="$REPO_ROOT/.trae/rules/AGENTS.md"
|
|
||||||
|
|
||||||
# Template file
|
# Template file
|
||||||
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md"
|
||||||
@@ -676,82 +675,67 @@ update_specific_agent() {
|
|||||||
kimi)
|
kimi)
|
||||||
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
||||||
;;
|
;;
|
||||||
trae)
|
|
||||||
update_agent_file "$TRAE_FILE" "Trae" || return 1
|
|
||||||
;;
|
|
||||||
generic)
|
generic)
|
||||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown agent type '$agent_type'"
|
log_error "Unknown agent type '$agent_type'"
|
||||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helper: skip non-existent files and files already updated (dedup by
|
|
||||||
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
|
||||||
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
|
||||||
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
|
||||||
# Note: defined at top level because bash 3.2 does not support true
|
|
||||||
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
|
|
||||||
# initialised exclusively inside update_all_existing_agents so that
|
|
||||||
# sourcing this script has no side effects on the caller's environment.
|
|
||||||
|
|
||||||
_update_if_new() {
|
|
||||||
local file="$1" name="$2"
|
|
||||||
[[ -f "$file" ]] || return 0
|
|
||||||
local real_path
|
|
||||||
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
|
||||||
local p
|
|
||||||
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
|
||||||
for p in "${_updated_paths[@]}"; do
|
|
||||||
[[ "$p" == "$real_path" ]] && return 0
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
# Record the file as seen before attempting the update so that:
|
|
||||||
# (a) aliases pointing to the same path are not retried on failure
|
|
||||||
# (b) _found_agent reflects file existence, not update success
|
|
||||||
_updated_paths+=("$real_path")
|
|
||||||
_found_agent=true
|
|
||||||
update_agent_file "$file" "$name"
|
|
||||||
}
|
|
||||||
|
|
||||||
update_all_existing_agents() {
|
update_all_existing_agents() {
|
||||||
_found_agent=false
|
local found_agent=false
|
||||||
_updated_paths=()
|
local _updated_paths=()
|
||||||
local _all_ok=true
|
|
||||||
|
|
||||||
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
|
# Helper: skip non-existent files and files already updated (dedup by
|
||||||
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
|
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
||||||
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
|
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
||||||
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
|
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
||||||
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
|
update_if_new() {
|
||||||
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
|
local file="$1" name="$2"
|
||||||
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
|
[[ -f "$file" ]] || return 0
|
||||||
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
local real_path
|
||||||
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
||||||
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
local p
|
||||||
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
||||||
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
for p in "${_updated_paths[@]}"; do
|
||||||
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
[[ "$p" == "$real_path" ]] && return 0
|
||||||
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
|
done
|
||||||
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
|
fi
|
||||||
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
|
update_agent_file "$file" "$name" || return 1
|
||||||
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
|
_updated_paths+=("$real_path")
|
||||||
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
|
found_agent=true
|
||||||
_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
|
update_if_new "$CLAUDE_FILE" "Claude Code"
|
||||||
|
update_if_new "$GEMINI_FILE" "Gemini CLI"
|
||||||
|
update_if_new "$COPILOT_FILE" "GitHub Copilot"
|
||||||
|
update_if_new "$CURSOR_FILE" "Cursor IDE"
|
||||||
|
update_if_new "$QWEN_FILE" "Qwen Code"
|
||||||
|
update_if_new "$AGENTS_FILE" "Codex/opencode"
|
||||||
|
update_if_new "$AMP_FILE" "Amp"
|
||||||
|
update_if_new "$KIRO_FILE" "Kiro CLI"
|
||||||
|
update_if_new "$BOB_FILE" "IBM Bob"
|
||||||
|
update_if_new "$WINDSURF_FILE" "Windsurf"
|
||||||
|
update_if_new "$KILOCODE_FILE" "Kilo Code"
|
||||||
|
update_if_new "$AUGGIE_FILE" "Auggie CLI"
|
||||||
|
update_if_new "$ROO_FILE" "Roo Code"
|
||||||
|
update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI"
|
||||||
|
update_if_new "$SHAI_FILE" "SHAI"
|
||||||
|
update_if_new "$TABNINE_FILE" "Tabnine CLI"
|
||||||
|
update_if_new "$QODER_FILE" "Qoder CLI"
|
||||||
|
update_if_new "$AGY_FILE" "Antigravity"
|
||||||
|
update_if_new "$VIBE_FILE" "Mistral Vibe"
|
||||||
|
update_if_new "$KIMI_FILE" "Kimi Code"
|
||||||
|
|
||||||
# If no agent files exist, create a default Claude file
|
# If no agent files exist, create a default Claude file
|
||||||
if [[ "$_found_agent" == false ]]; then
|
if [[ "$found_agent" == false ]]; then
|
||||||
log_info "No existing agent files found, creating default Claude file..."
|
log_info "No existing agent files found, creating default Claude file..."
|
||||||
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ "$_all_ok" == true ]]
|
|
||||||
}
|
}
|
||||||
print_summary() {
|
print_summary() {
|
||||||
echo
|
echo
|
||||||
@@ -770,7 +754,7 @@ print_summary() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|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
|
2. Plan Data Extraction
|
||||||
3. Agent File Management (create from template or update existing)
|
3. Agent File Management (create from template or update existing)
|
||||||
4. Content Generation (technology stack, recent changes, timestamp)
|
4. Content Generation (technology stack, recent changes, timestamp)
|
||||||
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, 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
|
.PARAMETER AgentType
|
||||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
|||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[Parameter(Position=0)]
|
[Parameter(Position=0)]
|
||||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','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
|
[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'
|
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
||||||
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md'
|
||||||
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
|
||||||
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/AGENTS.md'
|
|
||||||
|
|
||||||
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
|
||||||
|
|
||||||
@@ -409,9 +408,8 @@ function Update-SpecificAgent {
|
|||||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||||
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
||||||
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
|
|
||||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|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 $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
if (Test-Path $VIBE_FILE) { if (-not (Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
|
if (Test-Path $KIMI_FILE) { if (-not (Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false }; $found = $true }
|
||||||
if (Test-Path $TRAE_FILE) { if (-not (Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae')) { $ok = $false }; $found = $true }
|
|
||||||
if (-not $found) {
|
if (-not $found) {
|
||||||
Write-Info 'No existing agent files found, creating default Claude file...'
|
Write-Info 'No existing agent files found, creating default Claude file...'
|
||||||
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false }
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
# "platformdirs",
|
# "platformdirs",
|
||||||
# "readchar",
|
# "readchar",
|
||||||
# "httpx",
|
# "httpx",
|
||||||
# "json5",
|
|
||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
"""
|
"""
|
||||||
@@ -33,8 +32,6 @@ import tempfile
|
|||||||
import shutil
|
import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import json
|
import json
|
||||||
import json5
|
|
||||||
import stat
|
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, Optional, Tuple
|
||||||
@@ -275,13 +272,6 @@ AGENT_CONFIG = {
|
|||||||
"install_url": "https://code.kimi.com/",
|
"install_url": "https://code.kimi.com/",
|
||||||
"requires_cli": True,
|
"requires_cli": True,
|
||||||
},
|
},
|
||||||
"trae": {
|
|
||||||
"name": "Trae",
|
|
||||||
"folder": ".trae/",
|
|
||||||
"commands_subdir": "rules", # Trae uses .trae/rules/ for project rules
|
|
||||||
"install_url": None, # IDE-based
|
|
||||||
"requires_cli": False,
|
|
||||||
},
|
|
||||||
"generic": {
|
"generic": {
|
||||||
"name": "Generic (bring your own agent)",
|
"name": "Generic (bring your own agent)",
|
||||||
"folder": None, # Set dynamically via --ai-commands-dir
|
"folder": None, # Set dynamically via --ai-commands-dir
|
||||||
@@ -664,82 +654,37 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
|
|||||||
os.chdir(original_cwd)
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||||
"""Handle merging or copying of .vscode/settings.json files.
|
"""Handle merging or copying of .vscode/settings.json files."""
|
||||||
|
|
||||||
Note: when merge produces changes, rewritten output is normalized JSON and
|
|
||||||
existing JSONC comments/trailing commas are not preserved.
|
|
||||||
"""
|
|
||||||
def log(message, color="green"):
|
def log(message, color="green"):
|
||||||
if verbose and not tracker:
|
if verbose and not tracker:
|
||||||
console.print(f"[{color}]{message}[/] {rel_path}")
|
console.print(f"[{color}]{message}[/] {rel_path}")
|
||||||
|
|
||||||
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
|
|
||||||
"""Atomically write JSON while preserving existing mode bits when possible."""
|
|
||||||
temp_path: Optional[Path] = None
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode='w',
|
|
||||||
encoding='utf-8',
|
|
||||||
dir=target_file.parent,
|
|
||||||
prefix=f"{target_file.name}.",
|
|
||||||
suffix=".tmp",
|
|
||||||
delete=False,
|
|
||||||
) as f:
|
|
||||||
temp_path = Path(f.name)
|
|
||||||
json.dump(payload, f, indent=4)
|
|
||||||
f.write('\n')
|
|
||||||
|
|
||||||
if target_file.exists():
|
|
||||||
try:
|
|
||||||
existing_stat = target_file.stat()
|
|
||||||
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
|
|
||||||
if hasattr(os, "chown"):
|
|
||||||
try:
|
|
||||||
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
|
|
||||||
except PermissionError:
|
|
||||||
# Best-effort owner/group preservation without requiring elevated privileges.
|
|
||||||
pass
|
|
||||||
except OSError:
|
|
||||||
# Best-effort metadata preservation; data safety is prioritized.
|
|
||||||
pass
|
|
||||||
|
|
||||||
os.replace(temp_path, target_file)
|
|
||||||
except Exception:
|
|
||||||
if temp_path and temp_path.exists():
|
|
||||||
temp_path.unlink()
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(sub_item, 'r', encoding='utf-8') as f:
|
with open(sub_item, 'r', encoding='utf-8') as f:
|
||||||
# json5 natively supports comments and trailing commas (JSONC)
|
new_settings = json.load(f)
|
||||||
new_settings = json5.load(f)
|
|
||||||
|
|
||||||
if dest_file.exists():
|
if dest_file.exists():
|
||||||
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
||||||
if merged is not None:
|
with open(dest_file, 'w', encoding='utf-8') as f:
|
||||||
atomic_write_json(dest_file, merged)
|
json.dump(merged, f, indent=4)
|
||||||
log("Merged:", "green")
|
f.write('\n')
|
||||||
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
|
log("Merged:", "green")
|
||||||
else:
|
|
||||||
log("Skipped merge (preserved existing settings)", "yellow")
|
|
||||||
else:
|
else:
|
||||||
shutil.copy2(sub_item, dest_file)
|
shutil.copy2(sub_item, dest_file)
|
||||||
log("Copied (no existing settings.json):", "blue")
|
log("Copied (no existing settings.json):", "blue")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
|
||||||
if not dest_file.exists():
|
shutil.copy2(sub_item, dest_file)
|
||||||
shutil.copy2(sub_item, dest_file)
|
|
||||||
|
|
||||||
|
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
|
||||||
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
|
|
||||||
"""Merge new JSON content into existing JSON file.
|
"""Merge new JSON content into existing JSON file.
|
||||||
|
|
||||||
Performs a polite deep merge where:
|
Performs a deep merge where:
|
||||||
- New keys are added
|
- New keys are added
|
||||||
- Existing keys are preserved (not overwritten) unless both values are dictionaries
|
- Existing keys are preserved unless overwritten by new content
|
||||||
- Nested dictionaries are merged recursively only when both sides are dictionaries
|
- Nested dictionaries are merged recursively
|
||||||
- Lists and other values are preserved from base if they exist
|
- Lists and other values are replaced (not merged)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
existing_path: Path to existing JSON file
|
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
|
verbose: Whether to print merge details
|
||||||
|
|
||||||
Returns:
|
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
|
try:
|
||||||
existing_content = None
|
with open(existing_path, 'r', encoding='utf-8') as f:
|
||||||
exists = existing_path.exists()
|
existing_content = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
if exists:
|
# If file doesn't exist or is invalid, just use new content
|
||||||
try:
|
|
||||||
with open(existing_path, 'r', encoding='utf-8') as f:
|
|
||||||
# Handle comments (JSONC) natively with json5
|
|
||||||
# Note: json5 handles BOM automatically
|
|
||||||
existing_content = json5.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
# Handle race condition where file is deleted after exists() check
|
|
||||||
exists = False
|
|
||||||
except Exception as e:
|
|
||||||
if verbose:
|
|
||||||
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
|
|
||||||
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Validate template content
|
|
||||||
if not isinstance(new_content, dict):
|
|
||||||
if verbose:
|
|
||||||
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
return new_content
|
return new_content
|
||||||
|
|
||||||
# If existing content parsed but is not a dict, skip merge to avoid data loss
|
def deep_merge(base: dict, update: dict) -> dict:
|
||||||
if not isinstance(existing_content, dict):
|
"""Recursively merge update dict into base dict."""
|
||||||
if verbose:
|
|
||||||
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Recursively merge update dict into base dict, preserving base values."""
|
|
||||||
result = base.copy()
|
result = base.copy()
|
||||||
for key, value in update.items():
|
for key, value in update.items():
|
||||||
if key not in result:
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
# Add new key
|
|
||||||
result[key] = value
|
|
||||||
elif isinstance(result[key], dict) and isinstance(value, dict):
|
|
||||||
# Recursively merge nested dictionaries
|
# Recursively merge nested dictionaries
|
||||||
result[key] = deep_merge_polite(result[key], value)
|
result[key] = deep_merge(result[key], value)
|
||||||
else:
|
else:
|
||||||
# Key already exists and values are not both dicts; preserve existing value.
|
# Add new key or replace existing value
|
||||||
# This ensures user settings aren't overwritten by template defaults.
|
result[key] = value
|
||||||
pass
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
merged = deep_merge_polite(existing_content, new_content)
|
merged = deep_merge(existing_content, new_content)
|
||||||
|
|
||||||
# Detect if anything actually changed. If not, return None so the caller
|
|
||||||
# can skip rewriting the file (preserving user's comments/formatting).
|
|
||||||
if merged == existing_content:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
||||||
@@ -1256,12 +1165,7 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
else:
|
else:
|
||||||
templates_dir = project_path / commands_subdir
|
templates_dir = project_path / commands_subdir
|
||||||
|
|
||||||
# Only consider speckit.*.md templates so that user-authored command
|
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||||
# files (e.g. custom slash commands, agent files) coexisting in the
|
|
||||||
# same commands directory are not incorrectly converted into skills.
|
|
||||||
template_glob = "speckit.*.md"
|
|
||||||
|
|
||||||
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
|
|
||||||
# Fallback: try the repo-relative path (for running from source checkout)
|
# Fallback: try the repo-relative path (for running from source checkout)
|
||||||
# This also covers agents whose extracted commands are in a different
|
# This also covers agents whose extracted commands are in a different
|
||||||
# format (e.g. gemini/tabnine use .toml, not .md).
|
# format (e.g. gemini/tabnine use .toml, not .md).
|
||||||
@@ -1269,16 +1173,15 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
fallback_dir = script_dir / "templates" / "commands"
|
fallback_dir = script_dir / "templates" / "commands"
|
||||||
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
||||||
templates_dir = fallback_dir
|
templates_dir = fallback_dir
|
||||||
template_glob = "*.md"
|
|
||||||
|
|
||||||
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
|
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.error("ai-skills", "command templates not found")
|
tracker.error("ai-skills", "command templates not found")
|
||||||
else:
|
else:
|
||||||
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command_files = sorted(templates_dir.glob(template_glob))
|
command_files = sorted(templates_dir.glob("*.md"))
|
||||||
if not command_files:
|
if not command_files:
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.skip("ai-skills", "no command templates found")
|
tracker.skip("ai-skills", "no command templates found")
|
||||||
@@ -1317,14 +1220,11 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
body = content
|
body = content
|
||||||
|
|
||||||
command_name = command_file.stem
|
command_name = command_file.stem
|
||||||
# Normalize: extracted commands may be named "speckit.<cmd>.md"
|
# Normalize: extracted commands may be named "speckit.<cmd>.md";
|
||||||
# or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
|
# strip the "speckit." prefix so skill names stay clean and
|
||||||
# any trailing ".agent" suffix so skill names stay clean and
|
|
||||||
# SKILL_DESCRIPTIONS lookups work.
|
# SKILL_DESCRIPTIONS lookups work.
|
||||||
if command_name.startswith("speckit."):
|
if command_name.startswith("speckit."):
|
||||||
command_name = command_name[len("speckit."):]
|
command_name = command_name[len("speckit."):]
|
||||||
if command_name.endswith(".agent"):
|
|
||||||
command_name = command_name[:-len(".agent")]
|
|
||||||
# Kimi CLI discovers skills by directory name and invokes them as
|
# Kimi CLI discovers skills by directory name and invokes them as
|
||||||
# /skill:<name> — use dot separator to match packaging convention.
|
# /skill:<name> — use dot separator to match packaging convention.
|
||||||
if selected_ai == "kimi":
|
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
|
source_name = command_file.name
|
||||||
if source_name.startswith("speckit."):
|
if source_name.startswith("speckit."):
|
||||||
source_name = source_name[len("speckit."):]
|
source_name = source_name[len("speckit."):]
|
||||||
if source_name.endswith(".agent.md"):
|
|
||||||
source_name = source_name[:-len(".agent.md")] + ".md"
|
|
||||||
|
|
||||||
frontmatter_data = {
|
frontmatter_data = {
|
||||||
"name": skill_name,
|
"name": skill_name,
|
||||||
@@ -2102,11 +2000,6 @@ def preset_add(
|
|||||||
console.print("Run this command from a spec-kit project root")
|
console.print("Run this command from a spec-kit project root")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
manager = PresetManager(project_root)
|
manager = PresetManager(project_root)
|
||||||
speckit_version = get_speckit_version()
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
@@ -2284,7 +2177,6 @@ def preset_info(
|
|||||||
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||||
):
|
):
|
||||||
"""Show detailed information about a preset."""
|
"""Show detailed information about a preset."""
|
||||||
from .extensions import normalize_priority
|
|
||||||
from .presets import PresetCatalog, PresetManager, PresetError
|
from .presets import PresetCatalog, PresetManager, PresetError
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
@@ -2318,10 +2210,6 @@ def preset_info(
|
|||||||
if license_val:
|
if license_val:
|
||||||
console.print(f" License: {license_val}")
|
console.print(f" License: {license_val}")
|
||||||
console.print("\n [green]Status: installed[/green]")
|
console.print("\n [green]Status: installed[/green]")
|
||||||
# Get priority from registry
|
|
||||||
pack_metadata = manager.registry.get(pack_id)
|
|
||||||
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
|
|
||||||
console.print(f" [dim]Priority:[/dim] {priority}")
|
|
||||||
console.print()
|
console.print()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2353,58 +2241,6 @@ def preset_info(
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
@preset_app.command("set-priority")
|
|
||||||
def preset_set_priority(
|
|
||||||
pack_id: str = typer.Argument(help="Preset ID"),
|
|
||||||
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
||||||
):
|
|
||||||
"""Set the resolution priority of an installed preset."""
|
|
||||||
from .presets import PresetManager
|
|
||||||
|
|
||||||
project_root = Path.cwd()
|
|
||||||
|
|
||||||
# Check if we're in a spec-kit project
|
|
||||||
specify_dir = project_root / ".specify"
|
|
||||||
if not specify_dir.exists():
|
|
||||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
|
||||||
console.print("Run this command from a spec-kit project root")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
manager = PresetManager(project_root)
|
|
||||||
|
|
||||||
# Check if preset is installed
|
|
||||||
if not manager.registry.is_installed(pack_id):
|
|
||||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Get current metadata
|
|
||||||
metadata = manager.registry.get(pack_id)
|
|
||||||
if metadata is None or not isinstance(metadata, dict):
|
|
||||||
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
from .extensions import normalize_priority
|
|
||||||
raw_priority = metadata.get("priority")
|
|
||||||
# Only skip if the stored value is already a valid int equal to requested priority
|
|
||||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
||||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
||||||
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
|
|
||||||
raise typer.Exit(0)
|
|
||||||
|
|
||||||
old_priority = normalize_priority(raw_priority)
|
|
||||||
|
|
||||||
# Update priority
|
|
||||||
manager.registry.update(pack_id, {"priority": priority})
|
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}")
|
|
||||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Preset Catalog Commands =====
|
# ===== Preset Catalog Commands =====
|
||||||
|
|
||||||
|
|
||||||
@@ -2740,9 +2576,8 @@ def extension_list(
|
|||||||
status_color = "green" if ext["enabled"] else "red"
|
status_color = "green" if ext["enabled"] else "red"
|
||||||
|
|
||||||
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
||||||
console.print(f" [dim]{ext['id']}[/dim]")
|
|
||||||
console.print(f" {ext['description']}")
|
console.print(f" {ext['description']}")
|
||||||
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | 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()
|
console.print()
|
||||||
|
|
||||||
if available or all_extensions:
|
if available or all_extensions:
|
||||||
@@ -2930,7 +2765,6 @@ def extension_add(
|
|||||||
extension: str = typer.Argument(help="Extension name or path"),
|
extension: str = typer.Argument(help="Extension name or path"),
|
||||||
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
||||||
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
||||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
|
||||||
):
|
):
|
||||||
"""Install an extension."""
|
"""Install an extension."""
|
||||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
||||||
@@ -2944,11 +2778,6 @@ def extension_add(
|
|||||||
console.print("Run this command from a spec-kit project root")
|
console.print("Run this command from a spec-kit project root")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
speckit_version = get_speckit_version()
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
@@ -2965,7 +2794,7 @@ def extension_add(
|
|||||||
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
|
manifest = manager.install_from_directory(source_path, speckit_version)
|
||||||
|
|
||||||
elif from_url:
|
elif from_url:
|
||||||
# Install from URL (ZIP file)
|
# Install from URL (ZIP file)
|
||||||
@@ -2998,7 +2827,7 @@ def extension_add(
|
|||||||
zip_path.write_bytes(zip_data)
|
zip_path.write_bytes(zip_data)
|
||||||
|
|
||||||
# Install from downloaded ZIP
|
# Install from downloaded ZIP
|
||||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -3042,7 +2871,7 @@ def extension_add(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Install from downloaded ZIP
|
# 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:
|
finally:
|
||||||
# Clean up downloaded ZIP
|
# Clean up downloaded ZIP
|
||||||
if zip_path.exists():
|
if zip_path.exists():
|
||||||
@@ -3218,7 +3047,7 @@ def extension_info(
|
|||||||
extension: str = typer.Argument(help="Extension ID or name"),
|
extension: str = typer.Argument(help="Extension ID or name"),
|
||||||
):
|
):
|
||||||
"""Show detailed information about an extension."""
|
"""Show detailed information about an extension."""
|
||||||
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
|
from .extensions import ExtensionCatalog, ExtensionManager
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
|
|
||||||
@@ -3255,15 +3084,8 @@ def extension_info(
|
|||||||
# Get local manifest info
|
# Get local manifest info
|
||||||
ext_manifest = manager.get_extension(resolved_installed_id)
|
ext_manifest = manager.get_extension(resolved_installed_id)
|
||||||
metadata = manager.registry.get(resolved_installed_id)
|
metadata = manager.registry.get(resolved_installed_id)
|
||||||
metadata_is_dict = isinstance(metadata, dict)
|
|
||||||
if not metadata_is_dict:
|
|
||||||
console.print(
|
|
||||||
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
|
|
||||||
"some information may be unavailable."
|
|
||||||
)
|
|
||||||
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
|
|
||||||
|
|
||||||
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
|
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
|
||||||
console.print(f"ID: {resolved_installed_id}")
|
console.print(f"ID: {resolved_installed_id}")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -3291,8 +3113,6 @@ def extension_info(
|
|||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[green]✓ Installed[/green]")
|
console.print("[green]✓ Installed[/green]")
|
||||||
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
|
|
||||||
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
||||||
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -3308,8 +3128,6 @@ def extension_info(
|
|||||||
|
|
||||||
def _print_extension_info(ext_info: dict, manager):
|
def _print_extension_info(ext_info: dict, manager):
|
||||||
"""Print formatted extension info from catalog data."""
|
"""Print formatted extension info from catalog data."""
|
||||||
from .extensions import normalize_priority
|
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
||||||
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
||||||
@@ -3388,9 +3206,6 @@ def _print_extension_info(ext_info: dict, manager):
|
|||||||
install_allowed = ext_info.get("_install_allowed", True)
|
install_allowed = ext_info.get("_install_allowed", True)
|
||||||
if is_installed:
|
if is_installed:
|
||||||
console.print("[green]✓ Installed[/green]")
|
console.print("[green]✓ Installed[/green]")
|
||||||
metadata = manager.registry.get(ext_info['id'])
|
|
||||||
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
|
|
||||||
console.print(f"[dim]Priority:[/dim] {priority}")
|
|
||||||
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
||||||
elif install_allowed:
|
elif install_allowed:
|
||||||
console.print("[yellow]Not installed[/yellow]")
|
console.print("[yellow]Not installed[/yellow]")
|
||||||
@@ -3417,7 +3232,6 @@ def extension_update(
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
CommandRegistrar,
|
CommandRegistrar,
|
||||||
HookExecutor,
|
HookExecutor,
|
||||||
normalize_priority,
|
|
||||||
)
|
)
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
import shutil
|
import shutil
|
||||||
@@ -3457,7 +3271,7 @@ def extension_update(
|
|||||||
for ext_id in extensions_to_update:
|
for ext_id in extensions_to_update:
|
||||||
# Get installed version
|
# Get installed version
|
||||||
metadata = manager.registry.get(ext_id)
|
metadata = manager.registry.get(ext_id)
|
||||||
if metadata is None or 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)")
|
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -3642,13 +3456,13 @@ def extension_update(
|
|||||||
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
||||||
|
|
||||||
# 9. Restore metadata from backup (installed_at, enabled state)
|
# 9. Restore metadata from backup (installed_at, enabled state)
|
||||||
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
if backup_registry_entry:
|
||||||
# Copy current registry entry to avoid mutating internal
|
# Copy current registry entry to avoid mutating internal
|
||||||
# registry state before explicit restore().
|
# registry state before explicit restore().
|
||||||
current_metadata = manager.registry.get(extension_id)
|
current_metadata = manager.registry.get(extension_id)
|
||||||
if current_metadata is None or not isinstance(current_metadata, dict):
|
if current_metadata is None:
|
||||||
raise RuntimeError(
|
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)
|
new_metadata = dict(current_metadata)
|
||||||
|
|
||||||
@@ -3656,10 +3470,6 @@ def extension_update(
|
|||||||
if "installed_at" in backup_registry_entry:
|
if "installed_at" in backup_registry_entry:
|
||||||
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
||||||
|
|
||||||
# Preserve the original priority (normalized to handle corruption)
|
|
||||||
if "priority" in backup_registry_entry:
|
|
||||||
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
|
|
||||||
|
|
||||||
# If extension was disabled before update, disable it again
|
# If extension was disabled before update, disable it again
|
||||||
if not backup_registry_entry.get("enabled", True):
|
if not backup_registry_entry.get("enabled", True):
|
||||||
new_metadata["enabled"] = False
|
new_metadata["enabled"] = False
|
||||||
@@ -3713,7 +3523,7 @@ def extension_update(
|
|||||||
# (files that weren't in the original backup)
|
# (files that weren't in the original backup)
|
||||||
try:
|
try:
|
||||||
new_registry_entry = manager.registry.get(extension_id)
|
new_registry_entry = manager.registry.get(extension_id)
|
||||||
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
if new_registry_entry is None:
|
||||||
new_registered_commands = {}
|
new_registered_commands = {}
|
||||||
else:
|
else:
|
||||||
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||||
@@ -3833,10 +3643,10 @@ def extension_enable(
|
|||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension_id)
|
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)")
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if metadata.get("enabled", True):
|
if metadata.get("enabled", True):
|
||||||
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
@@ -3881,10 +3691,10 @@ def extension_disable(
|
|||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension_id)
|
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)")
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if not metadata.get("enabled", True):
|
if not metadata.get("enabled", True):
|
||||||
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
|
||||||
raise typer.Exit(0)
|
raise typer.Exit(0)
|
||||||
@@ -3906,57 +3716,6 @@ def extension_disable(
|
|||||||
console.print(f"To re-enable: specify extension enable {extension_id}")
|
console.print(f"To re-enable: specify extension enable {extension_id}")
|
||||||
|
|
||||||
|
|
||||||
@extension_app.command("set-priority")
|
|
||||||
def extension_set_priority(
|
|
||||||
extension: str = typer.Argument(help="Extension ID or name"),
|
|
||||||
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
|
||||||
):
|
|
||||||
"""Set the resolution priority of an installed extension."""
|
|
||||||
from .extensions import ExtensionManager
|
|
||||||
|
|
||||||
project_root = Path.cwd()
|
|
||||||
|
|
||||||
# Check if we're in a spec-kit project
|
|
||||||
specify_dir = project_root / ".specify"
|
|
||||||
if not specify_dir.exists():
|
|
||||||
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
|
||||||
console.print("Run this command from a spec-kit project root")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
manager = ExtensionManager(project_root)
|
|
||||||
|
|
||||||
# Resolve extension ID from argument (handles ambiguous names)
|
|
||||||
installed = manager.list_installed()
|
|
||||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
|
|
||||||
|
|
||||||
# Get current metadata
|
|
||||||
metadata = manager.registry.get(extension_id)
|
|
||||||
if metadata is None or not isinstance(metadata, dict):
|
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
from .extensions import normalize_priority
|
|
||||||
raw_priority = metadata.get("priority")
|
|
||||||
# Only skip if the stored value is already a valid int equal to requested priority
|
|
||||||
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
|
||||||
if isinstance(raw_priority, int) and raw_priority == priority:
|
|
||||||
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
|
|
||||||
raise typer.Exit(0)
|
|
||||||
|
|
||||||
old_priority = normalize_priority(raw_priority)
|
|
||||||
|
|
||||||
# Update priority
|
|
||||||
manager.registry.update(extension_id, {"priority": priority})
|
|
||||||
|
|
||||||
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
|
|
||||||
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@@ -135,12 +135,6 @@ class CommandRegistrar:
|
|||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": "/SKILL.md"
|
"extension": "/SKILL.md"
|
||||||
},
|
|
||||||
"trae": {
|
|
||||||
"dir": ".trae/rules",
|
|
||||||
"format": "markdown",
|
|
||||||
"args": "$ARGUMENTS",
|
|
||||||
"extension": ".md"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,26 +41,6 @@ class CompatibilityError(ExtensionError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def normalize_priority(value: Any, default: int = 10) -> int:
|
|
||||||
"""Normalize a stored priority value for sorting and display.
|
|
||||||
|
|
||||||
Corrupted registry data may contain missing, non-numeric, or non-positive
|
|
||||||
values. In those cases, fall back to the default priority.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value: Priority value to normalize (may be int, str, None, etc.)
|
|
||||||
default: Default priority to use for invalid values (default: 10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Normalized priority as positive integer (>= 1)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
priority = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
return priority if priority >= 1 else default
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CatalogEntry:
|
class CatalogEntry:
|
||||||
"""Represents a single catalog entry in the catalog stack."""
|
"""Represents a single catalog entry in the catalog stack."""
|
||||||
@@ -271,9 +251,6 @@ class ExtensionRegistry:
|
|||||||
raise KeyError(f"Extension '{extension_id}' is not installed")
|
raise KeyError(f"Extension '{extension_id}' is not installed")
|
||||||
# Merge new metadata with existing, preserving original installed_at
|
# Merge new metadata with existing, preserving original installed_at
|
||||||
existing = self.data["extensions"][extension_id]
|
existing = 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
|
# Merge: existing fields preserved, new fields override
|
||||||
merged = {**existing, **metadata}
|
merged = {**existing, **metadata}
|
||||||
# Always preserve original installed_at based on key existence, not truthiness,
|
# Always preserve original installed_at based on key existence, not truthiness,
|
||||||
@@ -347,32 +324,6 @@ class ExtensionRegistry:
|
|||||||
"""
|
"""
|
||||||
return extension_id in self.data["extensions"]
|
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:
|
class ExtensionManager:
|
||||||
"""Manages extension lifecycle: installation, removal, updates."""
|
"""Manages extension lifecycle: installation, removal, updates."""
|
||||||
@@ -489,8 +440,7 @@ class ExtensionManager:
|
|||||||
self,
|
self,
|
||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
speckit_version: str,
|
speckit_version: str,
|
||||||
register_commands: bool = True,
|
register_commands: bool = True
|
||||||
priority: int = 10,
|
|
||||||
) -> ExtensionManifest:
|
) -> ExtensionManifest:
|
||||||
"""Install extension from a local directory.
|
"""Install extension from a local directory.
|
||||||
|
|
||||||
@@ -498,19 +448,14 @@ class ExtensionManager:
|
|||||||
source_dir: Path to extension directory
|
source_dir: Path to extension directory
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
register_commands: If True, register commands with AI agents
|
register_commands: If True, register commands with AI agents
|
||||||
priority: Resolution priority (lower = higher precedence, default 10)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed extension manifest
|
Installed extension manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If manifest is invalid or priority is invalid
|
ValidationError: If manifest is invalid
|
||||||
CompatibilityError: If extension is incompatible
|
CompatibilityError: If extension is incompatible
|
||||||
"""
|
"""
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
|
||||||
|
|
||||||
# Load and validate manifest
|
# Load and validate manifest
|
||||||
manifest_path = source_dir / "extension.yml"
|
manifest_path = source_dir / "extension.yml"
|
||||||
manifest = ExtensionManifest(manifest_path)
|
manifest = ExtensionManifest(manifest_path)
|
||||||
@@ -552,7 +497,6 @@ class ExtensionManager:
|
|||||||
"source": "local",
|
"source": "local",
|
||||||
"manifest_hash": manifest.get_hash(),
|
"manifest_hash": manifest.get_hash(),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"priority": priority,
|
|
||||||
"registered_commands": registered_commands
|
"registered_commands": registered_commands
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -561,27 +505,21 @@ class ExtensionManager:
|
|||||||
def install_from_zip(
|
def install_from_zip(
|
||||||
self,
|
self,
|
||||||
zip_path: Path,
|
zip_path: Path,
|
||||||
speckit_version: str,
|
speckit_version: str
|
||||||
priority: int = 10,
|
|
||||||
) -> ExtensionManifest:
|
) -> ExtensionManifest:
|
||||||
"""Install extension from ZIP file.
|
"""Install extension from ZIP file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zip_path: Path to extension ZIP file
|
zip_path: Path to extension ZIP file
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
priority: Resolution priority (lower = higher precedence, default 10)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed extension manifest
|
Installed extension manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If manifest is invalid or priority is invalid
|
ValidationError: If manifest is invalid
|
||||||
CompatibilityError: If extension is incompatible
|
CompatibilityError: If extension is incompatible
|
||||||
"""
|
"""
|
||||||
# Validate priority early
|
|
||||||
if priority < 1:
|
|
||||||
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
temp_path = Path(tmpdir)
|
temp_path = Path(tmpdir)
|
||||||
|
|
||||||
@@ -616,7 +554,7 @@ class ExtensionManager:
|
|||||||
raise ValidationError("No extension.yml found in ZIP file")
|
raise ValidationError("No extension.yml found in ZIP file")
|
||||||
|
|
||||||
# Install from extracted directory
|
# Install from extracted directory
|
||||||
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
|
return self.install_from_directory(extension_dir, speckit_version)
|
||||||
|
|
||||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||||
"""Remove an installed extension.
|
"""Remove an installed extension.
|
||||||
@@ -694,9 +632,6 @@ class ExtensionManager:
|
|||||||
result = []
|
result = []
|
||||||
|
|
||||||
for ext_id, metadata in self.registry.list().items():
|
for ext_id, metadata in self.registry.list().items():
|
||||||
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
metadata = {}
|
|
||||||
ext_dir = self.extensions_dir / ext_id
|
ext_dir = self.extensions_dir / ext_id
|
||||||
manifest_path = ext_dir / "extension.yml"
|
manifest_path = ext_dir / "extension.yml"
|
||||||
|
|
||||||
@@ -708,7 +643,6 @@ class ExtensionManager:
|
|||||||
"version": metadata.get("version", "unknown"),
|
"version": metadata.get("version", "unknown"),
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"enabled": metadata.get("enabled", True),
|
"enabled": metadata.get("enabled", True),
|
||||||
"priority": normalize_priority(metadata.get("priority")),
|
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"command_count": len(manifest.commands),
|
"command_count": len(manifest.commands),
|
||||||
"hook_count": len(manifest.hooks)
|
"hook_count": len(manifest.hooks)
|
||||||
@@ -721,7 +655,6 @@ class ExtensionManager:
|
|||||||
"version": metadata.get("version", "unknown"),
|
"version": metadata.get("version", "unknown"),
|
||||||
"description": "⚠️ Corrupted extension",
|
"description": "⚠️ Corrupted extension",
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"priority": normalize_priority(metadata.get("priority")),
|
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"command_count": 0,
|
"command_count": 0,
|
||||||
"hook_count": 0
|
"hook_count": 0
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Presets are self-contained, versioned collections of templates
|
|||||||
customize the Spec-Driven Development workflow.
|
customize the Spec-Driven Development workflow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
@@ -24,8 +23,6 @@ import yaml
|
|||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
|
|
||||||
from .extensions import ExtensionRegistry, normalize_priority
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PresetCatalogEntry:
|
class PresetCatalogEntry:
|
||||||
@@ -274,38 +271,6 @@ class PresetRegistry:
|
|||||||
del self.data["presets"][pack_id]
|
del self.data["presets"][pack_id]
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
def update(self, pack_id: str, updates: dict):
|
|
||||||
"""Update preset metadata in registry.
|
|
||||||
|
|
||||||
Merges the provided updates with the existing entry, preserving any
|
|
||||||
fields not specified. The installed_at timestamp is always preserved
|
|
||||||
from the original entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pack_id: Preset ID
|
|
||||||
updates: Partial metadata to merge into existing metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If preset is not installed
|
|
||||||
"""
|
|
||||||
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]:
|
def get(self, pack_id: str) -> Optional[dict]:
|
||||||
"""Get preset metadata from registry.
|
"""Get preset metadata from registry.
|
||||||
|
|
||||||
@@ -329,26 +294,14 @@ class PresetRegistry:
|
|||||||
"""Get all installed presets sorted by priority.
|
"""Get all installed presets sorted by priority.
|
||||||
|
|
||||||
Lower priority number = higher precedence (checked first).
|
Lower priority number = higher precedence (checked first).
|
||||||
Presets with equal priority are sorted alphabetically by ID
|
|
||||||
for deterministic ordering.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (pack_id, metadata_copy) tuples sorted by priority.
|
List of (pack_id, metadata) tuples sorted by priority
|
||||||
Metadata is deep-copied to prevent accidental mutation.
|
|
||||||
"""
|
"""
|
||||||
packs = self.data.get("presets", {}) or {}
|
packs = self.data["presets"]
|
||||||
if not isinstance(packs, dict):
|
|
||||||
packs = {}
|
|
||||||
sortable_packs = []
|
|
||||||
for pack_id, meta in packs.items():
|
|
||||||
if not isinstance(meta, dict):
|
|
||||||
continue
|
|
||||||
metadata_copy = copy.deepcopy(meta)
|
|
||||||
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
|
||||||
sortable_packs.append((pack_id, metadata_copy))
|
|
||||||
return sorted(
|
return sorted(
|
||||||
sortable_packs,
|
packs.items(),
|
||||||
key=lambda item: (item[1]["priority"], item[0]),
|
key=lambda item: item[1].get("priority", 10),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_installed(self, pack_id: str) -> bool:
|
def is_installed(self, pack_id: str) -> bool:
|
||||||
@@ -727,13 +680,9 @@ class PresetManager:
|
|||||||
Installed preset manifest
|
Installed preset manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PresetValidationError: If manifest is invalid or priority is invalid
|
PresetValidationError: If manifest is invalid
|
||||||
PresetCompatibilityError: If pack is incompatible
|
PresetCompatibilityError: If pack is incompatible
|
||||||
"""
|
"""
|
||||||
# Validate priority
|
|
||||||
if priority < 1:
|
|
||||||
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
|
||||||
|
|
||||||
manifest_path = source_dir / "preset.yml"
|
manifest_path = source_dir / "preset.yml"
|
||||||
manifest = PresetManifest(manifest_path)
|
manifest = PresetManifest(manifest_path)
|
||||||
|
|
||||||
@@ -780,19 +729,14 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
zip_path: Path to preset ZIP file
|
zip_path: Path to preset ZIP file
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
priority: Resolution priority (lower = higher precedence, default 10)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed preset manifest
|
Installed preset manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PresetValidationError: If manifest is invalid or priority is invalid
|
PresetValidationError: If manifest is invalid
|
||||||
PresetCompatibilityError: If pack is incompatible
|
PresetCompatibilityError: If pack is incompatible
|
||||||
"""
|
"""
|
||||||
# Validate priority early
|
|
||||||
if priority < 1:
|
|
||||||
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
temp_path = Path(tmpdir)
|
temp_path = Path(tmpdir)
|
||||||
|
|
||||||
@@ -864,9 +808,6 @@ class PresetManager:
|
|||||||
result = []
|
result = []
|
||||||
|
|
||||||
for pack_id, metadata in self.registry.list().items():
|
for pack_id, metadata in self.registry.list().items():
|
||||||
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
metadata = {}
|
|
||||||
pack_dir = self.presets_dir / pack_id
|
pack_dir = self.presets_dir / pack_id
|
||||||
manifest_path = pack_dir / "preset.yml"
|
manifest_path = pack_dir / "preset.yml"
|
||||||
|
|
||||||
@@ -875,13 +816,13 @@ class PresetManager:
|
|||||||
result.append({
|
result.append({
|
||||||
"id": pack_id,
|
"id": pack_id,
|
||||||
"name": manifest.name,
|
"name": manifest.name,
|
||||||
"version": metadata.get("version", manifest.version),
|
"version": metadata["version"],
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"enabled": metadata.get("enabled", True),
|
"enabled": metadata.get("enabled", True),
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"template_count": len(manifest.templates),
|
"template_count": len(manifest.templates),
|
||||||
"tags": manifest.tags,
|
"tags": manifest.tags,
|
||||||
"priority": normalize_priority(metadata.get("priority")),
|
"priority": metadata.get("priority", 10),
|
||||||
})
|
})
|
||||||
except PresetValidationError:
|
except PresetValidationError:
|
||||||
result.append({
|
result.append({
|
||||||
@@ -893,7 +834,7 @@ class PresetManager:
|
|||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"template_count": 0,
|
"template_count": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"priority": normalize_priority(metadata.get("priority")),
|
"priority": metadata.get("priority", 10),
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1452,40 +1393,6 @@ class PresetResolver:
|
|||||||
self.overrides_dir = self.templates_dir / "overrides"
|
self.overrides_dir = self.templates_dir / "overrides"
|
||||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||||
|
|
||||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
|
||||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
|
||||||
|
|
||||||
Registered extensions use their stored priority; unregistered directories
|
|
||||||
get implicit priority=10. Results are sorted by (priority, ext_id) for
|
|
||||||
deterministic ordering.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of (priority, ext_id, metadata_or_none) tuples sorted by priority.
|
|
||||||
"""
|
|
||||||
if not self.extensions_dir.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(self.extensions_dir)
|
|
||||||
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(
|
def resolve(
|
||||||
self,
|
self,
|
||||||
template_name: str,
|
template_name: str,
|
||||||
@@ -1538,18 +1445,18 @@ class PresetResolver:
|
|||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
|
# Priority 3: Extension-provided templates
|
||||||
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
if self.extensions_dir.exists():
|
||||||
ext_dir = self.extensions_dir / ext_id
|
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||||
if not ext_dir.is_dir():
|
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
for subdir in subdirs:
|
for subdir in subdirs:
|
||||||
if subdir:
|
if subdir:
|
||||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||||
else:
|
else:
|
||||||
candidate = ext_dir / f"{template_name}{ext}"
|
candidate = ext_dir / "templates" / f"{template_name}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
# Priority 4: Core templates
|
# Priority 4: Core templates
|
||||||
if template_type == "template":
|
if template_type == "template":
|
||||||
@@ -1607,24 +1514,17 @@ class PresetResolver:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
|
if self.extensions_dir.exists():
|
||||||
ext_dir = self.extensions_dir / ext_id
|
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
||||||
if not ext_dir.is_dir():
|
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resolved.relative_to(ext_dir)
|
resolved.relative_to(ext_dir)
|
||||||
if ext_meta:
|
|
||||||
version = ext_meta.get("version", "?")
|
|
||||||
return {
|
return {
|
||||||
"path": resolved_str,
|
"path": resolved_str,
|
||||||
"source": f"extension:{ext_id} v{version}",
|
"source": f"extension:{ext_dir.name}",
|
||||||
}
|
}
|
||||||
else:
|
except ValueError:
|
||||||
return {
|
continue
|
||||||
"path": resolved_str,
|
|
||||||
"source": f"extension:{ext_id} (unregistered)",
|
|
||||||
}
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return {"path": resolved_str, "source": "core"}
|
return {"path": resolved_str, "source": "core"}
|
||||||
|
|||||||
@@ -233,79 +233,3 @@ class TestAgentConfigConsistency:
|
|||||||
def test_ai_help_includes_kimi(self):
|
def test_ai_help_includes_kimi(self):
|
||||||
"""CLI help text for --ai should include kimi."""
|
"""CLI help text for --ai should include kimi."""
|
||||||
assert "kimi" in AI_ASSISTANT_HELP
|
assert "kimi" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- Trae IDE consistency checks ---
|
|
||||||
|
|
||||||
def test_trae_in_agent_config(self):
|
|
||||||
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
|
|
||||||
assert "trae" in AGENT_CONFIG
|
|
||||||
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
|
|
||||||
assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules"
|
|
||||||
assert AGENT_CONFIG["trae"]["requires_cli"] is False
|
|
||||||
assert AGENT_CONFIG["trae"]["install_url"] is None
|
|
||||||
|
|
||||||
def test_trae_in_extension_registrar(self):
|
|
||||||
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
|
|
||||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
|
||||||
|
|
||||||
assert "trae" in cfg
|
|
||||||
trae_cfg = cfg["trae"]
|
|
||||||
assert trae_cfg["format"] == "markdown"
|
|
||||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
|
||||||
assert trae_cfg["extension"] == ".md"
|
|
||||||
|
|
||||||
def test_trae_in_release_agent_lists(self):
|
|
||||||
"""Bash and PowerShell release scripts should include trae in agent lists."""
|
|
||||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
|
||||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
|
||||||
assert sh_match is not None
|
|
||||||
sh_agents = sh_match.group(1).split()
|
|
||||||
|
|
||||||
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
|
||||||
assert ps_match is not None
|
|
||||||
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
|
||||||
|
|
||||||
assert "trae" in sh_agents
|
|
||||||
assert "trae" in ps_agents
|
|
||||||
|
|
||||||
def test_trae_in_release_scripts_generate_commands(self):
|
|
||||||
"""Release scripts should generate markdown commands for trae in .trae/rules."""
|
|
||||||
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
|
||||||
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert ".trae/rules" in sh_text
|
|
||||||
assert ".trae/rules" in ps_text
|
|
||||||
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
|
|
||||||
|
|
||||||
def test_trae_in_github_release_output(self):
|
|
||||||
"""GitHub release script should include trae template packages."""
|
|
||||||
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "spec-kit-template-trae-sh-" in gh_release_text
|
|
||||||
assert "spec-kit-template-trae-ps-" in gh_release_text
|
|
||||||
|
|
||||||
def test_trae_in_agent_context_scripts(self):
|
|
||||||
"""Agent context scripts should support trae agent type."""
|
|
||||||
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
|
||||||
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "trae" in bash_text
|
|
||||||
assert "TRAE_FILE" in bash_text
|
|
||||||
assert "trae" in pwsh_text
|
|
||||||
assert "TRAE_FILE" in pwsh_text
|
|
||||||
|
|
||||||
def test_trae_in_powershell_validate_set(self):
|
|
||||||
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
|
|
||||||
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
|
||||||
assert validate_set_match is not None
|
|
||||||
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
|
|
||||||
|
|
||||||
assert "trae" in validate_set_values
|
|
||||||
|
|
||||||
def test_ai_help_includes_trae(self):
|
|
||||||
"""CLI help text for --ai should include trae."""
|
|
||||||
assert "trae" in AI_ASSISTANT_HELP
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def templates_dir(project_dir):
|
|||||||
tpl_root.mkdir(parents=True, exist_ok=True)
|
tpl_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Template with valid YAML frontmatter
|
# Template with valid YAML frontmatter
|
||||||
(tpl_root / "speckit.specify.md").write_text(
|
(tpl_root / "specify.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: Create or update the feature specification.\n"
|
"description: Create or update the feature specification.\n"
|
||||||
"handoffs:\n"
|
"handoffs:\n"
|
||||||
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with minimal frontmatter
|
# Template with minimal frontmatter
|
||||||
(tpl_root / "speckit.plan.md").write_text(
|
(tpl_root / "plan.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: Generate implementation plan.\n"
|
"description: Generate implementation plan.\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
@@ -91,7 +91,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with no frontmatter
|
# Template with no frontmatter
|
||||||
(tpl_root / "speckit.tasks.md").write_text(
|
(tpl_root / "tasks.md").write_text(
|
||||||
"# Tasks Command\n"
|
"# Tasks Command\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Body without frontmatter.\n",
|
"Body without frontmatter.\n",
|
||||||
@@ -99,7 +99,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
||||||
(tpl_root / "speckit.empty_fm.md").write_text(
|
(tpl_root / "empty_fm.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -337,7 +337,7 @@ class TestInstallAiSkills:
|
|||||||
cmds_dir = project_dir / ".claude" / "commands"
|
cmds_dir = project_dir / ".claude" / "commands"
|
||||||
cmds_dir.mkdir(parents=True)
|
cmds_dir.mkdir(parents=True)
|
||||||
|
|
||||||
(cmds_dir / "speckit.broken.md").write_text(
|
(cmds_dir / "broken.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: [unclosed bracket\n"
|
"description: [unclosed bracket\n"
|
||||||
" invalid: yaml: content: here\n"
|
" invalid: yaml: content: here\n"
|
||||||
@@ -430,12 +430,9 @@ class TestInstallAiSkills:
|
|||||||
|
|
||||||
# Place .md templates in the agent's commands directory
|
# Place .md templates in the agent's commands directory
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
cmds_dir.mkdir(parents=True)
|
||||||
# Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md
|
(cmds_dir / "specify.md").write_text(
|
||||||
fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md"
|
|
||||||
(cmds_dir / fname).write_text(
|
|
||||||
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -451,100 +448,7 @@ class TestInstallAiSkills:
|
|||||||
assert expected_skill_name in skill_dirs
|
assert expected_skill_name in skill_dirs
|
||||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||||
|
|
||||||
def test_copilot_ignores_non_speckit_agents(self, project_dir):
|
|
||||||
"""Non-speckit markdown in .github/agents/ must not produce skills."""
|
|
||||||
agents_dir = project_dir / ".github" / "agents"
|
|
||||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
||||||
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
|
||||||
)
|
|
||||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
|
||||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "copilot")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert "speckit-plan" in skill_dirs
|
|
||||||
assert "speckit-my-custom-agent.agent" not in skill_dirs
|
|
||||||
assert "speckit-my-custom-agent" not in skill_dirs
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent_key,custom_file", [
|
|
||||||
("claude", "review.md"),
|
|
||||||
("cursor-agent", "deploy.md"),
|
|
||||||
("qwen", "my-workflow.md"),
|
|
||||||
])
|
|
||||||
def test_non_speckit_commands_ignored_for_all_agents(self, temp_dir, agent_key, custom_file):
|
|
||||||
"""User-authored command files must not produce skills for any agent."""
|
|
||||||
proj = temp_dir / f"proj-{agent_key}"
|
|
||||||
proj.mkdir()
|
|
||||||
|
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
(cmds_dir / "speckit.specify.md").write_text(
|
|
||||||
"---\ndescription: Create spec.\n---\n\n# Specify\n\nBody.\n"
|
|
||||||
)
|
|
||||||
(cmds_dir / custom_file).write_text(
|
|
||||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(proj, agent_key)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert "speckit-specify" in skill_dirs
|
|
||||||
custom_stem = Path(custom_file).stem
|
|
||||||
assert f"speckit-{custom_stem}" not in skill_dirs
|
|
||||||
|
|
||||||
def test_copilot_fallback_when_only_non_speckit_agents(self, project_dir):
|
|
||||||
"""Fallback to templates/commands/ when .github/agents/ has no speckit.*.md files."""
|
|
||||||
agents_dir = project_dir / ".github" / "agents"
|
|
||||||
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
# Only a user-authored agent, no speckit.* templates
|
|
||||||
(agents_dir / "my-custom-agent.agent.md").write_text(
|
|
||||||
"---\ndescription: A user custom agent\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(project_dir, "copilot")
|
|
||||||
|
|
||||||
# Should succeed via fallback to templates/commands/
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(project_dir, "copilot")
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
# Should have skills from fallback templates, not from the custom agent
|
|
||||||
assert "speckit-plan" in skill_dirs
|
|
||||||
assert not any("my-custom" in d for d in skill_dirs)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent_key", ["claude", "cursor-agent", "qwen"])
|
|
||||||
def test_fallback_when_only_non_speckit_commands(self, temp_dir, agent_key):
|
|
||||||
"""Fallback to templates/commands/ when agent dir has no speckit.*.md files."""
|
|
||||||
proj = temp_dir / f"proj-{agent_key}"
|
|
||||||
proj.mkdir()
|
|
||||||
|
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
|
||||||
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
|
||||||
cmds_dir.mkdir(parents=True)
|
|
||||||
# Only a user-authored command, no speckit.* templates
|
|
||||||
(cmds_dir / "my-custom-command.md").write_text(
|
|
||||||
"---\ndescription: User custom command\n---\n\n# Custom\n\nBody.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = install_ai_skills(proj, agent_key)
|
|
||||||
|
|
||||||
# Should succeed via fallback to templates/commands/
|
|
||||||
assert result is True
|
|
||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
|
||||||
assert skills_dir.exists()
|
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
|
||||||
assert not any("my-custom" in d for d in skill_dirs)
|
|
||||||
|
|
||||||
class TestCommandCoexistence:
|
class TestCommandCoexistence:
|
||||||
"""Verify install_ai_skills never touches command files.
|
"""Verify install_ai_skills never touches command files.
|
||||||
@@ -556,16 +460,14 @@ class TestCommandCoexistence:
|
|||||||
|
|
||||||
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
||||||
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
||||||
# Verify commands exist before (templates_dir adds 4 speckit.* files,
|
# Verify commands exist before
|
||||||
# commands_dir_claude overlaps with 3 of them)
|
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
|
||||||
before = list(commands_dir_claude.glob("speckit.*"))
|
|
||||||
assert len(before) >= 3
|
|
||||||
|
|
||||||
install_ai_skills(project_dir, "claude")
|
install_ai_skills(project_dir, "claude")
|
||||||
|
|
||||||
# Commands must still be there — install_ai_skills never touches them
|
# Commands must still be there — install_ai_skills never touches them
|
||||||
remaining = list(commands_dir_claude.glob("speckit.*"))
|
remaining = list(commands_dir_claude.glob("speckit.*"))
|
||||||
assert len(remaining) == len(before)
|
assert len(remaining) == 3
|
||||||
|
|
||||||
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
||||||
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from specify_cli.extensions import (
|
|||||||
ExtensionError,
|
ExtensionError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
CompatibilityError,
|
CompatibilityError,
|
||||||
normalize_priority,
|
|
||||||
version_satisfies,
|
version_satisfies,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,57 +121,6 @@ def project_dir(temp_dir):
|
|||||||
return proj_dir
|
return proj_dir
|
||||||
|
|
||||||
|
|
||||||
# ===== normalize_priority Tests =====
|
|
||||||
|
|
||||||
class TestNormalizePriority:
|
|
||||||
"""Test normalize_priority helper function."""
|
|
||||||
|
|
||||||
def test_valid_integer(self):
|
|
||||||
"""Test with valid integer priority."""
|
|
||||||
assert normalize_priority(5) == 5
|
|
||||||
assert normalize_priority(1) == 1
|
|
||||||
assert normalize_priority(100) == 100
|
|
||||||
|
|
||||||
def test_valid_string_number(self):
|
|
||||||
"""Test with string that can be converted to int."""
|
|
||||||
assert normalize_priority("5") == 5
|
|
||||||
assert normalize_priority("10") == 10
|
|
||||||
|
|
||||||
def test_zero_returns_default(self):
|
|
||||||
"""Test that zero priority returns default."""
|
|
||||||
assert normalize_priority(0) == 10
|
|
||||||
assert normalize_priority(0, default=5) == 5
|
|
||||||
|
|
||||||
def test_negative_returns_default(self):
|
|
||||||
"""Test that negative priority returns default."""
|
|
||||||
assert normalize_priority(-1) == 10
|
|
||||||
assert normalize_priority(-100, default=5) == 5
|
|
||||||
|
|
||||||
def test_none_returns_default(self):
|
|
||||||
"""Test that None returns default."""
|
|
||||||
assert normalize_priority(None) == 10
|
|
||||||
assert normalize_priority(None, default=5) == 5
|
|
||||||
|
|
||||||
def test_invalid_string_returns_default(self):
|
|
||||||
"""Test that non-numeric string returns default."""
|
|
||||||
assert normalize_priority("invalid") == 10
|
|
||||||
assert normalize_priority("abc", default=5) == 5
|
|
||||||
|
|
||||||
def test_float_truncates(self):
|
|
||||||
"""Test that float is truncated to int."""
|
|
||||||
assert normalize_priority(5.9) == 5
|
|
||||||
assert normalize_priority(3.1) == 3
|
|
||||||
|
|
||||||
def test_empty_string_returns_default(self):
|
|
||||||
"""Test that empty string returns default."""
|
|
||||||
assert normalize_priority("") == 10
|
|
||||||
|
|
||||||
def test_custom_default(self):
|
|
||||||
"""Test custom default value."""
|
|
||||||
assert normalize_priority(None, default=20) == 20
|
|
||||||
assert normalize_priority("invalid", default=1) == 1
|
|
||||||
|
|
||||||
|
|
||||||
# ===== ExtensionManifest Tests =====
|
# ===== ExtensionManifest Tests =====
|
||||||
|
|
||||||
class TestExtensionManifest:
|
class TestExtensionManifest:
|
||||||
@@ -2389,404 +2337,3 @@ class TestExtensionUpdateCLI:
|
|||||||
|
|
||||||
for cmd_file in command_files:
|
for cmd_file in command_files:
|
||||||
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
|
assert cmd_file.exists(), f"Expected command file to be restored after rollback: {cmd_file}"
|
||||||
|
|
||||||
|
|
||||||
class TestExtensionListCLI:
|
|
||||||
"""Test extension list CLI output format."""
|
|
||||||
|
|
||||||
def test_list_shows_extension_id(self, extension_dir, project_dir):
|
|
||||||
"""extension list should display the extension ID."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from unittest.mock import patch
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
# Install the extension using the manager
|
|
||||||
manager = ExtensionManager(project_dir)
|
|
||||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=project_dir):
|
|
||||||
result = runner.invoke(app, ["extension", "list"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
# Verify the extension ID is shown in the output
|
|
||||||
assert "test-ext" in result.output
|
|
||||||
# Verify name and version are also shown
|
|
||||||
assert "Test Extension" in result.output
|
|
||||||
assert "1.0.0" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtensionPriority:
|
|
||||||
"""Test extension priority-based resolution."""
|
|
||||||
|
|
||||||
def test_list_by_priority_empty(self, temp_dir):
|
|
||||||
"""Test list_by_priority on empty registry."""
|
|
||||||
extensions_dir = temp_dir / "extensions"
|
|
||||||
extensions_dir.mkdir()
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(extensions_dir)
|
|
||||||
result = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_list_by_priority_single(self, temp_dir):
|
|
||||||
"""Test list_by_priority with single extension."""
|
|
||||||
extensions_dir = temp_dir / "extensions"
|
|
||||||
extensions_dir.mkdir()
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(extensions_dir)
|
|
||||||
registry.add("test-ext", {"version": "1.0.0", "priority": 5})
|
|
||||||
|
|
||||||
result = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "test-ext"
|
|
||||||
assert result[0][1]["priority"] == 5
|
|
||||||
|
|
||||||
def test_list_by_priority_ordering(self, temp_dir):
|
|
||||||
"""Test list_by_priority returns extensions sorted by priority."""
|
|
||||||
extensions_dir = temp_dir / "extensions"
|
|
||||||
extensions_dir.mkdir()
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(extensions_dir)
|
|
||||||
# Add in non-priority order
|
|
||||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
|
||||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
|
||||||
registry.add("ext-mid", {"version": "1.0.0", "priority": 10})
|
|
||||||
|
|
||||||
result = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert len(result) == 3
|
|
||||||
# Lower priority number = higher precedence (first)
|
|
||||||
assert result[0][0] == "ext-high"
|
|
||||||
assert result[1][0] == "ext-mid"
|
|
||||||
assert result[2][0] == "ext-low"
|
|
||||||
|
|
||||||
def test_list_by_priority_default(self, temp_dir):
|
|
||||||
"""Test list_by_priority uses default priority of 10."""
|
|
||||||
extensions_dir = temp_dir / "extensions"
|
|
||||||
extensions_dir.mkdir()
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(extensions_dir)
|
|
||||||
# Add without explicit priority
|
|
||||||
registry.add("ext-default", {"version": "1.0.0"})
|
|
||||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
|
||||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
|
||||||
|
|
||||||
result = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert len(result) == 3
|
|
||||||
# ext-high (1), ext-default (10), ext-low (20)
|
|
||||||
assert result[0][0] == "ext-high"
|
|
||||||
assert result[1][0] == "ext-default"
|
|
||||||
assert result[2][0] == "ext-low"
|
|
||||||
|
|
||||||
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
|
||||||
"""Malformed priority values fall back to the default priority."""
|
|
||||||
extensions_dir = temp_dir / "extensions"
|
|
||||||
extensions_dir.mkdir()
|
|
||||||
|
|
||||||
registry = ExtensionRegistry(extensions_dir)
|
|
||||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
|
||||||
registry.data["extensions"]["ext-invalid"] = {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"priority": "high",
|
|
||||||
}
|
|
||||||
registry._save()
|
|
||||||
|
|
||||||
result = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
|
|
||||||
assert result[1][1]["priority"] == 10
|
|
||||||
|
|
||||||
def test_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,
|
PresetCompatibilityError,
|
||||||
VALID_PRESET_TEMPLATE_TYPES,
|
VALID_PRESET_TEMPLATE_TYPES,
|
||||||
)
|
)
|
||||||
from specify_cli.extensions import ExtensionRegistry
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Fixtures =====
|
# ===== Fixtures =====
|
||||||
@@ -574,24 +573,6 @@ class TestRegistryPriority:
|
|||||||
assert sorted_packs[0][0] == "pack-b"
|
assert sorted_packs[0][0] == "pack-b"
|
||||||
assert sorted_packs[1][0] == "pack-a"
|
assert sorted_packs[1][0] == "pack-a"
|
||||||
|
|
||||||
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
|
||||||
"""Malformed priority values fall back to the default priority."""
|
|
||||||
packs_dir = temp_dir / "packs"
|
|
||||||
packs_dir.mkdir()
|
|
||||||
registry = PresetRegistry(packs_dir)
|
|
||||||
|
|
||||||
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
|
|
||||||
registry.data["presets"]["pack-invalid"] = {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"priority": "high",
|
|
||||||
}
|
|
||||||
registry._save()
|
|
||||||
|
|
||||||
sorted_packs = registry.list_by_priority()
|
|
||||||
|
|
||||||
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
|
|
||||||
assert sorted_packs[1][1]["priority"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PresetResolver Tests =====
|
# ===== PresetResolver Tests =====
|
||||||
|
|
||||||
@@ -697,11 +678,6 @@ class TestPresetResolver:
|
|||||||
ext_template = ext_templates_dir / "custom-template.md"
|
ext_template = ext_templates_dir / "custom-template.md"
|
||||||
ext_template.write_text("# Extension Custom Template\n")
|
ext_template.write_text("# Extension Custom Template\n")
|
||||||
|
|
||||||
# Register extension in registry
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
||||||
|
|
||||||
resolver = PresetResolver(project_dir)
|
resolver = PresetResolver(project_dir)
|
||||||
result = resolver.resolve("custom-template")
|
result = resolver.resolve("custom-template")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
@@ -765,15 +741,10 @@ class TestPresetResolver:
|
|||||||
ext_template = ext_templates_dir / "unique-template.md"
|
ext_template = ext_templates_dir / "unique-template.md"
|
||||||
ext_template.write_text("# Unique\n")
|
ext_template.write_text("# Unique\n")
|
||||||
|
|
||||||
# Register extension in registry
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
||||||
|
|
||||||
resolver = PresetResolver(project_dir)
|
resolver = PresetResolver(project_dir)
|
||||||
result = resolver.resolve_with_source("unique-template")
|
result = resolver.resolve_with_source("unique-template")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["source"] == "extension:my-ext v1.0.0"
|
assert result["source"] == "extension:my-ext"
|
||||||
|
|
||||||
def test_resolve_with_source_not_found(self, project_dir):
|
def test_resolve_with_source_not_found(self, project_dir):
|
||||||
"""Test resolve_with_source for nonexistent template."""
|
"""Test resolve_with_source for nonexistent template."""
|
||||||
@@ -794,104 +765,6 @@ class TestPresetResolver:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestExtensionPriorityResolution:
|
|
||||||
"""Test extension priority resolution with registered and unregistered extensions."""
|
|
||||||
|
|
||||||
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
|
|
||||||
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create registered extension with priority 20 (lower precedence than 10)
|
|
||||||
registered_dir = extensions_dir / "registered-ext"
|
|
||||||
(registered_dir / "templates").mkdir(parents=True)
|
|
||||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
||||||
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
|
||||||
|
|
||||||
# Create unregistered extension directory (implicit priority 10)
|
|
||||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
||||||
(unregistered_dir / "templates").mkdir(parents=True)
|
|
||||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
||||||
|
|
||||||
# Unregistered (priority 10) should beat registered (priority 20)
|
|
||||||
resolver = PresetResolver(project_dir)
|
|
||||||
result = resolver.resolve("test-template")
|
|
||||||
assert result is not None
|
|
||||||
assert "From Unregistered" in result.read_text()
|
|
||||||
|
|
||||||
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
|
|
||||||
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create registered extension with priority 5 (higher precedence than 10)
|
|
||||||
registered_dir = extensions_dir / "registered-ext"
|
|
||||||
(registered_dir / "templates").mkdir(parents=True)
|
|
||||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
||||||
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
|
|
||||||
|
|
||||||
# Create unregistered extension directory (implicit priority 10)
|
|
||||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
||||||
(unregistered_dir / "templates").mkdir(parents=True)
|
|
||||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
||||||
|
|
||||||
# Registered (priority 5) should beat unregistered (priority 10)
|
|
||||||
resolver = PresetResolver(project_dir)
|
|
||||||
result = resolver.resolve("test-template")
|
|
||||||
assert result is not None
|
|
||||||
assert "From Registered" in result.read_text()
|
|
||||||
|
|
||||||
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
|
|
||||||
"""Test resolve_with_source correctly attributes unregistered extension."""
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create registered extension with priority 20
|
|
||||||
registered_dir = extensions_dir / "registered-ext"
|
|
||||||
(registered_dir / "templates").mkdir(parents=True)
|
|
||||||
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
|
||||||
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
|
||||||
|
|
||||||
# Create unregistered extension (implicit priority 10)
|
|
||||||
unregistered_dir = extensions_dir / "unregistered-ext"
|
|
||||||
(unregistered_dir / "templates").mkdir(parents=True)
|
|
||||||
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
|
||||||
|
|
||||||
# Attribution should show unregistered extension
|
|
||||||
resolver = PresetResolver(project_dir)
|
|
||||||
result = resolver.resolve_with_source("test-template")
|
|
||||||
assert result is not None
|
|
||||||
assert "unregistered-ext" in result["source"]
|
|
||||||
assert "(unregistered)" in result["source"]
|
|
||||||
|
|
||||||
def test_same_priority_sorted_alphabetically(self, project_dir):
|
|
||||||
"""Extensions with same priority are sorted alphabetically by ID."""
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
extensions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create two unregistered extensions (both implicit priority 10)
|
|
||||||
# "aaa-ext" should come before "zzz-ext" alphabetically
|
|
||||||
zzz_dir = extensions_dir / "zzz-ext"
|
|
||||||
(zzz_dir / "templates").mkdir(parents=True)
|
|
||||||
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
|
|
||||||
|
|
||||||
aaa_dir = extensions_dir / "aaa-ext"
|
|
||||||
(aaa_dir / "templates").mkdir(parents=True)
|
|
||||||
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
|
|
||||||
|
|
||||||
# AAA should win due to alphabetical ordering at same priority
|
|
||||||
resolver = PresetResolver(project_dir)
|
|
||||||
result = resolver.resolve("test-template")
|
|
||||||
assert result is not None
|
|
||||||
assert "From AAA" in result.read_text()
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PresetCatalog Tests =====
|
# ===== PresetCatalog Tests =====
|
||||||
|
|
||||||
|
|
||||||
@@ -1106,13 +979,8 @@ class TestIntegration:
|
|||||||
ext_templates_dir.mkdir(parents=True)
|
ext_templates_dir.mkdir(parents=True)
|
||||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||||
|
|
||||||
# Register extension in registry
|
|
||||||
extensions_dir = project_dir / ".specify" / "extensions"
|
|
||||||
ext_registry = ExtensionRegistry(extensions_dir)
|
|
||||||
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
|
||||||
|
|
||||||
result = resolver.resolve_with_source("spec-template")
|
result = resolver.resolve_with_source("spec-template")
|
||||||
assert result["source"] == "extension:my-ext v1.0.0"
|
assert result["source"] == "extension:my-ext"
|
||||||
|
|
||||||
# Install pack — should win over extension
|
# Install pack — should win over extension
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
@@ -1842,162 +1710,3 @@ class TestPresetSkills:
|
|||||||
|
|
||||||
metadata = manager.registry.get("self-test")
|
metadata = manager.registry.get("self-test")
|
||||||
assert metadata.get("registered_skills", []) == []
|
assert metadata.get("registered_skills", []) == []
|
||||||
|
|
||||||
|
|
||||||
class TestPresetSetPriority:
|
|
||||||
"""Test preset set-priority CLI command."""
|
|
||||||
|
|
||||||
def test_set_priority_changes_priority(self, project_dir, pack_dir):
|
|
||||||
"""Test set-priority command changes preset priority."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from unittest.mock import patch
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
# Install preset with default priority
|
|
||||||
manager = PresetManager(project_dir)
|
|
||||||
manager.install_from_directory(pack_dir, "0.1.5")
|
|
||||||
|
|
||||||
# Verify default priority
|
|
||||||
assert manager.registry.get("test-pack")["priority"] == 10
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=project_dir):
|
|
||||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "priority changed: 10 → 5" in result.output
|
|
||||||
|
|
||||||
# Reload registry to see updated value
|
|
||||||
manager2 = PresetManager(project_dir)
|
|
||||||
assert manager2.registry.get("test-pack")["priority"] == 5
|
|
||||||
|
|
||||||
def test_set_priority_same_value_no_change(self, project_dir, pack_dir):
|
|
||||||
"""Test set-priority with same value shows already set message."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from unittest.mock import patch
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
# Install preset with priority 5
|
|
||||||
manager = PresetManager(project_dir)
|
|
||||||
manager.install_from_directory(pack_dir, "0.1.5", priority=5)
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=project_dir):
|
|
||||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "5"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert "already has priority 5" in result.output
|
|
||||||
|
|
||||||
def test_set_priority_invalid_value(self, project_dir, pack_dir):
|
|
||||||
"""Test set-priority rejects invalid priority values."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from unittest.mock import patch
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
# Install preset
|
|
||||||
manager = PresetManager(project_dir)
|
|
||||||
manager.install_from_directory(pack_dir, "0.1.5")
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=project_dir):
|
|
||||||
result = runner.invoke(app, ["preset", "set-priority", "test-pack", "0"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1, result.output
|
|
||||||
assert "Priority must be a positive integer" in result.output
|
|
||||||
|
|
||||||
def test_set_priority_not_installed(self, project_dir):
|
|
||||||
"""Test set-priority fails for non-installed preset."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from unittest.mock import patch
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
|
|
||||||
with patch.object(Path, "cwd", return_value=project_dir):
|
|
||||||
result = runner.invoke(app, ["preset", "set-priority", "nonexistent", "5"])
|
|
||||||
|
|
||||||
assert result.exit_code == 1, result.output
|
|
||||||
assert "not installed" in result.output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPresetPriorityBackwardsCompatibility:
|
|
||||||
"""Test backwards compatibility for presets installed before priority feature."""
|
|
||||||
|
|
||||||
def test_legacy_preset_without_priority_field(self, temp_dir):
|
|
||||||
"""Presets installed before priority feature should default to 10."""
|
|
||||||
presets_dir = temp_dir / ".specify" / "presets"
|
|
||||||
presets_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
# Simulate legacy registry entry without priority field
|
|
||||||
registry = PresetRegistry(presets_dir)
|
|
||||||
registry.data["presets"]["legacy-pack"] = {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "local",
|
|
||||||
"enabled": True,
|
|
||||||
"installed_at": "2025-01-01T00:00:00Z",
|
|
||||||
# No "priority" field - simulates pre-feature preset
|
|
||||||
}
|
|
||||||
registry._save()
|
|
||||||
|
|
||||||
# Reload registry
|
|
||||||
registry2 = PresetRegistry(presets_dir)
|
|
||||||
|
|
||||||
# list_by_priority should use default of 10
|
|
||||||
result = registry2.list_by_priority()
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0][0] == "legacy-pack"
|
|
||||||
# Priority defaults to 10 and is normalized in returned metadata
|
|
||||||
assert result[0][1]["priority"] == 10
|
|
||||||
|
|
||||||
def test_legacy_preset_in_list_installed(self, project_dir, pack_dir):
|
|
||||||
"""list_installed returns priority=10 for legacy presets without priority field."""
|
|
||||||
manager = PresetManager(project_dir)
|
|
||||||
|
|
||||||
# Install preset normally
|
|
||||||
manager.install_from_directory(pack_dir, "0.1.5")
|
|
||||||
|
|
||||||
# Manually remove priority to simulate legacy preset
|
|
||||||
pack_data = manager.registry.data["presets"]["test-pack"]
|
|
||||||
del pack_data["priority"]
|
|
||||||
manager.registry._save()
|
|
||||||
|
|
||||||
# list_installed should still return priority=10
|
|
||||||
installed = manager.list_installed()
|
|
||||||
assert len(installed) == 1
|
|
||||||
assert installed[0]["priority"] == 10
|
|
||||||
|
|
||||||
def test_mixed_legacy_and_new_presets_ordering(self, temp_dir):
|
|
||||||
"""Legacy presets (no priority) sort with default=10 among prioritized presets."""
|
|
||||||
presets_dir = temp_dir / ".specify" / "presets"
|
|
||||||
presets_dir.mkdir(parents=True)
|
|
||||||
|
|
||||||
registry = PresetRegistry(presets_dir)
|
|
||||||
|
|
||||||
# Add preset with explicit priority=5
|
|
||||||
registry.add("pack-with-priority", {"version": "1.0.0", "priority": 5})
|
|
||||||
|
|
||||||
# Add legacy preset without priority (manually)
|
|
||||||
registry.data["presets"]["legacy-pack"] = {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "local",
|
|
||||||
"enabled": True,
|
|
||||||
# No priority field
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add another preset with priority=15
|
|
||||||
registry.add("low-priority-pack", {"version": "1.0.0", "priority": 15})
|
|
||||||
registry._save()
|
|
||||||
|
|
||||||
# Reload and check ordering
|
|
||||||
registry2 = PresetRegistry(presets_dir)
|
|
||||||
sorted_presets = registry2.list_by_priority()
|
|
||||||
|
|
||||||
# Should be: pack-with-priority (5), legacy-pack (default 10), low-priority-pack (15)
|
|
||||||
assert [p[0] for p in sorted_presets] == [
|
|
||||||
"pack-with-priority",
|
|
||||||
"legacy-pack",
|
|
||||||
"low-priority-pack",
|
|
||||||
]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user