Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
b93b2b7461 fix: move 0.4.0 section above 0.3.2 to restore newest-first ordering in CHANGELOG.md
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/1c1787ad-64df-4b8c-bd32-0bc5198f5029
2026-03-23 14:30:37 +00:00
copilot-swe-agent[bot]
a88083d67c Initial plan 2026-03-23 14:29:24 +00:00
github-actions[bot]
8e2449f6d4 chore: bump version to 0.4.0 2026-03-23 14:25:41 +00:00
31 changed files with 468 additions and 3698 deletions

View File

@@ -64,5 +64,5 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@v4

View File

@@ -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"

View File

@@ -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"

View File

@@ -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" ;;

File diff suppressed because it is too large Load Diff

View File

@@ -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
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](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

View File

@@ -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)
~~~

View File

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

View File

@@ -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:

View File

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

View File

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

View File

@@ -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",

View File

@@ -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": {}
}

View File

@@ -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 = [

View File

@@ -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() {

View File

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

View File

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

View File

@@ -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"
}

View File

@@ -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)")

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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.

View File

@@ -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"]

View File

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

View File

@@ -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:

View File

@@ -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()

View File

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

View File

@@ -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."""

View File

@@ -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 ───────────────────────────────────────────────