mirror of
https://github.com/github/spec-kit.git
synced 2026-03-26 23:33:08 +00:00
Compare commits
3 Commits
dependabot
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93b2b7461 | ||
|
|
a88083d67c | ||
|
|
8e2449f6d4 |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -64,5 +64,5 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
|
||||
22
.github/workflows/release-trigger.yml
vendored
22
.github/workflows/release-trigger.yml
vendored
@@ -100,16 +100,18 @@ jobs:
|
||||
COMMITS="- Initial release"
|
||||
fi
|
||||
|
||||
# Create new changelog entry — insert after the marker comment
|
||||
NEW_ENTRY=$(printf '%s\n' \
|
||||
"" \
|
||||
"## [${{ steps.version.outputs.version }}] - $DATE" \
|
||||
"" \
|
||||
"### Changed" \
|
||||
"" \
|
||||
"$COMMITS")
|
||||
|
||||
awk -v entry="$NEW_ENTRY" '/<!-- insert new changelog below this comment -->/ { print; print entry; next } {print}' CHANGELOG.md > CHANGELOG.md.tmp
|
||||
# Create new changelog entry
|
||||
{
|
||||
head -n 8 CHANGELOG.md
|
||||
echo ""
|
||||
echo "## [${{ steps.version.outputs.version }}] - $DATE"
|
||||
echo ""
|
||||
echo "### Changes"
|
||||
echo ""
|
||||
echo "$COMMITS"
|
||||
echo ""
|
||||
tail -n +9 CHANGELOG.md
|
||||
} > CHANGELOG.md.tmp
|
||||
mv CHANGELOG.md.tmp CHANGELOG.md
|
||||
|
||||
echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG"
|
||||
|
||||
@@ -202,7 +202,8 @@ agent: $basename
|
||||
}
|
||||
|
||||
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||
# Skills use hyphenated names (e.g. speckit-plan).
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
#
|
||||
# Technical debt note:
|
||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
||||
@@ -462,7 +463,7 @@ function Build-Variant {
|
||||
'kimi' {
|
||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi'
|
||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
|
||||
}
|
||||
'trae' {
|
||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||
|
||||
@@ -140,7 +140,8 @@ EOF
|
||||
}
|
||||
|
||||
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||
# Skills use hyphenated names (e.g. speckit-plan).
|
||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
||||
# current dotted-name exception (e.g. speckit.plan).
|
||||
#
|
||||
# Technical debt note:
|
||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
||||
@@ -320,7 +321,7 @@ build_variant() {
|
||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||
kimi)
|
||||
mkdir -p "$base_dir/.kimi/skills"
|
||||
create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;;
|
||||
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
||||
trae)
|
||||
mkdir -p "$base_dir/.trae/rules"
|
||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||
|
||||
1091
CHANGELOG.md
1091
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
67
README.md
67
README.md
@@ -22,10 +22,7 @@
|
||||
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
|
||||
- [⚡ Get Started](#-get-started)
|
||||
- [📽️ Video Overview](#️-video-overview)
|
||||
- [🧩 Community Extensions](#-community-extensions)
|
||||
- [🎨 Community Presets](#-community-presets)
|
||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||
- [🛠️ Community Friends](#️-community-friends)
|
||||
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||
@@ -158,56 +155,6 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
||||
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
|
||||
## 🧩 Community Extensions
|
||||
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](extensions/catalog.community.json):
|
||||
|
||||
**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [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 | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
|
||||
To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md).
|
||||
|
||||
## 🎨 Community Presets
|
||||
|
||||
The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json):
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
|
||||
To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md).
|
||||
|
||||
## 🚶 Community Walkthroughs
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
@@ -224,16 +171,6 @@ See Spec-Driven Development in action across different scenarios with these comm
|
||||
|
||||
- **[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.
|
||||
|
||||
- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit.
|
||||
|
||||
## 🛠️ Community Friends
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit:
|
||||
|
||||
- **[cc-sdd](https://github.com/rhuss/cc-sdd)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
|
||||
|
||||
- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH.
|
||||
|
||||
## 🤖 Supported AI Agents
|
||||
|
||||
| Agent | Support | Notes |
|
||||
@@ -292,7 +229,7 @@ The `specify` command supports the following options:
|
||||
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. |
|
||||
| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`) |
|
||||
| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts |
|
||||
|
||||
### Examples
|
||||
@@ -443,7 +380,7 @@ specify extension add <extension-name>
|
||||
|
||||
For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics.
|
||||
|
||||
See the [Extensions README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available.
|
||||
See the [Extensions README](./extensions/README.md) for the full guide, the complete community catalog, and how to build and publish your own.
|
||||
|
||||
### Presets — Customize Existing Workflows
|
||||
|
||||
|
||||
79
TESTING.md
79
TESTING.md
@@ -1,79 +0,0 @@
|
||||
# Manual Testing Guide
|
||||
|
||||
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
|
||||
2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)).
|
||||
3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated).
|
||||
4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order.
|
||||
5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install the CLI from your local branch
|
||||
cd <spec-kit-repo>
|
||||
uv venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
uv pip install -e .
|
||||
|
||||
# Initialize a test project using your local changes
|
||||
specify init /tmp/speckit-test --ai <agent> --offline
|
||||
cd /tmp/speckit-test
|
||||
|
||||
# Open in your agent
|
||||
```
|
||||
|
||||
## Reporting results
|
||||
|
||||
Paste this into your PR:
|
||||
|
||||
~~~markdown
|
||||
## Manual test results
|
||||
|
||||
**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh]
|
||||
|
||||
| Command tested | Notes |
|
||||
|----------------|-------|
|
||||
| `/speckit.command` | |
|
||||
~~~
|
||||
|
||||
## Determining which tests to run
|
||||
|
||||
Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR.
|
||||
|
||||
~~~text
|
||||
Read TESTING.md, then run `git diff --name-only main` to get my changed files.
|
||||
For each changed file, determine which slash commands it affects by reading
|
||||
the command templates in templates/commands/ to understand what each command
|
||||
invokes. Use these mapping rules:
|
||||
|
||||
- templates/commands/X.md → the command it defines
|
||||
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
|
||||
- templates/Z-template.md → every command that consumes that template during execution
|
||||
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
|
||||
- extensions/X/commands/* → the extension command it defines
|
||||
- extensions/X/scripts/* → every extension command that invokes that script
|
||||
- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected
|
||||
- presets/*/* → test preset scaffolding via `specify init` with the preset
|
||||
- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets
|
||||
|
||||
Include prerequisite tests (e.g., T5 requires T3 requires T1).
|
||||
|
||||
Output in this format:
|
||||
|
||||
### Test selection reasoning
|
||||
|
||||
| Changed file | Affects | Test | Why |
|
||||
|---|---|---|---|
|
||||
| (path) | (command) | T# | (reason) |
|
||||
|
||||
### Required tests
|
||||
|
||||
Number each test sequentially (T1, T2, ...). List prerequisite tests first.
|
||||
|
||||
- T1: /speckit.command — (reason)
|
||||
- T2: /speckit.command — (reason)
|
||||
~~~
|
||||
@@ -523,7 +523,7 @@ Submit to the community catalog for public discovery:
|
||||
|
||||
1. **Fork** spec-kit repository
|
||||
2. **Add entry** to `extensions/catalog.community.json`
|
||||
3. **Update** the Community Extensions table in `README.md` with your extension
|
||||
3. **Update** `extensions/README.md` with your extension
|
||||
4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
|
||||
5. **After merge**, your extension becomes available:
|
||||
- Users can browse `catalog.community.json` to discover your extension
|
||||
|
||||
@@ -204,9 +204,9 @@ Edit `extensions/catalog.community.json` and add your extension:
|
||||
- Use current timestamp for `created_at` and `updated_at`
|
||||
- Update the top-level `updated_at` to current time
|
||||
|
||||
### 3. Update Community Extensions Table
|
||||
### 3. Update Extensions README
|
||||
|
||||
Add your extension to the Community Extensions table in the project root `README.md`:
|
||||
Add your extension to the Available Extensions table in `extensions/README.md`:
|
||||
|
||||
```markdown
|
||||
| Your Extension Name | Brief description of what it does | `<category>` | <effect> | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
|
||||
@@ -234,7 +234,7 @@ Insert your extension in alphabetical order in the table.
|
||||
git checkout -b add-your-extension
|
||||
|
||||
# Commit your changes
|
||||
git add extensions/catalog.community.json README.md
|
||||
git add extensions/catalog.community.json extensions/README.md
|
||||
git commit -m "Add your-extension to community catalog
|
||||
|
||||
- Extension ID: your-extension
|
||||
@@ -273,7 +273,7 @@ Brief description of what your extension does.
|
||||
- [x] All commands working
|
||||
- [x] No security vulnerabilities
|
||||
- [x] Added to extensions/catalog.community.json
|
||||
- [x] Added to Community Extensions table in README.md
|
||||
- [x] Added to extensions/README.md Available Extensions table
|
||||
|
||||
### Testing
|
||||
Tested on:
|
||||
|
||||
@@ -187,21 +187,6 @@ Provided commands:
|
||||
Check: .specify/extensions/jira/
|
||||
```
|
||||
|
||||
### Automatic Agent Skill Registration
|
||||
|
||||
If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification.
|
||||
|
||||
```text
|
||||
✓ Extension installed successfully!
|
||||
|
||||
Jira Integration (v1.0.0)
|
||||
...
|
||||
|
||||
✓ 3 agent skill(s) auto-registered
|
||||
```
|
||||
|
||||
When an extension is removed, its corresponding skills are also cleaned up automatically. Pre-existing skills that were manually customized are never overwritten.
|
||||
|
||||
---
|
||||
|
||||
## Using Extensions
|
||||
|
||||
@@ -68,9 +68,36 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
|
||||
|
||||
## Available Community Extensions
|
||||
|
||||
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
|
||||
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
|
||||
|
||||
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
|
||||
**Categories:** `docs` — reads, validates, or generates spec artifacts · `code` — reviews, validates, or modifies source code · `process` — orchestrates workflow across phases · `integration` — syncs with external platforms · `visibility` — reports on project health or progress
|
||||
|
||||
**Effect:** `Read-only` — produces reports without modifying files · `Read+Write` — modifies files, creates artifacts, or updates specs
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [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 | `integration` | Read+Write | [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 | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
| Cognitive Squad | Multi-agent cognitive system with Triadic Model: understanding, internalization, application — with quality gates, backpropagation verification, and self-healing | `docs` | Read+Write | [cognitive-squad](https://github.com/Testimonial/cognitive-squad) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/spec-kit-ralph) |
|
||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
|
||||
| Understanding | Automated requirements quality analysis — 31 deterministic metrics against IEEE/ISO standards with experimental energy-based ambiguity detection | `docs` | Read-only | [understanding](https://github.com/Testimonial/understanding) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
|
||||
|
||||
## Adding Your Extension
|
||||
|
||||
@@ -3,39 +3,6 @@
|
||||
"updated_at": "2026-03-19T12:08:20Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
"name": "AI-Driven Engineering (AIDE)",
|
||||
"id": "aide",
|
||||
"description": "A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation.",
|
||||
"author": "mnriem",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/aide-v1.0.0/aide.zip",
|
||||
"repository": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/README.md",
|
||||
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/aide/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 7,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"workflow",
|
||||
"project-management",
|
||||
"ai-driven",
|
||||
"new-project",
|
||||
"planning",
|
||||
"experimental"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
"id": "archive",
|
||||
@@ -106,35 +73,6 @@
|
||||
"created_at": "2026-03-03T00:00:00Z",
|
||||
"updated_at": "2026-03-03T00:00:00Z"
|
||||
},
|
||||
"checkpoint": {
|
||||
"name": "Checkpoint Extension",
|
||||
"id": "checkpoint",
|
||||
"description": "An extension to commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end.",
|
||||
"author": "aaronrsun",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/aaronrsun/spec-kit-checkpoint/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/aaronrsun/spec-kit-checkpoint",
|
||||
"homepage": "https://github.com/aaronrsun/spec-kit-checkpoint",
|
||||
"documentation": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/README.md",
|
||||
"changelog": "https://github.com/aaronrsun/spec-kit-checkpoint/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"checkpoint",
|
||||
"commit"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-22T00:00:00Z",
|
||||
"updated_at": "2026-03-22T00:00:00Z"
|
||||
},
|
||||
"cleanup": {
|
||||
"name": "Cleanup Extension",
|
||||
"id": "cleanup",
|
||||
@@ -314,37 +252,6 @@
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-13T00:00:00Z"
|
||||
},
|
||||
"extensify": {
|
||||
"name": "Extensify",
|
||||
"id": "extensify",
|
||||
"description": "Create and validate extensions and extension catalogs.",
|
||||
"author": "mnriem",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip",
|
||||
"repository": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md",
|
||||
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"extensions",
|
||||
"workflow",
|
||||
"validation",
|
||||
"experimental"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"fleet": {
|
||||
"name": "Fleet Orchestrator",
|
||||
"id": "fleet",
|
||||
@@ -437,37 +344,6 @@
|
||||
"created_at": "2026-03-05T00:00:00Z",
|
||||
"updated_at": "2026-03-05T00:00:00Z"
|
||||
},
|
||||
"presetify": {
|
||||
"name": "Presetify",
|
||||
"id": "presetify",
|
||||
"description": "Create and validate presets and preset catalogs.",
|
||||
"author": "mnriem",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/presetify-v1.0.0/presetify.zip",
|
||||
"repository": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-extensions",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/README.md",
|
||||
"changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/presetify/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"presets",
|
||||
"workflow",
|
||||
"templates",
|
||||
"experimental"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-18T00:00:00Z",
|
||||
"updated_at": "2026-03-18T00:00:00Z"
|
||||
},
|
||||
"ralph": {
|
||||
"name": "Ralph Loop",
|
||||
"id": "ralph",
|
||||
|
||||
@@ -1,58 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-03-24T00:00:00Z",
|
||||
"updated_at": "2026-03-09T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
"version": "1.0.0",
|
||||
"description": "Adapts the AIDE workflow for in-place technology migrations (X → Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
|
||||
"author": "mnriem",
|
||||
"repository": "https://github.com/mnriem/spec-kit-presets",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-presets",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/aide-in-place/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"extensions": ["aide"]
|
||||
},
|
||||
"provides": {
|
||||
"templates": 2,
|
||||
"commands": 8
|
||||
},
|
||||
"tags": [
|
||||
"migration",
|
||||
"in-place",
|
||||
"brownfield",
|
||||
"aide"
|
||||
]
|
||||
},
|
||||
"pirate": {
|
||||
"name": "Pirate Speak (Full)",
|
||||
"id": "pirate",
|
||||
"version": "1.0.0",
|
||||
"description": "Arrr! Transforms all Spec Kit output into pirate speak. Specs, plans, and tasks be written fer scallywags.",
|
||||
"author": "mnriem",
|
||||
"repository": "https://github.com/mnriem/spec-kit-presets",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/pirate-v1.0.0/pirate.zip",
|
||||
"homepage": "https://github.com/mnriem/spec-kit-presets",
|
||||
"documentation": "https://github.com/mnriem/spec-kit-presets/blob/main/pirate/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 6,
|
||||
"commands": 9
|
||||
},
|
||||
"tags": [
|
||||
"pirate",
|
||||
"theme",
|
||||
"fun",
|
||||
"experimental"
|
||||
]
|
||||
}
|
||||
}
|
||||
"presets": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.4.3"
|
||||
version = "0.4.0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,48 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Common functions and variables for all scripts
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
find_specify_root() {
|
||||
local dir="${1:-$(pwd)}"
|
||||
# Normalize to absolute path to prevent infinite loop with relative paths
|
||||
# Use -- to handle paths starting with - (e.g., -P, -L)
|
||||
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
||||
local prev_dir=""
|
||||
while true; do
|
||||
if [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
# Stop if we've reached filesystem root or dirname stops changing
|
||||
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
||||
break
|
||||
fi
|
||||
prev_dir="$dir"
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
# Get repository root, with fallback for non-git repositories
|
||||
get_repo_root() {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
local specify_root
|
||||
if specify_root=$(find_specify_root); then
|
||||
echo "$specify_root"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git rev-parse --show-toplevel
|
||||
return
|
||||
else
|
||||
# Fall back to script location for non-git repos
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
fi
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(cd "$script_dir/../../.." && pwd)
|
||||
}
|
||||
|
||||
# Get current branch, with fallback for non-git repositories
|
||||
@@ -53,14 +20,14 @@ get_current_branch() {
|
||||
return
|
||||
fi
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
local repo_root=$(get_repo_root)
|
||||
if has_git; then
|
||||
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||
# Then check git if available
|
||||
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
return
|
||||
fi
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
local repo_root=$(get_repo_root)
|
||||
local specs_dir="$repo_root/specs"
|
||||
|
||||
if [[ -d "$specs_dir" ]]; then
|
||||
@@ -101,17 +68,9 @@ get_current_branch() {
|
||||
echo "main" # Final fallback
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
# Check if we have git available
|
||||
has_git() {
|
||||
# First check if git command is available (before calling get_repo_root which may use git)
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
local repo_root=$(get_repo_root)
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
[ -e "$repo_root/.git" ] || return 1
|
||||
# Verify it's actually a valid git work tree
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_feature_branch() {
|
||||
|
||||
@@ -80,6 +80,19 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to find the repository root by searching for existing project markers
|
||||
find_repo_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
@@ -89,9 +102,9 @@ get_highest_from_specs() {
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
# Only match sequential prefixes (###-*), skip timestamp dirs
|
||||
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -115,9 +128,9 @@ get_highest_from_branches() {
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||
|
||||
# Extract sequential feature number (>=3 digits), skip timestamp branches.
|
||||
if echo "$clean_branch" | grep -Eq '^[0-9]{3,}-' && ! echo "$clean_branch" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0")
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
||||
number=$(echo "$clean_branch" | grep -o '^[0-9]\{3\}' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
@@ -158,16 +171,21 @@ clean_branch_name() {
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||
# Resolve repository root. Prefer git information when available, but fall back
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialised with --no-git.
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
if has_git; then
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
HAS_GIT=true
|
||||
else
|
||||
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
|
||||
if [ -z "$REPO_ROOT" ]; then
|
||||
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,39 +1,7 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Common PowerShell functions analogous to common.sh
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
function Find-SpecifyRoot {
|
||||
param([string]$StartDir = (Get-Location).Path)
|
||||
|
||||
# Normalize to absolute path to prevent issues with relative paths
|
||||
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
|
||||
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
|
||||
$current = if ($resolved) { $resolved.Path } else { $null }
|
||||
if (-not $current) { return $null }
|
||||
|
||||
while ($true) {
|
||||
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
|
||||
return $current
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
|
||||
return $null
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory over git
|
||||
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
if ($specifyRoot) {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Fallback to git if no .specify found
|
||||
try {
|
||||
$result = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -42,10 +10,9 @@ function Get-RepoRoot {
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
# Final fallback to script location for non-git repos
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
|
||||
# Fall back to script location for non-git repos
|
||||
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
@@ -53,21 +20,19 @@ function Get-CurrentBranch {
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# Then check git if available at the spec-kit root (not parent)
|
||||
$repoRoot = Get-RepoRoot
|
||||
if (Test-HasGit) {
|
||||
try {
|
||||
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
|
||||
# Then check git if available
|
||||
try {
|
||||
$result = git rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $result
|
||||
}
|
||||
} catch {
|
||||
# Git command failed
|
||||
}
|
||||
|
||||
|
||||
# For non-git repos, try to find the latest feature directory
|
||||
$repoRoot = Get-RepoRoot
|
||||
$specsDir = Join-Path $repoRoot "specs"
|
||||
|
||||
if (Test-Path $specsDir) {
|
||||
@@ -104,23 +69,9 @@ function Get-CurrentBranch {
|
||||
return "main"
|
||||
}
|
||||
|
||||
# Check if we have git available at the spec-kit root level
|
||||
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||
function Test-HasGit {
|
||||
# First check if git command is available (before calling Get-RepoRoot which may use git)
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
return $false
|
||||
}
|
||||
$repoRoot = Get-RepoRoot
|
||||
# Check if .git exists (directory or file for worktrees/submodules)
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
|
||||
return $false
|
||||
}
|
||||
# Verify it's actually a valid git work tree
|
||||
try {
|
||||
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||
git rev-parse --show-toplevel 2>$null | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
|
||||
@@ -5,7 +5,7 @@ param(
|
||||
[switch]$Json,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[long]$Number = 0,
|
||||
[int]$Number = 0,
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
@@ -45,18 +45,39 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve repository root. Prefer git information when available, but fall back
|
||||
# to searching for repository markers so the workflow still functions in repositories that
|
||||
# were initialized with --no-git.
|
||||
function Find-RepositoryRoot {
|
||||
param(
|
||||
[string]$StartDir,
|
||||
[string[]]$Markers = @('.git', '.specify')
|
||||
)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in $Markers) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) {
|
||||
# Reached filesystem root without finding markers
|
||||
return $null
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
[long]$highest = 0
|
||||
$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
if ($_.Name -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +87,7 @@ function Get-HighestNumberFromSpecs {
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
[long]$highest = 0
|
||||
$highest = 0
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
@@ -74,12 +95,10 @@ function Get-HighestNumberFromBranches {
|
||||
# Clean branch name: remove leading markers and remote prefixes
|
||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
|
||||
# Extract sequential feature number (>=3 digits), skip timestamp branches.
|
||||
if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
# Extract feature number if branch matches pattern ###-*
|
||||
if ($cleanBranch -match '^(\d{3})-') {
|
||||
$num = [int]$matches[1]
|
||||
if ($num -gt $highest) { $highest = $num }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,14 +139,26 @@ function ConvertTo-CleanBranchName {
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
||||
if (-not $fallbackRoot) {
|
||||
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Load common functions (includes Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify over git
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
# Check if git is available at this repo root (not a parent)
|
||||
$hasGit = Test-HasGit
|
||||
try {
|
||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$hasGit = $true
|
||||
} else {
|
||||
throw "Git not available"
|
||||
}
|
||||
} catch {
|
||||
$repoRoot = $fallbackRoot
|
||||
$hasGit = $false
|
||||
}
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
@@ -295,3 +326,4 @@ if ($Json) {
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
|
||||
|
||||
@@ -1490,6 +1490,12 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
# Agent-specific skill directory overrides for agents whose skills directory
|
||||
# doesn't follow the standard <agent_folder>/skills/ pattern
|
||||
AGENT_SKILLS_DIR_OVERRIDES = {
|
||||
"codex": ".agents/skills", # Codex agent layout override
|
||||
}
|
||||
|
||||
# Default skills directory for agents not in AGENT_CONFIG
|
||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||
|
||||
@@ -1522,9 +1528,13 @@ SKILL_DESCRIPTIONS = {
|
||||
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
"""Resolve the agent-specific skills directory for the given AI assistant.
|
||||
|
||||
Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to
|
||||
``DEFAULT_SKILLS_DIR`` for unknown agents.
|
||||
Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to
|
||||
``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to
|
||||
``DEFAULT_SKILLS_DIR``.
|
||||
"""
|
||||
if selected_ai in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
return project_path / AGENT_SKILLS_DIR_OVERRIDES[selected_ai]
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai, {})
|
||||
agent_folder = agent_config.get("folder", "")
|
||||
if agent_folder:
|
||||
@@ -1638,7 +1648,10 @@ def install_ai_skills(
|
||||
command_name = command_name[len("speckit."):]
|
||||
if command_name.endswith(".agent"):
|
||||
command_name = command_name[:-len(".agent")]
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
if selected_ai == "kimi":
|
||||
skill_name = f"speckit.{command_name}"
|
||||
else:
|
||||
skill_name = f"speckit-{command_name}"
|
||||
|
||||
# Create skill directory (additive — never removes existing content)
|
||||
skill_dir = skills_dir / skill_name
|
||||
@@ -1717,64 +1730,8 @@ def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
||||
if not skills_dir.is_dir():
|
||||
return False
|
||||
|
||||
return any(skills_dir.glob("speckit-*/SKILL.md"))
|
||||
|
||||
|
||||
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
|
||||
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
|
||||
|
||||
Temporary migration helper:
|
||||
- Intended removal window: after 2026-06-25.
|
||||
- Purpose: one-time cleanup for projects initialized before Kimi moved to
|
||||
hyphenated skills (speckit-xxx).
|
||||
|
||||
Returns:
|
||||
Tuple[migrated_count, removed_count]
|
||||
- migrated_count: old dotted dir renamed to hyphenated dir
|
||||
- removed_count: old dotted dir deleted when equivalent hyphenated dir existed
|
||||
"""
|
||||
if not skills_dir.is_dir():
|
||||
return (0, 0)
|
||||
|
||||
migrated_count = 0
|
||||
removed_count = 0
|
||||
|
||||
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
|
||||
if not legacy_dir.is_dir():
|
||||
continue
|
||||
if not (legacy_dir / "SKILL.md").exists():
|
||||
continue
|
||||
|
||||
suffix = legacy_dir.name[len("speckit."):]
|
||||
if not suffix:
|
||||
continue
|
||||
|
||||
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
|
||||
|
||||
if not target_dir.exists():
|
||||
shutil.move(str(legacy_dir), str(target_dir))
|
||||
migrated_count += 1
|
||||
continue
|
||||
|
||||
# If the new target already exists, avoid destructive cleanup unless
|
||||
# both SKILL.md files are byte-identical.
|
||||
target_skill = target_dir / "SKILL.md"
|
||||
legacy_skill = legacy_dir / "SKILL.md"
|
||||
if target_skill.is_file():
|
||||
try:
|
||||
if target_skill.read_bytes() == legacy_skill.read_bytes():
|
||||
# Preserve legacy directory when it contains extra user files.
|
||||
has_extra_entries = any(
|
||||
child.name != "SKILL.md" for child in legacy_dir.iterdir()
|
||||
)
|
||||
if not has_extra_entries:
|
||||
shutil.rmtree(legacy_dir)
|
||||
removed_count += 1
|
||||
except OSError:
|
||||
# Best-effort migration: preserve legacy dir on read failures.
|
||||
pass
|
||||
|
||||
return (migrated_count, removed_count)
|
||||
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
||||
return any(skills_dir.glob(pattern))
|
||||
|
||||
|
||||
AGENT_SKILLS_MIGRATIONS = {
|
||||
@@ -2137,33 +2094,16 @@ def init(
|
||||
|
||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||
|
||||
# Determine skills directory and migrate any legacy Kimi dotted skills.
|
||||
migrated_legacy_kimi_skills = 0
|
||||
removed_legacy_kimi_skills = 0
|
||||
skills_dir: Optional[Path] = None
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
if selected_ai == "kimi" and skills_dir.is_dir():
|
||||
(
|
||||
migrated_legacy_kimi_skills,
|
||||
removed_legacy_kimi_skills,
|
||||
) = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
if ai_skills:
|
||||
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
||||
bundled_found = _has_bundled_skills(project_path, selected_ai)
|
||||
if bundled_found:
|
||||
detail = f"bundled skills → {skills_dir.relative_to(project_path)}"
|
||||
if migrated_legacy_kimi_skills or removed_legacy_kimi_skills:
|
||||
detail += (
|
||||
f" (migrated {migrated_legacy_kimi_skills}, "
|
||||
f"removed {removed_legacy_kimi_skills} legacy Kimi dotted skills)"
|
||||
)
|
||||
if tracker:
|
||||
tracker.start("ai-skills")
|
||||
tracker.complete("ai-skills", detail)
|
||||
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Using {detail}")
|
||||
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
|
||||
else:
|
||||
# Compatibility fallback: convert command templates to skills
|
||||
# when an older template archive does not include native skills.
|
||||
@@ -2348,7 +2288,7 @@ def init(
|
||||
if codex_skill_mode:
|
||||
return f"$speckit-{name}"
|
||||
if kimi_skill_mode:
|
||||
return f"/skill:speckit-{name}"
|
||||
return f"/skill:speckit.{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
||||
@@ -3654,15 +3594,6 @@ def extension_add(
|
||||
for cmd in manifest.commands:
|
||||
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
||||
|
||||
# Report agent skills registration
|
||||
reg_meta = manager.registry.get(manifest.id)
|
||||
reg_skills = reg_meta.get("registered_skills", []) if reg_meta else []
|
||||
# Normalize to guard against corrupted registry entries
|
||||
if not isinstance(reg_skills, list):
|
||||
reg_skills = []
|
||||
if reg_skills:
|
||||
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
|
||||
|
||||
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
||||
|
||||
@@ -3701,19 +3632,14 @@ def extension_remove(
|
||||
installed = manager.list_installed()
|
||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
||||
|
||||
# Get extension info for command and skill counts
|
||||
# Get extension info for command count
|
||||
ext_manifest = manager.get_extension(extension_id)
|
||||
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
||||
reg_meta = manager.registry.get(extension_id)
|
||||
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
|
||||
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
|
||||
|
||||
# Confirm removal
|
||||
if not force:
|
||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||
console.print(f" • {cmd_count} commands from AI agent")
|
||||
if skill_count:
|
||||
console.print(f" • {skill_count} agent skill(s)")
|
||||
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
||||
if not keep_config:
|
||||
console.print(" • Config files (will be backed up)")
|
||||
|
||||
@@ -10,8 +10,6 @@ from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
import platform
|
||||
import re
|
||||
from copy import deepcopy
|
||||
import yaml
|
||||
|
||||
|
||||
@@ -213,52 +211,24 @@ class CommandRegistrar:
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
"""Normalize script paths in frontmatter to generated project locations.
|
||||
|
||||
Rewrites known repo-relative and top-level script paths under the
|
||||
`scripts` and `agent_scripts` keys (for example `../../scripts/`,
|
||||
`../../templates/`, `../../memory/`, `scripts/`, `templates/`, and
|
||||
`memory/`) to the `.specify/...` paths used in generated projects.
|
||||
"""Adjust script paths from extension-relative to repo-relative.
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with normalized project paths
|
||||
Modified frontmatter with adjusted paths
|
||||
"""
|
||||
frontmatter = deepcopy(frontmatter)
|
||||
|
||||
for script_key in ("scripts", "agent_scripts"):
|
||||
scripts = frontmatter.get(script_key)
|
||||
if not isinstance(scripts, dict):
|
||||
continue
|
||||
|
||||
for key, script_path in scripts.items():
|
||||
if isinstance(script_path, str):
|
||||
scripts[key] = self._rewrite_project_relative_paths(script_path)
|
||||
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
|
||||
scripts[key] = f".specify/scripts/{script_path[14:]}"
|
||||
return frontmatter
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_project_relative_paths(text: str) -> str:
|
||||
"""Rewrite repo-relative paths to their generated project locations."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
|
||||
for old, new in (
|
||||
("../../memory/", ".specify/memory/"),
|
||||
("../../scripts/", ".specify/scripts/"),
|
||||
("../../templates/", ".specify/templates/"),
|
||||
):
|
||||
text = text.replace(old, new)
|
||||
|
||||
# Only rewrite top-level style references so extension-local paths like
|
||||
# ".specify/extensions/<ext>/scripts/..." remain intact.
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
|
||||
|
||||
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
|
||||
|
||||
def render_markdown_command(
|
||||
self,
|
||||
frontmatter: dict,
|
||||
@@ -307,25 +277,9 @@ class CommandRegistrar:
|
||||
toml_lines.append(f"# Source: {source_id}")
|
||||
toml_lines.append("")
|
||||
|
||||
# Keep TOML output valid even when body contains triple-quote delimiters.
|
||||
# Prefer multiline forms, then fall back to escaped basic string.
|
||||
if '"""' not in body:
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
elif "'''" not in body:
|
||||
toml_lines.append("prompt = '''")
|
||||
toml_lines.append(body)
|
||||
toml_lines.append("'''")
|
||||
else:
|
||||
escaped_body = (
|
||||
body.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
toml_lines.append(f'prompt = "{escaped_body}"')
|
||||
toml_lines.append('prompt = """')
|
||||
toml_lines.append(body)
|
||||
toml_lines.append('"""')
|
||||
|
||||
return "\n".join(toml_lines)
|
||||
|
||||
@@ -354,8 +308,8 @@ class CommandRegistrar:
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
if agent_name in {"codex", "kimi"}:
|
||||
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||
if agent_name == "codex":
|
||||
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
|
||||
|
||||
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||
skill_frontmatter = {
|
||||
@@ -370,8 +324,13 @@ class CommandRegistrar:
|
||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||
|
||||
@staticmethod
|
||||
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
|
||||
"""Resolve script placeholders for skills-backed agents."""
|
||||
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
|
||||
"""Resolve script placeholders for Codex skill overrides.
|
||||
|
||||
This intentionally scopes the fix to Codex, which is the newly
|
||||
migrated runtime path in this PR. Existing Kimi behavior is left
|
||||
unchanged for now.
|
||||
"""
|
||||
try:
|
||||
from . import load_init_options
|
||||
except ImportError:
|
||||
@@ -387,11 +346,7 @@ class CommandRegistrar:
|
||||
if not isinstance(agent_scripts, dict):
|
||||
agent_scripts = {}
|
||||
|
||||
init_opts = load_init_options(project_root)
|
||||
if not isinstance(init_opts, dict):
|
||||
init_opts = {}
|
||||
|
||||
script_variant = init_opts.get("script")
|
||||
script_variant = load_init_options(project_root).get("script")
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
||||
@@ -421,8 +376,7 @@ class CommandRegistrar:
|
||||
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
return CommandRegistrar._rewrite_project_relative_paths(body)
|
||||
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
|
||||
|
||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||
"""Convert argument placeholder format.
|
||||
@@ -446,9 +400,8 @@ class CommandRegistrar:
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
short_name = short_name.replace(".", "-")
|
||||
|
||||
return f"speckit-{short_name}"
|
||||
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
|
||||
@@ -510,283 +510,6 @@ class ExtensionManager:
|
||||
|
||||
return _ignore
|
||||
|
||||
def _get_skills_dir(self) -> Optional[Path]:
|
||||
"""Return the active skills directory for extension skill registration.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills
|
||||
are enabled and which agent was selected, then delegates to
|
||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
||||
|
||||
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
|
||||
``.kimi/skills`` exists, extension installs should still propagate
|
||||
command skills even when ``ai_skills`` is false.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills were not
|
||||
enabled and no native-skills fallback applies.
|
||||
"""
|
||||
from . import load_init_options, _get_skills_dir as resolve_skills_dir
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
opts = {}
|
||||
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
return None
|
||||
|
||||
skills_dir = resolve_skills_dir(self.project_root, agent)
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
|
||||
return skills_dir
|
||||
|
||||
def _register_extension_skills(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
) -> List[str]:
|
||||
"""Generate SKILL.md files for extension commands as agent skills.
|
||||
|
||||
For every command in the extension manifest, creates a SKILL.md
|
||||
file in the agent's skills directory following the agentskills.io
|
||||
specification. This is only done when ``--ai-skills`` was used
|
||||
during project initialisation.
|
||||
|
||||
Args:
|
||||
manifest: Extension manifest.
|
||||
extension_dir: Installed extension directory.
|
||||
|
||||
Returns:
|
||||
List of skill names that were created (for registry storage).
|
||||
"""
|
||||
skills_dir = self._get_skills_dir()
|
||||
if not skills_dir:
|
||||
return []
|
||||
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
import yaml
|
||||
|
||||
written: List[str] = []
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
opts = {}
|
||||
selected_ai = opts.get("ai")
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return []
|
||||
registrar = CommandRegistrar()
|
||||
|
||||
for cmd_info in manifest.commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
cmd_file_rel = cmd_info["file"]
|
||||
|
||||
# Guard against path traversal: reject absolute paths and ensure
|
||||
# the resolved file stays within the extension directory.
|
||||
cmd_path = Path(cmd_file_rel)
|
||||
if cmd_path.is_absolute():
|
||||
continue
|
||||
try:
|
||||
ext_root = extension_dir.resolve()
|
||||
source_file = (ext_root / cmd_path).resolve()
|
||||
source_file.relative_to(ext_root) # raises ValueError if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
if not source_file.is_file():
|
||||
continue
|
||||
|
||||
# Derive skill name from command name using the same hyphenated
|
||||
# convention as hook rendering and preset skill registration.
|
||||
short_name_raw = cmd_name
|
||||
if short_name_raw.startswith("speckit."):
|
||||
short_name_raw = short_name_raw[len("speckit."):]
|
||||
skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
|
||||
|
||||
# Check if skill already exists before creating the directory
|
||||
skill_subdir = skills_dir / skill_name
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
if skill_file.exists():
|
||||
# Do not overwrite user-customized skills
|
||||
continue
|
||||
|
||||
# Create skill directory; track whether we created it so we can clean
|
||||
# up safely if reading the source file subsequently fails.
|
||||
created_now = not skill_subdir.exists()
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Parse the command file — guard against IsADirectoryError / decode errors
|
||||
try:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
if created_now:
|
||||
try:
|
||||
skill_subdir.rmdir() # undo the mkdir; dir is empty at this point
|
||||
except OSError:
|
||||
pass # best-effort cleanup
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
frontmatter = registrar._adjust_script_paths(frontmatter)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
description = original_desc or f"Extension command: {cmd_name}"
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": description,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"extension:{manifest.id}",
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
|
||||
# Derive a human-friendly title from the command name
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
title_name = short_name.replace(".", " ").replace("-", " ").title()
|
||||
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# {title_name} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(skill_name)
|
||||
|
||||
return written
|
||||
|
||||
def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None:
|
||||
"""Remove SKILL.md directories for extension skills.
|
||||
|
||||
Called during extension removal to clean up skill files that
|
||||
were created by ``_register_extension_skills()``.
|
||||
|
||||
If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed
|
||||
init-options.json or toggled ai_skills after installation), we
|
||||
fall back to scanning all known agent skills directories so that
|
||||
orphaned skill directories are still cleaned up. In that case
|
||||
each candidate directory is verified against the SKILL.md
|
||||
``metadata.source`` field before removal to avoid accidentally
|
||||
deleting user-created skills with the same name.
|
||||
|
||||
Args:
|
||||
skill_names: List of skill names to remove.
|
||||
extension_id: Extension ID used to verify ownership during
|
||||
fallback candidate scanning.
|
||||
"""
|
||||
if not skill_names:
|
||||
return
|
||||
|
||||
skills_dir = self._get_skills_dir()
|
||||
|
||||
if skills_dir:
|
||||
# Fast path: we know the exact skills directory
|
||||
for skill_name in skill_names:
|
||||
# Guard against path traversal from a corrupted registry entry:
|
||||
# reject names that are absolute, contain path separators, or
|
||||
# resolve to a path outside the skills directory.
|
||||
sn_path = Path(skill_name)
|
||||
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
||||
continue
|
||||
try:
|
||||
skill_subdir = (skills_dir / skill_name).resolve()
|
||||
skill_subdir.relative_to(skills_dir.resolve()) # raises if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not skill_subdir.is_dir():
|
||||
continue
|
||||
# Safety check: only delete if SKILL.md exists and its
|
||||
# metadata.source matches exactly this extension — mirroring
|
||||
# the fallback branch — so a corrupted registry entry cannot
|
||||
# delete an unrelated user skill.
|
||||
skill_md = skill_subdir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError, Exception):
|
||||
continue
|
||||
shutil.rmtree(skill_subdir)
|
||||
else:
|
||||
# Fallback: scan all possible agent skills directories
|
||||
from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||
|
||||
candidate_dirs: set[Path] = set()
|
||||
for cfg in AGENT_CONFIG.values():
|
||||
folder = cfg.get("folder", "")
|
||||
if folder:
|
||||
candidate_dirs.add(self.project_root / folder.rstrip("/") / "skills")
|
||||
candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR)
|
||||
|
||||
for skills_candidate in candidate_dirs:
|
||||
if not skills_candidate.is_dir():
|
||||
continue
|
||||
for skill_name in skill_names:
|
||||
# Same path-traversal guard as the fast path above
|
||||
sn_path = Path(skill_name)
|
||||
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
||||
continue
|
||||
try:
|
||||
skill_subdir = (skills_candidate / skill_name).resolve()
|
||||
skill_subdir.relative_to(skills_candidate.resolve()) # raises if outside
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not skill_subdir.is_dir():
|
||||
continue
|
||||
# Safety check: only delete if SKILL.md exists and its
|
||||
# metadata.source matches exactly this extension. If the
|
||||
# file is missing or unreadable we skip to avoid deleting
|
||||
# unrelated user-created directories.
|
||||
skill_md = skill_subdir / "SKILL.md"
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Only remove skills explicitly created by this extension
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError, Exception):
|
||||
# If we can't verify, skip to avoid accidental deletion
|
||||
continue
|
||||
shutil.rmtree(skill_subdir)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
manifest: ExtensionManifest,
|
||||
@@ -878,10 +601,6 @@ class ExtensionManager:
|
||||
manifest, dest_dir, self.project_root
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
# was used during project initialisation (feature parity).
|
||||
registered_skills = self._register_extension_skills(manifest, dest_dir)
|
||||
|
||||
# Register hooks
|
||||
hook_executor = HookExecutor(self.project_root)
|
||||
hook_executor.register_hooks(manifest)
|
||||
@@ -893,8 +612,7 @@ class ExtensionManager:
|
||||
"manifest_hash": manifest.get_hash(),
|
||||
"enabled": True,
|
||||
"priority": priority,
|
||||
"registered_commands": registered_commands,
|
||||
"registered_skills": registered_skills,
|
||||
"registered_commands": registered_commands
|
||||
})
|
||||
|
||||
return manifest
|
||||
@@ -972,15 +690,9 @@ class ExtensionManager:
|
||||
if not self.registry.is_installed(extension_id):
|
||||
return False
|
||||
|
||||
# Get registered commands and skills before removal
|
||||
# Get registered commands before removal
|
||||
metadata = self.registry.get(extension_id)
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
raw_skills = metadata.get("registered_skills", []) if metadata else []
|
||||
# Normalize: must be a list of plain strings to avoid corrupted-registry errors
|
||||
if isinstance(raw_skills, list):
|
||||
registered_skills = [s for s in raw_skills if isinstance(s, str)]
|
||||
else:
|
||||
registered_skills = []
|
||||
|
||||
extension_dir = self.extensions_dir / extension_id
|
||||
|
||||
@@ -989,9 +701,6 @@ class ExtensionManager:
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
# Unregister agent skills
|
||||
self._unregister_extension_skills(registered_skills, extension_id)
|
||||
|
||||
if keep_config:
|
||||
# Preserve config files, only remove non-config files
|
||||
if extension_dir.exists():
|
||||
@@ -1935,52 +1644,6 @@ class HookExecutor:
|
||||
self.project_root = project_root
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.config_file = project_root / ".specify" / "extensions.yml"
|
||||
self._init_options_cache: Optional[Dict[str, Any]] = None
|
||||
|
||||
def _load_init_options(self) -> Dict[str, Any]:
|
||||
"""Load persisted init options used to determine invocation style.
|
||||
|
||||
Uses the shared helper from specify_cli and caches values per executor
|
||||
instance to avoid repeated filesystem reads during hook rendering.
|
||||
"""
|
||||
if self._init_options_cache is None:
|
||||
from . import load_init_options
|
||||
|
||||
payload = load_init_options(self.project_root)
|
||||
self._init_options_cache = payload if isinstance(payload, dict) else {}
|
||||
return self._init_options_cache
|
||||
|
||||
@staticmethod
|
||||
def _skill_name_from_command(command: Any) -> str:
|
||||
"""Map a command id like speckit.plan to speckit-plan skill name."""
|
||||
if not isinstance(command, str):
|
||||
return ""
|
||||
command_id = command.strip()
|
||||
if not command_id.startswith("speckit."):
|
||||
return ""
|
||||
return f"speckit-{command_id[len('speckit.'):].replace('.', '-')}"
|
||||
|
||||
def _render_hook_invocation(self, command: Any) -> str:
|
||||
"""Render an agent-specific invocation string for a hook command."""
|
||||
if not isinstance(command, str):
|
||||
return ""
|
||||
|
||||
command_id = command.strip()
|
||||
if not command_id:
|
||||
return ""
|
||||
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if codex_skill_mode and skill_name:
|
||||
return f"${skill_name}"
|
||||
if kimi_skill_mode and skill_name:
|
||||
return f"/skill:{skill_name}"
|
||||
|
||||
return f"/{command_id}"
|
||||
|
||||
def get_project_config(self) -> Dict[str, Any]:
|
||||
"""Load project-level extension configuration.
|
||||
@@ -2224,27 +1887,21 @@ class HookExecutor:
|
||||
for hook in hooks:
|
||||
extension = hook.get("extension")
|
||||
command = hook.get("command")
|
||||
invocation = self._render_hook_invocation(command)
|
||||
command_text = command if isinstance(command, str) and command.strip() else "<missing command>"
|
||||
display_invocation = invocation or (
|
||||
f"/{command_text}" if command_text != "<missing command>" else "/<missing command>"
|
||||
)
|
||||
optional = hook.get("optional", True)
|
||||
prompt = hook.get("prompt", "")
|
||||
description = hook.get("description", "")
|
||||
|
||||
if optional:
|
||||
lines.append(f"\n**Optional Hook**: {extension}")
|
||||
lines.append(f"Command: `{display_invocation}`")
|
||||
lines.append(f"Command: `/{command}`")
|
||||
if description:
|
||||
lines.append(f"Description: {description}")
|
||||
lines.append(f"\nPrompt: {prompt}")
|
||||
lines.append(f"To execute: `{display_invocation}`")
|
||||
lines.append(f"To execute: `/{command}`")
|
||||
else:
|
||||
lines.append(f"\n**Automatic Hook**: {extension}")
|
||||
lines.append(f"Executing: `{display_invocation}`")
|
||||
lines.append(f"EXECUTE_COMMAND: {command_text}")
|
||||
lines.append(f"EXECUTE_COMMAND_INVOCATION: {display_invocation}")
|
||||
lines.append(f"Executing: `/{command}`")
|
||||
lines.append(f"EXECUTE_COMMAND: {command}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -2308,7 +1965,6 @@ class HookExecutor:
|
||||
"""
|
||||
return {
|
||||
"command": hook.get("command"),
|
||||
"invocation": self._render_hook_invocation(hook.get("command")),
|
||||
"extension": hook.get("extension"),
|
||||
"optional": hook.get("optional", True),
|
||||
"description": hook.get("description", ""),
|
||||
@@ -2352,3 +2008,4 @@ class HookExecutor:
|
||||
hook["enabled"] = False
|
||||
|
||||
self.save_project_config(config)
|
||||
|
||||
|
||||
@@ -556,31 +556,24 @@ class PresetManager:
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
def _get_skills_dir(self) -> Optional[Path]:
|
||||
"""Return the active skills directory for preset skill overrides.
|
||||
"""Return the skills directory if ``--ai-skills`` was used during init.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills
|
||||
are enabled and which agent was selected, then delegates to
|
||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
||||
|
||||
Kimi is treated as a native-skills agent: if ``ai == "kimi"`` and
|
||||
``.kimi/skills`` exists, presets should still propagate command
|
||||
overrides to skills even when ``ai_skills`` is false.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills were not
|
||||
enabled and no native-skills fallback applies.
|
||||
enabled or the init-options file is missing.
|
||||
"""
|
||||
from . import load_init_options, _get_skills_dir
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
opts = {}
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
if not opts.get("ai_skills"):
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
agent = opts.get("ai")
|
||||
if not agent:
|
||||
return None
|
||||
|
||||
skills_dir = _get_skills_dir(self.project_root, agent)
|
||||
@@ -589,76 +582,6 @@ class PresetManager:
|
||||
|
||||
return skills_dir
|
||||
|
||||
@staticmethod
|
||||
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
|
||||
"""Return the modern and legacy skill directory names for a command."""
|
||||
raw_short_name = cmd_name
|
||||
if raw_short_name.startswith("speckit."):
|
||||
raw_short_name = raw_short_name[len("speckit."):]
|
||||
|
||||
modern_skill_name = f"speckit-{raw_short_name.replace('.', '-')}"
|
||||
legacy_skill_name = f"speckit.{raw_short_name}"
|
||||
return modern_skill_name, legacy_skill_name
|
||||
|
||||
@staticmethod
|
||||
def _skill_title_from_command(cmd_name: str) -> str:
|
||||
"""Return a human-friendly title for a skill command name."""
|
||||
title_name = cmd_name
|
||||
if title_name.startswith("speckit."):
|
||||
title_name = title_name[len("speckit."):]
|
||||
return title_name.replace(".", " ").replace("-", " ").title()
|
||||
|
||||
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Index extension-backed skill restore data by skill directory name."""
|
||||
from .extensions import ExtensionManifest, ValidationError
|
||||
|
||||
resolver = PresetResolver(self.project_root)
|
||||
extensions_dir = self.project_root / ".specify" / "extensions"
|
||||
restore_index: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for _priority, ext_id, _metadata in resolver._get_all_extensions_by_priority():
|
||||
ext_dir = extensions_dir / ext_id
|
||||
manifest_path = ext_dir / "extension.yml"
|
||||
if not manifest_path.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
ext_root = ext_dir.resolve()
|
||||
for cmd_info in manifest.commands:
|
||||
cmd_name = cmd_info.get("name")
|
||||
cmd_file_rel = cmd_info.get("file")
|
||||
if not isinstance(cmd_name, str) or not isinstance(cmd_file_rel, str):
|
||||
continue
|
||||
|
||||
cmd_path = Path(cmd_file_rel)
|
||||
if cmd_path.is_absolute():
|
||||
continue
|
||||
|
||||
try:
|
||||
source_file = (ext_root / cmd_path).resolve()
|
||||
source_file.relative_to(ext_root)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
if not source_file.is_file():
|
||||
continue
|
||||
|
||||
restore_info = {
|
||||
"command_name": cmd_name,
|
||||
"source_file": source_file,
|
||||
"source": f"extension:{manifest.id}",
|
||||
}
|
||||
modern_skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
restore_index.setdefault(modern_skill_name, restore_info)
|
||||
if legacy_skill_name != modern_skill_name:
|
||||
restore_index.setdefault(legacy_skill_name, restore_info)
|
||||
|
||||
return restore_index
|
||||
|
||||
def _register_skills(
|
||||
self,
|
||||
manifest: "PresetManifest",
|
||||
@@ -706,15 +629,9 @@ class PresetManager:
|
||||
return []
|
||||
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
|
||||
init_opts = load_init_options(self.project_root)
|
||||
if not isinstance(init_opts, dict):
|
||||
init_opts = {}
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return []
|
||||
registrar = CommandRegistrar()
|
||||
opts = load_init_options(self.project_root)
|
||||
selected_ai = opts.get("ai", "")
|
||||
|
||||
written: List[str] = []
|
||||
|
||||
@@ -726,61 +643,62 @@ class PresetManager:
|
||||
continue
|
||||
|
||||
# Derive the short command name (e.g. "specify" from "speckit.specify")
|
||||
raw_short_name = cmd_name
|
||||
if raw_short_name.startswith("speckit."):
|
||||
raw_short_name = raw_short_name[len("speckit."):]
|
||||
short_name = raw_short_name.replace(".", "-")
|
||||
skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||
skill_title = self._skill_title_from_command(cmd_name)
|
||||
short_name = cmd_name
|
||||
if short_name.startswith("speckit."):
|
||||
short_name = short_name[len("speckit."):]
|
||||
if selected_ai == "kimi":
|
||||
skill_name = f"speckit.{short_name}"
|
||||
else:
|
||||
skill_name = f"speckit-{short_name}"
|
||||
|
||||
# Only overwrite skills that already exist under skills_dir,
|
||||
# including Kimi native skills when ai_skills is false.
|
||||
# If both modern and legacy directories exist, update both.
|
||||
target_skill_names: List[str] = []
|
||||
if (skills_dir / skill_name).is_dir():
|
||||
target_skill_names.append(skill_name)
|
||||
if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir():
|
||||
target_skill_names.append(legacy_skill_name)
|
||||
if not target_skill_names:
|
||||
# Only overwrite if the skill already exists (i.e. --ai-skills was used)
|
||||
skill_subdir = skills_dir / skill_name
|
||||
if not skill_subdir.exists():
|
||||
continue
|
||||
|
||||
# Parse the command file
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
body = parts[2].strip()
|
||||
else:
|
||||
frontmatter = {}
|
||||
body = content
|
||||
else:
|
||||
frontmatter = {}
|
||||
body = content
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
short_name,
|
||||
original_desc or f"Spec-kit workflow command: {short_name}",
|
||||
)
|
||||
frontmatter = dict(frontmatter)
|
||||
frontmatter["description"] = enhanced_desc
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"preset:{manifest.id}",
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# Speckit {short_name.title()} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
for target_skill_name in target_skill_names:
|
||||
frontmatter_data = {
|
||||
"name": target_skill_name,
|
||||
"description": enhanced_desc,
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": f"preset:{manifest.id}",
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# Speckit {skill_title} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
|
||||
skill_file = skills_dir / target_skill_name / "SKILL.md"
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(target_skill_name)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
written.append(skill_name)
|
||||
|
||||
return written
|
||||
|
||||
@@ -802,17 +720,10 @@ class PresetManager:
|
||||
if not skills_dir:
|
||||
return
|
||||
|
||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from . import SKILL_DESCRIPTIONS
|
||||
|
||||
# Locate core command templates from the project's installed templates
|
||||
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
|
||||
init_opts = load_init_options(self.project_root)
|
||||
if not isinstance(init_opts, dict):
|
||||
init_opts = {}
|
||||
selected_ai = init_opts.get("ai")
|
||||
registrar = CommandRegistrar()
|
||||
extension_restore_index = self._build_extension_skill_restore_index()
|
||||
|
||||
for skill_name in skill_names:
|
||||
# Derive command name from skill name (speckit-specify -> specify)
|
||||
@@ -824,10 +735,7 @@ class PresetManager:
|
||||
|
||||
skill_subdir = skills_dir / skill_name
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
if not skill_subdir.is_dir():
|
||||
continue
|
||||
if not skill_file.is_file():
|
||||
# Only manage directories that contain the expected skill entrypoint.
|
||||
if not skill_file.exists():
|
||||
continue
|
||||
|
||||
# Try to find the core command template
|
||||
@@ -838,11 +746,19 @@ class PresetManager:
|
||||
if core_file:
|
||||
# Restore from core template
|
||||
content = core_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
body = parts[2].strip()
|
||||
else:
|
||||
frontmatter = {}
|
||||
body = content
|
||||
else:
|
||||
frontmatter = {}
|
||||
body = content
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||
@@ -860,49 +776,16 @@ class PresetManager:
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_title = self._skill_title_from_command(short_name)
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# Speckit {skill_title} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
continue
|
||||
|
||||
extension_restore = extension_restore_index.get(skill_name)
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
|
||||
command_name = extension_restore["command_name"]
|
||||
title_name = self._skill_title_from_command(command_name)
|
||||
|
||||
frontmatter_data = {
|
||||
"name": skill_name,
|
||||
"description": frontmatter.get("description", f"Extension command: {command_name}"),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
"metadata": {
|
||||
"author": "github-spec-kit",
|
||||
"source": extension_restore["source"],
|
||||
},
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||
skill_content = (
|
||||
f"---\n"
|
||||
f"{frontmatter_text}\n"
|
||||
f"---\n\n"
|
||||
f"# {title_name} Skill\n\n"
|
||||
f"# Speckit {short_name.title()} Skill\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
skill_file.write_text(skill_content, encoding="utf-8")
|
||||
else:
|
||||
# No core or extension template — remove the skill entirely
|
||||
# No core template — remove the skill entirely
|
||||
shutil.rmtree(skill_subdir)
|
||||
|
||||
def install_from_directory(
|
||||
@@ -1032,26 +915,17 @@ class PresetManager:
|
||||
if not self.registry.is_installed(pack_id):
|
||||
return False
|
||||
|
||||
# Unregister commands from AI agents
|
||||
metadata = self.registry.get(pack_id)
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
if registered_commands:
|
||||
self._unregister_commands(registered_commands)
|
||||
|
||||
# Restore original skills when preset is removed
|
||||
registered_skills = metadata.get("registered_skills", []) if metadata else []
|
||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
if registered_skills:
|
||||
self._unregister_skills(registered_skills, pack_dir)
|
||||
try:
|
||||
from . import NATIVE_SKILLS_AGENTS
|
||||
except ImportError:
|
||||
NATIVE_SKILLS_AGENTS = set()
|
||||
registered_commands = {
|
||||
agent_name: cmd_names
|
||||
for agent_name, cmd_names in registered_commands.items()
|
||||
if agent_name not in NATIVE_SKILLS_AGENTS
|
||||
}
|
||||
|
||||
# Unregister non-skill command files from AI agents.
|
||||
if registered_commands:
|
||||
self._unregister_commands(registered_commands)
|
||||
|
||||
if pack_dir.exists():
|
||||
shutil.rmtree(pack_dir)
|
||||
|
||||
@@ -44,7 +44,7 @@ Load only the minimal necessary context from each artifact:
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
@@ -71,7 +71,7 @@ Load only the minimal necessary context from each artifact:
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
@@ -105,7 +105,7 @@ Focus on high-signal findings. Limit to 50 findings total; aggregate remainder i
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ Execution steps:
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
|
||||
@@ -113,16 +113,3 @@
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
|
||||
@@ -24,8 +24,8 @@ import specify_cli
|
||||
|
||||
from specify_cli import (
|
||||
_get_skills_dir,
|
||||
_migrate_legacy_kimi_dotted_skills,
|
||||
install_ai_skills,
|
||||
AGENT_SKILLS_DIR_OVERRIDES,
|
||||
DEFAULT_SKILLS_DIR,
|
||||
SKILL_DESCRIPTIONS,
|
||||
AGENT_CONFIG,
|
||||
@@ -169,8 +169,8 @@ class TestGetSkillsDir:
|
||||
result = _get_skills_dir(project_dir, "copilot")
|
||||
assert result == project_dir / ".github" / "skills"
|
||||
|
||||
def test_codex_skills_dir_from_agent_config(self, project_dir):
|
||||
"""Codex should resolve skills directory from AGENT_CONFIG folder."""
|
||||
def test_codex_uses_override(self, project_dir):
|
||||
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
|
||||
result = _get_skills_dir(project_dir, "codex")
|
||||
assert result == project_dir / ".agents" / "skills"
|
||||
|
||||
@@ -203,71 +203,12 @@ class TestGetSkillsDir:
|
||||
# Should always end with "skills"
|
||||
assert result.name == "skills"
|
||||
|
||||
class TestKimiLegacySkillMigration:
|
||||
"""Test temporary migration from Kimi dotted skill names to hyphenated names."""
|
||||
|
||||
def test_migrates_legacy_dotted_skill_directory(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 1
|
||||
assert removed == 0
|
||||
assert not legacy_dir.exists()
|
||||
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_removes_legacy_dir_when_hyphenated_target_exists_with_same_content(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 1
|
||||
assert not legacy_dir.exists()
|
||||
assert (target_dir / "SKILL.md").read_text() == "legacy"
|
||||
|
||||
def test_keeps_legacy_dir_when_hyphenated_target_differs(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("new")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy_dir.exists()
|
||||
assert (legacy_dir / "SKILL.md").read_text() == "legacy"
|
||||
assert (target_dir / "SKILL.md").read_text() == "new"
|
||||
|
||||
def test_keeps_legacy_dir_when_matching_target_but_extra_files_exist(self, project_dir):
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
legacy_dir = skills_dir / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "SKILL.md").write_text("legacy")
|
||||
(legacy_dir / "notes.txt").write_text("custom")
|
||||
target_dir = skills_dir / "speckit-plan"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "SKILL.md").write_text("legacy")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy_dir.exists()
|
||||
assert (legacy_dir / "notes.txt").read_text() == "custom"
|
||||
def test_override_takes_precedence_over_config(self, project_dir):
|
||||
"""AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG."""
|
||||
for agent_key in AGENT_SKILLS_DIR_OVERRIDES:
|
||||
result = _get_skills_dir(project_dir, agent_key)
|
||||
expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ===== install_ai_skills Tests =====
|
||||
@@ -532,7 +473,8 @@ class TestInstallAiSkills:
|
||||
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()]
|
||||
expected_skill_name = "speckit-specify"
|
||||
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
||||
assert expected_skill_name in skill_dirs
|
||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||
|
||||
@@ -831,32 +773,6 @@ class TestNewProjectCommandSkip:
|
||||
mock_skills.assert_called_once()
|
||||
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
||||
|
||||
def test_kimi_legacy_migration_runs_without_ai_skills_flag(self, tmp_path):
|
||||
"""Kimi init should migrate dotted legacy skills even when --ai-skills is not set."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "kimi-legacy-no-ai-skills"
|
||||
|
||||
def fake_download(project_path, *args, **kwargs):
|
||||
legacy_dir = project_path / ".kimi" / "skills" / "speckit.plan"
|
||||
legacy_dir.mkdir(parents=True, exist_ok=True)
|
||||
(legacy_dir / "SKILL.md").write_text("---\nname: speckit.plan\n---\n\nlegacy\n")
|
||||
|
||||
with patch("specify_cli.download_and_extract_template", side_effect=fake_download), \
|
||||
patch("specify_cli.ensure_executable_scripts"), \
|
||||
patch("specify_cli.ensure_constitution_from_template"), \
|
||||
patch("specify_cli.is_git_repo", return_value=False), \
|
||||
patch("specify_cli.shutil.which", return_value="/usr/bin/kimi"):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "kimi", "--script", "sh", "--no-git"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (target / ".kimi" / "skills" / "speckit.plan").exists()
|
||||
assert (target / ".kimi" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_codex_ai_skills_here_mode_preserves_existing_codex_dir(self, tmp_path, monkeypatch):
|
||||
"""Codex --here skills init should not delete a pre-existing .codex directory."""
|
||||
from typer.testing import CliRunner
|
||||
@@ -1202,12 +1118,12 @@ class TestCliValidation:
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
||||
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
|
||||
"""Kimi next-steps guidance should display /skill:speckit.* usage."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
def _fake_download(*args, **kwargs):
|
||||
project_path = Path(args[0])
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit-specify"
|
||||
skill_dir = project_path / ".kimi" / "skills" / "speckit.specify"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||
|
||||
@@ -1221,7 +1137,7 @@ class TestCliValidation:
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit-constitution" in result.output
|
||||
assert "/skill:speckit.constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ def _expected_cmd_dir(project_path: Path, agent: str) -> Path:
|
||||
|
||||
# Agents whose commands are laid out as <skills_dir>/<name>/SKILL.md.
|
||||
# Maps agent -> separator used in skill directory names.
|
||||
_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "-"}
|
||||
_SKILL_AGENTS: dict[str, str] = {"codex": "-", "kimi": "."}
|
||||
|
||||
|
||||
def _expected_ext(agent: str) -> str:
|
||||
|
||||
@@ -1,741 +0,0 @@
|
||||
"""
|
||||
Unit tests for extension skill auto-registration.
|
||||
|
||||
Tests cover:
|
||||
- SKILL.md generation when --ai-skills was used during init
|
||||
- No skills created when ai_skills not active
|
||||
- SKILL.md content correctness
|
||||
- Existing user-modified skills not overwritten
|
||||
- Skill cleanup on extension removal
|
||||
- Registry metadata includes registered_skills
|
||||
"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.extensions import (
|
||||
ExtensionManifest,
|
||||
ExtensionManager,
|
||||
ExtensionError,
|
||||
)
|
||||
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
||||
"""Write a .specify/init-options.json file."""
|
||||
opts_dir = project_root / ".specify"
|
||||
opts_dir.mkdir(parents=True, exist_ok=True)
|
||||
opts_file = opts_dir / "init-options.json"
|
||||
opts_file.write_text(json.dumps({
|
||||
"ai": ai,
|
||||
"ai_skills": ai_skills,
|
||||
"script": "sh",
|
||||
}))
|
||||
|
||||
|
||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||
"""Create and return the expected skills directory for the given agent."""
|
||||
# Match the logic in _get_skills_dir() from specify_cli
|
||||
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||
|
||||
agent_config = AGENT_CONFIG.get(ai, {})
|
||||
agent_folder = agent_config.get("folder", "")
|
||||
if agent_folder:
|
||||
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
||||
else:
|
||||
skills_dir = project_root / DEFAULT_SKILLS_DIR
|
||||
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skills_dir
|
||||
|
||||
|
||||
def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
||||
"""Create a complete extension directory with manifest and command files."""
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "A test extension for skill registration",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.{ext_id}.hello",
|
||||
"file": "commands/hello.md",
|
||||
"description": "Test hello command",
|
||||
},
|
||||
{
|
||||
"name": f"speckit.{ext_id}.world",
|
||||
"file": "commands/world.md",
|
||||
"description": "Test world command",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
|
||||
(commands_dir / "hello.md").write_text(
|
||||
"---\n"
|
||||
"description: \"Test hello command\"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Hello Command\n"
|
||||
"\n"
|
||||
"Run this to say hello.\n"
|
||||
"$ARGUMENTS\n"
|
||||
)
|
||||
|
||||
(commands_dir / "world.md").write_text(
|
||||
"---\n"
|
||||
"description: \"Test world command\"\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# World Command\n"
|
||||
"\n"
|
||||
"Run this to greet the world.\n"
|
||||
)
|
||||
|
||||
return ext_dir
|
||||
|
||||
|
||||
# ===== Fixtures =====
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
yield Path(tmpdir)
|
||||
shutil.rmtree(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(temp_dir):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = temp_dir / "project"
|
||||
proj_dir.mkdir()
|
||||
|
||||
# Create .specify directory
|
||||
specify_dir = proj_dir / ".specify"
|
||||
specify_dir.mkdir()
|
||||
|
||||
return proj_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def extension_dir(temp_dir):
|
||||
"""Create a complete extension directory."""
|
||||
return _create_extension_dir(temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_project(project_dir):
|
||||
"""Create a project with --ai-skills enabled and skills directory."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
return project_dir, skills_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_skills_project(project_dir):
|
||||
"""Create a project without --ai-skills."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
return project_dir
|
||||
|
||||
|
||||
# ===== ExtensionManager._get_skills_dir Tests =====
|
||||
|
||||
class TestExtensionManagerGetSkillsDir:
|
||||
"""Test _get_skills_dir() on ExtensionManager."""
|
||||
|
||||
def test_returns_skills_dir_when_active(self, skills_project):
|
||||
"""Should return skills dir when ai_skills is true and dir exists."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result == skills_dir
|
||||
|
||||
def test_returns_none_when_no_ai_skills(self, no_skills_project):
|
||||
"""Should return None when ai_skills is false."""
|
||||
manager = ExtensionManager(no_skills_project)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_init_options(self, project_dir):
|
||||
"""Should return None when init-options.json is missing."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_skills_dir_missing(self, project_dir):
|
||||
"""Should return None when skills dir doesn't exist on disk."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
# Don't create the skills directory
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
|
||||
"""Kimi should still use its native skills dir when ai_skills is false."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result == skills_dir
|
||||
|
||||
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===== Extension Skill Registration Tests =====
|
||||
|
||||
class TestExtensionSkillRegistration:
|
||||
"""Test _register_extension_skills() on ExtensionManager."""
|
||||
|
||||
def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir):
|
||||
"""Skills should be created when ai_skills is enabled."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Check that skill directories were created
|
||||
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
||||
assert "speckit-test-ext-hello" in skill_dirs
|
||||
assert "speckit-test-ext-world" in skill_dirs
|
||||
|
||||
def test_skill_md_content_correct(self, skills_project, extension_dir):
|
||||
"""SKILL.md should have correct agentskills.io structure."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
|
||||
# Check structure
|
||||
assert content.startswith("---\n")
|
||||
assert "name: speckit-test-ext-hello" in content
|
||||
assert "description:" in content
|
||||
assert "Test hello command" in content
|
||||
assert "source: extension:test-ext" in content
|
||||
assert "author: github-spec-kit" in content
|
||||
assert "compatibility:" in content
|
||||
assert "Run this to say hello." in content
|
||||
|
||||
def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
|
||||
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
content = skill_file.read_text()
|
||||
|
||||
assert content.startswith("---\n")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["name"] == "speckit-test-ext-hello"
|
||||
assert "description" in parsed
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
"""No skills should be created when ai_skills is false."""
|
||||
manager = ExtensionManager(no_skills_project)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Verify registry
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_no_skills_when_init_options_missing(self, project_dir, extension_dir):
|
||||
"""No skills should be created when init-options.json is absent."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
|
||||
"""Pre-existing SKILL.md should not be overwritten."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
# Pre-create a custom skill
|
||||
custom_dir = skills_dir / "speckit-test-ext-hello"
|
||||
custom_dir.mkdir(parents=True)
|
||||
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
|
||||
(custom_dir / "SKILL.md").write_text(custom_content)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Custom skill should be untouched
|
||||
assert (custom_dir / "SKILL.md").read_text() == custom_content
|
||||
|
||||
# But the other skill should still be created
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
||||
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
||||
|
||||
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
||||
"""Registry should contain registered_skills list."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "registered_skills" in metadata
|
||||
assert len(metadata["registered_skills"]) == 2
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
|
||||
"""Kimi agent should use the same hyphenated skill names as hooks."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
||||
_create_skills_dir(project_dir, ai="kimi")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""Kimi should still auto-register extension skills in native-skills mode."""
|
||||
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Auto-registered extension skills should resolve script placeholders."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "scripted-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "scripted-ext",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.scripted-ext.plan",
|
||||
"file": "commands/plan.md",
|
||||
"description": "Scripted plan command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted plan command\n"
|
||||
"scripts:\n"
|
||||
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
||||
"agent_scripts:\n"
|
||||
" sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n"
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Then {AGENT_SCRIPT}\n"
|
||||
"Review templates/checklist.md and memory/constitution.md for __AGENT__.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{AGENT_SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh claude" in content
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
ext_dir = temp_dir / "missing-cmd-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "missing-cmd-ext",
|
||||
"name": "Missing Cmd Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.missing-cmd-ext.exists",
|
||||
"file": "commands/exists.md",
|
||||
"description": "Exists",
|
||||
},
|
||||
{
|
||||
"name": "speckit.missing-cmd-ext.ghost",
|
||||
"file": "commands/ghost.md",
|
||||
"description": "Does not exist",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "commands" / "exists.md").write_text(
|
||||
"---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n"
|
||||
)
|
||||
# Intentionally do NOT create ghost.md
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
|
||||
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
class TestExtensionSkillUnregistration:
|
||||
"""Test _unregister_extension_skills() on ExtensionManager."""
|
||||
|
||||
def test_skills_removed_on_extension_remove(self, skills_project, extension_dir):
|
||||
"""Removing an extension should clean up its skill directories."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||
|
||||
# Remove extension
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
# Skills should be gone
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
||||
"""Non-extension skills should not be affected by extension removal."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
# Pre-create a custom skill
|
||||
custom_dir = skills_dir / "my-custom-skill"
|
||||
custom_dir.mkdir(parents=True)
|
||||
(custom_dir / "SKILL.md").write_text("# My Custom Skill\n")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
manager.remove(manifest.id, keep_config=False)
|
||||
|
||||
# Custom skill should still exist
|
||||
assert (custom_dir / "SKILL.md").exists()
|
||||
assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n"
|
||||
|
||||
def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir):
|
||||
"""Gracefully handle case where skill dirs were already deleted."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Manually delete skill dirs before calling remove
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext-hello")
|
||||
shutil.rmtree(skills_dir / "speckit-test-ext-world")
|
||||
|
||||
# Should not raise
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir):
|
||||
"""Removal without active skills should not attempt skill cleanup."""
|
||||
manager = ExtensionManager(no_skills_project)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Should not raise even though no skills exist
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
|
||||
# ===== Command File Without Frontmatter =====
|
||||
|
||||
class TestExtensionSkillEdgeCases:
|
||||
"""Test edge cases in extension skill registration."""
|
||||
|
||||
def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir):
|
||||
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_command_without_frontmatter(self, skills_project, temp_dir):
|
||||
"""Commands without YAML frontmatter should still produce valid skills."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
ext_dir = temp_dir / "nofm-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "nofm-ext",
|
||||
"name": "No Frontmatter Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.nofm-ext.plain",
|
||||
"file": "commands/plain.md",
|
||||
"description": "Plain command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "commands" / "plain.md").write_text(
|
||||
"# Plain Command\n\nBody without frontmatter.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-nofm-ext-plain" in content
|
||||
# Fallback description when no frontmatter description
|
||||
assert "Extension command: speckit.nofm-ext.plain" in content
|
||||
assert "Body without frontmatter." in content
|
||||
|
||||
def test_gemini_agent_skills(self, project_dir, temp_dir):
|
||||
"""Gemini agent should use .gemini/skills/ for skill directory."""
|
||||
_create_init_options(project_dir, ai="gemini", ai_skills=True)
|
||||
_create_skills_dir(project_dir, ai="gemini")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".gemini" / "skills"
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||
|
||||
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
|
||||
"""Installing and removing different extensions should be independent."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a")
|
||||
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest_a = manager.install_from_directory(
|
||||
ext_dir_a, "0.1.0", register_commands=False
|
||||
)
|
||||
manifest_b = manager.install_from_directory(
|
||||
ext_dir_b, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Both should have skills
|
||||
assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
|
||||
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||
|
||||
# Remove ext-a
|
||||
manager.remove("ext-a", keep_config=False)
|
||||
|
||||
# ext-a skills gone, ext-b skills preserved
|
||||
assert not (skills_dir / "speckit-ext-a-hello").exists()
|
||||
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||
|
||||
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
|
||||
"""Commands with invalid YAML frontmatter should still produce valid skills."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
ext_dir = temp_dir / "badfm-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "badfm-ext",
|
||||
"name": "Bad Frontmatter Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.badfm-ext.broken",
|
||||
"file": "commands/broken.md",
|
||||
"description": "Broken frontmatter",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
# Malformed YAML: invalid key-value syntax
|
||||
(ext_dir / "commands" / "broken.md").write_text(
|
||||
"---\n"
|
||||
"description: [invalid yaml\n"
|
||||
" unclosed: bracket\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Broken Command\n"
|
||||
"\n"
|
||||
"This body should still be used.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Should not raise
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
# Fallback description since frontmatter was invalid
|
||||
assert "Extension command: speckit.badfm-ext.broken" in content
|
||||
assert "This body should still be used." in content
|
||||
|
||||
def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir):
|
||||
"""Skills should be cleaned up even if init-options.json is deleted after install."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
# Delete init-options.json to simulate user change
|
||||
init_opts = project_dir / ".specify" / "init-options.json"
|
||||
init_opts.unlink()
|
||||
|
||||
# Remove should still clean up via fallback scan
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
|
||||
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Verify skills exist
|
||||
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||
|
||||
# Toggle ai_skills to false
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
|
||||
# Remove should still clean up via fallback scan
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
@@ -22,7 +22,6 @@ from specify_cli.extensions import (
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
CommandRegistrar,
|
||||
HookExecutor,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
@@ -760,81 +759,6 @@ $ARGUMENTS
|
||||
assert "Prüfe Konformität" in output
|
||||
assert "\\u" not in output
|
||||
|
||||
def test_adjust_script_paths_does_not_mutate_input(self):
|
||||
"""Path adjustments should not mutate caller-owned frontmatter dicts."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": "../../scripts/bash/setup-plan.sh {ARGS}",
|
||||
"ps": "../../scripts/powershell/setup-plan.ps1 {ARGS}",
|
||||
}
|
||||
}
|
||||
before = json.loads(json.dumps(original))
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original)
|
||||
|
||||
assert original == before
|
||||
assert adjusted["scripts"]["sh"] == ".specify/scripts/bash/setup-plan.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_adjust_script_paths_preserves_extension_local_paths(self):
|
||||
"""Extension-local script paths should not be rewritten into .specify/.specify."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": ".specify/extensions/test-ext/scripts/setup.sh {ARGS}",
|
||||
"ps": "scripts/powershell/setup-plan.ps1 {ARGS}",
|
||||
}
|
||||
}
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original)
|
||||
|
||||
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
|
||||
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
body = (
|
||||
"Read `.specify/extensions/test-ext/templates/spec.md`\n"
|
||||
"Run scripts/bash/setup-plan.sh\n"
|
||||
)
|
||||
|
||||
rewritten = AgentCommandRegistrar._rewrite_project_relative_paths(body)
|
||||
|
||||
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
|
||||
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
|
||||
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
'line1\n"""danger"""\nline2',
|
||||
"extension:test-ext",
|
||||
)
|
||||
|
||||
assert "prompt = '''" in output
|
||||
assert '"""danger"""' in output
|
||||
|
||||
def test_render_toml_command_escapes_when_both_triple_quote_styles_exist(self):
|
||||
"""If body has both triple quote styles, fall back to escaped basic string."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": "x"},
|
||||
'a """ b\nc \'\'\' d',
|
||||
"extension:test-ext",
|
||||
)
|
||||
|
||||
assert 'prompt = "' in output
|
||||
assert "\\n" in output
|
||||
assert "\\\"\\\"\\\"" in output
|
||||
|
||||
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||
"""Test registering commands for Claude agent."""
|
||||
# Create .claude directory
|
||||
@@ -951,11 +875,11 @@ $ARGUMENTS
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-hello" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test.hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
assert "name: speckit-test-hello" in content
|
||||
assert "name: speckit-test.hello" in content
|
||||
assert "description: Test hello command" in content
|
||||
assert "compatibility:" in content
|
||||
assert "metadata:" in content
|
||||
@@ -1020,7 +944,7 @@ Agent __AGENT__
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-test.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -1070,12 +994,12 @@ Agent __AGENT__
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
primary = skills_dir / "speckit-alias-cmd" / "SKILL.md"
|
||||
primary = skills_dir / "speckit-alias.cmd" / "SKILL.md"
|
||||
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||
|
||||
assert primary.exists()
|
||||
assert alias.exists()
|
||||
assert "name: speckit-alias-cmd" in primary.read_text()
|
||||
assert "name: speckit-alias.cmd" in primary.read_text()
|
||||
assert "name: speckit-shortcut" in alias.read_text()
|
||||
|
||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||
@@ -1132,7 +1056,7 @@ Then {AGENT_SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-fallback-plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-fallback.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -1141,62 +1065,6 @@ Then {AGENT_SCRIPT}
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
|
||||
|
||||
def test_codex_skill_registration_handles_non_dict_init_options(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Non-dict init-options payloads should not crash skill placeholder resolution."""
|
||||
import yaml
|
||||
|
||||
ext_dir = temp_dir / "ext-script-list-init"
|
||||
ext_dir.mkdir()
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-script-list-init",
|
||||
"name": "List init options",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.list.plan",
|
||||
"file": "commands/plan.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands" / "plan.md").write_text(
|
||||
"""---
|
||||
description: "List init scripted command"
|
||||
scripts:
|
||||
sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"
|
||||
---
|
||||
|
||||
Run {SCRIPT}
|
||||
"""
|
||||
)
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
content = (skills_dir / "speckit-list-plan" / "SKILL.md").read_text()
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
|
||||
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
@@ -1253,7 +1121,7 @@ Then {AGENT_SCRIPT}
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
||||
|
||||
skill_file = skills_dir / "speckit-windows-plan" / "SKILL.md"
|
||||
skill_file = skills_dir / "speckit-windows.plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
|
||||
content = skill_file.read_text()
|
||||
@@ -3363,128 +3231,3 @@ class TestExtensionPriorityBackwardsCompatibility:
|
||||
assert result[0][0] == "ext-with-priority"
|
||||
assert result[1][0] == "legacy-ext"
|
||||
assert result[2][0] == "ext-low-priority"
|
||||
|
||||
|
||||
class TestHookInvocationRendering:
|
||||
"""Test hook invocation formatting for different agent modes."""
|
||||
|
||||
def test_kimi_hooks_render_skill_invocation(self, project_dir):
|
||||
"""Kimi projects should render /skill:speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/skill:speckit-plan`" in message
|
||||
assert "EXECUTE_COMMAND: speckit.plan" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
||||
|
||||
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
||||
"""Codex projects with --ai-skills should render $speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.tasks",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "$speckit-tasks"
|
||||
|
||||
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
||||
"""Custom hook commands should keep slash invocation style."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "pre_tasks_test",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/pre_tasks_test`" in message
|
||||
assert "EXECUTE_COMMAND: pre_tasks_test" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message
|
||||
|
||||
def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir):
|
||||
"""Multi-segment extension command ids should map to hyphenated skills."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"after_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.test.hello",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/skill:speckit-test-hello`" in message
|
||||
assert "EXECUTE_COMMAND: speckit.test.hello" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message
|
||||
|
||||
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
|
||||
"""Init options should be loaded once per executor instance."""
|
||||
calls = {"count": 0}
|
||||
|
||||
def fake_load_init_options(_project_root):
|
||||
calls["count"] += 1
|
||||
return {"ai": "kimi", "ai_skills": False}
|
||||
|
||||
monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options)
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan"
|
||||
assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
|
||||
assert calls["count"] == 1
|
||||
|
||||
def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
|
||||
"""Hook messages should still render actionable command placeholders."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
message = hook_executor.format_hook_message(
|
||||
"after_tasks",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": None,
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/<missing command>`" in message
|
||||
assert "EXECUTE_COMMAND: <missing command>" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" in message
|
||||
|
||||
@@ -1170,12 +1170,8 @@ class TestPresetCatalog:
|
||||
assert not catalog.cache_file.exists()
|
||||
assert not catalog.cache_metadata_file.exists()
|
||||
|
||||
def test_search_with_cached_data(self, project_dir, monkeypatch):
|
||||
def test_search_with_cached_data(self, project_dir):
|
||||
"""Test search with cached catalog data."""
|
||||
from unittest.mock import patch
|
||||
|
||||
# Only use the default catalog to prevent fetching the community catalog from the network
|
||||
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1204,26 +1200,23 @@ class TestPresetCatalog:
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}))
|
||||
|
||||
# Isolate from community catalog so results are deterministic
|
||||
default_only = [PresetCatalogEntry(url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True)]
|
||||
with patch.object(catalog, "get_active_catalogs", return_value=default_only):
|
||||
# Search by query
|
||||
results = catalog.search(query="agile")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "safe-agile"
|
||||
# Search by query
|
||||
results = catalog.search(query="agile")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "safe-agile"
|
||||
|
||||
# Search by tag
|
||||
results = catalog.search(tag="hipaa")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "healthcare"
|
||||
# Search by tag
|
||||
results = catalog.search(tag="hipaa")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "healthcare"
|
||||
|
||||
# Search by author
|
||||
results = catalog.search(author="agile-community")
|
||||
assert len(results) == 1
|
||||
# Search by author
|
||||
results = catalog.search(author="agile-community")
|
||||
assert len(results) == 1
|
||||
|
||||
# Search all
|
||||
results = catalog.search()
|
||||
assert len(results) == 2
|
||||
# Search all
|
||||
results = catalog.search()
|
||||
assert len(results) == 2
|
||||
|
||||
def test_get_pack_info(self, project_dir):
|
||||
"""Test getting info for a specific pack."""
|
||||
@@ -1942,10 +1935,10 @@ class TestInitOptions:
|
||||
class TestPresetSkills:
|
||||
"""Tests for preset skill registration and unregistration."""
|
||||
|
||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True):
|
||||
from specify_cli import save_init_options
|
||||
|
||||
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
|
||||
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills})
|
||||
|
||||
def _create_skill(self, skills_dir, skill_name, body="original body"):
|
||||
skill_dir = skills_dir / skill_name
|
||||
@@ -1995,26 +1988,6 @@ class TestPresetSkills:
|
||||
content = skill_file.read_text()
|
||||
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
|
||||
|
||||
def test_get_skills_dir_returns_none_for_non_string_ai(self, project_dir):
|
||||
"""Corrupted init-options ai values should not crash preset skill resolution."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text('{"ai":["codex"],"ai_skills":true,"script":"sh"}')
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
assert manager._get_skills_dir() is None
|
||||
|
||||
def test_get_skills_dir_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted non-dict init-options payloads should fail closed."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
assert manager._get_skills_dir() is None
|
||||
|
||||
def test_skill_not_updated_without_init_options(self, project_dir, temp_dir):
|
||||
"""When no init-options.json exists, preset install should not touch skills."""
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
@@ -2060,52 +2033,6 @@ class TestPresetSkills:
|
||||
assert "preset:self-test" not in content, "Preset content should be gone"
|
||||
assert "templates/commands/specify.md" in content, "Should reference core template"
|
||||
|
||||
def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir):
|
||||
"""Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths."""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True, script="sh")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="old")
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
(core_cmds / "specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Core specify command\n"
|
||||
"scripts:\n"
|
||||
" sh: .specify/scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"Run:\n"
|
||||
"{SCRIPT}\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
manager.remove("self-test")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
||||
|
||||
def test_skill_not_overridden_when_skill_path_is_file(self, project_dir):
|
||||
"""Preset install should skip non-directory skill targets."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skills_dir / "speckit-specify").write_text("not-a-directory")
|
||||
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
|
||||
|
||||
assert (skills_dir / "speckit-specify").is_file()
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" not in metadata.get("registered_skills", [])
|
||||
|
||||
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
|
||||
"""Skills should not be created when no existing skill dir is found."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
@@ -2120,304 +2047,6 @@ class TestPresetSkills:
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert metadata.get("registered_skills", []) == []
|
||||
|
||||
def test_extension_skill_override_matches_hyphenated_multisegment_name(self, project_dir, temp_dir):
|
||||
"""Preset overrides for speckit.<ext>.<cmd> should target speckit-<ext>-<cmd> skills."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="untouched")
|
||||
(project_dir / ".specify" / "extensions" / "fakeext").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-override"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-override\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-override",
|
||||
"name": "Ext Skill Override",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:ext-skill-override" in content
|
||||
assert "name: speckit-fakeext-cmd" in content
|
||||
assert "# Speckit Fakeext Cmd Skill" in content
|
||||
|
||||
metadata = manager.registry.get("ext-skill-override")
|
||||
assert "speckit-fakeext-cmd" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_extension_skill_restored_on_preset_remove(self, project_dir, temp_dir):
|
||||
"""Preset removal should restore an extension-backed skill instead of deleting it."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-fakeext-cmd", body="original extension skill")
|
||||
|
||||
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
|
||||
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(extension_dir / "commands" / "cmd.md").write_text(
|
||||
"---\n"
|
||||
"description: Extension fakeext cmd\n"
|
||||
"scripts:\n"
|
||||
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"extension:fakeext\n"
|
||||
"Run {SCRIPT}\n"
|
||||
)
|
||||
extension_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "fakeext",
|
||||
"name": "Fake Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/cmd.md",
|
||||
"description": "Fake extension command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(extension_dir / "extension.yml", "w") as f:
|
||||
yaml.dump(extension_manifest, f)
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-restore"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-restore\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-restore",
|
||||
"name": "Ext Skill Restore",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-fakeext-cmd" / "SKILL.md"
|
||||
assert "preset:ext-skill-restore" in skill_file.read_text()
|
||||
|
||||
manager.remove("ext-skill-restore")
|
||||
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:ext-skill-restore" not in content
|
||||
assert "source: extension:fakeext" in content
|
||||
assert "extension:fakeext" in content
|
||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||
assert "# Fakeext Cmd Skill" in content
|
||||
|
||||
def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, temp_dir):
|
||||
"""Preset removal should not delete arbitrary directories missing SKILL.md."""
|
||||
self._write_init_options(project_dir, ai="codex")
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
stray_skill_dir = skills_dir / "speckit-fakeext-cmd"
|
||||
stray_skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
note_file = stray_skill_dir / "notes.txt"
|
||||
note_file.write_text("user content", encoding="utf-8")
|
||||
|
||||
preset_dir = temp_dir / "ext-skill-missing-file"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.fakeext.cmd.md").write_text(
|
||||
"---\ndescription: Override fakeext cmd\n---\n\npreset:ext-skill-missing-file\n"
|
||||
)
|
||||
preset_manifest = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "ext-skill-missing-file",
|
||||
"name": "Ext Skill Missing File",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.fakeext.cmd",
|
||||
"file": "commands/speckit.fakeext.cmd.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(preset_manifest, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
installed_preset_dir = manager.presets_dir / "ext-skill-missing-file"
|
||||
shutil.copytree(preset_dir, installed_preset_dir)
|
||||
manager.registry.add(
|
||||
"ext-skill-missing-file",
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"source": str(preset_dir),
|
||||
"provides_templates": ["speckit.fakeext.cmd"],
|
||||
"registered_skills": ["speckit-fakeext-cmd"],
|
||||
"priority": 10,
|
||||
},
|
||||
)
|
||||
|
||||
manager.remove("ext-skill-missing-file")
|
||||
|
||||
assert stray_skill_dir.is_dir()
|
||||
assert note_file.read_text(encoding="utf-8") == "user content"
|
||||
|
||||
def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir):
|
||||
"""Preset overrides should still target legacy dotted Kimi skill directories."""
|
||||
self._write_init_options(project_dir, ai="kimi")
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit.specify", body="untouched")
|
||||
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "name: speckit.specify" in content
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit.specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""Kimi presets should still propagate command overrides to existing skills."""
|
||||
self._write_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content
|
||||
assert "name: speckit-specify" in content
|
||||
|
||||
metadata = manager.registry.get("self-test")
|
||||
assert "speckit-specify" in metadata.get("registered_skills", [])
|
||||
|
||||
def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Kimi preset skill overrides should resolve placeholders and rewrite project paths."""
|
||||
self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh")
|
||||
skills_dir = project_dir / ".kimi" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = temp_dir / "kimi-placeholder-override"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
||||
"---\n"
|
||||
"description: Kimi placeholder override\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/create-new-feature.sh --json \"{ARGS}\"\n"
|
||||
"---\n\n"
|
||||
"Execute `{SCRIPT}` for __AGENT__\n"
|
||||
"Review templates/checklist.md and memory/constitution.md\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "kimi-placeholder-override",
|
||||
"name": "Kimi Placeholder Override",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.specify",
|
||||
"file": "commands/speckit.specify.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert ".specify/scripts/bash/create-new-feature.sh --json \"$ARGUMENTS\"" in content
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
assert "for kimi" in content
|
||||
|
||||
def test_preset_skill_registration_handles_non_dict_init_options(self, project_dir, temp_dir):
|
||||
"""Non-dict init-options payloads should not crash preset install/remove flows."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text("[]")
|
||||
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
(project_dir / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
|
||||
manager.install_from_directory(self_test_dir, "0.1.5")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "untouched" in content
|
||||
|
||||
|
||||
class TestPresetSetPriority:
|
||||
"""Test preset set-priority CLI command."""
|
||||
|
||||
@@ -14,7 +14,6 @@ import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
||||
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
|
||||
|
||||
@@ -148,24 +147,6 @@ class TestSequentialBranch:
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
|
||||
|
||||
def test_sequential_supports_four_digit_prefixes(self, git_repo: Path):
|
||||
"""Sequential numbering should continue past 999 without truncation."""
|
||||
(git_repo / "specs" / "999-last-3digit").mkdir(parents=True)
|
||||
(git_repo / "specs" / "1000-first-4digit").mkdir(parents=True)
|
||||
result = run_script(git_repo, "--short-name", "next-feat", "Next feature")
|
||||
assert result.returncode == 0, result.stderr
|
||||
branch = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("BRANCH_NAME:"):
|
||||
branch = line.split(":", 1)[1].strip()
|
||||
assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"
|
||||
|
||||
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
|
||||
"""PowerShell scanner should parse large prefixes without [int] casts."""
|
||||
content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
|
||||
assert "[long]::TryParse($matches[1], [ref]$num)" in content
|
||||
assert "$num = [int]$matches[1]" not in content
|
||||
|
||||
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user