mirror of
https://github.com/github/spec-kit.git
synced 2026-03-18 03:13:07 +00:00
Compare commits
18 Commits
chore/rele
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a21bdef01 | ||
|
|
f21eb71990 | ||
|
|
b471b5e6f3 | ||
|
|
489ced56ba | ||
|
|
6644f69a96 | ||
|
|
a177a1a6d1 | ||
|
|
c12b8c1763 | ||
|
|
d2ecf6560d | ||
|
|
7a5762fe6a | ||
|
|
9c0c1446ec | ||
|
|
82b8ce4295 | ||
|
|
2cf332db1b | ||
|
|
b1650f884d | ||
|
|
23bd645054 | ||
|
|
bef9c2cb59 | ||
|
|
4f81fc298f | ||
|
|
4a3234496e | ||
|
|
f92d81bbec |
@@ -51,6 +51,10 @@ echo -e "\n🤖 Installing OpenCode CLI..."
|
|||||||
run_command "npm install -g opencode-ai@latest"
|
run_command "npm install -g opencode-ai@latest"
|
||||||
echo "✅ Done"
|
echo "✅ Done"
|
||||||
|
|
||||||
|
echo -e "\n🤖 Installing Pi Coding Agent..."
|
||||||
|
run_command "npm install -g @mariozechner/pi-coding-agent@latest"
|
||||||
|
echo "✅ Done"
|
||||||
|
|
||||||
echo -e "\n🤖 Installing Kiro CLI..."
|
echo -e "\n🤖 Installing Kiro CLI..."
|
||||||
# https://kiro.dev/docs/cli/
|
# https://kiro.dev/docs/cli/
|
||||||
KIRO_INSTALLER_URL="https://kiro.dev/install.sh"
|
KIRO_INSTALLER_URL="https://kiro.dev/install.sh"
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ gh release create "$VERSION" \
|
|||||||
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-vibe-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
.genreleases/spec-kit-template-kimi-sh-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
.genreleases/spec-kit-template-kimi-ps-"$VERSION".zip \
|
||||||
|
.genreleases/spec-kit-template-trae-sh-"$VERSION".zip \
|
||||||
|
.genreleases/spec-kit-template-trae-ps-"$VERSION".zip \
|
||||||
|
.genreleases/spec-kit-template-pi-sh-"$VERSION".zip \
|
||||||
|
.genreleases/spec-kit-template-pi-ps-"$VERSION".zip \
|
||||||
.genreleases/spec-kit-template-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, generic
|
Valid agents: claude, gemini, copilot, cursor-agent, qwen, opencode, windsurf, codex, kilocode, auggie, roo, codebuddy, amp, kiro-cli, bob, qodercli, shai, tabnine, agy, vibe, kimi, trae, pi, 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,6 +454,15 @@ 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
|
||||||
|
}
|
||||||
|
'pi' {
|
||||||
|
$cmdDir = Join-Path $baseDir ".pi/prompts"
|
||||||
|
Generate-Commands -Agent 'pi' -Extension 'md' -ArgFormat '$ARGUMENTS' -OutputDir $cmdDir -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
|
||||||
@@ -470,7 +479,7 @@ function Build-Variant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Define all agents and scripts
|
# Define all agents and scripts
|
||||||
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'generic')
|
$AllAgents = @('claude', 'gemini', 'copilot', 'cursor-agent', 'qwen', 'opencode', 'windsurf', 'codex', 'kilocode', 'auggie', 'roo', 'codebuddy', 'amp', 'kiro-cli', 'bob', 'qodercli', 'shai', 'tabnine', 'agy', 'vibe', 'kimi', 'trae', 'pi', '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 generic (default: all)
|
# AGENTS : space or comma separated subset of: claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi 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,6 +291,12 @@ 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" ;;
|
||||||
|
pi)
|
||||||
|
mkdir -p "$base_dir/.pi/prompts"
|
||||||
|
generate_commands pi md "\$ARGUMENTS" "$base_dir/.pi/prompts" "$script" ;;
|
||||||
generic)
|
generic)
|
||||||
mkdir -p "$base_dir/.speckit/commands"
|
mkdir -p "$base_dir/.speckit/commands"
|
||||||
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
generate_commands generic md "\$ARGUMENTS" "$base_dir/.speckit/commands" "$script" ;;
|
||||||
@@ -300,7 +306,7 @@ build_variant() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Determine agent list
|
# Determine agent list
|
||||||
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi generic)
|
ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf codex kilocode auggie roo codebuddy amp shai tabnine kiro-cli agy bob vibe qodercli kimi trae pi generic)
|
||||||
ALL_SCRIPTS=(sh ps)
|
ALL_SCRIPTS=(sh ps)
|
||||||
|
|
||||||
norm_list() {
|
norm_list() {
|
||||||
|
|||||||
19
AGENTS.md
19
AGENTS.md
@@ -33,11 +33,11 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
|||||||
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
|
| **Cursor** | `.cursor/commands/` | Markdown | `cursor-agent` | Cursor CLI |
|
||||||
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
|
| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI |
|
||||||
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
|
| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI |
|
||||||
| **Codex CLI** | `.codex/commands/` | Markdown | `codex` | Codex CLI |
|
| **Codex CLI** | `.codex/prompts/` | Markdown | `codex` | Codex CLI |
|
||||||
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows |
|
||||||
| **Kilo Code** | `.kilocode/rules/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE |
|
||||||
| **Auggie CLI** | `.augment/rules/` | Markdown | `auggie` | Auggie CLI |
|
| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI |
|
||||||
| **Roo Code** | `.roo/rules/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE |
|
||||||
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
|
| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI |
|
||||||
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
|
| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI |
|
||||||
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
|
| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI |
|
||||||
@@ -45,7 +45,9 @@ Specify supports multiple AI agents by generating agent-specific command files a
|
|||||||
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
|
| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI |
|
||||||
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI |
|
||||||
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) |
|
||||||
|
| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent |
|
||||||
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE |
|
||||||
|
| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE |
|
||||||
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent |
|
||||||
|
|
||||||
### Step-by-Step Integration Guide
|
### Step-by-Step Integration Guide
|
||||||
@@ -84,7 +86,7 @@ This eliminates the need for special-case mappings throughout the codebase.
|
|||||||
- `folder`: Directory where agent-specific files are stored (relative to project root)
|
- `folder`: Directory where agent-specific files are stored (relative to project root)
|
||||||
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
|
- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`)
|
||||||
- Most agents use `"commands"` (e.g., `.claude/commands/`)
|
- Most agents use `"commands"` (e.g., `.claude/commands/`)
|
||||||
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli), `"command"` (opencode - singular)
|
- Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular)
|
||||||
- This field enables `--ai-skills` to locate command templates correctly for skill generation
|
- This field enables `--ai-skills` to locate command templates correctly for skill generation
|
||||||
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
|
- `install_url`: Installation documentation URL (set to `None` for IDE-based agents)
|
||||||
- `requires_cli`: Whether the agent requires a CLI tool check during initialization
|
- `requires_cli`: Whether the agent requires a CLI tool check during initialization
|
||||||
@@ -322,6 +324,7 @@ Require a command-line tool to be installed:
|
|||||||
- **SHAI**: `shai` CLI
|
- **SHAI**: `shai` CLI
|
||||||
- **Tabnine CLI**: `tabnine` CLI
|
- **Tabnine CLI**: `tabnine` CLI
|
||||||
- **Kimi Code**: `kimi` CLI
|
- **Kimi Code**: `kimi` CLI
|
||||||
|
- **Pi Coding Agent**: `pi` CLI
|
||||||
|
|
||||||
### IDE-Based Agents
|
### IDE-Based Agents
|
||||||
|
|
||||||
@@ -335,7 +338,7 @@ Work within integrated development environments:
|
|||||||
|
|
||||||
### Markdown Format
|
### Markdown Format
|
||||||
|
|
||||||
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen
|
Used by: Claude, Cursor, opencode, Windsurf, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi
|
||||||
|
|
||||||
**Standard format:**
|
**Standard format:**
|
||||||
|
|
||||||
@@ -373,6 +376,10 @@ Command content with {SCRIPT} and {{args}} placeholders.
|
|||||||
## Directory Conventions
|
## Directory Conventions
|
||||||
|
|
||||||
- **CLI agents**: Usually `.<agent-name>/commands/`
|
- **CLI agents**: Usually `.<agent-name>/commands/`
|
||||||
|
- **Common prompt-based exceptions**:
|
||||||
|
- Codex: `.codex/prompts/`
|
||||||
|
- Kiro CLI: `.kiro/prompts/`
|
||||||
|
- Pi: `.pi/prompts/`
|
||||||
- **IDE agents**: Follow IDE-specific patterns:
|
- **IDE agents**: Follow IDE-specific patterns:
|
||||||
- Copilot: `.github/agents/`
|
- Copilot: `.github/agents/`
|
||||||
- Cursor: `.cursor/commands/`
|
- Cursor: `.cursor/commands/`
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,6 +7,26 @@ Recent changes to the Specify CLI and templates are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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
|
||||||
@@ -35,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- feat(cli): polite deep merge for VSCode settings.json with JSONC support via `json5` and zero-data-loss fallbacks
|
||||||
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
- feat(presets): Pluggable preset system with preset catalog and template resolver
|
||||||
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
- Preset manifest (`preset.yml`) with validation for artifact, command, and script types
|
||||||
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
- `PresetManifest`, `PresetRegistry`, `PresetManager`, `PresetCatalog`, `PresetResolver` classes in `src/specify_cli/presets.py`
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -158,6 +158,10 @@ See Spec-Driven Development in action across different scenarios with these comm
|
|||||||
|
|
||||||
- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution.
|
- **[Brownfield 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 |
|
||||||
@@ -176,6 +180,7 @@ See Spec-Driven Development in action across different scenarios with these comm
|
|||||||
| [Jules](https://jules.google.com/) | ✅ | |
|
| [Jules](https://jules.google.com/) | ✅ | |
|
||||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | |
|
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | |
|
||||||
| [opencode](https://opencode.ai/) | ✅ | |
|
| [opencode](https://opencode.ai/) | ✅ | |
|
||||||
|
| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
|
| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | |
|
||||||
| [Roo Code](https://roocode.com/) | ✅ | |
|
| [Roo Code](https://roocode.com/) | ✅ | |
|
||||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
|
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | |
|
||||||
@@ -184,6 +189,7 @@ 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
|
||||||
@@ -195,14 +201,14 @@ The `specify` command supports the following options:
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `init` | Initialize a new Specify project from the latest template |
|
| `init` | Initialize a new Specify project from the latest template |
|
||||||
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`) |
|
| `check` | Check for installed tools (`git`, `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `pi`) |
|
||||||
|
|
||||||
### `specify init` Arguments & Options
|
### `specify init` Arguments & Options
|
||||||
|
|
||||||
| Argument/Option | Type | Description |
|
| Argument/Option | Type | Description |
|
||||||
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
| `<project-name>` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) |
|
||||||
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, or `generic` (requires `--ai-commands-dir`) |
|
| `--ai` | Option | AI assistant to use: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `pi`, or `generic` (requires `--ai-commands-dir`) |
|
||||||
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) |
|
||||||
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||||
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code |
|
||||||
@@ -247,6 +253,9 @@ specify init my-project --ai vibe
|
|||||||
# Initialize with IBM Bob support
|
# Initialize with IBM Bob support
|
||||||
specify init my-project --ai bob
|
specify init my-project --ai bob
|
||||||
|
|
||||||
|
# Initialize with Pi Coding Agent support
|
||||||
|
specify init my-project --ai pi
|
||||||
|
|
||||||
# Initialize with Antigravity support
|
# Initialize with Antigravity support
|
||||||
specify init my-project --ai agy --ai-skills
|
specify init my-project --ai agy --ai-skills
|
||||||
|
|
||||||
@@ -424,7 +433,7 @@ specify init . --force --ai claude
|
|||||||
specify init --here --force --ai claude
|
specify init --here --force --ai claude
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
specify init <project_name> --ai claude --ignore-agent-tools
|
specify init <project_name> --ai claude --ignore-agent-tools
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
|
||||||
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli) or [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
|
||||||
- [uv](https://docs.astral.sh/uv/) for package management
|
- [uv](https://docs.astral.sh/uv/) for package management
|
||||||
- [Python 3.11+](https://www.python.org/downloads/)
|
- [Python 3.11+](https://www.python.org/downloads/)
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
@@ -35,6 +35,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <project_name
|
|||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini
|
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai gemini
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot
|
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai copilot
|
||||||
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy
|
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai codebuddy
|
||||||
|
uvx --from git+https://github.com/github/spec-kit.git specify init <project_name> --ai pi
|
||||||
```
|
```
|
||||||
|
|
||||||
### Specify Script Type (Shell vs PowerShell)
|
### Specify Script Type (Shell vs PowerShell)
|
||||||
|
|||||||
@@ -289,8 +289,9 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la .claude/commands/ # Claude Code
|
ls -la .claude/commands/ # Claude Code
|
||||||
ls -la .gemini/commands/ # Gemini
|
ls -la .gemini/commands/ # Gemini
|
||||||
ls -la .cursor/commands/ # Cursor
|
ls -la .cursor/commands/ # Cursor
|
||||||
|
ls -la .pi/prompts/ # Pi Coding Agent
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Check agent-specific setup:**
|
3. **Check agent-specific setup:**
|
||||||
@@ -398,7 +399,7 @@ The `specify` CLI tool is used for:
|
|||||||
- **Upgrades:** `specify init --here --force` to update templates and commands
|
- **Upgrades:** `specify init --here --force` to update templates and commands
|
||||||
- **Diagnostics:** `specify check` to verify tool installation
|
- **Diagnostics:** `specify check` to verify tool installation
|
||||||
|
|
||||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
|
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again.
|
||||||
|
|
||||||
**If your agent isn't recognizing slash commands:**
|
**If your agent isn't recognizing slash commands:**
|
||||||
|
|
||||||
@@ -410,6 +411,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
|||||||
|
|
||||||
# For Claude
|
# For Claude
|
||||||
ls -la .claude/commands/
|
ls -la .claude/commands/
|
||||||
|
|
||||||
|
# For Pi
|
||||||
|
ls -la .pi/prompts/
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Restart your IDE/editor completely** (not just reload window)
|
2. **Restart your IDE/editor completely** (not just reload window)
|
||||||
|
|||||||
@@ -72,12 +72,18 @@ 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,12 +359,15 @@ specify extension add jira
|
|||||||
"installed_at": "2026-01-28T14:30:00Z",
|
"installed_at": "2026-01-28T14:30:00Z",
|
||||||
"source": "catalog",
|
"source": "catalog",
|
||||||
"manifest_hash": "sha256:abc123...",
|
"manifest_hash": "sha256:abc123...",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"priority": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Priority Field**: Extensions are ordered by `priority` (lower = higher precedence). Default is 10. Used for template resolution when multiple extensions provide the same template.
|
||||||
|
|
||||||
### 3. Configuration
|
### 3. Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1084,11 +1087,15 @@ List installed extensions in current project.
|
|||||||
$ specify extension list
|
$ specify extension list
|
||||||
|
|
||||||
Installed Extensions:
|
Installed Extensions:
|
||||||
✓ jira (v1.0.0) - Jira Integration
|
✓ Jira Integration (v1.0.0)
|
||||||
Commands: 3 | Hooks: 2 | Status: Enabled
|
jira
|
||||||
|
Create Jira issues from spec-kit artifacts
|
||||||
|
Commands: 3 | Hooks: 2 | Priority: 10 | Status: Enabled
|
||||||
|
|
||||||
✓ linear (v0.9.0) - Linear Integration
|
✓ Linear Integration (v0.9.0)
|
||||||
Commands: 1 | Hooks: 1 | Status: Enabled
|
linear
|
||||||
|
Create Linear issues from spec-kit artifacts
|
||||||
|
Commands: 1 | Hooks: 1 | Priority: 10 | Status: Enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
@@ -1196,10 +1203,9 @@ Next steps:
|
|||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
|
|
||||||
- `--from URL`: Install from custom URL or Git repo
|
- `--from URL`: Install from a remote URL (archive). Does not accept Git repositories directly.
|
||||||
- `--version VERSION`: Install specific version
|
- `--dev`: Install from a local path in development mode (the PATH is the positional `extension` argument).
|
||||||
- `--dev PATH`: Install from local path (development mode)
|
- `--priority NUMBER`: Set resolution priority (lower = higher precedence, default 10)
|
||||||
- `--no-register`: Skip command registration (manual setup)
|
|
||||||
|
|
||||||
#### `specify extension remove NAME`
|
#### `specify extension remove NAME`
|
||||||
|
|
||||||
@@ -1280,6 +1286,29 @@ $ specify extension disable jira
|
|||||||
To re-enable: specify extension enable jira
|
To re-enable: specify extension enable jira
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `specify extension set-priority NAME PRIORITY`
|
||||||
|
|
||||||
|
Change the resolution priority of an installed extension.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ specify extension set-priority jira 5
|
||||||
|
|
||||||
|
✓ Extension 'Jira Integration' priority changed: 10 → 5
|
||||||
|
|
||||||
|
Lower priority = higher precedence in template resolution
|
||||||
|
```
|
||||||
|
|
||||||
|
**Priority Values:**
|
||||||
|
|
||||||
|
- Lower numbers = higher precedence (checked first in resolution)
|
||||||
|
- Default priority is 10
|
||||||
|
- Must be a positive integer (1 or higher)
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
|
||||||
|
- Ensure a critical extension's templates take precedence
|
||||||
|
- Override default resolution order when multiple extensions provide similar templates
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Compatibility & Versioning
|
## Compatibility & Versioning
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-03-13T12:00:00Z",
|
"updated_at": "2026-03-17T00: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",
|
||||||
@@ -74,6 +105,92 @@
|
|||||||
"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",
|
||||||
@@ -124,7 +241,12 @@
|
|||||||
"commands": 2,
|
"commands": 2,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": ["orchestration", "workflow", "human-in-the-loop", "parallel"],
|
"tags": [
|
||||||
|
"orchestration",
|
||||||
|
"workflow",
|
||||||
|
"human-in-the-loop",
|
||||||
|
"parallel"
|
||||||
|
],
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
@@ -191,13 +313,49 @@
|
|||||||
"commands": 2,
|
"commands": 2,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": ["implementation", "automation", "loop", "copilot"],
|
"tags": [
|
||||||
|
"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",
|
||||||
@@ -249,7 +407,15 @@
|
|||||||
"commands": 7,
|
"commands": 7,
|
||||||
"hooks": 1
|
"hooks": 1
|
||||||
},
|
},
|
||||||
"tags": ["code-review", "quality", "review", "testing", "error-handling", "type-design", "simplification"],
|
"tags": [
|
||||||
|
"code-review",
|
||||||
|
"quality",
|
||||||
|
"review",
|
||||||
|
"testing",
|
||||||
|
"error-handling",
|
||||||
|
"type-design",
|
||||||
|
"simplification"
|
||||||
|
],
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
@@ -291,7 +457,7 @@
|
|||||||
"understanding": {
|
"understanding": {
|
||||||
"name": "Understanding",
|
"name": "Understanding",
|
||||||
"id": "understanding",
|
"id": "understanding",
|
||||||
"description": "Automated requirements quality analysis — validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
|
"description": "Automated requirements quality analysis \u2014 validates specs against IEEE/ISO standards using 31 deterministic metrics. Catches ambiguity, missing testability, and structural issues before they reach implementation. Includes experimental energy-based ambiguity detection using local LM token perplexity.",
|
||||||
"author": "Ladislav Bihari",
|
"author": "Ladislav Bihari",
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
"download_url": "https://github.com/Testimonial/understanding/archive/refs/tags/v3.4.0.zip",
|
||||||
@@ -329,6 +495,38 @@
|
|||||||
"created_at": "2026-03-07T00:00:00Z",
|
"created_at": "2026-03-07T00:00:00Z",
|
||||||
"updated_at": "2026-03-07T00:00:00Z"
|
"updated_at": "2026-03-07T00:00:00Z"
|
||||||
},
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "Project Status",
|
||||||
|
"id": "status",
|
||||||
|
"description": "Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary.",
|
||||||
|
"author": "KhawarHabibKhan",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"download_url": "https://github.com/KhawarHabibKhan/spec-kit-status/archive/refs/tags/v1.0.0.zip",
|
||||||
|
"repository": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
||||||
|
"homepage": "https://github.com/KhawarHabibKhan/spec-kit-status",
|
||||||
|
"documentation": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/README.md",
|
||||||
|
"changelog": "https://github.com/KhawarHabibKhan/spec-kit-status/blob/main/CHANGELOG.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 1,
|
||||||
|
"hooks": 0
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"status",
|
||||||
|
"workflow",
|
||||||
|
"progress",
|
||||||
|
"feature-tracking",
|
||||||
|
"task-progress"
|
||||||
|
],
|
||||||
|
"verified": false,
|
||||||
|
"downloads": 0,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-03-16T00:00:00Z",
|
||||||
|
"updated_at": "2026-03-16T00:00:00Z"
|
||||||
|
},
|
||||||
"v-model": {
|
"v-model": {
|
||||||
"name": "V-Model Extension Pack",
|
"name": "V-Model Extension Pack",
|
||||||
"id": "v-model",
|
"id": "v-model",
|
||||||
@@ -361,6 +559,37 @@
|
|||||||
"created_at": "2026-02-20T00:00:00Z",
|
"created_at": "2026-02-20T00:00:00Z",
|
||||||
"updated_at": "2026-02-22T00:00:00Z"
|
"updated_at": "2026-02-22T00:00:00Z"
|
||||||
},
|
},
|
||||||
|
"learn": {
|
||||||
|
"name": "Learning Extension",
|
||||||
|
"id": "learn",
|
||||||
|
"description": "Generate educational guides from implementations and enhance clarifications with mentoring context.",
|
||||||
|
"author": "Vianca Martinez",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"download_url": "https://github.com/imviancagrace/spec-kit-learn/archive/refs/tags/v1.0.0.zip",
|
||||||
|
"repository": "https://github.com/imviancagrace/spec-kit-learn",
|
||||||
|
"homepage": "https://github.com/imviancagrace/spec-kit-learn",
|
||||||
|
"documentation": "https://github.com/imviancagrace/spec-kit-learn/blob/main/README.md",
|
||||||
|
"changelog": "https://github.com/imviancagrace/spec-kit-learn/blob/main/CHANGELOG.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 2,
|
||||||
|
"hooks": 1
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"learning",
|
||||||
|
"education",
|
||||||
|
"mentoring",
|
||||||
|
"knowledge-transfer"
|
||||||
|
],
|
||||||
|
"verified": false,
|
||||||
|
"downloads": 0,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-03-17T00:00:00Z",
|
||||||
|
"updated_at": "2026-03-17T00:00:00Z"
|
||||||
|
},
|
||||||
"verify": {
|
"verify": {
|
||||||
"name": "Verify Extension",
|
"name": "Verify Extension",
|
||||||
"id": "verify",
|
"id": "verify",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"packaging>=23.0",
|
"packaging>=23.0",
|
||||||
"pathspec>=0.12.0",
|
"pathspec>=0.12.0",
|
||||||
|
"json5>=0.13.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ if $JSON_MODE; then
|
|||||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
json_docs="[]"
|
json_docs="[]"
|
||||||
else
|
else
|
||||||
json_docs=$(printf '"%s",' "${docs[@]}")
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
json_docs="[${json_docs%,}]"
|
json_docs="[${json_docs%,}]"
|
||||||
fi
|
fi
|
||||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ has_jq() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||||
# Handles backslash, double-quote, and control characters (newline, tab, carriage return).
|
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||||
json_escape() {
|
json_escape() {
|
||||||
local s="$1"
|
local s="$1"
|
||||||
s="${s//\\/\\\\}"
|
s="${s//\\/\\\\}"
|
||||||
@@ -169,6 +169,10 @@ 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,9 +198,11 @@ resolve_template() {
|
|||||||
if [ -d "$presets_dir" ]; then
|
if [ -d "$presets_dir" ]; then
|
||||||
local registry_file="$presets_dir/.registry"
|
local registry_file="$presets_dir/.registry"
|
||||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
# Read preset IDs sorted by priority (lower number = higher precedence)
|
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||||
local sorted_presets
|
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||||
sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||||
|
local sorted_presets=""
|
||||||
|
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
import json, sys, os
|
import json, sys, os
|
||||||
try:
|
try:
|
||||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
@@ -206,14 +212,17 @@ try:
|
|||||||
print(pid)
|
print(pid)
|
||||||
except Exception:
|
except Exception:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
" 2>/dev/null)
|
" 2>/dev/null); then
|
||||||
if [ $? -eq 0 ] && [ -n "$sorted_presets" ]; then
|
if [ -n "$sorted_presets" ]; then
|
||||||
while IFS= read -r preset_id; do
|
# python3 succeeded and returned preset IDs — search in priority order
|
||||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
while IFS= read -r preset_id; do
|
||||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
done <<< "$sorted_presets"
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
fi
|
||||||
|
# python3 succeeded but registry has no presets — nothing to search
|
||||||
else
|
else
|
||||||
# python3 returned empty list — fall through to directory scan
|
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||||
for preset in "$presets_dir"/*/; do
|
for preset in "$presets_dir"/*/; do
|
||||||
[ -d "$preset" ] || continue
|
[ -d "$preset" ] || continue
|
||||||
local candidate="$preset/templates/${template_name}.md"
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
@@ -246,8 +255,9 @@ except Exception:
|
|||||||
local core="$base/${template_name}.md"
|
local core="$base/${template_name}.md"
|
||||||
[ -f "$core" ] && echo "$core" && return 0
|
[ -f "$core" ] && echo "$core" && return 0
|
||||||
|
|
||||||
# Return success with empty output so callers using set -e don't abort;
|
# Template not found in any location.
|
||||||
# callers check [ -n "$TEMPLATE" ] to detect "not found".
|
# Return 1 so callers can distinguish "not found" from "found".
|
||||||
return 0
|
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ check_existing_branches() {
|
|||||||
local specs_dir="$1"
|
local specs_dir="$1"
|
||||||
|
|
||||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
git fetch --all --prune 2>/dev/null || true
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
|
||||||
# Get highest number from ALL branches (not just matching short name)
|
# Get highest number from ALL branches (not just matching short name)
|
||||||
local highest_branch=$(get_highest_from_branches)
|
local highest_branch=$(get_highest_from_branches)
|
||||||
@@ -162,17 +162,6 @@ clean_branch_name() {
|
|||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
|
||||||
json_escape() {
|
|
||||||
local s="$1"
|
|
||||||
s="${s//\\/\\\\}"
|
|
||||||
s="${s//\"/\\\"}"
|
|
||||||
s="${s//$'\n'/\\n}"
|
|
||||||
s="${s//$'\t'/\\t}"
|
|
||||||
s="${s//$'\r'/\\r}"
|
|
||||||
printf '%s' "$s"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve repository root. Prefer git information when available, but fall back
|
# Resolve repository root. Prefer git information when available, but fall back
|
||||||
# to searching for repository markers so the workflow still functions in repositories that
|
# to searching for repository markers so the workflow still functions in repositories that
|
||||||
# were initialised with --no-git.
|
# were initialised with --no-git.
|
||||||
@@ -308,9 +297,14 @@ fi
|
|||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT")
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then cp "$TEMPLATE" "$SPEC_FILE"; else touch "$SPEC_FILE"; fi
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||||
|
cp "$TEMPLATE" "$SPEC_FILE"
|
||||||
|
else
|
||||||
|
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||||
|
touch "$SPEC_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Inform the user how to persist the feature variable in their own shell
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
|||||||
mkdir -p "$FEATURE_DIR"
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
# Copy plan template if it exists
|
# Copy plan template if it exists
|
||||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT")
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
echo "Copied plan template to $IMPL_PLAN"
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
#
|
#
|
||||||
# 5. Multi-Agent Support
|
# 5. Multi-Agent Support
|
||||||
# - Handles agent-specific file paths and naming conventions
|
# - Handles agent-specific file paths and naming conventions
|
||||||
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Antigravity or Generic
|
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, Antigravity or Generic
|
||||||
# - Can update single agents or all existing agent files
|
# - Can update single agents or all existing agent files
|
||||||
# - Creates default Claude file if no agent files exist
|
# - Creates default Claude file if no agent files exist
|
||||||
#
|
#
|
||||||
# Usage: ./update-agent-context.sh [agent_type]
|
# Usage: ./update-agent-context.sh [agent_type]
|
||||||
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic
|
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic
|
||||||
# Leave empty to update all existing agent files
|
# Leave empty to update all existing agent files
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -73,7 +73,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
|
|||||||
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
|
||||||
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
|
||||||
QODER_FILE="$REPO_ROOT/QODER.md"
|
QODER_FILE="$REPO_ROOT/QODER.md"
|
||||||
# AMP, Kiro CLI, and IBM Bob all share AGENTS.md — use AGENTS_FILE to avoid
|
# Amp, Kiro CLI, IBM Bob, and Pi all share AGENTS.md — use AGENTS_FILE to avoid
|
||||||
# updating the same file multiple times.
|
# updating the same file multiple times.
|
||||||
AMP_FILE="$AGENTS_FILE"
|
AMP_FILE="$AGENTS_FILE"
|
||||||
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
SHAI_FILE="$REPO_ROOT/SHAI.md"
|
||||||
@@ -83,6 +83,7 @@ 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"
|
||||||
@@ -675,67 +676,85 @@ update_specific_agent() {
|
|||||||
kimi)
|
kimi)
|
||||||
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
update_agent_file "$KIMI_FILE" "Kimi Code" || return 1
|
||||||
;;
|
;;
|
||||||
|
trae)
|
||||||
|
update_agent_file "$TRAE_FILE" "Trae" || return 1
|
||||||
|
;;
|
||||||
|
pi)
|
||||||
|
update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1
|
||||||
|
;;
|
||||||
generic)
|
generic)
|
||||||
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
log_error "Unknown agent type '$agent_type'"
|
log_error "Unknown agent type '$agent_type'"
|
||||||
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic"
|
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Helper: skip non-existent files and files already updated (dedup by
|
||||||
|
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
||||||
|
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
||||||
|
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
||||||
|
# Note: defined at top level because bash 3.2 does not support true
|
||||||
|
# nested/local functions. _updated_paths, _found_agent, and _all_ok are
|
||||||
|
# initialised exclusively inside update_all_existing_agents so that
|
||||||
|
# sourcing this script has no side effects on the caller's environment.
|
||||||
|
|
||||||
|
_update_if_new() {
|
||||||
|
local file="$1" name="$2"
|
||||||
|
[[ -f "$file" ]] || return 0
|
||||||
|
local real_path
|
||||||
|
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
||||||
|
local p
|
||||||
|
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
||||||
|
for p in "${_updated_paths[@]}"; do
|
||||||
|
[[ "$p" == "$real_path" ]] && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
# Record the file as seen before attempting the update so that:
|
||||||
|
# (a) aliases pointing to the same path are not retried on failure
|
||||||
|
# (b) _found_agent reflects file existence, not update success
|
||||||
|
_updated_paths+=("$real_path")
|
||||||
|
_found_agent=true
|
||||||
|
update_agent_file "$file" "$name"
|
||||||
|
}
|
||||||
|
|
||||||
update_all_existing_agents() {
|
update_all_existing_agents() {
|
||||||
local found_agent=false
|
_found_agent=false
|
||||||
local _updated_paths=()
|
_updated_paths=()
|
||||||
|
local _all_ok=true
|
||||||
|
|
||||||
# Helper: skip non-existent files and files already updated (dedup by
|
_update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false
|
||||||
# realpath so that variables pointing to the same file — e.g. AMP_FILE,
|
_update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false
|
||||||
# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once).
|
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
|
||||||
# Uses a linear array instead of associative array for bash 3.2 compatibility.
|
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
|
||||||
update_if_new() {
|
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
|
||||||
local file="$1" name="$2"
|
_update_if_new "$AGENTS_FILE" "Codex/opencode" || _all_ok=false
|
||||||
[[ -f "$file" ]] || return 0
|
_update_if_new "$AMP_FILE" "Amp" || _all_ok=false
|
||||||
local real_path
|
_update_if_new "$KIRO_FILE" "Kiro CLI" || _all_ok=false
|
||||||
real_path=$(realpath "$file" 2>/dev/null || echo "$file")
|
_update_if_new "$BOB_FILE" "IBM Bob" || _all_ok=false
|
||||||
local p
|
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
|
||||||
if [[ ${#_updated_paths[@]} -gt 0 ]]; then
|
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
|
||||||
for p in "${_updated_paths[@]}"; do
|
_update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false
|
||||||
[[ "$p" == "$real_path" ]] && return 0
|
_update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false
|
||||||
done
|
_update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false
|
||||||
fi
|
_update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false
|
||||||
update_agent_file "$file" "$name" || return 1
|
_update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false
|
||||||
_updated_paths+=("$real_path")
|
_update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false
|
||||||
found_agent=true
|
_update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false
|
||||||
}
|
_update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false
|
||||||
|
_update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false
|
||||||
update_if_new "$CLAUDE_FILE" "Claude Code"
|
_update_if_new "$TRAE_FILE" "Trae" || _all_ok=false
|
||||||
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
|
||||||
@@ -754,7 +773,7 @@ print_summary() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic]"
|
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|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, generic)
|
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, generic)
|
||||||
|
|
||||||
.PARAMETER AgentType
|
.PARAMETER AgentType
|
||||||
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
|
||||||
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
|
|||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[Parameter(Position=0)]
|
[Parameter(Position=0)]
|
||||||
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','generic')]
|
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','qodercli','vibe','kimi','trae','pi','generic')]
|
||||||
[string]$AgentType
|
[string]$AgentType
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ $AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md'
|
|||||||
$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
|
$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'
|
||||||
|
|
||||||
@@ -408,8 +409,10 @@ function Update-SpecificAgent {
|
|||||||
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' }
|
||||||
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' }
|
||||||
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' }
|
||||||
|
'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' }
|
||||||
|
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
|
||||||
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
|
||||||
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|generic'; return $false }
|
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic'; return $false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +438,7 @@ function Update-AllExistingAgents {
|
|||||||
if (Test-Path $BOB_FILE) { if (-not (Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob')) { $ok = $false }; $found = $true }
|
if (Test-Path $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 }
|
||||||
@@ -449,7 +453,7 @@ function Print-Summary {
|
|||||||
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
|
||||||
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|generic]'
|
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|generic]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function Main {
|
function Main {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# "platformdirs",
|
# "platformdirs",
|
||||||
# "readchar",
|
# "readchar",
|
||||||
# "httpx",
|
# "httpx",
|
||||||
|
# "json5",
|
||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
"""
|
"""
|
||||||
@@ -32,6 +33,8 @@ 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
|
||||||
@@ -272,6 +275,20 @@ AGENT_CONFIG = {
|
|||||||
"install_url": "https://code.kimi.com/",
|
"install_url": "https://code.kimi.com/",
|
||||||
"requires_cli": True,
|
"requires_cli": True,
|
||||||
},
|
},
|
||||||
|
"trae": {
|
||||||
|
"name": "Trae",
|
||||||
|
"folder": ".trae/",
|
||||||
|
"commands_subdir": "rules", # Trae uses .trae/rules/ for project rules
|
||||||
|
"install_url": None, # IDE-based
|
||||||
|
"requires_cli": False,
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"name": "Pi Coding Agent",
|
||||||
|
"folder": ".pi/",
|
||||||
|
"commands_subdir": "prompts",
|
||||||
|
"install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
|
||||||
|
"requires_cli": True,
|
||||||
|
},
|
||||||
"generic": {
|
"generic": {
|
||||||
"name": "Generic (bring your own agent)",
|
"name": "Generic (bring your own agent)",
|
||||||
"folder": None, # Set dynamically via --ai-commands-dir
|
"folder": None, # Set dynamically via --ai-commands-dir
|
||||||
@@ -654,37 +671,82 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option
|
|||||||
os.chdir(original_cwd)
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None:
|
||||||
"""Handle merging or copying of .vscode/settings.json files."""
|
"""Handle merging or copying of .vscode/settings.json files.
|
||||||
|
|
||||||
|
Note: when merge produces changes, rewritten output is normalized JSON and
|
||||||
|
existing JSONC comments/trailing commas are not preserved.
|
||||||
|
"""
|
||||||
def log(message, color="green"):
|
def log(message, color="green"):
|
||||||
if verbose and not tracker:
|
if verbose and not tracker:
|
||||||
console.print(f"[{color}]{message}[/] {rel_path}")
|
console.print(f"[{color}]{message}[/] {rel_path}")
|
||||||
|
|
||||||
|
def atomic_write_json(target_file: Path, payload: dict[str, Any]) -> None:
|
||||||
|
"""Atomically write JSON while preserving existing mode bits when possible."""
|
||||||
|
temp_path: Optional[Path] = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode='w',
|
||||||
|
encoding='utf-8',
|
||||||
|
dir=target_file.parent,
|
||||||
|
prefix=f"{target_file.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as f:
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
json.dump(payload, f, indent=4)
|
||||||
|
f.write('\n')
|
||||||
|
|
||||||
|
if target_file.exists():
|
||||||
|
try:
|
||||||
|
existing_stat = target_file.stat()
|
||||||
|
os.chmod(temp_path, stat.S_IMODE(existing_stat.st_mode))
|
||||||
|
if hasattr(os, "chown"):
|
||||||
|
try:
|
||||||
|
os.chown(temp_path, existing_stat.st_uid, existing_stat.st_gid)
|
||||||
|
except PermissionError:
|
||||||
|
# Best-effort owner/group preservation without requiring elevated privileges.
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
# Best-effort metadata preservation; data safety is prioritized.
|
||||||
|
pass
|
||||||
|
|
||||||
|
os.replace(temp_path, target_file)
|
||||||
|
except Exception:
|
||||||
|
if temp_path and temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(sub_item, 'r', encoding='utf-8') as f:
|
with open(sub_item, 'r', encoding='utf-8') as f:
|
||||||
new_settings = json.load(f)
|
# json5 natively supports comments and trailing commas (JSONC)
|
||||||
|
new_settings = json5.load(f)
|
||||||
|
|
||||||
if dest_file.exists():
|
if dest_file.exists():
|
||||||
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
|
||||||
with open(dest_file, 'w', encoding='utf-8') as f:
|
if merged is not None:
|
||||||
json.dump(merged, f, indent=4)
|
atomic_write_json(dest_file, merged)
|
||||||
f.write('\n')
|
log("Merged:", "green")
|
||||||
log("Merged:", "green")
|
log("Note: comments/trailing commas are normalized when rewritten", "yellow")
|
||||||
|
else:
|
||||||
|
log("Skipped merge (preserved existing settings)", "yellow")
|
||||||
else:
|
else:
|
||||||
shutil.copy2(sub_item, dest_file)
|
shutil.copy2(sub_item, dest_file)
|
||||||
log("Copied (no existing settings.json):", "blue")
|
log("Copied (no existing settings.json):", "blue")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"Warning: Could not merge, copying instead: {e}", "yellow")
|
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||||
shutil.copy2(sub_item, dest_file)
|
if not dest_file.exists():
|
||||||
|
shutil.copy2(sub_item, dest_file)
|
||||||
|
|
||||||
def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = False) -> dict:
|
|
||||||
|
def merge_json_files(existing_path: Path, new_content: Any, verbose: bool = False) -> Optional[dict[str, Any]]:
|
||||||
"""Merge new JSON content into existing JSON file.
|
"""Merge new JSON content into existing JSON file.
|
||||||
|
|
||||||
Performs a deep merge where:
|
Performs a polite deep merge where:
|
||||||
- New keys are added
|
- New keys are added
|
||||||
- Existing keys are preserved unless overwritten by new content
|
- Existing keys are preserved (not overwritten) unless both values are dictionaries
|
||||||
- Nested dictionaries are merged recursively
|
- Nested dictionaries are merged recursively only when both sides are dictionaries
|
||||||
- Lists and other values are replaced (not merged)
|
- Lists and other values are preserved from base if they exist
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
existing_path: Path to existing JSON file
|
existing_path: Path to existing JSON file
|
||||||
@@ -692,28 +754,64 @@ def merge_json_files(existing_path: Path, new_content: dict, verbose: bool = Fal
|
|||||||
verbose: Whether to print merge details
|
verbose: Whether to print merge details
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Merged JSON content as dict
|
Merged JSON content as dict, or None if the existing file should be left untouched.
|
||||||
"""
|
"""
|
||||||
try:
|
# Load existing content first to have a safe fallback
|
||||||
with open(existing_path, 'r', encoding='utf-8') as f:
|
existing_content = None
|
||||||
existing_content = json.load(f)
|
exists = existing_path.exists()
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
# If file doesn't exist or is invalid, just use new content
|
if exists:
|
||||||
|
try:
|
||||||
|
with open(existing_path, 'r', encoding='utf-8') as f:
|
||||||
|
# Handle comments (JSONC) natively with json5
|
||||||
|
# Note: json5 handles BOM automatically
|
||||||
|
existing_content = json5.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Handle race condition where file is deleted after exists() check
|
||||||
|
exists = False
|
||||||
|
except Exception as e:
|
||||||
|
if verbose:
|
||||||
|
console.print(f"[yellow]Warning: Could not read or parse existing JSON in {existing_path.name} ({e}).[/yellow]")
|
||||||
|
# Skip merge to preserve existing file if unparseable or inaccessible (e.g. PermissionError)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate template content
|
||||||
|
if not isinstance(new_content, dict):
|
||||||
|
if verbose:
|
||||||
|
console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Preserving existing settings.[/yellow]")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not exists:
|
||||||
return new_content
|
return new_content
|
||||||
|
|
||||||
def deep_merge(base: dict, update: dict) -> dict:
|
# If existing content parsed but is not a dict, skip merge to avoid data loss
|
||||||
"""Recursively merge update dict into base dict."""
|
if not isinstance(existing_content, dict):
|
||||||
|
if verbose:
|
||||||
|
console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object. Skipping merge to avoid data loss.[/yellow]")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def deep_merge_polite(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Recursively merge update dict into base dict, preserving base values."""
|
||||||
result = base.copy()
|
result = base.copy()
|
||||||
for key, value in update.items():
|
for key, value in update.items():
|
||||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
if key not in result:
|
||||||
# Recursively merge nested dictionaries
|
# Add new key
|
||||||
result[key] = deep_merge(result[key], value)
|
|
||||||
else:
|
|
||||||
# Add new key or replace existing value
|
|
||||||
result[key] = value
|
result[key] = value
|
||||||
|
elif isinstance(result[key], dict) and isinstance(value, dict):
|
||||||
|
# Recursively merge nested dictionaries
|
||||||
|
result[key] = deep_merge_polite(result[key], value)
|
||||||
|
else:
|
||||||
|
# Key already exists and values are not both dicts; preserve existing value.
|
||||||
|
# This ensures user settings aren't overwritten by template defaults.
|
||||||
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
merged = deep_merge(existing_content, new_content)
|
merged = deep_merge_polite(existing_content, new_content)
|
||||||
|
|
||||||
|
# Detect if anything actually changed. If not, return None so the caller
|
||||||
|
# can skip rewriting the file (preserving user's comments/formatting).
|
||||||
|
if merged == existing_content:
|
||||||
|
return None
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
console.print(f"[cyan]Merged JSON file:[/cyan] {existing_path.name}")
|
||||||
@@ -1165,7 +1263,12 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
else:
|
else:
|
||||||
templates_dir = project_path / commands_subdir
|
templates_dir = project_path / commands_subdir
|
||||||
|
|
||||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
# Only consider speckit.*.md templates so that user-authored command
|
||||||
|
# files (e.g. custom slash commands, agent files) coexisting in the
|
||||||
|
# same commands directory are not incorrectly converted into skills.
|
||||||
|
template_glob = "speckit.*.md"
|
||||||
|
|
||||||
|
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
|
||||||
# Fallback: try the repo-relative path (for running from source checkout)
|
# Fallback: try the repo-relative path (for running from source checkout)
|
||||||
# This also covers agents whose extracted commands are in a different
|
# This also covers agents whose extracted commands are in a different
|
||||||
# format (e.g. gemini/tabnine use .toml, not .md).
|
# format (e.g. gemini/tabnine use .toml, not .md).
|
||||||
@@ -1173,15 +1276,16 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
fallback_dir = script_dir / "templates" / "commands"
|
fallback_dir = script_dir / "templates" / "commands"
|
||||||
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
if fallback_dir.exists() and any(fallback_dir.glob("*.md")):
|
||||||
templates_dir = fallback_dir
|
templates_dir = fallback_dir
|
||||||
|
template_glob = "*.md"
|
||||||
|
|
||||||
if not templates_dir.exists() or not any(templates_dir.glob("*.md")):
|
if not templates_dir.exists() or not any(templates_dir.glob(template_glob)):
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.error("ai-skills", "command templates not found")
|
tracker.error("ai-skills", "command templates not found")
|
||||||
else:
|
else:
|
||||||
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
console.print("[yellow]Warning: command templates not found, skipping skills installation[/yellow]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
command_files = sorted(templates_dir.glob("*.md"))
|
command_files = sorted(templates_dir.glob(template_glob))
|
||||||
if not command_files:
|
if not command_files:
|
||||||
if tracker:
|
if tracker:
|
||||||
tracker.skip("ai-skills", "no command templates found")
|
tracker.skip("ai-skills", "no command templates found")
|
||||||
@@ -1220,11 +1324,14 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
body = content
|
body = content
|
||||||
|
|
||||||
command_name = command_file.stem
|
command_name = command_file.stem
|
||||||
# Normalize: extracted commands may be named "speckit.<cmd>.md";
|
# Normalize: extracted commands may be named "speckit.<cmd>.md"
|
||||||
# strip the "speckit." prefix so skill names stay clean and
|
# or "speckit.<cmd>.agent.md"; strip the "speckit." prefix and
|
||||||
|
# any trailing ".agent" suffix so skill names stay clean and
|
||||||
# SKILL_DESCRIPTIONS lookups work.
|
# SKILL_DESCRIPTIONS lookups work.
|
||||||
if command_name.startswith("speckit."):
|
if command_name.startswith("speckit."):
|
||||||
command_name = command_name[len("speckit."):]
|
command_name = command_name[len("speckit."):]
|
||||||
|
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":
|
||||||
@@ -1249,6 +1356,8 @@ def install_ai_skills(project_path: Path, selected_ai: str, tracker: StepTracker
|
|||||||
source_name = command_file.name
|
source_name = command_file.name
|
||||||
if source_name.startswith("speckit."):
|
if source_name.startswith("speckit."):
|
||||||
source_name = source_name[len("speckit."):]
|
source_name = source_name[len("speckit."):]
|
||||||
|
if source_name.endswith(".agent.md"):
|
||||||
|
source_name = source_name[:-len(".agent.md")] + ".md"
|
||||||
|
|
||||||
frontmatter_data = {
|
frontmatter_data = {
|
||||||
"name": skill_name,
|
"name": skill_name,
|
||||||
@@ -2000,6 +2109,11 @@ def preset_add(
|
|||||||
console.print("Run this command from a spec-kit project root")
|
console.print("Run this command from a spec-kit project root")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Validate priority
|
||||||
|
if priority < 1:
|
||||||
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
manager = PresetManager(project_root)
|
manager = PresetManager(project_root)
|
||||||
speckit_version = get_speckit_version()
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
@@ -2177,6 +2291,7 @@ def preset_info(
|
|||||||
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
|
||||||
):
|
):
|
||||||
"""Show detailed information about a preset."""
|
"""Show detailed information about a preset."""
|
||||||
|
from .extensions import normalize_priority
|
||||||
from .presets import PresetCatalog, PresetManager, PresetError
|
from .presets import PresetCatalog, PresetManager, PresetError
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
@@ -2210,6 +2325,10 @@ def preset_info(
|
|||||||
if license_val:
|
if license_val:
|
||||||
console.print(f" License: {license_val}")
|
console.print(f" License: {license_val}")
|
||||||
console.print("\n [green]Status: installed[/green]")
|
console.print("\n [green]Status: installed[/green]")
|
||||||
|
# Get priority from registry
|
||||||
|
pack_metadata = manager.registry.get(pack_id)
|
||||||
|
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
|
||||||
|
console.print(f" [dim]Priority:[/dim] {priority}")
|
||||||
console.print()
|
console.print()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2241,6 +2360,58 @@ 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 =====
|
||||||
|
|
||||||
|
|
||||||
@@ -2576,8 +2747,9 @@ def extension_list(
|
|||||||
status_color = "green" if ext["enabled"] else "red"
|
status_color = "green" if ext["enabled"] else "red"
|
||||||
|
|
||||||
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
|
||||||
|
console.print(f" [dim]{ext['id']}[/dim]")
|
||||||
console.print(f" {ext['description']}")
|
console.print(f" {ext['description']}")
|
||||||
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
if available or all_extensions:
|
if available or all_extensions:
|
||||||
@@ -2765,6 +2937,7 @@ def extension_add(
|
|||||||
extension: str = typer.Argument(help="Extension name or path"),
|
extension: str = typer.Argument(help="Extension name or path"),
|
||||||
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
||||||
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
||||||
|
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||||
):
|
):
|
||||||
"""Install an extension."""
|
"""Install an extension."""
|
||||||
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
|
||||||
@@ -2778,6 +2951,11 @@ def extension_add(
|
|||||||
console.print("Run this command from a spec-kit project root")
|
console.print("Run this command from a spec-kit project root")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Validate priority
|
||||||
|
if priority < 1:
|
||||||
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
manager = ExtensionManager(project_root)
|
manager = ExtensionManager(project_root)
|
||||||
speckit_version = get_speckit_version()
|
speckit_version = get_speckit_version()
|
||||||
|
|
||||||
@@ -2794,7 +2972,7 @@ def extension_add(
|
|||||||
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
manifest = manager.install_from_directory(source_path, speckit_version)
|
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
|
||||||
|
|
||||||
elif from_url:
|
elif from_url:
|
||||||
# Install from URL (ZIP file)
|
# Install from URL (ZIP file)
|
||||||
@@ -2827,7 +3005,7 @@ def extension_add(
|
|||||||
zip_path.write_bytes(zip_data)
|
zip_path.write_bytes(zip_data)
|
||||||
|
|
||||||
# Install from downloaded ZIP
|
# Install from downloaded ZIP
|
||||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
@@ -2871,7 +3049,7 @@ def extension_add(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Install from downloaded ZIP
|
# Install from downloaded ZIP
|
||||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||||
finally:
|
finally:
|
||||||
# Clean up downloaded ZIP
|
# Clean up downloaded ZIP
|
||||||
if zip_path.exists():
|
if zip_path.exists():
|
||||||
@@ -3047,7 +3225,7 @@ def extension_info(
|
|||||||
extension: str = typer.Argument(help="Extension ID or name"),
|
extension: str = typer.Argument(help="Extension ID or name"),
|
||||||
):
|
):
|
||||||
"""Show detailed information about an extension."""
|
"""Show detailed information about an extension."""
|
||||||
from .extensions import ExtensionCatalog, ExtensionManager
|
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
|
||||||
|
|
||||||
project_root = Path.cwd()
|
project_root = Path.cwd()
|
||||||
|
|
||||||
@@ -3084,8 +3262,15 @@ def extension_info(
|
|||||||
# Get local manifest info
|
# Get local manifest info
|
||||||
ext_manifest = manager.get_extension(resolved_installed_id)
|
ext_manifest = manager.get_extension(resolved_installed_id)
|
||||||
metadata = manager.registry.get(resolved_installed_id)
|
metadata = manager.registry.get(resolved_installed_id)
|
||||||
|
metadata_is_dict = isinstance(metadata, dict)
|
||||||
|
if not metadata_is_dict:
|
||||||
|
console.print(
|
||||||
|
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
|
||||||
|
"some information may be unavailable."
|
||||||
|
)
|
||||||
|
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
|
||||||
|
|
||||||
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
|
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
|
||||||
console.print(f"ID: {resolved_installed_id}")
|
console.print(f"ID: {resolved_installed_id}")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
@@ -3113,6 +3298,8 @@ def extension_info(
|
|||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print("[green]✓ Installed[/green]")
|
console.print("[green]✓ Installed[/green]")
|
||||||
|
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
|
||||||
|
console.print(f"[dim]Priority:[/dim] {priority}")
|
||||||
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -3128,6 +3315,8 @@ def extension_info(
|
|||||||
|
|
||||||
def _print_extension_info(ext_info: dict, manager):
|
def _print_extension_info(ext_info: dict, manager):
|
||||||
"""Print formatted extension info from catalog data."""
|
"""Print formatted extension info from catalog data."""
|
||||||
|
from .extensions import normalize_priority
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
|
||||||
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
|
||||||
@@ -3206,6 +3395,9 @@ def _print_extension_info(ext_info: dict, manager):
|
|||||||
install_allowed = ext_info.get("_install_allowed", True)
|
install_allowed = ext_info.get("_install_allowed", True)
|
||||||
if is_installed:
|
if is_installed:
|
||||||
console.print("[green]✓ Installed[/green]")
|
console.print("[green]✓ Installed[/green]")
|
||||||
|
metadata = manager.registry.get(ext_info['id'])
|
||||||
|
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
|
||||||
|
console.print(f"[dim]Priority:[/dim] {priority}")
|
||||||
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
|
||||||
elif install_allowed:
|
elif install_allowed:
|
||||||
console.print("[yellow]Not installed[/yellow]")
|
console.print("[yellow]Not installed[/yellow]")
|
||||||
@@ -3232,6 +3424,7 @@ def extension_update(
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
CommandRegistrar,
|
CommandRegistrar,
|
||||||
HookExecutor,
|
HookExecutor,
|
||||||
|
normalize_priority,
|
||||||
)
|
)
|
||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
import shutil
|
import shutil
|
||||||
@@ -3271,7 +3464,7 @@ def extension_update(
|
|||||||
for ext_id in extensions_to_update:
|
for ext_id in extensions_to_update:
|
||||||
# Get installed version
|
# Get installed version
|
||||||
metadata = manager.registry.get(ext_id)
|
metadata = manager.registry.get(ext_id)
|
||||||
if metadata is None or "version" not in metadata:
|
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
|
||||||
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
console.print(f"⚠ {ext_id}: Registry entry corrupted or missing (skipping)")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -3456,13 +3649,13 @@ def extension_update(
|
|||||||
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
|
||||||
|
|
||||||
# 9. Restore metadata from backup (installed_at, enabled state)
|
# 9. Restore metadata from backup (installed_at, enabled state)
|
||||||
if backup_registry_entry:
|
if backup_registry_entry and isinstance(backup_registry_entry, dict):
|
||||||
# Copy current registry entry to avoid mutating internal
|
# Copy current registry entry to avoid mutating internal
|
||||||
# registry state before explicit restore().
|
# registry state before explicit restore().
|
||||||
current_metadata = manager.registry.get(extension_id)
|
current_metadata = manager.registry.get(extension_id)
|
||||||
if current_metadata is None:
|
if current_metadata is None or not isinstance(current_metadata, dict):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Registry entry for '{extension_id}' missing after install — update incomplete"
|
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
|
||||||
)
|
)
|
||||||
new_metadata = dict(current_metadata)
|
new_metadata = dict(current_metadata)
|
||||||
|
|
||||||
@@ -3470,6 +3663,10 @@ def extension_update(
|
|||||||
if "installed_at" in backup_registry_entry:
|
if "installed_at" in backup_registry_entry:
|
||||||
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
|
||||||
|
|
||||||
|
# Preserve the original priority (normalized to handle corruption)
|
||||||
|
if "priority" in backup_registry_entry:
|
||||||
|
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
|
||||||
|
|
||||||
# If extension was disabled before update, disable it again
|
# If extension was disabled before update, disable it again
|
||||||
if not backup_registry_entry.get("enabled", True):
|
if not backup_registry_entry.get("enabled", True):
|
||||||
new_metadata["enabled"] = False
|
new_metadata["enabled"] = False
|
||||||
@@ -3523,7 +3720,7 @@ def extension_update(
|
|||||||
# (files that weren't in the original backup)
|
# (files that weren't in the original backup)
|
||||||
try:
|
try:
|
||||||
new_registry_entry = manager.registry.get(extension_id)
|
new_registry_entry = manager.registry.get(extension_id)
|
||||||
if new_registry_entry is None:
|
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
||||||
new_registered_commands = {}
|
new_registered_commands = {}
|
||||||
else:
|
else:
|
||||||
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||||
@@ -3643,7 +3840,7 @@ def extension_enable(
|
|||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension_id)
|
metadata = manager.registry.get(extension_id)
|
||||||
if metadata is None:
|
if metadata is None or not isinstance(metadata, dict):
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@@ -3691,7 +3888,7 @@ def extension_disable(
|
|||||||
|
|
||||||
# Update registry
|
# Update registry
|
||||||
metadata = manager.registry.get(extension_id)
|
metadata = manager.registry.get(extension_id)
|
||||||
if metadata is None:
|
if metadata is None or not isinstance(metadata, dict):
|
||||||
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@@ -3716,6 +3913,57 @@ def extension_disable(
|
|||||||
console.print(f"To re-enable: specify extension enable {extension_id}")
|
console.print(f"To re-enable: specify extension enable {extension_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@extension_app.command("set-priority")
|
||||||
|
def extension_set_priority(
|
||||||
|
extension: str = typer.Argument(help="Extension ID or name"),
|
||||||
|
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
|
||||||
|
):
|
||||||
|
"""Set the resolution priority of an installed extension."""
|
||||||
|
from .extensions import ExtensionManager
|
||||||
|
|
||||||
|
project_root = Path.cwd()
|
||||||
|
|
||||||
|
# Check if we're in a spec-kit project
|
||||||
|
specify_dir = project_root / ".specify"
|
||||||
|
if not specify_dir.exists():
|
||||||
|
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
|
||||||
|
console.print("Run this command from a spec-kit project root")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Validate priority
|
||||||
|
if priority < 1:
|
||||||
|
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_root)
|
||||||
|
|
||||||
|
# Resolve extension ID from argument (handles ambiguous names)
|
||||||
|
installed = manager.list_installed()
|
||||||
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
|
||||||
|
|
||||||
|
# Get current metadata
|
||||||
|
metadata = manager.registry.get(extension_id)
|
||||||
|
if metadata is None or not isinstance(metadata, dict):
|
||||||
|
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
from .extensions import normalize_priority
|
||||||
|
raw_priority = metadata.get("priority")
|
||||||
|
# Only skip if the stored value is already a valid int equal to requested priority
|
||||||
|
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
|
||||||
|
if isinstance(raw_priority, int) and raw_priority == priority:
|
||||||
|
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
old_priority = normalize_priority(raw_priority)
|
||||||
|
|
||||||
|
# Update priority
|
||||||
|
manager.registry.update(extension_id, {"priority": priority})
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority} → {priority}")
|
||||||
|
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ class CommandRegistrar:
|
|||||||
"args": "$ARGUMENTS",
|
"args": "$ARGUMENTS",
|
||||||
"extension": ".md"
|
"extension": ".md"
|
||||||
},
|
},
|
||||||
|
"pi": {
|
||||||
|
"dir": ".pi/prompts",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md"
|
||||||
|
},
|
||||||
"amp": {
|
"amp": {
|
||||||
"dir": ".agents/commands",
|
"dir": ".agents/commands",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
@@ -135,6 +141,12 @@ 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,6 +41,26 @@ class CompatibilityError(ExtensionError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_priority(value: Any, default: int = 10) -> int:
|
||||||
|
"""Normalize a stored priority value for sorting and display.
|
||||||
|
|
||||||
|
Corrupted registry data may contain missing, non-numeric, or non-positive
|
||||||
|
values. In those cases, fall back to the default priority.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Priority value to normalize (may be int, str, None, etc.)
|
||||||
|
default: Default priority to use for invalid values (default: 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized priority as positive integer (>= 1)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
priority = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return priority if priority >= 1 else default
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CatalogEntry:
|
class CatalogEntry:
|
||||||
"""Represents a single catalog entry in the catalog stack."""
|
"""Represents a single catalog entry in the catalog stack."""
|
||||||
@@ -251,6 +271,9 @@ 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,
|
||||||
@@ -324,6 +347,32 @@ 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."""
|
||||||
@@ -440,7 +489,8 @@ class ExtensionManager:
|
|||||||
self,
|
self,
|
||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
speckit_version: str,
|
speckit_version: str,
|
||||||
register_commands: bool = True
|
register_commands: bool = True,
|
||||||
|
priority: int = 10,
|
||||||
) -> ExtensionManifest:
|
) -> ExtensionManifest:
|
||||||
"""Install extension from a local directory.
|
"""Install extension from a local directory.
|
||||||
|
|
||||||
@@ -448,14 +498,19 @@ class ExtensionManager:
|
|||||||
source_dir: Path to extension directory
|
source_dir: Path to extension directory
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
register_commands: If True, register commands with AI agents
|
register_commands: If True, register commands with AI agents
|
||||||
|
priority: Resolution priority (lower = higher precedence, default 10)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed extension manifest
|
Installed extension manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If manifest is invalid
|
ValidationError: If manifest is invalid or priority is invalid
|
||||||
CompatibilityError: If extension is incompatible
|
CompatibilityError: If extension is incompatible
|
||||||
"""
|
"""
|
||||||
|
# Validate priority
|
||||||
|
if priority < 1:
|
||||||
|
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
||||||
|
|
||||||
# Load and validate manifest
|
# Load and validate manifest
|
||||||
manifest_path = source_dir / "extension.yml"
|
manifest_path = source_dir / "extension.yml"
|
||||||
manifest = ExtensionManifest(manifest_path)
|
manifest = ExtensionManifest(manifest_path)
|
||||||
@@ -497,6 +552,7 @@ class ExtensionManager:
|
|||||||
"source": "local",
|
"source": "local",
|
||||||
"manifest_hash": manifest.get_hash(),
|
"manifest_hash": manifest.get_hash(),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
|
"priority": priority,
|
||||||
"registered_commands": registered_commands
|
"registered_commands": registered_commands
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -505,21 +561,27 @@ class ExtensionManager:
|
|||||||
def install_from_zip(
|
def install_from_zip(
|
||||||
self,
|
self,
|
||||||
zip_path: Path,
|
zip_path: Path,
|
||||||
speckit_version: str
|
speckit_version: str,
|
||||||
|
priority: int = 10,
|
||||||
) -> ExtensionManifest:
|
) -> ExtensionManifest:
|
||||||
"""Install extension from ZIP file.
|
"""Install extension from ZIP file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zip_path: Path to extension ZIP file
|
zip_path: Path to extension ZIP file
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
|
priority: Resolution priority (lower = higher precedence, default 10)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed extension manifest
|
Installed extension manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: If manifest is invalid
|
ValidationError: If manifest is invalid or priority is invalid
|
||||||
CompatibilityError: If extension is incompatible
|
CompatibilityError: If extension is incompatible
|
||||||
"""
|
"""
|
||||||
|
# Validate priority early
|
||||||
|
if priority < 1:
|
||||||
|
raise ValidationError("Priority must be a positive integer (1 or higher)")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
temp_path = Path(tmpdir)
|
temp_path = Path(tmpdir)
|
||||||
|
|
||||||
@@ -554,7 +616,7 @@ class ExtensionManager:
|
|||||||
raise ValidationError("No extension.yml found in ZIP file")
|
raise ValidationError("No extension.yml found in ZIP file")
|
||||||
|
|
||||||
# Install from extracted directory
|
# Install from extracted directory
|
||||||
return self.install_from_directory(extension_dir, speckit_version)
|
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
|
||||||
|
|
||||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||||
"""Remove an installed extension.
|
"""Remove an installed extension.
|
||||||
@@ -632,6 +694,9 @@ class ExtensionManager:
|
|||||||
result = []
|
result = []
|
||||||
|
|
||||||
for ext_id, metadata in self.registry.list().items():
|
for ext_id, metadata in self.registry.list().items():
|
||||||
|
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
ext_dir = self.extensions_dir / ext_id
|
ext_dir = self.extensions_dir / ext_id
|
||||||
manifest_path = ext_dir / "extension.yml"
|
manifest_path = ext_dir / "extension.yml"
|
||||||
|
|
||||||
@@ -643,6 +708,7 @@ class ExtensionManager:
|
|||||||
"version": metadata.get("version", "unknown"),
|
"version": metadata.get("version", "unknown"),
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"enabled": metadata.get("enabled", True),
|
"enabled": metadata.get("enabled", True),
|
||||||
|
"priority": normalize_priority(metadata.get("priority")),
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"command_count": len(manifest.commands),
|
"command_count": len(manifest.commands),
|
||||||
"hook_count": len(manifest.hooks)
|
"hook_count": len(manifest.hooks)
|
||||||
@@ -655,6 +721,7 @@ class ExtensionManager:
|
|||||||
"version": metadata.get("version", "unknown"),
|
"version": metadata.get("version", "unknown"),
|
||||||
"description": "⚠️ Corrupted extension",
|
"description": "⚠️ Corrupted extension",
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
|
"priority": normalize_priority(metadata.get("priority")),
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"command_count": 0,
|
"command_count": 0,
|
||||||
"hook_count": 0
|
"hook_count": 0
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Presets are self-contained, versioned collections of templates
|
|||||||
customize the Spec-Driven Development workflow.
|
customize the Spec-Driven Development workflow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
@@ -23,6 +24,8 @@ import yaml
|
|||||||
from packaging import version as pkg_version
|
from packaging import version as pkg_version
|
||||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||||
|
|
||||||
|
from .extensions import ExtensionRegistry, normalize_priority
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PresetCatalogEntry:
|
class PresetCatalogEntry:
|
||||||
@@ -271,6 +274,38 @@ 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.
|
||||||
|
|
||||||
@@ -294,14 +329,26 @@ 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) tuples sorted by priority
|
List of (pack_id, metadata_copy) tuples sorted by priority.
|
||||||
|
Metadata is deep-copied to prevent accidental mutation.
|
||||||
"""
|
"""
|
||||||
packs = self.data["presets"]
|
packs = self.data.get("presets", {}) or {}
|
||||||
|
if not isinstance(packs, dict):
|
||||||
|
packs = {}
|
||||||
|
sortable_packs = []
|
||||||
|
for pack_id, meta in packs.items():
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
continue
|
||||||
|
metadata_copy = copy.deepcopy(meta)
|
||||||
|
metadata_copy["priority"] = normalize_priority(metadata_copy.get("priority", 10))
|
||||||
|
sortable_packs.append((pack_id, metadata_copy))
|
||||||
return sorted(
|
return sorted(
|
||||||
packs.items(),
|
sortable_packs,
|
||||||
key=lambda item: item[1].get("priority", 10),
|
key=lambda item: (item[1]["priority"], item[0]),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_installed(self, pack_id: str) -> bool:
|
def is_installed(self, pack_id: str) -> bool:
|
||||||
@@ -680,9 +727,13 @@ class PresetManager:
|
|||||||
Installed preset manifest
|
Installed preset manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PresetValidationError: If manifest is invalid
|
PresetValidationError: If manifest is invalid or priority is invalid
|
||||||
PresetCompatibilityError: If pack is incompatible
|
PresetCompatibilityError: If pack is incompatible
|
||||||
"""
|
"""
|
||||||
|
# Validate priority
|
||||||
|
if priority < 1:
|
||||||
|
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
||||||
|
|
||||||
manifest_path = source_dir / "preset.yml"
|
manifest_path = source_dir / "preset.yml"
|
||||||
manifest = PresetManifest(manifest_path)
|
manifest = PresetManifest(manifest_path)
|
||||||
|
|
||||||
@@ -729,14 +780,19 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
zip_path: Path to preset ZIP file
|
zip_path: Path to preset ZIP file
|
||||||
speckit_version: Current spec-kit version
|
speckit_version: Current spec-kit version
|
||||||
|
priority: Resolution priority (lower = higher precedence, default 10)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Installed preset manifest
|
Installed preset manifest
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PresetValidationError: If manifest is invalid
|
PresetValidationError: If manifest is invalid or priority is invalid
|
||||||
PresetCompatibilityError: If pack is incompatible
|
PresetCompatibilityError: If pack is incompatible
|
||||||
"""
|
"""
|
||||||
|
# Validate priority early
|
||||||
|
if priority < 1:
|
||||||
|
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
temp_path = Path(tmpdir)
|
temp_path = Path(tmpdir)
|
||||||
|
|
||||||
@@ -808,6 +864,9 @@ class PresetManager:
|
|||||||
result = []
|
result = []
|
||||||
|
|
||||||
for pack_id, metadata in self.registry.list().items():
|
for pack_id, metadata in self.registry.list().items():
|
||||||
|
# Ensure metadata is a dictionary to avoid AttributeError when using .get()
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
metadata = {}
|
||||||
pack_dir = self.presets_dir / pack_id
|
pack_dir = self.presets_dir / pack_id
|
||||||
manifest_path = pack_dir / "preset.yml"
|
manifest_path = pack_dir / "preset.yml"
|
||||||
|
|
||||||
@@ -816,13 +875,13 @@ class PresetManager:
|
|||||||
result.append({
|
result.append({
|
||||||
"id": pack_id,
|
"id": pack_id,
|
||||||
"name": manifest.name,
|
"name": manifest.name,
|
||||||
"version": metadata["version"],
|
"version": metadata.get("version", manifest.version),
|
||||||
"description": manifest.description,
|
"description": manifest.description,
|
||||||
"enabled": metadata.get("enabled", True),
|
"enabled": metadata.get("enabled", True),
|
||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"template_count": len(manifest.templates),
|
"template_count": len(manifest.templates),
|
||||||
"tags": manifest.tags,
|
"tags": manifest.tags,
|
||||||
"priority": metadata.get("priority", 10),
|
"priority": normalize_priority(metadata.get("priority")),
|
||||||
})
|
})
|
||||||
except PresetValidationError:
|
except PresetValidationError:
|
||||||
result.append({
|
result.append({
|
||||||
@@ -834,7 +893,7 @@ class PresetManager:
|
|||||||
"installed_at": metadata.get("installed_at"),
|
"installed_at": metadata.get("installed_at"),
|
||||||
"template_count": 0,
|
"template_count": 0,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"priority": metadata.get("priority", 10),
|
"priority": normalize_priority(metadata.get("priority")),
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1393,6 +1452,40 @@ 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,
|
||||||
@@ -1445,18 +1538,18 @@ class PresetResolver:
|
|||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
# Priority 3: Extension-provided templates
|
# Priority 3: Extension-provided templates (sorted by priority — lower number wins)
|
||||||
if self.extensions_dir.exists():
|
for _priority, ext_id, _metadata in self._get_all_extensions_by_priority():
|
||||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
ext_dir = self.extensions_dir / ext_id
|
||||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
if not ext_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
for subdir in subdirs:
|
for subdir in subdirs:
|
||||||
if subdir:
|
if subdir:
|
||||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||||
else:
|
else:
|
||||||
candidate = ext_dir / "templates" / f"{template_name}{ext}"
|
candidate = ext_dir / f"{template_name}{ext}"
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
# Priority 4: Core templates
|
# Priority 4: Core templates
|
||||||
if template_type == "template":
|
if template_type == "template":
|
||||||
@@ -1514,17 +1607,24 @@ class PresetResolver:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.extensions_dir.exists():
|
for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority():
|
||||||
for ext_dir in sorted(self.extensions_dir.iterdir()):
|
ext_dir = self.extensions_dir / ext_id
|
||||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
if not ext_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
resolved.relative_to(ext_dir)
|
resolved.relative_to(ext_dir)
|
||||||
|
if ext_meta:
|
||||||
|
version = ext_meta.get("version", "?")
|
||||||
return {
|
return {
|
||||||
"path": resolved_str,
|
"path": resolved_str,
|
||||||
"source": f"extension:{ext_dir.name}",
|
"source": f"extension:{ext_id} v{version}",
|
||||||
}
|
}
|
||||||
except ValueError:
|
else:
|
||||||
continue
|
return {
|
||||||
|
"path": resolved_str,
|
||||||
|
"source": f"extension:{ext_id} (unregistered)",
|
||||||
|
}
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
return {"path": resolved_str, "source": "core"}
|
return {"path": resolved_str, "source": "core"}
|
||||||
|
|||||||
@@ -233,3 +233,157 @@ class TestAgentConfigConsistency:
|
|||||||
def test_ai_help_includes_kimi(self):
|
def test_ai_help_includes_kimi(self):
|
||||||
"""CLI help text for --ai should include kimi."""
|
"""CLI help text for --ai should include kimi."""
|
||||||
assert "kimi" in AI_ASSISTANT_HELP
|
assert "kimi" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
|
# --- Trae IDE consistency checks ---
|
||||||
|
|
||||||
|
def test_trae_in_agent_config(self):
|
||||||
|
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
|
||||||
|
assert "trae" in AGENT_CONFIG
|
||||||
|
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
|
||||||
|
assert AGENT_CONFIG["trae"]["commands_subdir"] == "rules"
|
||||||
|
assert AGENT_CONFIG["trae"]["requires_cli"] is False
|
||||||
|
assert AGENT_CONFIG["trae"]["install_url"] is None
|
||||||
|
|
||||||
|
def test_trae_in_extension_registrar(self):
|
||||||
|
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
|
||||||
|
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
|
assert "trae" in cfg
|
||||||
|
trae_cfg = cfg["trae"]
|
||||||
|
assert trae_cfg["format"] == "markdown"
|
||||||
|
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||||
|
assert trae_cfg["extension"] == ".md"
|
||||||
|
|
||||||
|
def test_trae_in_release_agent_lists(self):
|
||||||
|
"""Bash and PowerShell release scripts should include trae in agent lists."""
|
||||||
|
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||||
|
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
||||||
|
assert sh_match is not None
|
||||||
|
sh_agents = sh_match.group(1).split()
|
||||||
|
|
||||||
|
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
||||||
|
assert ps_match is not None
|
||||||
|
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
||||||
|
|
||||||
|
assert "trae" in sh_agents
|
||||||
|
assert "trae" in ps_agents
|
||||||
|
|
||||||
|
def test_trae_in_release_scripts_generate_commands(self):
|
||||||
|
"""Release scripts should generate markdown commands for trae in .trae/rules."""
|
||||||
|
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||||
|
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert ".trae/rules" in sh_text
|
||||||
|
assert ".trae/rules" in ps_text
|
||||||
|
assert re.search(r"'trae'\s*\{.*?\.trae/rules", ps_text, re.S) is not None
|
||||||
|
|
||||||
|
def test_trae_in_github_release_output(self):
|
||||||
|
"""GitHub release script should include trae template packages."""
|
||||||
|
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "spec-kit-template-trae-sh-" in gh_release_text
|
||||||
|
assert "spec-kit-template-trae-ps-" in gh_release_text
|
||||||
|
|
||||||
|
def test_trae_in_agent_context_scripts(self):
|
||||||
|
"""Agent context scripts should support trae agent type."""
|
||||||
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
|
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "trae" in bash_text
|
||||||
|
assert "TRAE_FILE" in bash_text
|
||||||
|
assert "trae" in pwsh_text
|
||||||
|
assert "TRAE_FILE" in pwsh_text
|
||||||
|
|
||||||
|
def test_trae_in_powershell_validate_set(self):
|
||||||
|
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
|
||||||
|
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||||
|
assert validate_set_match is not None
|
||||||
|
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
|
||||||
|
|
||||||
|
assert "trae" in validate_set_values
|
||||||
|
|
||||||
|
def test_ai_help_includes_trae(self):
|
||||||
|
"""CLI help text for --ai should include trae."""
|
||||||
|
assert "trae" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
|
# --- Pi Coding Agent consistency checks ---
|
||||||
|
|
||||||
|
def test_pi_in_agent_config(self):
|
||||||
|
"""AGENT_CONFIG should include pi with correct folder and commands_subdir."""
|
||||||
|
assert "pi" in AGENT_CONFIG
|
||||||
|
assert AGENT_CONFIG["pi"]["folder"] == ".pi/"
|
||||||
|
assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts"
|
||||||
|
assert AGENT_CONFIG["pi"]["requires_cli"] is True
|
||||||
|
assert AGENT_CONFIG["pi"]["install_url"] is not None
|
||||||
|
|
||||||
|
def test_pi_in_extension_registrar(self):
|
||||||
|
"""Extension command registrar should include pi using .pi/prompts."""
|
||||||
|
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||||
|
|
||||||
|
assert "pi" in cfg
|
||||||
|
pi_cfg = cfg["pi"]
|
||||||
|
assert pi_cfg["dir"] == ".pi/prompts"
|
||||||
|
assert pi_cfg["format"] == "markdown"
|
||||||
|
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||||
|
assert pi_cfg["extension"] == ".md"
|
||||||
|
|
||||||
|
def test_pi_in_release_agent_lists(self):
|
||||||
|
"""Bash and PowerShell release scripts should include pi in agent lists."""
|
||||||
|
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||||
|
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
sh_match = re.search(r"ALL_AGENTS=\(([^)]*)\)", sh_text)
|
||||||
|
assert sh_match is not None
|
||||||
|
sh_agents = sh_match.group(1).split()
|
||||||
|
|
||||||
|
ps_match = re.search(r"\$AllAgents = @\(([^)]*)\)", ps_text)
|
||||||
|
assert ps_match is not None
|
||||||
|
ps_agents = re.findall(r"'([^']+)'", ps_match.group(1))
|
||||||
|
|
||||||
|
assert "pi" in sh_agents
|
||||||
|
assert "pi" in ps_agents
|
||||||
|
|
||||||
|
def test_release_scripts_generate_pi_prompt_templates(self):
|
||||||
|
"""Release scripts should generate Markdown prompt templates for pi in .pi/prompts."""
|
||||||
|
sh_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.sh").read_text(encoding="utf-8")
|
||||||
|
ps_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-release-packages.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert ".pi/prompts" in sh_text
|
||||||
|
assert ".pi/prompts" in ps_text
|
||||||
|
assert re.search(r"pi\)\s*\n.*?\.pi/prompts", sh_text, re.S) is not None
|
||||||
|
assert re.search(r"'pi'\s*\{.*?\.pi/prompts", ps_text, re.S) is not None
|
||||||
|
|
||||||
|
def test_pi_in_powershell_validate_set(self):
|
||||||
|
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
|
||||||
|
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
|
||||||
|
assert validate_set_match is not None
|
||||||
|
validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1))
|
||||||
|
|
||||||
|
assert "pi" in validate_set_values
|
||||||
|
|
||||||
|
def test_pi_in_github_release_output(self):
|
||||||
|
"""GitHub release script should include pi template packages."""
|
||||||
|
gh_release_text = (REPO_ROOT / ".github" / "workflows" / "scripts" / "create-github-release.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "spec-kit-template-pi-sh-" in gh_release_text
|
||||||
|
assert "spec-kit-template-pi-ps-" in gh_release_text
|
||||||
|
|
||||||
|
def test_agent_context_scripts_include_pi(self):
|
||||||
|
"""Agent context scripts should support pi agent type."""
|
||||||
|
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
|
||||||
|
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "pi" in bash_text
|
||||||
|
assert "Pi Coding Agent" in bash_text
|
||||||
|
assert "pi" in pwsh_text
|
||||||
|
assert "Pi Coding Agent" in pwsh_text
|
||||||
|
|
||||||
|
def test_ai_help_includes_pi(self):
|
||||||
|
"""CLI help text for --ai should include pi."""
|
||||||
|
assert "pi" in AI_ASSISTANT_HELP
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def templates_dir(project_dir):
|
|||||||
tpl_root.mkdir(parents=True, exist_ok=True)
|
tpl_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Template with valid YAML frontmatter
|
# Template with valid YAML frontmatter
|
||||||
(tpl_root / "specify.md").write_text(
|
(tpl_root / "speckit.specify.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: Create or update the feature specification.\n"
|
"description: Create or update the feature specification.\n"
|
||||||
"handoffs:\n"
|
"handoffs:\n"
|
||||||
@@ -79,7 +79,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with minimal frontmatter
|
# Template with minimal frontmatter
|
||||||
(tpl_root / "plan.md").write_text(
|
(tpl_root / "speckit.plan.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: Generate implementation plan.\n"
|
"description: Generate implementation plan.\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
@@ -91,7 +91,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with no frontmatter
|
# Template with no frontmatter
|
||||||
(tpl_root / "tasks.md").write_text(
|
(tpl_root / "speckit.tasks.md").write_text(
|
||||||
"# Tasks Command\n"
|
"# Tasks Command\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Body without frontmatter.\n",
|
"Body without frontmatter.\n",
|
||||||
@@ -99,7 +99,7 @@ def templates_dir(project_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
# Template with empty YAML frontmatter (yaml.safe_load returns None)
|
||||||
(tpl_root / "empty_fm.md").write_text(
|
(tpl_root / "speckit.empty_fm.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"---\n"
|
"---\n"
|
||||||
"\n"
|
"\n"
|
||||||
@@ -182,6 +182,11 @@ class TestGetSkillsDir:
|
|||||||
result = _get_skills_dir(project_dir, "kiro-cli")
|
result = _get_skills_dir(project_dir, "kiro-cli")
|
||||||
assert result == project_dir / ".kiro" / "skills"
|
assert result == project_dir / ".kiro" / "skills"
|
||||||
|
|
||||||
|
def test_pi_skills_dir(self, project_dir):
|
||||||
|
"""Pi should use .pi/skills/."""
|
||||||
|
result = _get_skills_dir(project_dir, "pi")
|
||||||
|
assert result == project_dir / ".pi" / "skills"
|
||||||
|
|
||||||
def test_unknown_agent_uses_default(self, project_dir):
|
def test_unknown_agent_uses_default(self, project_dir):
|
||||||
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
|
"""Unknown agents should fall back to DEFAULT_SKILLS_DIR."""
|
||||||
result = _get_skills_dir(project_dir, "nonexistent-agent")
|
result = _get_skills_dir(project_dir, "nonexistent-agent")
|
||||||
@@ -337,7 +342,7 @@ class TestInstallAiSkills:
|
|||||||
cmds_dir = project_dir / ".claude" / "commands"
|
cmds_dir = project_dir / ".claude" / "commands"
|
||||||
cmds_dir.mkdir(parents=True)
|
cmds_dir.mkdir(parents=True)
|
||||||
|
|
||||||
(cmds_dir / "broken.md").write_text(
|
(cmds_dir / "speckit.broken.md").write_text(
|
||||||
"---\n"
|
"---\n"
|
||||||
"description: [unclosed bracket\n"
|
"description: [unclosed bracket\n"
|
||||||
" invalid: yaml: content: here\n"
|
" invalid: yaml: content: here\n"
|
||||||
@@ -422,6 +427,27 @@ class TestInstallAiSkills:
|
|||||||
assert (cmds_dir / "speckit.specify.md").exists()
|
assert (cmds_dir / "speckit.specify.md").exists()
|
||||||
assert (cmds_dir / "speckit.plan.md").exists()
|
assert (cmds_dir / "speckit.plan.md").exists()
|
||||||
|
|
||||||
|
def test_pi_prompt_dir_installs_skills(self, project_dir):
|
||||||
|
"""Pi should install skills directly from .pi/prompts/."""
|
||||||
|
prompts_dir = project_dir / ".pi" / "prompts"
|
||||||
|
prompts_dir.mkdir(parents=True)
|
||||||
|
(prompts_dir / "speckit.specify.md").write_text(
|
||||||
|
"---\ndescription: Create or update the feature specification.\n---\n\n# Specify\n\nBody.\n"
|
||||||
|
)
|
||||||
|
(prompts_dir / "speckit.plan.md").write_text(
|
||||||
|
"---\ndescription: Generate implementation plan.\n---\n\n# Plan\n\nBody.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = install_ai_skills(project_dir, "pi")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
skills_dir = project_dir / ".pi" / "skills"
|
||||||
|
assert skills_dir.exists()
|
||||||
|
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||||
|
assert len(skill_dirs) >= 1
|
||||||
|
assert (prompts_dir / "speckit.specify.md").exists()
|
||||||
|
assert (prompts_dir / "speckit.plan.md").exists()
|
||||||
|
|
||||||
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
|
@pytest.mark.parametrize("agent_key", [k for k in AGENT_CONFIG.keys() if k != "generic"])
|
||||||
def test_skills_install_for_all_agents(self, temp_dir, agent_key):
|
def test_skills_install_for_all_agents(self, temp_dir, agent_key):
|
||||||
"""install_ai_skills should produce skills for every configured agent."""
|
"""install_ai_skills should produce skills for every configured agent."""
|
||||||
@@ -430,9 +456,12 @@ class TestInstallAiSkills:
|
|||||||
|
|
||||||
# Place .md templates in the agent's commands directory
|
# Place .md templates in the agent's commands directory
|
||||||
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
agent_folder = AGENT_CONFIG[agent_key]["folder"]
|
||||||
cmds_dir = proj / agent_folder.rstrip("/") / "commands"
|
commands_subdir = AGENT_CONFIG[agent_key].get("commands_subdir", "commands")
|
||||||
|
cmds_dir = proj / agent_folder.rstrip("/") / commands_subdir
|
||||||
cmds_dir.mkdir(parents=True)
|
cmds_dir.mkdir(parents=True)
|
||||||
(cmds_dir / "specify.md").write_text(
|
# Copilot uses speckit.*.agent.md templates; other agents use speckit.*.md
|
||||||
|
fname = "speckit.specify.agent.md" if agent_key == "copilot" else "speckit.specify.md"
|
||||||
|
(cmds_dir / fname).write_text(
|
||||||
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
"---\ndescription: Test command\n---\n\n# Test\n\nBody.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -448,7 +477,100 @@ 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.
|
||||||
@@ -460,14 +582,16 @@ class TestCommandCoexistence:
|
|||||||
|
|
||||||
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
def test_existing_commands_preserved_claude(self, project_dir, templates_dir, commands_dir_claude):
|
||||||
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
"""install_ai_skills must NOT remove pre-existing .claude/commands files."""
|
||||||
# Verify commands exist before
|
# Verify commands exist before (templates_dir adds 4 speckit.* files,
|
||||||
assert len(list(commands_dir_claude.glob("speckit.*"))) == 3
|
# commands_dir_claude overlaps with 3 of them)
|
||||||
|
before = list(commands_dir_claude.glob("speckit.*"))
|
||||||
|
assert len(before) >= 3
|
||||||
|
|
||||||
install_ai_skills(project_dir, "claude")
|
install_ai_skills(project_dir, "claude")
|
||||||
|
|
||||||
# Commands must still be there — install_ai_skills never touches them
|
# Commands must still be there — install_ai_skills never touches them
|
||||||
remaining = list(commands_dir_claude.glob("speckit.*"))
|
remaining = list(commands_dir_claude.glob("speckit.*"))
|
||||||
assert len(remaining) == 3
|
assert len(remaining) == len(before)
|
||||||
|
|
||||||
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
def test_existing_commands_preserved_gemini(self, project_dir, templates_dir, commands_dir_gemini):
|
||||||
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
"""install_ai_skills must NOT remove pre-existing .gemini/commands files."""
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from specify_cli.extensions import (
|
|||||||
ExtensionError,
|
ExtensionError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
CompatibilityError,
|
CompatibilityError,
|
||||||
|
normalize_priority,
|
||||||
version_satisfies,
|
version_satisfies,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,6 +122,57 @@ def project_dir(temp_dir):
|
|||||||
return proj_dir
|
return proj_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ===== normalize_priority Tests =====
|
||||||
|
|
||||||
|
class TestNormalizePriority:
|
||||||
|
"""Test normalize_priority helper function."""
|
||||||
|
|
||||||
|
def test_valid_integer(self):
|
||||||
|
"""Test with valid integer priority."""
|
||||||
|
assert normalize_priority(5) == 5
|
||||||
|
assert normalize_priority(1) == 1
|
||||||
|
assert normalize_priority(100) == 100
|
||||||
|
|
||||||
|
def test_valid_string_number(self):
|
||||||
|
"""Test with string that can be converted to int."""
|
||||||
|
assert normalize_priority("5") == 5
|
||||||
|
assert normalize_priority("10") == 10
|
||||||
|
|
||||||
|
def test_zero_returns_default(self):
|
||||||
|
"""Test that zero priority returns default."""
|
||||||
|
assert normalize_priority(0) == 10
|
||||||
|
assert normalize_priority(0, default=5) == 5
|
||||||
|
|
||||||
|
def test_negative_returns_default(self):
|
||||||
|
"""Test that negative priority returns default."""
|
||||||
|
assert normalize_priority(-1) == 10
|
||||||
|
assert normalize_priority(-100, default=5) == 5
|
||||||
|
|
||||||
|
def test_none_returns_default(self):
|
||||||
|
"""Test that None returns default."""
|
||||||
|
assert normalize_priority(None) == 10
|
||||||
|
assert normalize_priority(None, default=5) == 5
|
||||||
|
|
||||||
|
def test_invalid_string_returns_default(self):
|
||||||
|
"""Test that non-numeric string returns default."""
|
||||||
|
assert normalize_priority("invalid") == 10
|
||||||
|
assert normalize_priority("abc", default=5) == 5
|
||||||
|
|
||||||
|
def test_float_truncates(self):
|
||||||
|
"""Test that float is truncated to int."""
|
||||||
|
assert normalize_priority(5.9) == 5
|
||||||
|
assert normalize_priority(3.1) == 3
|
||||||
|
|
||||||
|
def test_empty_string_returns_default(self):
|
||||||
|
"""Test that empty string returns default."""
|
||||||
|
assert normalize_priority("") == 10
|
||||||
|
|
||||||
|
def test_custom_default(self):
|
||||||
|
"""Test custom default value."""
|
||||||
|
assert normalize_priority(None, default=20) == 20
|
||||||
|
assert normalize_priority("invalid", default=1) == 1
|
||||||
|
|
||||||
|
|
||||||
# ===== ExtensionManifest Tests =====
|
# ===== ExtensionManifest Tests =====
|
||||||
|
|
||||||
class TestExtensionManifest:
|
class TestExtensionManifest:
|
||||||
@@ -541,6 +593,15 @@ class TestCommandRegistrar:
|
|||||||
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
assert "codex" in CommandRegistrar.AGENT_CONFIGS
|
||||||
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
assert CommandRegistrar.AGENT_CONFIGS["codex"]["dir"] == ".codex/prompts"
|
||||||
|
|
||||||
|
def test_pi_agent_config_present(self):
|
||||||
|
"""Pi should be mapped to .pi/prompts."""
|
||||||
|
assert "pi" in CommandRegistrar.AGENT_CONFIGS
|
||||||
|
cfg = CommandRegistrar.AGENT_CONFIGS["pi"]
|
||||||
|
assert cfg["dir"] == ".pi/prompts"
|
||||||
|
assert cfg["format"] == "markdown"
|
||||||
|
assert cfg["args"] == "$ARGUMENTS"
|
||||||
|
assert cfg["extension"] == ".md"
|
||||||
|
|
||||||
def test_qwen_agent_config_is_markdown(self):
|
def test_qwen_agent_config_is_markdown(self):
|
||||||
"""Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
|
"""Qwen should use Markdown format with $ARGUMENTS (not TOML)."""
|
||||||
assert "qwen" in CommandRegistrar.AGENT_CONFIGS
|
assert "qwen" in CommandRegistrar.AGENT_CONFIGS
|
||||||
@@ -2337,3 +2398,404 @@ 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"
|
||||||
|
|||||||
190
tests/test_merge.py
Normal file
190
tests/test_merge.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import stat
|
||||||
|
|
||||||
|
from specify_cli import merge_json_files
|
||||||
|
from specify_cli import handle_vscode_settings
|
||||||
|
|
||||||
|
# --- Dimension 2: Polite Deep Merge Strategy ---
|
||||||
|
|
||||||
|
def test_merge_json_files_type_mismatch_preservation(tmp_path):
|
||||||
|
"""If user has a string but template wants a dict, PRESERVE user's string."""
|
||||||
|
existing_file = tmp_path / "settings.json"
|
||||||
|
# User might have overridden a setting with a simple string or different type
|
||||||
|
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
|
||||||
|
|
||||||
|
# Template might expect a dict for the same key (hypothetically)
|
||||||
|
new_settings = {
|
||||||
|
"chat.editor.fontFamily": {"font": "TemplateFont"}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_json_files(existing_file, new_settings)
|
||||||
|
# Result is None because user settings were preserved and nothing else changed
|
||||||
|
assert merged is None
|
||||||
|
|
||||||
|
def test_merge_json_files_deep_nesting(tmp_path):
|
||||||
|
"""Verify deep recursive merging of new keys."""
|
||||||
|
existing_file = tmp_path / "settings.json"
|
||||||
|
existing_file.write_text("""
|
||||||
|
{
|
||||||
|
"a": {
|
||||||
|
"b": {
|
||||||
|
"c": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
new_settings = {
|
||||||
|
"a": {
|
||||||
|
"b": {
|
||||||
|
"d": 2 # New nested key
|
||||||
|
},
|
||||||
|
"e": 3 # New mid-level key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_json_files(existing_file, new_settings)
|
||||||
|
assert merged["a"]["b"]["c"] == 1
|
||||||
|
assert merged["a"]["b"]["d"] == 2
|
||||||
|
assert merged["a"]["e"] == 3
|
||||||
|
|
||||||
|
def test_merge_json_files_empty_existing(tmp_path):
|
||||||
|
"""Merging into an empty/new file."""
|
||||||
|
existing_file = tmp_path / "empty.json"
|
||||||
|
existing_file.write_text("{}")
|
||||||
|
|
||||||
|
new_settings = {"a": 1}
|
||||||
|
merged = merge_json_files(existing_file, new_settings)
|
||||||
|
assert merged == {"a": 1}
|
||||||
|
|
||||||
|
# --- Dimension 3: Real-world Simulation ---
|
||||||
|
|
||||||
|
def test_merge_vscode_realistic_scenario(tmp_path):
|
||||||
|
"""A realistic VSCode settings.json with many existing preferences, comments, and trailing commas."""
|
||||||
|
existing_file = tmp_path / "vscode_settings.json"
|
||||||
|
existing_file.write_text("""
|
||||||
|
{
|
||||||
|
"editor.fontSize": 12,
|
||||||
|
"editor.formatOnSave": true, /* block comment */
|
||||||
|
"files.exclude": {
|
||||||
|
"**/.git": true,
|
||||||
|
"**/node_modules": true,
|
||||||
|
},
|
||||||
|
"chat.promptFilesRecommendations": {
|
||||||
|
"existing.tool": true,
|
||||||
|
} // User comment
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
template_settings = {
|
||||||
|
"chat.promptFilesRecommendations": {
|
||||||
|
"speckit.specify": True,
|
||||||
|
"speckit.plan": True
|
||||||
|
},
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
".specify/scripts/bash/": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_json_files(existing_file, template_settings)
|
||||||
|
|
||||||
|
# Check preservation
|
||||||
|
assert merged["editor.fontSize"] == 12
|
||||||
|
assert merged["files.exclude"]["**/.git"] is True
|
||||||
|
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
|
||||||
|
|
||||||
|
# Check additions
|
||||||
|
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
|
||||||
|
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
|
||||||
|
|
||||||
|
# --- Dimension 4: Error Handling & Robustness ---
|
||||||
|
|
||||||
|
def test_merge_json_files_with_bom(tmp_path):
|
||||||
|
"""Test files with UTF-8 BOM (sometimes created on Windows)."""
|
||||||
|
existing_file = tmp_path / "bom.json"
|
||||||
|
content = '{"a": 1}'
|
||||||
|
# Prepend UTF-8 BOM
|
||||||
|
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
|
||||||
|
|
||||||
|
new_settings = {"b": 2}
|
||||||
|
merged = merge_json_files(existing_file, new_settings)
|
||||||
|
assert merged == {"a": 1, "b": 2}
|
||||||
|
|
||||||
|
def test_merge_json_files_not_a_dictionary_template(tmp_path):
|
||||||
|
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
|
||||||
|
existing_file = tmp_path / "ok.json"
|
||||||
|
existing_file.write_text('{"a": 1}')
|
||||||
|
|
||||||
|
# Secure fallback: return None to skip writing and avoid clobbering
|
||||||
|
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
|
||||||
|
|
||||||
|
def test_merge_json_files_unparseable_existing(tmp_path):
|
||||||
|
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
|
||||||
|
bad_file = tmp_path / "bad.json"
|
||||||
|
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
|
||||||
|
|
||||||
|
assert merge_json_files(bad_file, {"b": 2}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_json_files_list_preservation(tmp_path):
|
||||||
|
"""Verify that existing list values are preserved and NOT merged or overwritten."""
|
||||||
|
existing_file = tmp_path / "list.json"
|
||||||
|
existing_file.write_text('{"my.list": ["user_item"]}')
|
||||||
|
|
||||||
|
template_settings = {
|
||||||
|
"my.list": ["template_item"]
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_json_files(existing_file, template_settings)
|
||||||
|
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
|
||||||
|
# Since nothing changed, it returns None.
|
||||||
|
assert merged is None
|
||||||
|
|
||||||
|
def test_merge_json_files_no_changes(tmp_path):
|
||||||
|
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
|
||||||
|
existing_file = tmp_path / "no_change.json"
|
||||||
|
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
|
||||||
|
|
||||||
|
template_settings = {
|
||||||
|
"a": 1, # Already exists
|
||||||
|
"b": {"c": 2} # Already exists nested
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should return None because result == existing
|
||||||
|
assert merge_json_files(existing_file, template_settings) is None
|
||||||
|
|
||||||
|
def test_merge_json_files_type_mismatch_no_op(tmp_path):
|
||||||
|
"""If a key exists with different type and we preserve it, it might still result in no change."""
|
||||||
|
existing_file = tmp_path / "mismatch_no_op.json"
|
||||||
|
existing_file.write_text('{"a": "user_string"}')
|
||||||
|
|
||||||
|
template_settings = {
|
||||||
|
"a": {"key": "template_dict"} # Mismatch, will be ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should return None because we preserved the user's string and nothing else changed
|
||||||
|
assert merge_json_files(existing_file, template_settings) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
|
||||||
|
"""Atomic rewrite should preserve existing file mode bits."""
|
||||||
|
vscode_dir = tmp_path / ".vscode"
|
||||||
|
vscode_dir.mkdir()
|
||||||
|
dest_file = vscode_dir / "settings.json"
|
||||||
|
template_file = tmp_path / "template_settings.json"
|
||||||
|
|
||||||
|
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
|
||||||
|
dest_file.chmod(0o640)
|
||||||
|
before_mode = stat.S_IMODE(dest_file.stat().st_mode)
|
||||||
|
|
||||||
|
template_file.write_text('{"b": 2}\n', encoding="utf-8")
|
||||||
|
|
||||||
|
handle_vscode_settings(
|
||||||
|
template_file,
|
||||||
|
dest_file,
|
||||||
|
"settings.json",
|
||||||
|
verbose=False,
|
||||||
|
tracker=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
|
||||||
|
assert after_mode == before_mode
|
||||||
@@ -32,6 +32,7 @@ from specify_cli.presets import (
|
|||||||
PresetCompatibilityError,
|
PresetCompatibilityError,
|
||||||
VALID_PRESET_TEMPLATE_TYPES,
|
VALID_PRESET_TEMPLATE_TYPES,
|
||||||
)
|
)
|
||||||
|
from specify_cli.extensions import ExtensionRegistry
|
||||||
|
|
||||||
|
|
||||||
# ===== Fixtures =====
|
# ===== Fixtures =====
|
||||||
@@ -573,6 +574,24 @@ class TestRegistryPriority:
|
|||||||
assert sorted_packs[0][0] == "pack-b"
|
assert sorted_packs[0][0] == "pack-b"
|
||||||
assert sorted_packs[1][0] == "pack-a"
|
assert sorted_packs[1][0] == "pack-a"
|
||||||
|
|
||||||
|
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
||||||
|
"""Malformed priority values fall back to the default priority."""
|
||||||
|
packs_dir = temp_dir / "packs"
|
||||||
|
packs_dir.mkdir()
|
||||||
|
registry = PresetRegistry(packs_dir)
|
||||||
|
|
||||||
|
registry.add("pack-high", {"version": "1.0.0", "priority": 1})
|
||||||
|
registry.data["presets"]["pack-invalid"] = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"priority": "high",
|
||||||
|
}
|
||||||
|
registry._save()
|
||||||
|
|
||||||
|
sorted_packs = registry.list_by_priority()
|
||||||
|
|
||||||
|
assert [item[0] for item in sorted_packs] == ["pack-high", "pack-invalid"]
|
||||||
|
assert sorted_packs[1][1]["priority"] == 10
|
||||||
|
|
||||||
|
|
||||||
# ===== PresetResolver Tests =====
|
# ===== PresetResolver Tests =====
|
||||||
|
|
||||||
@@ -678,6 +697,11 @@ 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
|
||||||
@@ -741,10 +765,15 @@ class TestPresetResolver:
|
|||||||
ext_template = ext_templates_dir / "unique-template.md"
|
ext_template = ext_templates_dir / "unique-template.md"
|
||||||
ext_template.write_text("# Unique\n")
|
ext_template.write_text("# Unique\n")
|
||||||
|
|
||||||
|
# Register extension in registry
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
ext_registry = ExtensionRegistry(extensions_dir)
|
||||||
|
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
||||||
|
|
||||||
resolver = PresetResolver(project_dir)
|
resolver = PresetResolver(project_dir)
|
||||||
result = resolver.resolve_with_source("unique-template")
|
result = resolver.resolve_with_source("unique-template")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["source"] == "extension:my-ext"
|
assert result["source"] == "extension:my-ext v1.0.0"
|
||||||
|
|
||||||
def test_resolve_with_source_not_found(self, project_dir):
|
def test_resolve_with_source_not_found(self, project_dir):
|
||||||
"""Test resolve_with_source for nonexistent template."""
|
"""Test resolve_with_source for nonexistent template."""
|
||||||
@@ -765,6 +794,104 @@ class TestPresetResolver:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionPriorityResolution:
|
||||||
|
"""Test extension priority resolution with registered and unregistered extensions."""
|
||||||
|
|
||||||
|
def test_unregistered_beats_registered_with_lower_precedence(self, project_dir):
|
||||||
|
"""Unregistered extension (implicit priority 10) beats registered with priority 20."""
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create registered extension with priority 20 (lower precedence than 10)
|
||||||
|
registered_dir = extensions_dir / "registered-ext"
|
||||||
|
(registered_dir / "templates").mkdir(parents=True)
|
||||||
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||||
|
|
||||||
|
ext_registry = ExtensionRegistry(extensions_dir)
|
||||||
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
||||||
|
|
||||||
|
# Create unregistered extension directory (implicit priority 10)
|
||||||
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||||
|
(unregistered_dir / "templates").mkdir(parents=True)
|
||||||
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||||
|
|
||||||
|
# Unregistered (priority 10) should beat registered (priority 20)
|
||||||
|
resolver = PresetResolver(project_dir)
|
||||||
|
result = resolver.resolve("test-template")
|
||||||
|
assert result is not None
|
||||||
|
assert "From Unregistered" in result.read_text()
|
||||||
|
|
||||||
|
def test_registered_with_higher_precedence_beats_unregistered(self, project_dir):
|
||||||
|
"""Registered extension with priority 5 beats unregistered (implicit priority 10)."""
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create registered extension with priority 5 (higher precedence than 10)
|
||||||
|
registered_dir = extensions_dir / "registered-ext"
|
||||||
|
(registered_dir / "templates").mkdir(parents=True)
|
||||||
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||||
|
|
||||||
|
ext_registry = ExtensionRegistry(extensions_dir)
|
||||||
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 5})
|
||||||
|
|
||||||
|
# Create unregistered extension directory (implicit priority 10)
|
||||||
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||||
|
(unregistered_dir / "templates").mkdir(parents=True)
|
||||||
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||||
|
|
||||||
|
# Registered (priority 5) should beat unregistered (priority 10)
|
||||||
|
resolver = PresetResolver(project_dir)
|
||||||
|
result = resolver.resolve("test-template")
|
||||||
|
assert result is not None
|
||||||
|
assert "From Registered" in result.read_text()
|
||||||
|
|
||||||
|
def test_unregistered_attribution_with_priority_ordering(self, project_dir):
|
||||||
|
"""Test resolve_with_source correctly attributes unregistered extension."""
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create registered extension with priority 20
|
||||||
|
registered_dir = extensions_dir / "registered-ext"
|
||||||
|
(registered_dir / "templates").mkdir(parents=True)
|
||||||
|
(registered_dir / "templates" / "test-template.md").write_text("# From Registered\n")
|
||||||
|
|
||||||
|
ext_registry = ExtensionRegistry(extensions_dir)
|
||||||
|
ext_registry.add("registered-ext", {"version": "1.0.0", "priority": 20})
|
||||||
|
|
||||||
|
# Create unregistered extension (implicit priority 10)
|
||||||
|
unregistered_dir = extensions_dir / "unregistered-ext"
|
||||||
|
(unregistered_dir / "templates").mkdir(parents=True)
|
||||||
|
(unregistered_dir / "templates" / "test-template.md").write_text("# From Unregistered\n")
|
||||||
|
|
||||||
|
# Attribution should show unregistered extension
|
||||||
|
resolver = PresetResolver(project_dir)
|
||||||
|
result = resolver.resolve_with_source("test-template")
|
||||||
|
assert result is not None
|
||||||
|
assert "unregistered-ext" in result["source"]
|
||||||
|
assert "(unregistered)" in result["source"]
|
||||||
|
|
||||||
|
def test_same_priority_sorted_alphabetically(self, project_dir):
|
||||||
|
"""Extensions with same priority are sorted alphabetically by ID."""
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create two unregistered extensions (both implicit priority 10)
|
||||||
|
# "aaa-ext" should come before "zzz-ext" alphabetically
|
||||||
|
zzz_dir = extensions_dir / "zzz-ext"
|
||||||
|
(zzz_dir / "templates").mkdir(parents=True)
|
||||||
|
(zzz_dir / "templates" / "test-template.md").write_text("# From ZZZ\n")
|
||||||
|
|
||||||
|
aaa_dir = extensions_dir / "aaa-ext"
|
||||||
|
(aaa_dir / "templates").mkdir(parents=True)
|
||||||
|
(aaa_dir / "templates" / "test-template.md").write_text("# From AAA\n")
|
||||||
|
|
||||||
|
# AAA should win due to alphabetical ordering at same priority
|
||||||
|
resolver = PresetResolver(project_dir)
|
||||||
|
result = resolver.resolve("test-template")
|
||||||
|
assert result is not None
|
||||||
|
assert "From AAA" in result.read_text()
|
||||||
|
|
||||||
|
|
||||||
# ===== PresetCatalog Tests =====
|
# ===== PresetCatalog Tests =====
|
||||||
|
|
||||||
|
|
||||||
@@ -979,8 +1106,13 @@ class TestIntegration:
|
|||||||
ext_templates_dir.mkdir(parents=True)
|
ext_templates_dir.mkdir(parents=True)
|
||||||
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
(ext_templates_dir / "spec-template.md").write_text("# Extension\n")
|
||||||
|
|
||||||
|
# Register extension in registry
|
||||||
|
extensions_dir = project_dir / ".specify" / "extensions"
|
||||||
|
ext_registry = ExtensionRegistry(extensions_dir)
|
||||||
|
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
|
||||||
|
|
||||||
result = resolver.resolve_with_source("spec-template")
|
result = resolver.resolve_with_source("spec-template")
|
||||||
assert result["source"] == "extension:my-ext"
|
assert result["source"] == "extension:my-ext v1.0.0"
|
||||||
|
|
||||||
# Install pack — should win over extension
|
# Install pack — should win over extension
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
@@ -1710,3 +1842,162 @@ 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