mirror of
https://github.com/github/spec-kit.git
synced 2026-03-27 07:43:08 +00:00
Compare commits
18 Commits
copilot/ex
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aff14ea5a0 | ||
|
|
ccc44dd00a | ||
|
|
2c2fea8783 | ||
|
|
4b4bd735a3 | ||
|
|
36019ebf1b | ||
|
|
fb152eb824 | ||
|
|
00e5dc1f91 | ||
|
|
eeda669c19 | ||
|
|
ebc61067e8 | ||
|
|
2c2936022c | ||
|
|
816c1160e9 | ||
|
|
bc766c3101 | ||
|
|
f132f748e3 | ||
|
|
ee65758e2b | ||
|
|
a01180955d | ||
|
|
b1ba972978 | ||
|
|
24247c24c9 | ||
|
|
dc7f09a711 |
22
.github/workflows/release-trigger.yml
vendored
22
.github/workflows/release-trigger.yml
vendored
@@ -100,18 +100,16 @@ jobs:
|
|||||||
COMMITS="- Initial release"
|
COMMITS="- Initial release"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create new changelog entry
|
# Create new changelog entry — insert after the marker comment
|
||||||
{
|
NEW_ENTRY=$(printf '%s\n' \
|
||||||
head -n 8 CHANGELOG.md
|
"" \
|
||||||
echo ""
|
"## [${{ steps.version.outputs.version }}] - $DATE" \
|
||||||
echo "## [${{ steps.version.outputs.version }}] - $DATE"
|
"" \
|
||||||
echo ""
|
"### Changed" \
|
||||||
echo "### Changes"
|
"" \
|
||||||
echo ""
|
"$COMMITS")
|
||||||
echo "$COMMITS"
|
|
||||||
echo ""
|
awk -v entry="$NEW_ENTRY" '/<!-- insert new changelog below this comment -->/ { print; print entry; next } {print}' CHANGELOG.md > CHANGELOG.md.tmp
|
||||||
tail -n +9 CHANGELOG.md
|
|
||||||
} > CHANGELOG.md.tmp
|
|
||||||
mv CHANGELOG.md.tmp CHANGELOG.md
|
mv CHANGELOG.md.tmp CHANGELOG.md
|
||||||
|
|
||||||
echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG"
|
echo "✅ Updated CHANGELOG.md with commits since $PREVIOUS_TAG"
|
||||||
|
|||||||
@@ -202,8 +202,7 @@ agent: $basename
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
# Create skills in <skills_dir>\<name>\SKILL.md format.
|
||||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
# Skills use hyphenated names (e.g. speckit-plan).
|
||||||
# current dotted-name exception (e.g. speckit.plan).
|
|
||||||
#
|
#
|
||||||
# Technical debt note:
|
# Technical debt note:
|
||||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
||||||
@@ -463,7 +462,7 @@ function Build-Variant {
|
|||||||
'kimi' {
|
'kimi' {
|
||||||
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
$skillsDir = Join-Path $baseDir ".kimi/skills"
|
||||||
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $skillsDir | Out-Null
|
||||||
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi' -Separator '.'
|
New-Skills -SkillsDir $skillsDir -ScriptVariant $Script -AgentName 'kimi'
|
||||||
}
|
}
|
||||||
'trae' {
|
'trae' {
|
||||||
$rulesDir = Join-Path $baseDir ".trae/rules"
|
$rulesDir = Join-Path $baseDir ".trae/rules"
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
# Create skills in <skills_dir>/<name>/SKILL.md format.
|
||||||
# Most agents use hyphenated names (e.g. speckit-plan); Kimi is the
|
# Skills use hyphenated names (e.g. speckit-plan).
|
||||||
# current dotted-name exception (e.g. speckit.plan).
|
|
||||||
#
|
#
|
||||||
# Technical debt note:
|
# Technical debt note:
|
||||||
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
# Keep SKILL.md frontmatter aligned with `install_ai_skills()` and extension
|
||||||
@@ -321,7 +320,7 @@ build_variant() {
|
|||||||
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
generate_commands vibe md "\$ARGUMENTS" "$base_dir/.vibe/prompts" "$script" ;;
|
||||||
kimi)
|
kimi)
|
||||||
mkdir -p "$base_dir/.kimi/skills"
|
mkdir -p "$base_dir/.kimi/skills"
|
||||||
create_skills "$base_dir/.kimi/skills" "$script" "kimi" "." ;;
|
create_skills "$base_dir/.kimi/skills" "$script" "kimi" ;;
|
||||||
trae)
|
trae)
|
||||||
mkdir -p "$base_dir/.trae/rules"
|
mkdir -p "$base_dir/.trae/rules"
|
||||||
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
generate_commands trae md "\$ARGUMENTS" "$base_dir/.trae/rules" "$script" ;;
|
||||||
|
|||||||
1081
CHANGELOG.md
1081
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
67
README.md
67
README.md
@@ -22,7 +22,10 @@
|
|||||||
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
|
- [🤔 What is Spec-Driven Development?](#-what-is-spec-driven-development)
|
||||||
- [⚡ Get Started](#-get-started)
|
- [⚡ Get Started](#-get-started)
|
||||||
- [📽️ Video Overview](#️-video-overview)
|
- [📽️ Video Overview](#️-video-overview)
|
||||||
|
- [🧩 Community Extensions](#-community-extensions)
|
||||||
|
- [🎨 Community Presets](#-community-presets)
|
||||||
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
- [🚶 Community Walkthroughs](#-community-walkthroughs)
|
||||||
|
- [🛠️ Community Friends](#️-community-friends)
|
||||||
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
- [🤖 Supported AI Agents](#-supported-ai-agents)
|
||||||
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
- [🔧 Specify CLI Reference](#-specify-cli-reference)
|
||||||
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
- [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets)
|
||||||
@@ -155,6 +158,56 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c
|
|||||||
|
|
||||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
[](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
|
## 🚶 Community Walkthroughs
|
||||||
|
|
||||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||||
@@ -171,6 +224,16 @@ 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 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
|
## 🤖 Supported AI Agents
|
||||||
|
|
||||||
| Agent | Support | Notes |
|
| Agent | Support | Notes |
|
||||||
@@ -229,7 +292,7 @@ The `specify` command supports the following options:
|
|||||||
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
|
||||||
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
| `--debug` | Flag | Enable detailed debug output for troubleshooting |
|
||||||
| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) |
|
| `--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`) |
|
| `--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. |
|
||||||
| `--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 |
|
| `--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
|
### Examples
|
||||||
@@ -380,7 +443,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.
|
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, the complete community catalog, and how to build and publish your own.
|
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.
|
||||||
|
|
||||||
### Presets — Customize Existing Workflows
|
### Presets — Customize Existing Workflows
|
||||||
|
|
||||||
|
|||||||
79
TESTING.md
Normal file
79
TESTING.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Manual Testing Guide
|
||||||
|
|
||||||
|
Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing.
|
||||||
|
2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)).
|
||||||
|
3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated).
|
||||||
|
4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order.
|
||||||
|
5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the CLI from your local branch
|
||||||
|
cd <spec-kit-repo>
|
||||||
|
uv venv .venv
|
||||||
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||||
|
uv pip install -e .
|
||||||
|
|
||||||
|
# Initialize a test project using your local changes
|
||||||
|
specify init /tmp/speckit-test --ai <agent> --offline
|
||||||
|
cd /tmp/speckit-test
|
||||||
|
|
||||||
|
# Open in your agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reporting results
|
||||||
|
|
||||||
|
Paste this into your PR:
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
## Manual test results
|
||||||
|
|
||||||
|
**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh]
|
||||||
|
|
||||||
|
| Command tested | Notes |
|
||||||
|
|----------------|-------|
|
||||||
|
| `/speckit.command` | |
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Determining which tests to run
|
||||||
|
|
||||||
|
Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR.
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Read TESTING.md, then run `git diff --name-only main` to get my changed files.
|
||||||
|
For each changed file, determine which slash commands it affects by reading
|
||||||
|
the command templates in templates/commands/ to understand what each command
|
||||||
|
invokes. Use these mapping rules:
|
||||||
|
|
||||||
|
- templates/commands/X.md → the command it defines
|
||||||
|
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
|
||||||
|
- templates/Z-template.md → every command that consumes that template during execution
|
||||||
|
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
|
||||||
|
- extensions/X/commands/* → the extension command it defines
|
||||||
|
- extensions/X/scripts/* → every extension command that invokes that script
|
||||||
|
- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected
|
||||||
|
- presets/*/* → test preset scaffolding via `specify init` with the preset
|
||||||
|
- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets
|
||||||
|
|
||||||
|
Include prerequisite tests (e.g., T5 requires T3 requires T1).
|
||||||
|
|
||||||
|
Output in this format:
|
||||||
|
|
||||||
|
### Test selection reasoning
|
||||||
|
|
||||||
|
| Changed file | Affects | Test | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| (path) | (command) | T# | (reason) |
|
||||||
|
|
||||||
|
### Required tests
|
||||||
|
|
||||||
|
Number each test sequentially (T1, T2, ...). List prerequisite tests first.
|
||||||
|
|
||||||
|
- T1: /speckit.command — (reason)
|
||||||
|
- T2: /speckit.command — (reason)
|
||||||
|
~~~
|
||||||
@@ -523,7 +523,7 @@ Submit to the community catalog for public discovery:
|
|||||||
|
|
||||||
1. **Fork** spec-kit repository
|
1. **Fork** spec-kit repository
|
||||||
2. **Add entry** to `extensions/catalog.community.json`
|
2. **Add entry** to `extensions/catalog.community.json`
|
||||||
3. **Update** `extensions/README.md` with your extension
|
3. **Update** the Community Extensions table in `README.md` with your extension
|
||||||
4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
|
4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md)
|
||||||
5. **After merge**, your extension becomes available:
|
5. **After merge**, your extension becomes available:
|
||||||
- Users can browse `catalog.community.json` to discover your extension
|
- Users can browse `catalog.community.json` to discover your extension
|
||||||
|
|||||||
@@ -204,9 +204,9 @@ Edit `extensions/catalog.community.json` and add your extension:
|
|||||||
- Use current timestamp for `created_at` and `updated_at`
|
- Use current timestamp for `created_at` and `updated_at`
|
||||||
- Update the top-level `updated_at` to current time
|
- Update the top-level `updated_at` to current time
|
||||||
|
|
||||||
### 3. Update Extensions README
|
### 3. Update Community Extensions Table
|
||||||
|
|
||||||
Add your extension to the Available Extensions table in `extensions/README.md`:
|
Add your extension to the Community Extensions table in the project root `README.md`:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
| Your Extension Name | Brief description of what it does | `<category>` | <effect> | [repo-name](https://github.com/your-org/spec-kit-your-extension) |
|
| 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
|
git checkout -b add-your-extension
|
||||||
|
|
||||||
# Commit your changes
|
# Commit your changes
|
||||||
git add extensions/catalog.community.json extensions/README.md
|
git add extensions/catalog.community.json README.md
|
||||||
git commit -m "Add your-extension to community catalog
|
git commit -m "Add your-extension to community catalog
|
||||||
|
|
||||||
- Extension ID: your-extension
|
- Extension ID: your-extension
|
||||||
@@ -273,7 +273,7 @@ Brief description of what your extension does.
|
|||||||
- [x] All commands working
|
- [x] All commands working
|
||||||
- [x] No security vulnerabilities
|
- [x] No security vulnerabilities
|
||||||
- [x] Added to extensions/catalog.community.json
|
- [x] Added to extensions/catalog.community.json
|
||||||
- [x] Added to extensions/README.md Available Extensions table
|
- [x] Added to Community Extensions table in README.md
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
Tested on:
|
Tested on:
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ Provided commands:
|
|||||||
Check: .specify/extensions/jira/
|
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
|
## Using Extensions
|
||||||
|
|||||||
@@ -68,36 +68,9 @@ specify extension add --from https://github.com/org/spec-kit-ext/archive/refs/ta
|
|||||||
|
|
||||||
## Available Community Extensions
|
## Available Community Extensions
|
||||||
|
|
||||||
The following community-contributed extensions are available in [`catalog.community.json`](catalog.community.json):
|
See the [Community Extensions](../README.md#-community-extensions) section in the main README for the full list of available community-contributed extensions.
|
||||||
|
|
||||||
**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
|
For the raw catalog data, see [`catalog.community.json`](catalog.community.json).
|
||||||
|
|
||||||
**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
|
## Adding Your Extension
|
||||||
|
|||||||
@@ -3,6 +3,39 @@
|
|||||||
"updated_at": "2026-03-19T12:08:20Z",
|
"updated_at": "2026-03-19T12:08:20Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
|
"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": {
|
"archive": {
|
||||||
"name": "Archive Extension",
|
"name": "Archive Extension",
|
||||||
"id": "archive",
|
"id": "archive",
|
||||||
@@ -73,6 +106,35 @@
|
|||||||
"created_at": "2026-03-03T00:00:00Z",
|
"created_at": "2026-03-03T00:00:00Z",
|
||||||
"updated_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": {
|
"cleanup": {
|
||||||
"name": "Cleanup Extension",
|
"name": "Cleanup Extension",
|
||||||
"id": "cleanup",
|
"id": "cleanup",
|
||||||
@@ -252,6 +314,37 @@
|
|||||||
"created_at": "2026-03-13T00:00:00Z",
|
"created_at": "2026-03-13T00:00:00Z",
|
||||||
"updated_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": {
|
"fleet": {
|
||||||
"name": "Fleet Orchestrator",
|
"name": "Fleet Orchestrator",
|
||||||
"id": "fleet",
|
"id": "fleet",
|
||||||
@@ -344,6 +437,37 @@
|
|||||||
"created_at": "2026-03-05T00:00:00Z",
|
"created_at": "2026-03-05T00:00:00Z",
|
||||||
"updated_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": {
|
"ralph": {
|
||||||
"name": "Ralph Loop",
|
"name": "Ralph Loop",
|
||||||
"id": "ralph",
|
"id": "ralph",
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Git Branching Workflow Extension
|
|
||||||
|
|
||||||
Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection for Spec Kit.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This extension provides Git branching operations as an optional, self-contained module. It manages:
|
|
||||||
|
|
||||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
|
||||||
- **Branch validation** to ensure branches follow naming conventions
|
|
||||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
|
||||||
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
|
||||||
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
|
||||||
|
|
||||||
## Hooks
|
|
||||||
|
|
||||||
| Event | Command | Optional | Description |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
|
||||||
| `after_implement` | `speckit.git.validate` | Yes | Validate branch naming after implementation |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Branch numbering strategy: "sequential" or "timestamp"
|
|
||||||
branch_numbering: sequential
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install from the bundled extension directory
|
|
||||||
specify extension add extensions/git --dev
|
|
||||||
|
|
||||||
# Or it auto-installs during specify init (migration period)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Disabling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Disable the git extension (spec creation continues without branching)
|
|
||||||
specify extension disable git
|
|
||||||
|
|
||||||
# Re-enable it
|
|
||||||
specify extension enable git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
When Git is not installed or the directory is not a Git repository:
|
|
||||||
- Spec directories are still created under `specs/`
|
|
||||||
- Branch creation is skipped with a warning
|
|
||||||
- Branch validation is skipped with a warning
|
|
||||||
- Remote detection returns empty results
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
The extension bundles cross-platform scripts:
|
|
||||||
|
|
||||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
|
||||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
|
||||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
|
||||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create a new feature branch for the given specification.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation (spec directory will still be created)
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
|
|
||||||
|
|
||||||
If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to:
|
|
||||||
- **Bash**: `scripts/bash/create-new-feature.sh`
|
|
||||||
- **PowerShell**: `scripts/powershell/create-new-feature.ps1`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- The script will still create the spec directory under `specs/`
|
|
||||||
- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The workflow continues normally without branch creation
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `SPEC_FILE`: Path to the created spec file
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3}-` (e.g., `001-feature-name`, `042-fix-bug`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the `###` portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Git Branching Workflow Extension Configuration
|
|
||||||
# Copy this file to .specify/extensions/git/git-config.yml to customize
|
|
||||||
|
|
||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
|
||||||
branch_numbering: sequential
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
schema_version: "1.0"
|
|
||||||
|
|
||||||
extension:
|
|
||||||
id: git
|
|
||||||
name: "Git Branching Workflow"
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
|
||||||
author: spec-kit-core
|
|
||||||
repository: https://github.com/github/spec-kit
|
|
||||||
license: MIT
|
|
||||||
|
|
||||||
requires:
|
|
||||||
speckit_version: ">=0.2.0"
|
|
||||||
tools:
|
|
||||||
- name: git
|
|
||||||
required: false
|
|
||||||
|
|
||||||
provides:
|
|
||||||
commands:
|
|
||||||
- name: speckit.git.feature
|
|
||||||
file: commands/speckit.git.feature.md
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
- name: speckit.git.validate
|
|
||||||
file: commands/speckit.git.validate.md
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
- name: speckit.git.remote
|
|
||||||
file: commands/speckit.git.remote.md
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
|
|
||||||
hooks:
|
|
||||||
before_specify:
|
|
||||||
command: speckit.git.feature
|
|
||||||
optional: false
|
|
||||||
description: "Create feature branch before specification"
|
|
||||||
after_implement:
|
|
||||||
command: speckit.git.validate
|
|
||||||
optional: true
|
|
||||||
prompt: "Verify feature branch naming?"
|
|
||||||
description: "Validate branch naming after implementation"
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- "git"
|
|
||||||
- "branching"
|
|
||||||
- "workflow"
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
branch_numbering: sequential
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
JSON_MODE=false
|
|
||||||
SHORT_NAME=""
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
USE_TIMESTAMP=false
|
|
||||||
ARGS=()
|
|
||||||
i=1
|
|
||||||
while [ $i -le $# ]; do
|
|
||||||
arg="${!i}"
|
|
||||||
case "$arg" in
|
|
||||||
--json)
|
|
||||||
JSON_MODE=true
|
|
||||||
;;
|
|
||||||
--short-name)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
# Check if the next argument is another option (starts with --)
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SHORT_NAME="$next_arg"
|
|
||||||
;;
|
|
||||||
--number)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BRANCH_NUMBER="$next_arg"
|
|
||||||
;;
|
|
||||||
--timestamp)
|
|
||||||
USE_TIMESTAMP=true
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --json Output in JSON format"
|
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
echo " --help, -h Show this help message"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
|
||||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
ARGS+=("$arg")
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Usage: $0 [--json] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
|
||||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
|
||||||
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"
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
if [ -d "$specs_dir" ]; then
|
|
||||||
for dir in "$specs_dir"/*; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
dirname=$(basename "$dir")
|
|
||||||
# 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
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to get highest number from git branches
|
|
||||||
get_highest_from_branches() {
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
# Get all branches (local and remote)
|
|
||||||
branches=$(git branch -a 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -n "$branches" ]; then
|
|
||||||
while IFS= read -r branch; do
|
|
||||||
# Clean branch name: remove leading markers and remote prefixes
|
|
||||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
|
||||||
|
|
||||||
# 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
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$branches"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to check existing branches (local and remote) and return next available number
|
|
||||||
check_existing_branches() {
|
|
||||||
local specs_dir="$1"
|
|
||||||
|
|
||||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
|
||||||
git fetch --all --prune >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
# Get highest number from ALL branches (not just matching short name)
|
|
||||||
local highest_branch=$(get_highest_from_branches)
|
|
||||||
|
|
||||||
# Get highest number from ALL specs (not just matching short name)
|
|
||||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
|
||||||
|
|
||||||
# Take the maximum of both
|
|
||||||
local max_num=$highest_branch
|
|
||||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
|
||||||
max_num=$highest_spec
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Return next number
|
|
||||||
echo $((max_num + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to clean and format a branch name
|
|
||||||
clean_branch_name() {
|
|
||||||
local name="$1"
|
|
||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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 common.sh using the following priority:
|
|
||||||
# 1. common.sh next to this script (source checkout layout)
|
|
||||||
# 2. .specify/scripts/bash/common.sh under the project root (installed project)
|
|
||||||
# 3. scripts/bash/common.sh under the project root (source checkout fallback)
|
|
||||||
# 4. git-common.sh next to this script (minimal fallback)
|
|
||||||
_common_loaded=false
|
|
||||||
|
|
||||||
if [ -f "$SCRIPT_DIR/common.sh" ]; then
|
|
||||||
source "$SCRIPT_DIR/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
else
|
|
||||||
# When running from an extension install (.specify/extensions/git/scripts/bash/),
|
|
||||||
# resolve to .specify/ (4 levels up), then to the project root (5 levels up).
|
|
||||||
_dot_specify="$(cd "$SCRIPT_DIR/../../../.." 2>/dev/null && pwd)"
|
|
||||||
_project_root="$(cd "$SCRIPT_DIR/../../../../.." 2>/dev/null && pwd)"
|
|
||||||
|
|
||||||
if [ -n "$_dot_specify" ] && [ -f "$_dot_specify/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_dot_specify/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -n "$_project_root" ] && [ -f "$_project_root/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_project_root/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
|
||||||
source "$SCRIPT_DIR/git-common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_common_loaded" != "true" ]; then
|
|
||||||
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If only git-common.sh was loaded, verify that the required helpers
|
|
||||||
# (resolve_template, json_escape) are available. These are provided by the
|
|
||||||
# core common.sh; git-common.sh only supplies has_git / check_feature_branch.
|
|
||||||
if ! type resolve_template >/dev/null 2>&1 || ! type json_escape >/dev/null 2>&1; then
|
|
||||||
echo "Error: resolve_template/json_escape not defined. The core common.sh is required but could not be located." >&2
|
|
||||||
echo "Tried: $SCRIPT_DIR/common.sh, .specify/scripts/bash/common.sh, scripts/bash/common.sh" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
SPECS_DIR="$REPO_ROOT/specs"
|
|
||||||
mkdir -p "$SPECS_DIR"
|
|
||||||
|
|
||||||
# Function to generate branch name with stop word filtering and length filtering
|
|
||||||
generate_branch_name() {
|
|
||||||
local description="$1"
|
|
||||||
|
|
||||||
# Common stop words to filter out
|
|
||||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
|
||||||
|
|
||||||
# Convert to lowercase and split into words
|
|
||||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
|
||||||
|
|
||||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
|
||||||
local meaningful_words=()
|
|
||||||
for word in $clean_name; do
|
|
||||||
# Skip empty words
|
|
||||||
[ -z "$word" ] && continue
|
|
||||||
|
|
||||||
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
|
||||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
|
||||||
if [ ${#word} -ge 3 ]; then
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
|
||||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# If we have meaningful words, use first 3-4 of them
|
|
||||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
|
||||||
local max_words=3
|
|
||||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
|
||||||
|
|
||||||
local result=""
|
|
||||||
local count=0
|
|
||||||
for word in "${meaningful_words[@]}"; do
|
|
||||||
if [ $count -ge $max_words ]; then break; fi
|
|
||||||
if [ -n "$result" ]; then result="$result-"; fi
|
|
||||||
result="$result$word"
|
|
||||||
count=$((count + 1))
|
|
||||||
done
|
|
||||||
echo "$result"
|
|
||||||
else
|
|
||||||
# Fallback to original logic if no meaningful words found
|
|
||||||
local cleaned=$(clean_branch_name "$description")
|
|
||||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate branch name
|
|
||||||
if [ -n "$SHORT_NAME" ]; then
|
|
||||||
# Use provided short name, just clean it up
|
|
||||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
|
||||||
else
|
|
||||||
# Generate from description with smart filtering
|
|
||||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Warn if --number and --timestamp are both specified
|
|
||||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
|
||||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine branch prefix
|
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
else
|
|
||||||
# Determine branch number
|
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
|
||||||
if [ "$HAS_GIT" = true ]; then
|
|
||||||
# Check existing branches on remotes
|
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
|
||||||
else
|
|
||||||
# Fall back to local directory check
|
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
|
||||||
# Validate and truncate if necessary
|
|
||||||
MAX_BRANCH_LENGTH=244
|
|
||||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
|
||||||
# Calculate how much we need to trim from suffix
|
|
||||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
|
||||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
|
||||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
|
||||||
|
|
||||||
# Truncate suffix at word boundary if possible
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
|
||||||
# Remove trailing hyphen if truncation created one
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
|
||||||
|
|
||||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
|
||||||
|
|
||||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
|
||||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$HAS_GIT" = true ]; then
|
|
||||||
if ! git checkout -q -b "$BRANCH_NAME" >/dev/null 2>&1; then
|
|
||||||
# Check if branch already exists
|
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'. Please check your git configuration and try again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
|
||||||
mkdir -p "$FEATURE_DIR"
|
|
||||||
|
|
||||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
|
||||||
SPEC_FILE="$FEATURE_DIR/spec.md"
|
|
||||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
|
||||||
cp "$TEMPLATE" "$SPEC_FILE"
|
|
||||||
else
|
|
||||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
|
||||||
touch "$SPEC_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Inform the user how to persist the feature variable in their own shell
|
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
|
||||||
|
|
||||||
if $JSON_MODE; then
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
jq -cn \
|
|
||||||
--arg branch_name "$BRANCH_NAME" \
|
|
||||||
--arg spec_file "$SPEC_FILE" \
|
|
||||||
--arg feature_num "$FEATURE_NUM" \
|
|
||||||
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
|
||||||
else
|
|
||||||
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
|
||||||
echo "SPEC_FILE: $SPEC_FILE"
|
|
||||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
|
||||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/bash/common.sh — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
# Check if we have git available
|
|
||||||
has_git() {
|
|
||||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate that a branch name matches the expected feature branch pattern.
|
|
||||||
# Accepts sequential (###-*) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
|
||||||
check_feature_branch() {
|
|
||||||
local branch="$1"
|
|
||||||
local has_git_repo="$2"
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if [[ "$has_git_repo" != "true" ]]; then
|
|
||||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! "$branch" =~ ^[0-9]{3}- ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
|
||||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
|
||||||
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Create a new feature
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[switch]$Json,
|
|
||||||
[string]$ShortName,
|
|
||||||
[Parameter()]
|
|
||||||
[int]$Number = 0,
|
|
||||||
[switch]$Timestamp,
|
|
||||||
[switch]$Help,
|
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
|
||||||
[string[]]$FeatureDescription
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Show help if requested
|
|
||||||
if ($Help) {
|
|
||||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Options:"
|
|
||||||
Write-Host " -Json Output in JSON format"
|
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
Write-Host " -Help Show this help message"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Examples:"
|
|
||||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
|
||||||
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
|
||||||
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if feature description provided
|
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
||||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
||||||
|
|
||||||
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
|
||||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
||||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
||||||
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)
|
|
||||||
|
|
||||||
$highest = 0
|
|
||||||
if (Test-Path $SpecsDir) {
|
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
||||||
if ($_.Name -match '^(\d{3})-') {
|
|
||||||
$num = [int]$matches[1]
|
|
||||||
if ($num -gt $highest) { $highest = $num }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromBranches {
|
|
||||||
param()
|
|
||||||
|
|
||||||
$highest = 0
|
|
||||||
try {
|
|
||||||
$branches = git branch -a 2>$null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
foreach ($branch in $branches) {
|
|
||||||
# Clean branch name: remove leading markers and remote prefixes
|
|
||||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
|
||||||
|
|
||||||
# Extract feature number if branch matches pattern ###-*
|
|
||||||
if ($cleanBranch -match '^(\d{3})-') {
|
|
||||||
$num = [int]$matches[1]
|
|
||||||
if ($num -gt $highest) { $highest = $num }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
# If git command fails, return 0
|
|
||||||
Write-Verbose "Could not check Git branches: $_"
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-NextBranchNumber {
|
|
||||||
param(
|
|
||||||
[string]$SpecsDir
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
|
||||||
try {
|
|
||||||
git fetch --all --prune 2>$null | Out-Null
|
|
||||||
} catch {
|
|
||||||
# Ignore fetch errors
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get highest number from ALL branches (not just matching short name)
|
|
||||||
$highestBranch = Get-HighestNumberFromBranches
|
|
||||||
|
|
||||||
# Get highest number from ALL specs (not just matching short name)
|
|
||||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
|
||||||
|
|
||||||
# Take the maximum of both
|
|
||||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
|
||||||
|
|
||||||
# Return next number
|
|
||||||
return $maxNum + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConvertTo-CleanBranchName {
|
|
||||||
param([string]$Name)
|
|
||||||
|
|
||||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
||||||
}
|
|
||||||
$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).
|
|
||||||
# Search locations in priority order:
|
|
||||||
# 1. common.ps1 next to this script (source checkout layout)
|
|
||||||
# 2. .specify/scripts/powershell/common.ps1 under the project root (installed project)
|
|
||||||
# 3. scripts/powershell/common.ps1 under the project root (source checkout fallback)
|
|
||||||
# 4. git-common.ps1 next to this script (minimal fallback)
|
|
||||||
$commonLoaded = $false
|
|
||||||
|
|
||||||
if (Test-Path "$PSScriptRoot/common.ps1") {
|
|
||||||
. "$PSScriptRoot/common.ps1"
|
|
||||||
$commonLoaded = $true
|
|
||||||
} else {
|
|
||||||
$coreCommonCandidates = @()
|
|
||||||
|
|
||||||
if ($fallbackRoot) {
|
|
||||||
$coreCommonCandidates += (Join-Path $fallbackRoot ".specify/scripts/powershell/common.ps1")
|
|
||||||
$coreCommonCandidates += (Join-Path $fallbackRoot "scripts/powershell/common.ps1")
|
|
||||||
}
|
|
||||||
|
|
||||||
$coreCommonCandidates += "$PSScriptRoot/git-common.ps1"
|
|
||||||
|
|
||||||
foreach ($candidate in $coreCommonCandidates) {
|
|
||||||
if ($candidate -and (Test-Path $candidate)) {
|
|
||||||
. $candidate
|
|
||||||
$commonLoaded = $true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $commonLoaded) {
|
|
||||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
|
||||||
}
|
|
||||||
|
|
||||||
# If only git-common.ps1 was loaded, verify that Resolve-Template is available.
|
|
||||||
# Resolve-Template is provided by the core common.ps1; git-common.ps1 only
|
|
||||||
# supplies Test-HasGit / Test-FeatureBranch.
|
|
||||||
if (-not (Get-Command Resolve-Template -ErrorAction SilentlyContinue)) {
|
|
||||||
throw ("Resolve-Template not defined. The core common.ps1 is required but could not be located. " +
|
|
||||||
"Tried: $PSScriptRoot/common.ps1, .specify/scripts/powershell/common.ps1, scripts/powershell/common.ps1")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
$specsDir = Join-Path $repoRoot 'specs'
|
|
||||||
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
|
||||||
|
|
||||||
# Function to generate branch name with stop word filtering and length filtering
|
|
||||||
function Get-BranchName {
|
|
||||||
param([string]$Description)
|
|
||||||
|
|
||||||
# Common stop words to filter out
|
|
||||||
$stopWords = @(
|
|
||||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
||||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
||||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
||||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
||||||
'want', 'need', 'add', 'get', 'set'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to lowercase and extract words (alphanumeric only)
|
|
||||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
||||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
||||||
|
|
||||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
|
||||||
$meaningfulWords = @()
|
|
||||||
foreach ($word in $words) {
|
|
||||||
# Skip stop words
|
|
||||||
if ($stopWords -contains $word) { continue }
|
|
||||||
|
|
||||||
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
|
||||||
if ($word.Length -ge 3) {
|
|
||||||
$meaningfulWords += $word
|
|
||||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
|
||||||
# Keep short words if they appear as uppercase in original (likely acronyms)
|
|
||||||
$meaningfulWords += $word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# If we have meaningful words, use first 3-4 of them
|
|
||||||
if ($meaningfulWords.Count -gt 0) {
|
|
||||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
||||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
||||||
return $result
|
|
||||||
} else {
|
|
||||||
# Fallback to original logic if no meaningful words found
|
|
||||||
$result = ConvertTo-CleanBranchName -Name $Description
|
|
||||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
||||||
return [string]::Join('-', $fallbackWords)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate branch name
|
|
||||||
if ($ShortName) {
|
|
||||||
# Use provided short name, just clean it up
|
|
||||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
||||||
} else {
|
|
||||||
# Generate from description with smart filtering
|
|
||||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
# Warn if -Number and -Timestamp are both specified
|
|
||||||
if ($Timestamp -and $Number -ne 0) {
|
|
||||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
|
||||||
$Number = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Determine branch prefix
|
|
||||||
if ($Timestamp) {
|
|
||||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
} else {
|
|
||||||
# Determine branch number
|
|
||||||
if ($Number -eq 0) {
|
|
||||||
if ($hasGit) {
|
|
||||||
# Check existing branches on remotes
|
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
|
||||||
} else {
|
|
||||||
# Fall back to local directory check
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
}
|
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
|
||||||
# Validate and truncate if necessary
|
|
||||||
$maxBranchLength = 244
|
|
||||||
if ($branchName.Length -gt $maxBranchLength) {
|
|
||||||
# Calculate how much we need to trim from suffix
|
|
||||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
|
||||||
$prefixLength = $featureNum.Length + 1
|
|
||||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
|
||||||
|
|
||||||
# Truncate suffix
|
|
||||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
|
||||||
# Remove trailing hyphen if truncation created one
|
|
||||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
|
||||||
|
|
||||||
$originalBranchName = $branchName
|
|
||||||
$branchName = "$featureNum-$truncatedSuffix"
|
|
||||||
|
|
||||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
|
||||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasGit) {
|
|
||||||
$branchCreated = $false
|
|
||||||
try {
|
|
||||||
git checkout -q -b $branchName 2>$null | Out-Null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
$branchCreated = $true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
# Exception during git command
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $branchCreated) {
|
|
||||||
# Check if branch already exists
|
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
|
||||||
if ($existingBranch) {
|
|
||||||
if ($Timestamp) {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureDir = Join-Path $specsDir $branchName
|
|
||||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
|
||||||
|
|
||||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
|
||||||
$specFile = Join-Path $featureDir 'spec.md'
|
|
||||||
if ($template -and (Test-Path $template)) {
|
|
||||||
Copy-Item $template $specFile -Force
|
|
||||||
} else {
|
|
||||||
New-Item -ItemType File -Path $specFile | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set the SPECIFY_FEATURE environment variable for the current session
|
|
||||||
$env:SPECIFY_FEATURE = $branchName
|
|
||||||
|
|
||||||
if ($Json) {
|
|
||||||
$obj = [PSCustomObject]@{
|
|
||||||
BRANCH_NAME = $branchName
|
|
||||||
SPEC_FILE = $specFile
|
|
||||||
FEATURE_NUM = $featureNum
|
|
||||||
HAS_GIT = $hasGit
|
|
||||||
}
|
|
||||||
$obj | ConvertTo-Json -Compress
|
|
||||||
} else {
|
|
||||||
Write-Output "BRANCH_NAME: $branchName"
|
|
||||||
Write-Output "SPEC_FILE: $specFile"
|
|
||||||
Write-Output "FEATURE_NUM: $featureNum"
|
|
||||||
Write-Output "HAS_GIT: $hasGit"
|
|
||||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
function Test-HasGit {
|
|
||||||
try {
|
|
||||||
git rev-parse --show-toplevel 2>$null | Out-Null
|
|
||||||
return ($LASTEXITCODE -eq 0)
|
|
||||||
} catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-FeatureBranch {
|
|
||||||
param(
|
|
||||||
[string]$Branch,
|
|
||||||
[bool]$HasGit = $true
|
|
||||||
)
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if (-not $HasGit) {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Branch -notmatch '^[0-9]{3}-' -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
|
|
||||||
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,58 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-03-09T00:00:00Z",
|
"updated_at": "2026-03-24T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||||
"presets": {}
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.4.0"
|
version = "0.4.3"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -41,7 +41,6 @@ packages = ["src/specify_cli"]
|
|||||||
"templates/commands" = "specify_cli/core_pack/commands"
|
"templates/commands" = "specify_cli/core_pack/commands"
|
||||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
|
||||||
".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh"
|
".github/workflows/scripts/create-release-packages.sh" = "specify_cli/core_pack/release_scripts/create-release-packages.sh"
|
||||||
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
|
".github/workflows/scripts/create-release-packages.ps1" = "specify_cli/core_pack/release_scripts/create-release-packages.ps1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Common functions and variables for all scripts
|
# Common functions and variables for all scripts
|
||||||
|
|
||||||
# Get repository root, with fallback for non-git repositories
|
# 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_repo_root() {
|
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
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
git rev-parse --show-toplevel
|
git rev-parse --show-toplevel
|
||||||
else
|
return
|
||||||
# Fall back to script location for non-git repos
|
fi
|
||||||
|
|
||||||
|
# Final fallback to script location for non-git repos
|
||||||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
(cd "$script_dir/../../.." && pwd)
|
(cd "$script_dir/../../.." && pwd)
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get current branch, with fallback for non-git repositories
|
# Get current branch, with fallback for non-git repositories
|
||||||
@@ -20,14 +53,14 @@ get_current_branch() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Then check git if available
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
local repo_root=$(get_repo_root)
|
||||||
git rev-parse --abbrev-ref HEAD
|
if has_git; then
|
||||||
|
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# For non-git repos, try to find the latest feature directory
|
# For non-git repos, try to find the latest feature directory
|
||||||
local repo_root=$(get_repo_root)
|
|
||||||
local specs_dir="$repo_root/specs"
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
if [[ -d "$specs_dir" ]]; then
|
if [[ -d "$specs_dir" ]]; then
|
||||||
@@ -68,9 +101,17 @@ get_current_branch() {
|
|||||||
echo "main" # Final fallback
|
echo "main" # Final fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if we have git available
|
# 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)
|
||||||
has_git() {
|
has_git() {
|
||||||
git rev-parse --show-toplevel >/dev/null 2>&1
|
# 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
|
||||||
}
|
}
|
||||||
|
|
||||||
check_feature_branch() {
|
check_feature_branch() {
|
||||||
|
|||||||
@@ -80,19 +80,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Function to get highest number from specs directory
|
||||||
get_highest_from_specs() {
|
get_highest_from_specs() {
|
||||||
local specs_dir="$1"
|
local specs_dir="$1"
|
||||||
@@ -102,9 +89,9 @@ get_highest_from_specs() {
|
|||||||
for dir in "$specs_dir"/*; do
|
for dir in "$specs_dir"/*; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
dirname=$(basename "$dir")
|
dirname=$(basename "$dir")
|
||||||
# Only match sequential prefixes (###-*), skip timestamp dirs
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||||
if echo "$dirname" | grep -q '^[0-9]\{3\}-'; then
|
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
number=$(echo "$dirname" | grep -o '^[0-9]\{3\}')
|
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||||
number=$((10#$number))
|
number=$((10#$number))
|
||||||
if [ "$number" -gt "$highest" ]; then
|
if [ "$number" -gt "$highest" ]; then
|
||||||
highest=$number
|
highest=$number
|
||||||
@@ -128,9 +115,9 @@ get_highest_from_branches() {
|
|||||||
# Clean branch name: remove leading markers and remote prefixes
|
# Clean branch name: remove leading markers and remote prefixes
|
||||||
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
clean_branch=$(echo "$branch" | sed 's/^[* ]*//; s|^remotes/[^/]*/||')
|
||||||
|
|
||||||
# Extract feature number if branch matches pattern ###-*
|
# Extract sequential feature number (>=3 digits), skip timestamp branches.
|
||||||
if echo "$clean_branch" | grep -q '^[0-9]\{3\}-'; then
|
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 -o '^[0-9]\{3\}' || echo "0")
|
number=$(echo "$clean_branch" | grep -Eo '^[0-9]+' || echo "0")
|
||||||
number=$((10#$number))
|
number=$((10#$number))
|
||||||
if [ "$number" -gt "$highest" ]; then
|
if [ "$number" -gt "$highest" ]; then
|
||||||
highest=$number
|
highest=$number
|
||||||
@@ -171,21 +158,16 @@ clean_branch_name() {
|
|||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve repository root. Prefer git information when available, but fall back
|
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||||
# 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)"
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/common.sh"
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
REPO_ROOT=$(get_repo_root)
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
||||||
|
# Check if git is available at this repo root (not a parent)
|
||||||
|
if has_git; then
|
||||||
HAS_GIT=true
|
HAS_GIT=true
|
||||||
else
|
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
|
HAS_GIT=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,39 @@
|
|||||||
#!/usr/bin/env pwsh
|
#!/usr/bin/env pwsh
|
||||||
# Common PowerShell functions analogous to common.sh
|
# 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 {
|
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 {
|
try {
|
||||||
$result = git rev-parse --show-toplevel 2>$null
|
$result = git rev-parse --show-toplevel 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
@@ -11,8 +43,9 @@ function Get-RepoRoot {
|
|||||||
# Git command failed
|
# Git command failed
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fall back to script location for non-git repos
|
# Final fallback to script location for non-git repos
|
||||||
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path
|
# Use -LiteralPath to handle paths with wildcard characters
|
||||||
|
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-CurrentBranch {
|
function Get-CurrentBranch {
|
||||||
@@ -21,18 +54,20 @@ function Get-CurrentBranch {
|
|||||||
return $env:SPECIFY_FEATURE
|
return $env:SPECIFY_FEATURE
|
||||||
}
|
}
|
||||||
|
|
||||||
# Then check git if available
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
|
$repoRoot = Get-RepoRoot
|
||||||
|
if (Test-HasGit) {
|
||||||
try {
|
try {
|
||||||
$result = git rev-parse --abbrev-ref HEAD 2>$null
|
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
return $result
|
return $result
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
# Git command failed
|
# Git command failed
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# For non-git repos, try to find the latest feature directory
|
# For non-git repos, try to find the latest feature directory
|
||||||
$repoRoot = Get-RepoRoot
|
|
||||||
$specsDir = Join-Path $repoRoot "specs"
|
$specsDir = Join-Path $repoRoot "specs"
|
||||||
|
|
||||||
if (Test-Path $specsDir) {
|
if (Test-Path $specsDir) {
|
||||||
@@ -69,9 +104,23 @@ function Get-CurrentBranch {
|
|||||||
return "main"
|
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 {
|
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 {
|
try {
|
||||||
git rev-parse --show-toplevel 2>$null | Out-Null
|
$null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
|
||||||
return ($LASTEXITCODE -eq 0)
|
return ($LASTEXITCODE -eq 0)
|
||||||
} catch {
|
} catch {
|
||||||
return $false
|
return $false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ param(
|
|||||||
[switch]$Json,
|
[switch]$Json,
|
||||||
[string]$ShortName,
|
[string]$ShortName,
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[int]$Number = 0,
|
[long]$Number = 0,
|
||||||
[switch]$Timestamp,
|
[switch]$Timestamp,
|
||||||
[switch]$Help,
|
[switch]$Help,
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
@@ -45,39 +45,18 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|||||||
exit 1
|
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 {
|
function Get-HighestNumberFromSpecs {
|
||||||
param([string]$SpecsDir)
|
param([string]$SpecsDir)
|
||||||
|
|
||||||
$highest = 0
|
[long]$highest = 0
|
||||||
if (Test-Path $SpecsDir) {
|
if (Test-Path $SpecsDir) {
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||||
if ($_.Name -match '^(\d{3})-') {
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||||
$num = [int]$matches[1]
|
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||||
if ($num -gt $highest) { $highest = $num }
|
[long]$num = 0
|
||||||
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +66,7 @@ function Get-HighestNumberFromSpecs {
|
|||||||
function Get-HighestNumberFromBranches {
|
function Get-HighestNumberFromBranches {
|
||||||
param()
|
param()
|
||||||
|
|
||||||
$highest = 0
|
[long]$highest = 0
|
||||||
try {
|
try {
|
||||||
$branches = git branch -a 2>$null
|
$branches = git branch -a 2>$null
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
@@ -95,10 +74,12 @@ function Get-HighestNumberFromBranches {
|
|||||||
# Clean branch name: remove leading markers and remote prefixes
|
# Clean branch name: remove leading markers and remote prefixes
|
||||||
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
$cleanBranch = $branch.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||||
|
|
||||||
# Extract feature number if branch matches pattern ###-*
|
# Extract sequential feature number (>=3 digits), skip timestamp branches.
|
||||||
if ($cleanBranch -match '^(\d{3})-') {
|
if ($cleanBranch -match '^(\d{3,})-' -and $cleanBranch -notmatch '^\d{8}-\d{6}-') {
|
||||||
$num = [int]$matches[1]
|
[long]$num = 0
|
||||||
if ($num -gt $highest) { $highest = $num }
|
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||||
|
$highest = $num
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,26 +120,14 @@ function ConvertTo-CleanBranchName {
|
|||||||
|
|
||||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||||
}
|
}
|
||||||
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot)
|
# Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
|
||||||
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"
|
. "$PSScriptRoot/common.ps1"
|
||||||
|
|
||||||
try {
|
# Use common.ps1 functions which prioritize .specify over git
|
||||||
$repoRoot = git rev-parse --show-toplevel 2>$null
|
$repoRoot = Get-RepoRoot
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
$hasGit = $true
|
# Check if git is available at this repo root (not a parent)
|
||||||
} else {
|
$hasGit = Test-HasGit
|
||||||
throw "Git not available"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$repoRoot = $fallbackRoot
|
|
||||||
$hasGit = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
@@ -326,4 +295,3 @@ if ($Json) {
|
|||||||
Write-Output "HAS_GIT: $hasGit"
|
Write-Output "HAS_GIT: $hasGit"
|
||||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1159,70 +1159,6 @@ def _locate_core_pack() -> Path | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _locate_bundled_git_extension() -> Path | None:
|
|
||||||
"""Return the path to the bundled git extension, or None.
|
|
||||||
|
|
||||||
Checks the wheel's core_pack first, then falls back to the
|
|
||||||
source-checkout ``extensions/git/`` directory.
|
|
||||||
"""
|
|
||||||
core = _locate_core_pack()
|
|
||||||
if core is not None:
|
|
||||||
candidate = core / "extensions" / "git"
|
|
||||||
if (candidate / "extension.yml").is_file():
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
# Source-checkout / editable install: look relative to repo root
|
|
||||||
repo_root = Path(__file__).parent.parent.parent
|
|
||||||
candidate = repo_root / "extensions" / "git"
|
|
||||||
if (candidate / "extension.yml").is_file():
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _install_bundled_git_extension(project_path: Path) -> bool:
|
|
||||||
"""Auto-install the bundled git extension during ``specify init``.
|
|
||||||
|
|
||||||
This is a migration-period mechanism (pre-1.0.0) that auto-enables
|
|
||||||
the git extension so that existing branching workflows continue to work.
|
|
||||||
Before 1.0.0, this auto-install will be removed and the extension will
|
|
||||||
become opt-in.
|
|
||||||
|
|
||||||
Returns True if the extension was installed or already present,
|
|
||||||
False otherwise.
|
|
||||||
"""
|
|
||||||
ext_source = _locate_bundled_git_extension()
|
|
||||||
if ext_source is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from .extensions import ExtensionManager
|
|
||||||
manager = ExtensionManager(project_path)
|
|
||||||
|
|
||||||
# Skip if already installed (e.g. via preset), but only if the
|
|
||||||
# on-disk extension manifest still exists. This guards against
|
|
||||||
# stale/corrupted registry entries.
|
|
||||||
if manager.registry.is_installed("git"):
|
|
||||||
ext_manifest = project_path / ".specify" / "extensions" / "git" / "extension.yml"
|
|
||||||
if ext_manifest.is_file():
|
|
||||||
return True
|
|
||||||
# Registry is stale — remove entry so reinstall can proceed
|
|
||||||
manager.registry.remove("git")
|
|
||||||
|
|
||||||
speckit_ver = get_speckit_version()
|
|
||||||
manager.install_from_directory(ext_source, speckit_ver)
|
|
||||||
return True
|
|
||||||
except Exception as exc:
|
|
||||||
# Non-fatal: branching still works via core scripts during migration,
|
|
||||||
# but log a warning so users can tell the auto-install did not happen.
|
|
||||||
console.print(
|
|
||||||
"[dim yellow]Warning: failed to auto-install bundled git extension; "
|
|
||||||
"branching via the git extension may be unavailable. "
|
|
||||||
f"Details: {exc}[/dim yellow]"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _locate_release_script() -> tuple[Path, str]:
|
def _locate_release_script() -> tuple[Path, str]:
|
||||||
"""Return (script_path, shell_cmd) for the platform-appropriate release script.
|
"""Return (script_path, shell_cmd) for the platform-appropriate release script.
|
||||||
|
|
||||||
@@ -1554,12 +1490,6 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
|
|||||||
return {}
|
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 directory for agents not in AGENT_CONFIG
|
||||||
DEFAULT_SKILLS_DIR = ".agents/skills"
|
DEFAULT_SKILLS_DIR = ".agents/skills"
|
||||||
|
|
||||||
@@ -1592,13 +1522,9 @@ SKILL_DESCRIPTIONS = {
|
|||||||
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||||
"""Resolve the agent-specific skills directory for the given AI assistant.
|
"""Resolve the agent-specific skills directory for the given AI assistant.
|
||||||
|
|
||||||
Uses ``AGENT_SKILLS_DIR_OVERRIDES`` first, then falls back to
|
Uses ``AGENT_CONFIG[agent]["folder"] + "skills"`` and falls back to
|
||||||
``AGENT_CONFIG[agent]["folder"] + "skills"``, and finally to
|
``DEFAULT_SKILLS_DIR`` for unknown agents.
|
||||||
``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_config = AGENT_CONFIG.get(selected_ai, {})
|
||||||
agent_folder = agent_config.get("folder", "")
|
agent_folder = agent_config.get("folder", "")
|
||||||
if agent_folder:
|
if agent_folder:
|
||||||
@@ -1712,10 +1638,7 @@ def install_ai_skills(
|
|||||||
command_name = command_name[len("speckit."):]
|
command_name = command_name[len("speckit."):]
|
||||||
if command_name.endswith(".agent"):
|
if command_name.endswith(".agent"):
|
||||||
command_name = command_name[:-len(".agent")]
|
command_name = command_name[:-len(".agent")]
|
||||||
if selected_ai == "kimi":
|
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||||
skill_name = f"speckit.{command_name}"
|
|
||||||
else:
|
|
||||||
skill_name = f"speckit-{command_name}"
|
|
||||||
|
|
||||||
# Create skill directory (additive — never removes existing content)
|
# Create skill directory (additive — never removes existing content)
|
||||||
skill_dir = skills_dir / skill_name
|
skill_dir = skills_dir / skill_name
|
||||||
@@ -1794,8 +1717,64 @@ def _has_bundled_skills(project_path: Path, selected_ai: str) -> bool:
|
|||||||
if not skills_dir.is_dir():
|
if not skills_dir.is_dir():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pattern = "speckit.*/SKILL.md" if selected_ai == "kimi" else "speckit-*/SKILL.md"
|
return any(skills_dir.glob("speckit-*/SKILL.md"))
|
||||||
return any(skills_dir.glob(pattern))
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
AGENT_SKILLS_MIGRATIONS = {
|
AGENT_SKILLS_MIGRATIONS = {
|
||||||
@@ -2158,16 +2137,33 @@ def init(
|
|||||||
|
|
||||||
ensure_constitution_from_template(project_path, tracker=tracker)
|
ensure_constitution_from_template(project_path, tracker=tracker)
|
||||||
|
|
||||||
if ai_skills:
|
# 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:
|
if selected_ai in NATIVE_SKILLS_AGENTS:
|
||||||
skills_dir = _get_skills_dir(project_path, selected_ai)
|
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:
|
||||||
bundled_found = _has_bundled_skills(project_path, selected_ai)
|
bundled_found = _has_bundled_skills(project_path, selected_ai)
|
||||||
if bundled_found:
|
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:
|
if tracker:
|
||||||
tracker.start("ai-skills")
|
tracker.start("ai-skills")
|
||||||
tracker.complete("ai-skills", f"bundled skills → {skills_dir.relative_to(project_path)}")
|
tracker.complete("ai-skills", detail)
|
||||||
else:
|
else:
|
||||||
console.print(f"[green]✓[/green] Using bundled agent skills in {skills_dir.relative_to(project_path)}/")
|
console.print(f"[green]✓[/green] Using {detail}")
|
||||||
else:
|
else:
|
||||||
# Compatibility fallback: convert command templates to skills
|
# Compatibility fallback: convert command templates to skills
|
||||||
# when an older template archive does not include native skills.
|
# when an older template archive does not include native skills.
|
||||||
@@ -2240,11 +2236,6 @@ def init(
|
|||||||
"speckit_version": get_speckit_version(),
|
"speckit_version": get_speckit_version(),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Auto-install the bundled git extension (migration period, pre-1.0.0).
|
|
||||||
# This preserves backward compatibility for existing branching workflows.
|
|
||||||
# Before 1.0.0, this will be removed and git becomes opt-in.
|
|
||||||
_install_bundled_git_extension(project_path)
|
|
||||||
|
|
||||||
# Install preset if specified
|
# Install preset if specified
|
||||||
if preset:
|
if preset:
|
||||||
try:
|
try:
|
||||||
@@ -2357,7 +2348,7 @@ def init(
|
|||||||
if codex_skill_mode:
|
if codex_skill_mode:
|
||||||
return f"$speckit-{name}"
|
return f"$speckit-{name}"
|
||||||
if kimi_skill_mode:
|
if kimi_skill_mode:
|
||||||
return f"/skill:speckit.{name}"
|
return f"/skill:speckit-{name}"
|
||||||
return f"/speckit.{name}"
|
return f"/speckit.{name}"
|
||||||
|
|
||||||
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
|
||||||
@@ -3663,6 +3654,15 @@ def extension_add(
|
|||||||
for cmd in manifest.commands:
|
for cmd in manifest.commands:
|
||||||
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
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("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||||
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
console.print(f" Check: .specify/extensions/{manifest.id}/")
|
||||||
|
|
||||||
@@ -3701,14 +3701,19 @@ def extension_remove(
|
|||||||
installed = manager.list_installed()
|
installed = manager.list_installed()
|
||||||
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
extension_id, display_name = _resolve_installed_extension(extension, installed, "remove")
|
||||||
|
|
||||||
# Get extension info for command count
|
# Get extension info for command and skill counts
|
||||||
ext_manifest = manager.get_extension(extension_id)
|
ext_manifest = manager.get_extension(extension_id)
|
||||||
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
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
|
# Confirm removal
|
||||||
if not force:
|
if not force:
|
||||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||||
console.print(f" • {cmd_count} commands from AI agent")
|
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}/")
|
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
||||||
if not keep_config:
|
if not keep_config:
|
||||||
console.print(" • Config files (will be backed up)")
|
console.print(" • Config files (will be backed up)")
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
|||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
@@ -211,24 +213,52 @@ class CommandRegistrar:
|
|||||||
return f"---\n{yaml_str}---\n"
|
return f"---\n{yaml_str}---\n"
|
||||||
|
|
||||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||||
"""Adjust script paths from extension-relative to repo-relative.
|
"""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.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
frontmatter: Frontmatter dictionary
|
frontmatter: Frontmatter dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Modified frontmatter with adjusted paths
|
Modified frontmatter with normalized project paths
|
||||||
"""
|
"""
|
||||||
|
frontmatter = deepcopy(frontmatter)
|
||||||
|
|
||||||
for script_key in ("scripts", "agent_scripts"):
|
for script_key in ("scripts", "agent_scripts"):
|
||||||
scripts = frontmatter.get(script_key)
|
scripts = frontmatter.get(script_key)
|
||||||
if not isinstance(scripts, dict):
|
if not isinstance(scripts, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for key, script_path in scripts.items():
|
for key, script_path in scripts.items():
|
||||||
if isinstance(script_path, str) and script_path.startswith("../../scripts/"):
|
if isinstance(script_path, str):
|
||||||
scripts[key] = f".specify/scripts/{script_path[14:]}"
|
scripts[key] = self._rewrite_project_relative_paths(script_path)
|
||||||
return frontmatter
|
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(
|
def render_markdown_command(
|
||||||
self,
|
self,
|
||||||
frontmatter: dict,
|
frontmatter: dict,
|
||||||
@@ -277,9 +307,25 @@ class CommandRegistrar:
|
|||||||
toml_lines.append(f"# Source: {source_id}")
|
toml_lines.append(f"# Source: {source_id}")
|
||||||
toml_lines.append("")
|
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('prompt = """')
|
||||||
toml_lines.append(body)
|
toml_lines.append(body)
|
||||||
toml_lines.append('"""')
|
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}"')
|
||||||
|
|
||||||
return "\n".join(toml_lines)
|
return "\n".join(toml_lines)
|
||||||
|
|
||||||
@@ -308,8 +354,8 @@ class CommandRegistrar:
|
|||||||
if not isinstance(frontmatter, dict):
|
if not isinstance(frontmatter, dict):
|
||||||
frontmatter = {}
|
frontmatter = {}
|
||||||
|
|
||||||
if agent_name == "codex":
|
if agent_name in {"codex", "kimi"}:
|
||||||
body = self._resolve_codex_skill_placeholders(frontmatter, body, project_root)
|
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
|
||||||
|
|
||||||
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
|
||||||
skill_frontmatter = {
|
skill_frontmatter = {
|
||||||
@@ -324,13 +370,8 @@ class CommandRegistrar:
|
|||||||
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
return self.render_frontmatter(skill_frontmatter) + "\n" + body
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_codex_skill_placeholders(frontmatter: dict, body: str, project_root: Path) -> str:
|
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
|
||||||
"""Resolve script placeholders for Codex skill overrides.
|
"""Resolve script placeholders for skills-backed agents."""
|
||||||
|
|
||||||
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:
|
try:
|
||||||
from . import load_init_options
|
from . import load_init_options
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -346,7 +387,11 @@ class CommandRegistrar:
|
|||||||
if not isinstance(agent_scripts, dict):
|
if not isinstance(agent_scripts, dict):
|
||||||
agent_scripts = {}
|
agent_scripts = {}
|
||||||
|
|
||||||
script_variant = load_init_options(project_root).get("script")
|
init_opts = load_init_options(project_root)
|
||||||
|
if not isinstance(init_opts, dict):
|
||||||
|
init_opts = {}
|
||||||
|
|
||||||
|
script_variant = init_opts.get("script")
|
||||||
if script_variant not in {"sh", "ps"}:
|
if script_variant not in {"sh", "ps"}:
|
||||||
fallback_order = []
|
fallback_order = []
|
||||||
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
|
||||||
@@ -376,7 +421,8 @@ class CommandRegistrar:
|
|||||||
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||||
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
|
||||||
|
|
||||||
return body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", "codex")
|
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||||
|
return CommandRegistrar._rewrite_project_relative_paths(body)
|
||||||
|
|
||||||
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
|
||||||
"""Convert argument placeholder format.
|
"""Convert argument placeholder format.
|
||||||
@@ -400,8 +446,9 @@ class CommandRegistrar:
|
|||||||
short_name = cmd_name
|
short_name = cmd_name
|
||||||
if short_name.startswith("speckit."):
|
if short_name.startswith("speckit."):
|
||||||
short_name = short_name[len("speckit."):]
|
short_name = short_name[len("speckit."):]
|
||||||
|
short_name = short_name.replace(".", "-")
|
||||||
|
|
||||||
return f"speckit.{short_name}" if agent_name == "kimi" else f"speckit-{short_name}"
|
return f"speckit-{short_name}"
|
||||||
|
|
||||||
def register_commands(
|
def register_commands(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -510,6 +510,283 @@ class ExtensionManager:
|
|||||||
|
|
||||||
return _ignore
|
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(
|
def check_compatibility(
|
||||||
self,
|
self,
|
||||||
manifest: ExtensionManifest,
|
manifest: ExtensionManifest,
|
||||||
@@ -601,6 +878,10 @@ class ExtensionManager:
|
|||||||
manifest, dest_dir, self.project_root
|
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
|
# Register hooks
|
||||||
hook_executor = HookExecutor(self.project_root)
|
hook_executor = HookExecutor(self.project_root)
|
||||||
hook_executor.register_hooks(manifest)
|
hook_executor.register_hooks(manifest)
|
||||||
@@ -612,7 +893,8 @@ class ExtensionManager:
|
|||||||
"manifest_hash": manifest.get_hash(),
|
"manifest_hash": manifest.get_hash(),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"priority": priority,
|
"priority": priority,
|
||||||
"registered_commands": registered_commands
|
"registered_commands": registered_commands,
|
||||||
|
"registered_skills": registered_skills,
|
||||||
})
|
})
|
||||||
|
|
||||||
return manifest
|
return manifest
|
||||||
@@ -690,9 +972,15 @@ class ExtensionManager:
|
|||||||
if not self.registry.is_installed(extension_id):
|
if not self.registry.is_installed(extension_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Get registered commands before removal
|
# Get registered commands and skills before removal
|
||||||
metadata = self.registry.get(extension_id)
|
metadata = self.registry.get(extension_id)
|
||||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
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
|
extension_dir = self.extensions_dir / extension_id
|
||||||
|
|
||||||
@@ -701,6 +989,9 @@ class ExtensionManager:
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.unregister_commands(registered_commands, self.project_root)
|
registrar.unregister_commands(registered_commands, self.project_root)
|
||||||
|
|
||||||
|
# Unregister agent skills
|
||||||
|
self._unregister_extension_skills(registered_skills, extension_id)
|
||||||
|
|
||||||
if keep_config:
|
if keep_config:
|
||||||
# Preserve config files, only remove non-config files
|
# Preserve config files, only remove non-config files
|
||||||
if extension_dir.exists():
|
if extension_dir.exists():
|
||||||
@@ -1644,6 +1935,52 @@ class HookExecutor:
|
|||||||
self.project_root = project_root
|
self.project_root = project_root
|
||||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||||
self.config_file = project_root / ".specify" / "extensions.yml"
|
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]:
|
def get_project_config(self) -> Dict[str, Any]:
|
||||||
"""Load project-level extension configuration.
|
"""Load project-level extension configuration.
|
||||||
@@ -1887,21 +2224,27 @@ class HookExecutor:
|
|||||||
for hook in hooks:
|
for hook in hooks:
|
||||||
extension = hook.get("extension")
|
extension = hook.get("extension")
|
||||||
command = hook.get("command")
|
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)
|
optional = hook.get("optional", True)
|
||||||
prompt = hook.get("prompt", "")
|
prompt = hook.get("prompt", "")
|
||||||
description = hook.get("description", "")
|
description = hook.get("description", "")
|
||||||
|
|
||||||
if optional:
|
if optional:
|
||||||
lines.append(f"\n**Optional Hook**: {extension}")
|
lines.append(f"\n**Optional Hook**: {extension}")
|
||||||
lines.append(f"Command: `/{command}`")
|
lines.append(f"Command: `{display_invocation}`")
|
||||||
if description:
|
if description:
|
||||||
lines.append(f"Description: {description}")
|
lines.append(f"Description: {description}")
|
||||||
lines.append(f"\nPrompt: {prompt}")
|
lines.append(f"\nPrompt: {prompt}")
|
||||||
lines.append(f"To execute: `/{command}`")
|
lines.append(f"To execute: `{display_invocation}`")
|
||||||
else:
|
else:
|
||||||
lines.append(f"\n**Automatic Hook**: {extension}")
|
lines.append(f"\n**Automatic Hook**: {extension}")
|
||||||
lines.append(f"Executing: `/{command}`")
|
lines.append(f"Executing: `{display_invocation}`")
|
||||||
lines.append(f"EXECUTE_COMMAND: {command}")
|
lines.append(f"EXECUTE_COMMAND: {command_text}")
|
||||||
|
lines.append(f"EXECUTE_COMMAND_INVOCATION: {display_invocation}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -1965,6 +2308,7 @@ class HookExecutor:
|
|||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"command": hook.get("command"),
|
"command": hook.get("command"),
|
||||||
|
"invocation": self._render_hook_invocation(hook.get("command")),
|
||||||
"extension": hook.get("extension"),
|
"extension": hook.get("extension"),
|
||||||
"optional": hook.get("optional", True),
|
"optional": hook.get("optional", True),
|
||||||
"description": hook.get("description", ""),
|
"description": hook.get("description", ""),
|
||||||
@@ -2008,4 +2352,3 @@ class HookExecutor:
|
|||||||
hook["enabled"] = False
|
hook["enabled"] = False
|
||||||
|
|
||||||
self.save_project_config(config)
|
self.save_project_config(config)
|
||||||
|
|
||||||
|
|||||||
@@ -556,24 +556,31 @@ class PresetManager:
|
|||||||
registrar.unregister_commands(registered_commands, self.project_root)
|
registrar.unregister_commands(registered_commands, self.project_root)
|
||||||
|
|
||||||
def _get_skills_dir(self) -> Optional[Path]:
|
def _get_skills_dir(self) -> Optional[Path]:
|
||||||
"""Return the skills directory if ``--ai-skills`` was used during init.
|
"""Return the active skills directory for preset skill overrides.
|
||||||
|
|
||||||
Reads ``.specify/init-options.json`` to determine whether skills
|
Reads ``.specify/init-options.json`` to determine whether skills
|
||||||
are enabled and which agent was selected, then delegates to
|
are enabled and which agent was selected, then delegates to
|
||||||
the module-level ``_get_skills_dir()`` helper for the concrete path.
|
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:
|
Returns:
|
||||||
The skills directory ``Path``, or ``None`` if skills were not
|
The skills directory ``Path``, or ``None`` if skills were not
|
||||||
enabled or the init-options file is missing.
|
enabled and no native-skills fallback applies.
|
||||||
"""
|
"""
|
||||||
from . import load_init_options, _get_skills_dir
|
from . import load_init_options, _get_skills_dir
|
||||||
|
|
||||||
opts = load_init_options(self.project_root)
|
opts = load_init_options(self.project_root)
|
||||||
if not opts.get("ai_skills"):
|
if not isinstance(opts, dict):
|
||||||
|
opts = {}
|
||||||
|
agent = opts.get("ai")
|
||||||
|
if not isinstance(agent, str) or not agent:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
agent = opts.get("ai")
|
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||||
if not agent:
|
if not ai_skills_enabled and agent != "kimi":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
skills_dir = _get_skills_dir(self.project_root, agent)
|
skills_dir = _get_skills_dir(self.project_root, agent)
|
||||||
@@ -582,6 +589,76 @@ class PresetManager:
|
|||||||
|
|
||||||
return skills_dir
|
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(
|
def _register_skills(
|
||||||
self,
|
self,
|
||||||
manifest: "PresetManifest",
|
manifest: "PresetManifest",
|
||||||
@@ -629,9 +706,15 @@ class PresetManager:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
from . import SKILL_DESCRIPTIONS, load_init_options
|
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||||
|
from .agents import CommandRegistrar
|
||||||
|
|
||||||
opts = load_init_options(self.project_root)
|
init_opts = load_init_options(self.project_root)
|
||||||
selected_ai = opts.get("ai", "")
|
if not isinstance(init_opts, dict):
|
||||||
|
init_opts = {}
|
||||||
|
selected_ai = init_opts.get("ai")
|
||||||
|
if not isinstance(selected_ai, str):
|
||||||
|
return []
|
||||||
|
registrar = CommandRegistrar()
|
||||||
|
|
||||||
written: List[str] = []
|
written: List[str] = []
|
||||||
|
|
||||||
@@ -643,43 +726,42 @@ class PresetManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Derive the short command name (e.g. "specify" from "speckit.specify")
|
# Derive the short command name (e.g. "specify" from "speckit.specify")
|
||||||
short_name = cmd_name
|
raw_short_name = cmd_name
|
||||||
if short_name.startswith("speckit."):
|
if raw_short_name.startswith("speckit."):
|
||||||
short_name = short_name[len("speckit."):]
|
raw_short_name = raw_short_name[len("speckit."):]
|
||||||
if selected_ai == "kimi":
|
short_name = raw_short_name.replace(".", "-")
|
||||||
skill_name = f"speckit.{short_name}"
|
skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name)
|
||||||
else:
|
skill_title = self._skill_title_from_command(cmd_name)
|
||||||
skill_name = f"speckit-{short_name}"
|
|
||||||
|
|
||||||
# Only overwrite if the skill already exists (i.e. --ai-skills was used)
|
# Only overwrite skills that already exist under skills_dir,
|
||||||
skill_subdir = skills_dir / skill_name
|
# including Kimi native skills when ai_skills is false.
|
||||||
if not skill_subdir.exists():
|
# 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:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse the command file
|
# Parse the command file
|
||||||
content = source_file.read_text(encoding="utf-8")
|
content = source_file.read_text(encoding="utf-8")
|
||||||
if content.startswith("---"):
|
frontmatter, body = registrar.parse_frontmatter(content)
|
||||||
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", "")
|
original_desc = frontmatter.get("description", "")
|
||||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||||
short_name,
|
short_name,
|
||||||
original_desc or f"Spec-kit workflow command: {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
|
||||||
|
)
|
||||||
|
|
||||||
|
for target_skill_name in target_skill_names:
|
||||||
frontmatter_data = {
|
frontmatter_data = {
|
||||||
"name": skill_name,
|
"name": target_skill_name,
|
||||||
"description": enhanced_desc,
|
"description": enhanced_desc,
|
||||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -692,13 +774,13 @@ class PresetManager:
|
|||||||
f"---\n"
|
f"---\n"
|
||||||
f"{frontmatter_text}\n"
|
f"{frontmatter_text}\n"
|
||||||
f"---\n\n"
|
f"---\n\n"
|
||||||
f"# Speckit {short_name.title()} Skill\n\n"
|
f"# Speckit {skill_title} Skill\n\n"
|
||||||
f"{body}\n"
|
f"{body}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
skill_file = skill_subdir / "SKILL.md"
|
skill_file = skills_dir / target_skill_name / "SKILL.md"
|
||||||
skill_file.write_text(skill_content, encoding="utf-8")
|
skill_file.write_text(skill_content, encoding="utf-8")
|
||||||
written.append(skill_name)
|
written.append(target_skill_name)
|
||||||
|
|
||||||
return written
|
return written
|
||||||
|
|
||||||
@@ -720,10 +802,17 @@ class PresetManager:
|
|||||||
if not skills_dir:
|
if not skills_dir:
|
||||||
return
|
return
|
||||||
|
|
||||||
from . import SKILL_DESCRIPTIONS
|
from . import SKILL_DESCRIPTIONS, load_init_options
|
||||||
|
from .agents import CommandRegistrar
|
||||||
|
|
||||||
# Locate core command templates from the project's installed templates
|
# Locate core command templates from the project's installed templates
|
||||||
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
|
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:
|
for skill_name in skill_names:
|
||||||
# Derive command name from skill name (speckit-specify -> specify)
|
# Derive command name from skill name (speckit-specify -> specify)
|
||||||
@@ -735,7 +824,10 @@ class PresetManager:
|
|||||||
|
|
||||||
skill_subdir = skills_dir / skill_name
|
skill_subdir = skills_dir / skill_name
|
||||||
skill_file = skill_subdir / "SKILL.md"
|
skill_file = skill_subdir / "SKILL.md"
|
||||||
if not skill_file.exists():
|
if not skill_subdir.is_dir():
|
||||||
|
continue
|
||||||
|
if not skill_file.is_file():
|
||||||
|
# Only manage directories that contain the expected skill entrypoint.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Try to find the core command template
|
# Try to find the core command template
|
||||||
@@ -746,19 +838,11 @@ class PresetManager:
|
|||||||
if core_file:
|
if core_file:
|
||||||
# Restore from core template
|
# Restore from core template
|
||||||
content = core_file.read_text(encoding="utf-8")
|
content = core_file.read_text(encoding="utf-8")
|
||||||
if content.startswith("---"):
|
frontmatter, body = registrar.parse_frontmatter(content)
|
||||||
parts = content.split("---", 2)
|
if isinstance(selected_ai, str):
|
||||||
if len(parts) >= 3:
|
body = registrar.resolve_skill_placeholders(
|
||||||
frontmatter = yaml.safe_load(parts[1])
|
selected_ai, frontmatter, body, self.project_root
|
||||||
if not isinstance(frontmatter, dict):
|
)
|
||||||
frontmatter = {}
|
|
||||||
body = parts[2].strip()
|
|
||||||
else:
|
|
||||||
frontmatter = {}
|
|
||||||
body = content
|
|
||||||
else:
|
|
||||||
frontmatter = {}
|
|
||||||
body = content
|
|
||||||
|
|
||||||
original_desc = frontmatter.get("description", "")
|
original_desc = frontmatter.get("description", "")
|
||||||
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
enhanced_desc = SKILL_DESCRIPTIONS.get(
|
||||||
@@ -776,16 +860,49 @@ class PresetManager:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
||||||
|
skill_title = self._skill_title_from_command(short_name)
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"{frontmatter_text}\n"
|
f"{frontmatter_text}\n"
|
||||||
f"---\n\n"
|
f"---\n\n"
|
||||||
f"# Speckit {short_name.title()} Skill\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"{body}\n"
|
f"{body}\n"
|
||||||
)
|
)
|
||||||
skill_file.write_text(skill_content, encoding="utf-8")
|
skill_file.write_text(skill_content, encoding="utf-8")
|
||||||
else:
|
else:
|
||||||
# No core template — remove the skill entirely
|
# No core or extension template — remove the skill entirely
|
||||||
shutil.rmtree(skill_subdir)
|
shutil.rmtree(skill_subdir)
|
||||||
|
|
||||||
def install_from_directory(
|
def install_from_directory(
|
||||||
@@ -915,17 +1032,26 @@ class PresetManager:
|
|||||||
if not self.registry.is_installed(pack_id):
|
if not self.registry.is_installed(pack_id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Unregister commands from AI agents
|
|
||||||
metadata = self.registry.get(pack_id)
|
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
|
# Restore original skills when preset is removed
|
||||||
registered_skills = metadata.get("registered_skills", []) if metadata else []
|
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
|
pack_dir = self.presets_dir / pack_id
|
||||||
if registered_skills:
|
if registered_skills:
|
||||||
self._unregister_skills(registered_skills, pack_dir)
|
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():
|
if pack_dir.exists():
|
||||||
shutil.rmtree(pack_dir)
|
shutil.rmtree(pack_dir)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Load only the minimal necessary context from each artifact:
|
|||||||
|
|
||||||
- Overview/Context
|
- Overview/Context
|
||||||
- Functional Requirements
|
- Functional Requirements
|
||||||
- Non-Functional Requirements
|
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||||
- User Stories
|
- User Stories
|
||||||
- Edge Cases (if present)
|
- 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):
|
Create internal representations (do not include raw artifacts in output):
|
||||||
|
|
||||||
- **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`)
|
- **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%").
|
||||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
- **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)
|
- **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
|
- **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
|
- Requirements with zero associated tasks
|
||||||
- Tasks with no mapped requirement/story
|
- Tasks with no mapped requirement/story
|
||||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||||
|
|
||||||
#### F. Inconsistency
|
#### F. Inconsistency
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ Execution steps:
|
|||||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
- 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.
|
- 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.
|
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (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).
|
- 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.
|
- 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.
|
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||||
|
|||||||
@@ -73,40 +73,12 @@ Given that feature description, do this:
|
|||||||
- "Create a dashboard for analytics" → "analytics-dashboard"
|
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||||
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||||
|
|
||||||
2. **Create the feature branch** (unless already handled by a `before_specify` hook — see Pre-Execution Checks above). If a mandatory `before_specify` hook for `speckit.git.feature` already executed and created the branch, **skip this step entirely** and use the branch/spec information from the hook result. Otherwise:
|
2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
|
||||||
|
|
||||||
**Git extension check**: Before running the branch creation script, check if the git extension is enabled:
|
**Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value.
|
||||||
- Check if `.specify/extensions/.registry` exists (a single JSON file tracking all extensions)
|
|
||||||
- If it exists, read the JSON and look for an `extensions.git` entry
|
|
||||||
- **Only skip branching** when `extensions.git.enabled` is **present and explicitly `false`**. In all other cases — including when the registry file is missing, when `extensions.git` has no entry, or when `"enabled"` is `true` or absent — proceed with normal branch creation (extension script if present, else core `{SCRIPT}` fallback)
|
|
||||||
- If the git extension is **disabled** (explicitly `"enabled": false`), **skip branch creation entirely** — do **not** run the branch creation script. Instead:
|
|
||||||
- Derive a spec directory name from the short name, e.g. `specs/<short-name>/`
|
|
||||||
- Explicitly set the following variables so later steps can use them:
|
|
||||||
- `FEATURE_DIR="specs/<short-name>"`
|
|
||||||
- `SPEC_FILE="$FEATURE_DIR/spec.md"`
|
|
||||||
- Ensure the directory and spec file exist:
|
|
||||||
- Bash:
|
|
||||||
- `mkdir -p "$FEATURE_DIR"`
|
|
||||||
- `touch "$SPEC_FILE"`
|
|
||||||
- PowerShell:
|
|
||||||
- `New-Item -ItemType Directory -Path $FEATURE_DIR -Force | Out-Null`
|
|
||||||
- `New-Item -ItemType File -Path $SPEC_FILE -Force | Out-Null`
|
|
||||||
- Then proceed directly to step 3 using `FEATURE_DIR` and `SPEC_FILE`
|
|
||||||
- If the registry file does not exist, proceed with branch creation using the default behavior (backward compatibility)
|
|
||||||
|
|
||||||
Run the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically:
|
|
||||||
|
|
||||||
**Branch numbering mode**: Before running the script, determine the branch numbering strategy:
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value (extension config takes precedence)
|
|
||||||
2. If not found, check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
|
||||||
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
- If `"sequential"` or absent, do not add any extra flag (default behavior)
|
||||||
|
|
||||||
**Script resolution**: Use the extension's bundled scripts when available, falling back to core scripts:
|
|
||||||
- **Bash**: If `.specify/extensions/git/scripts/bash/create-new-feature.sh` exists, use it; otherwise, fall back to `{SCRIPT}`
|
|
||||||
- **PowerShell**: If `.specify/extensions/git/scripts/powershell/create-new-feature.ps1` exists, use it; otherwise, fall back to `{SCRIPT}`
|
|
||||||
|
|
||||||
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
|
||||||
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
|
||||||
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
|
||||||
|
|||||||
@@ -113,3 +113,16 @@
|
|||||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
- **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-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%"]
|
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right assumptions based on reasonable defaults
|
||||||
|
chosen when the feature description did not specify certain details.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||||
|
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||||
|
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||||
|
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import specify_cli
|
|||||||
|
|
||||||
from specify_cli import (
|
from specify_cli import (
|
||||||
_get_skills_dir,
|
_get_skills_dir,
|
||||||
|
_migrate_legacy_kimi_dotted_skills,
|
||||||
install_ai_skills,
|
install_ai_skills,
|
||||||
AGENT_SKILLS_DIR_OVERRIDES,
|
|
||||||
DEFAULT_SKILLS_DIR,
|
DEFAULT_SKILLS_DIR,
|
||||||
SKILL_DESCRIPTIONS,
|
SKILL_DESCRIPTIONS,
|
||||||
AGENT_CONFIG,
|
AGENT_CONFIG,
|
||||||
@@ -169,8 +169,8 @@ class TestGetSkillsDir:
|
|||||||
result = _get_skills_dir(project_dir, "copilot")
|
result = _get_skills_dir(project_dir, "copilot")
|
||||||
assert result == project_dir / ".github" / "skills"
|
assert result == project_dir / ".github" / "skills"
|
||||||
|
|
||||||
def test_codex_uses_override(self, project_dir):
|
def test_codex_skills_dir_from_agent_config(self, project_dir):
|
||||||
"""Codex should use the AGENT_SKILLS_DIR_OVERRIDES value."""
|
"""Codex should resolve skills directory from AGENT_CONFIG folder."""
|
||||||
result = _get_skills_dir(project_dir, "codex")
|
result = _get_skills_dir(project_dir, "codex")
|
||||||
assert result == project_dir / ".agents" / "skills"
|
assert result == project_dir / ".agents" / "skills"
|
||||||
|
|
||||||
@@ -203,12 +203,71 @@ class TestGetSkillsDir:
|
|||||||
# Should always end with "skills"
|
# Should always end with "skills"
|
||||||
assert result.name == "skills"
|
assert result.name == "skills"
|
||||||
|
|
||||||
def test_override_takes_precedence_over_config(self, project_dir):
|
class TestKimiLegacySkillMigration:
|
||||||
"""AGENT_SKILLS_DIR_OVERRIDES should take precedence over AGENT_CONFIG."""
|
"""Test temporary migration from Kimi dotted skill names to hyphenated names."""
|
||||||
for agent_key in AGENT_SKILLS_DIR_OVERRIDES:
|
|
||||||
result = _get_skills_dir(project_dir, agent_key)
|
def test_migrates_legacy_dotted_skill_directory(self, project_dir):
|
||||||
expected = project_dir / AGENT_SKILLS_DIR_OVERRIDES[agent_key]
|
skills_dir = project_dir / ".kimi" / "skills"
|
||||||
assert result == expected
|
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"
|
||||||
|
|
||||||
|
|
||||||
# ===== install_ai_skills Tests =====
|
# ===== install_ai_skills Tests =====
|
||||||
@@ -473,8 +532,7 @@ class TestInstallAiSkills:
|
|||||||
skills_dir = _get_skills_dir(proj, agent_key)
|
skills_dir = _get_skills_dir(proj, agent_key)
|
||||||
assert skills_dir.exists()
|
assert skills_dir.exists()
|
||||||
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
skill_dirs = [d.name for d in skills_dir.iterdir() if d.is_dir()]
|
||||||
# Kimi uses dotted skill names; other agents use hyphen-separated names.
|
expected_skill_name = "speckit-specify"
|
||||||
expected_skill_name = "speckit.specify" if agent_key == "kimi" else "speckit-specify"
|
|
||||||
assert expected_skill_name in skill_dirs
|
assert expected_skill_name in skill_dirs
|
||||||
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
assert (skills_dir / expected_skill_name / "SKILL.md").exists()
|
||||||
|
|
||||||
@@ -773,6 +831,32 @@ class TestNewProjectCommandSkip:
|
|||||||
mock_skills.assert_called_once()
|
mock_skills.assert_called_once()
|
||||||
assert mock_skills.call_args.kwargs.get("overwrite_existing") is True
|
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):
|
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."""
|
"""Codex --here skills init should not delete a pre-existing .codex directory."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
@@ -1118,12 +1202,12 @@ class TestCliValidation:
|
|||||||
assert "Optional skills that you can use for your specs" in result.output
|
assert "Optional skills that you can use for your specs" in result.output
|
||||||
|
|
||||||
def test_kimi_next_steps_show_skill_invocation(self, monkeypatch):
|
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
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
def _fake_download(*args, **kwargs):
|
def _fake_download(*args, **kwargs):
|
||||||
project_path = Path(args[0])
|
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.mkdir(parents=True, exist_ok=True)
|
||||||
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
(skill_dir / "SKILL.md").write_text("---\ndescription: Test skill\n---\n\nBody.\n")
|
||||||
|
|
||||||
@@ -1137,7 +1221,7 @@ class TestCliValidation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
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 "/speckit.constitution" not in result.output
|
||||||
assert "Optional skills that you can use for your specs" in result.output
|
assert "Optional skills that you can use for your specs" in result.output
|
||||||
|
|
||||||
|
|||||||
@@ -87,84 +87,3 @@ class TestBranchNumberingValidation:
|
|||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
|
|
||||||
class TestGitExtensionAutoInstall:
|
|
||||||
"""Tests for bundled git extension auto-install during specify init."""
|
|
||||||
|
|
||||||
def test_git_extension_installed_during_init(self, tmp_path: Path, monkeypatch):
|
|
||||||
"""Verify that `specify init` auto-installs the bundled git extension."""
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
from specify_cli import app
|
|
||||||
|
|
||||||
def _fake_download(project_path, *args, **kwargs):
|
|
||||||
Path(project_path).mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
monkeypatch.setattr("specify_cli.download_and_extract_template", _fake_download)
|
|
||||||
|
|
||||||
project_dir = tmp_path / "proj"
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
# Extension files should exist
|
|
||||||
ext_dir = project_dir / ".specify" / "extensions" / "git"
|
|
||||||
assert ext_dir.is_dir(), "git extension directory not created"
|
|
||||||
assert (ext_dir / "extension.yml").is_file(), "extension.yml not installed"
|
|
||||||
|
|
||||||
# Registry should contain the git extension
|
|
||||||
registry_file = project_dir / ".specify" / "extensions" / ".registry"
|
|
||||||
assert registry_file.is_file(), "extension registry not created"
|
|
||||||
registry = json.loads(registry_file.read_text())
|
|
||||||
assert "git" in registry.get("extensions", {}), "git not in registry"
|
|
||||||
assert registry["extensions"]["git"]["enabled"] is True
|
|
||||||
|
|
||||||
def test_git_extension_noop_when_already_installed(self, tmp_path: Path):
|
|
||||||
"""_install_bundled_git_extension should no-op if git is already installed."""
|
|
||||||
from specify_cli import _install_bundled_git_extension
|
|
||||||
from specify_cli.extensions import ExtensionManager
|
|
||||||
|
|
||||||
project_dir = tmp_path / "proj"
|
|
||||||
(project_dir / ".specify").mkdir(parents=True)
|
|
||||||
|
|
||||||
# First install
|
|
||||||
result1 = _install_bundled_git_extension(project_dir)
|
|
||||||
assert result1 is True
|
|
||||||
|
|
||||||
# Second install should also succeed (no-op)
|
|
||||||
result2 = _install_bundled_git_extension(project_dir)
|
|
||||||
assert result2 is True
|
|
||||||
|
|
||||||
# Only one entry in registry
|
|
||||||
manager = ExtensionManager(project_dir)
|
|
||||||
assert manager.registry.is_installed("git")
|
|
||||||
|
|
||||||
def test_git_extension_reinstalls_when_directory_missing(self, tmp_path: Path):
|
|
||||||
"""_install_bundled_git_extension should reinstall if registry says installed but directory is gone."""
|
|
||||||
import shutil
|
|
||||||
from specify_cli import _install_bundled_git_extension
|
|
||||||
from specify_cli.extensions import ExtensionManager
|
|
||||||
|
|
||||||
project_dir = tmp_path / "proj"
|
|
||||||
(project_dir / ".specify").mkdir(parents=True)
|
|
||||||
|
|
||||||
# First install
|
|
||||||
result1 = _install_bundled_git_extension(project_dir)
|
|
||||||
assert result1 is True
|
|
||||||
|
|
||||||
ext_dir = project_dir / ".specify" / "extensions" / "git"
|
|
||||||
assert ext_dir.is_dir()
|
|
||||||
|
|
||||||
# Simulate stale registry: delete extension directory but keep registry
|
|
||||||
shutil.rmtree(ext_dir)
|
|
||||||
assert not ext_dir.exists()
|
|
||||||
|
|
||||||
# Registry still says installed
|
|
||||||
manager = ExtensionManager(project_dir)
|
|
||||||
assert manager.registry.is_installed("git")
|
|
||||||
|
|
||||||
# Re-install should detect missing directory and reinstall
|
|
||||||
result2 = _install_bundled_git_extension(project_dir)
|
|
||||||
assert result2 is True
|
|
||||||
assert ext_dir.is_dir(), "extension directory should be reinstalled"
|
|
||||||
assert (ext_dir / "extension.yml").is_file(), "extension.yml should be reinstalled"
|
|
||||||
|
|||||||
@@ -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.
|
# Agents whose commands are laid out as <skills_dir>/<name>/SKILL.md.
|
||||||
# Maps agent -> separator used in skill directory names.
|
# 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:
|
def _expected_ext(agent: str) -> str:
|
||||||
|
|||||||
741
tests/test_extension_skills.py
Normal file
741
tests/test_extension_skills.py
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for extension skill auto-registration.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- SKILL.md generation when --ai-skills was used during init
|
||||||
|
- No skills created when ai_skills not active
|
||||||
|
- SKILL.md content correctness
|
||||||
|
- Existing user-modified skills not overwritten
|
||||||
|
- Skill cleanup on extension removal
|
||||||
|
- Registry metadata includes registered_skills
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from specify_cli.extensions import (
|
||||||
|
ExtensionManifest,
|
||||||
|
ExtensionManager,
|
||||||
|
ExtensionError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Helpers =====
|
||||||
|
|
||||||
|
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
||||||
|
"""Write a .specify/init-options.json file."""
|
||||||
|
opts_dir = project_root / ".specify"
|
||||||
|
opts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
opts_file = opts_dir / "init-options.json"
|
||||||
|
opts_file.write_text(json.dumps({
|
||||||
|
"ai": ai,
|
||||||
|
"ai_skills": ai_skills,
|
||||||
|
"script": "sh",
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||||
|
"""Create and return the expected skills directory for the given agent."""
|
||||||
|
# Match the logic in _get_skills_dir() from specify_cli
|
||||||
|
from specify_cli import AGENT_CONFIG, DEFAULT_SKILLS_DIR
|
||||||
|
|
||||||
|
agent_config = AGENT_CONFIG.get(ai, {})
|
||||||
|
agent_folder = agent_config.get("folder", "")
|
||||||
|
if agent_folder:
|
||||||
|
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
||||||
|
else:
|
||||||
|
skills_dir = project_root / DEFAULT_SKILLS_DIR
|
||||||
|
|
||||||
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return skills_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
||||||
|
"""Create a complete extension directory with manifest and command files."""
|
||||||
|
ext_dir = temp_dir / ext_id
|
||||||
|
ext_dir.mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": ext_id,
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test extension for skill registration",
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0",
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": f"speckit.{ext_id}.hello",
|
||||||
|
"file": "commands/hello.md",
|
||||||
|
"description": "Test hello command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": f"speckit.{ext_id}.world",
|
||||||
|
"file": "commands/world.md",
|
||||||
|
"description": "Test world command",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
commands_dir = ext_dir / "commands"
|
||||||
|
commands_dir.mkdir()
|
||||||
|
|
||||||
|
(commands_dir / "hello.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"description: \"Test hello command\"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"# Hello Command\n"
|
||||||
|
"\n"
|
||||||
|
"Run this to say hello.\n"
|
||||||
|
"$ARGUMENTS\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
(commands_dir / "world.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"description: \"Test world command\"\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"# World Command\n"
|
||||||
|
"\n"
|
||||||
|
"Run this to greet the world.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ext_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Fixtures =====
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for tests."""
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
yield Path(tmpdir)
|
||||||
|
shutil.rmtree(tmpdir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project_dir(temp_dir):
|
||||||
|
"""Create a mock spec-kit project directory."""
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
|
||||||
|
# Create .specify directory
|
||||||
|
specify_dir = proj_dir / ".specify"
|
||||||
|
specify_dir.mkdir()
|
||||||
|
|
||||||
|
return proj_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def extension_dir(temp_dir):
|
||||||
|
"""Create a complete extension directory."""
|
||||||
|
return _create_extension_dir(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def skills_project(project_dir):
|
||||||
|
"""Create a project with --ai-skills enabled and skills directory."""
|
||||||
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||||
|
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||||
|
return project_dir, skills_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def no_skills_project(project_dir):
|
||||||
|
"""Create a project without --ai-skills."""
|
||||||
|
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||||
|
return project_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ===== ExtensionManager._get_skills_dir Tests =====
|
||||||
|
|
||||||
|
class TestExtensionManagerGetSkillsDir:
|
||||||
|
"""Test _get_skills_dir() on ExtensionManager."""
|
||||||
|
|
||||||
|
def test_returns_skills_dir_when_active(self, skills_project):
|
||||||
|
"""Should return skills dir when ai_skills is true and dir exists."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result == skills_dir
|
||||||
|
|
||||||
|
def test_returns_none_when_no_ai_skills(self, no_skills_project):
|
||||||
|
"""Should return None when ai_skills is false."""
|
||||||
|
manager = ExtensionManager(no_skills_project)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_init_options(self, project_dir):
|
||||||
|
"""Should return None when init-options.json is missing."""
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_when_skills_dir_missing(self, project_dir):
|
||||||
|
"""Should return None when skills dir doesn't exist on disk."""
|
||||||
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||||
|
# Don't create the skills directory
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
|
||||||
|
"""Kimi should still use its native skills dir when ai_skills is false."""
|
||||||
|
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||||
|
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result == skills_dir
|
||||||
|
|
||||||
|
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
||||||
|
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
||||||
|
opts_file = project_dir / ".specify" / "init-options.json"
|
||||||
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
opts_file.write_text("[]")
|
||||||
|
_create_skills_dir(project_dir, ai="claude")
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
result = manager._get_skills_dir()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Extension Skill Registration Tests =====
|
||||||
|
|
||||||
|
class TestExtensionSkillRegistration:
|
||||||
|
"""Test _register_extension_skills() on ExtensionManager."""
|
||||||
|
|
||||||
|
def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir):
|
||||||
|
"""Skills should be created when ai_skills is enabled."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that skill directories were created
|
||||||
|
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
||||||
|
assert "speckit-test-ext-hello" in skill_dirs
|
||||||
|
assert "speckit-test-ext-world" in skill_dirs
|
||||||
|
|
||||||
|
def test_skill_md_content_correct(self, skills_project, extension_dir):
|
||||||
|
"""SKILL.md should have correct agentskills.io structure."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
content = skill_file.read_text()
|
||||||
|
|
||||||
|
# Check structure
|
||||||
|
assert content.startswith("---\n")
|
||||||
|
assert "name: speckit-test-ext-hello" in content
|
||||||
|
assert "description:" in content
|
||||||
|
assert "Test hello command" in content
|
||||||
|
assert "source: extension:test-ext" in content
|
||||||
|
assert "author: github-spec-kit" in content
|
||||||
|
assert "compatibility:" in content
|
||||||
|
assert "Run this to say hello." in content
|
||||||
|
|
||||||
|
def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
|
||||||
|
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||||
|
content = skill_file.read_text()
|
||||||
|
|
||||||
|
assert content.startswith("---\n")
|
||||||
|
parts = content.split("---", 2)
|
||||||
|
assert len(parts) >= 3
|
||||||
|
parsed = yaml.safe_load(parts[1])
|
||||||
|
assert isinstance(parsed, dict)
|
||||||
|
assert parsed["name"] == "speckit-test-ext-hello"
|
||||||
|
assert "description" in parsed
|
||||||
|
|
||||||
|
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||||
|
"""No skills should be created when ai_skills is false."""
|
||||||
|
manager = ExtensionManager(no_skills_project)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify registry
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert metadata["registered_skills"] == []
|
||||||
|
|
||||||
|
def test_no_skills_when_init_options_missing(self, project_dir, extension_dir):
|
||||||
|
"""No skills should be created when init-options.json is absent."""
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert metadata["registered_skills"] == []
|
||||||
|
|
||||||
|
def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
|
||||||
|
"""Pre-existing SKILL.md should not be overwritten."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
# Pre-create a custom skill
|
||||||
|
custom_dir = skills_dir / "speckit-test-ext-hello"
|
||||||
|
custom_dir.mkdir(parents=True)
|
||||||
|
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
|
||||||
|
(custom_dir / "SKILL.md").write_text(custom_content)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom skill should be untouched
|
||||||
|
assert (custom_dir / "SKILL.md").read_text() == custom_content
|
||||||
|
|
||||||
|
# But the other skill should still be created
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||||
|
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
||||||
|
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
||||||
|
|
||||||
|
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
||||||
|
"""Registry should contain registered_skills list."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert "registered_skills" in metadata
|
||||||
|
assert len(metadata["registered_skills"]) == 2
|
||||||
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||||
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||||
|
|
||||||
|
def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
|
||||||
|
"""Kimi agent should use the same hyphenated skill names as hooks."""
|
||||||
|
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
||||||
|
_create_skills_dir(project_dir, ai="kimi")
|
||||||
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||||
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||||
|
|
||||||
|
def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||||
|
"""Kimi should still auto-register extension skills in native-skills mode."""
|
||||||
|
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
||||||
|
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
||||||
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
||||||
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
||||||
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||||
|
"""Auto-registered extension skills should resolve script placeholders."""
|
||||||
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||||
|
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "scripted-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "scripted-ext",
|
||||||
|
"name": "Scripted Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.scripted-ext.plan",
|
||||||
|
"file": "commands/plan.md",
|
||||||
|
"description": "Scripted plan command",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
(ext_dir / "commands" / "plan.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"description: Scripted plan command\n"
|
||||||
|
"scripts:\n"
|
||||||
|
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
||||||
|
"agent_scripts:\n"
|
||||||
|
" sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n"
|
||||||
|
"---\n\n"
|
||||||
|
"Run {SCRIPT}\n"
|
||||||
|
"Then {AGENT_SCRIPT}\n"
|
||||||
|
"Review templates/checklist.md and memory/constitution.md for __AGENT__.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text()
|
||||||
|
assert "{SCRIPT}" not in content
|
||||||
|
assert "{AGENT_SCRIPT}" not in content
|
||||||
|
assert "{ARGS}" not in content
|
||||||
|
assert "__AGENT__" not in content
|
||||||
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||||
|
assert ".specify/scripts/bash/update-agent-context.sh claude" in content
|
||||||
|
assert ".specify/templates/checklist.md" in content
|
||||||
|
assert ".specify/memory/constitution.md" in content
|
||||||
|
|
||||||
|
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||||
|
"""Commands with missing source files should be skipped gracefully."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "missing-cmd-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "missing-cmd-ext",
|
||||||
|
"name": "Missing Cmd Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.missing-cmd-ext.exists",
|
||||||
|
"file": "commands/exists.md",
|
||||||
|
"description": "Exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "speckit.missing-cmd-ext.ghost",
|
||||||
|
"file": "commands/ghost.md",
|
||||||
|
"description": "Does not exist",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
(ext_dir / "commands" / "exists.md").write_text(
|
||||||
|
"---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n"
|
||||||
|
)
|
||||||
|
# Intentionally do NOT create ghost.md
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
|
||||||
|
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Extension Skill Unregistration Tests =====
|
||||||
|
|
||||||
|
class TestExtensionSkillUnregistration:
|
||||||
|
"""Test _unregister_extension_skills() on ExtensionManager."""
|
||||||
|
|
||||||
|
def test_skills_removed_on_extension_remove(self, skills_project, extension_dir):
|
||||||
|
"""Removing an extension should clean up its skill directories."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify skills exist
|
||||||
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||||
|
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
# Remove extension
|
||||||
|
result = manager.remove(manifest.id, keep_config=False)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Skills should be gone
|
||||||
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||||
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||||
|
|
||||||
|
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
||||||
|
"""Non-extension skills should not be affected by extension removal."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
# Pre-create a custom skill
|
||||||
|
custom_dir = skills_dir / "my-custom-skill"
|
||||||
|
custom_dir.mkdir(parents=True)
|
||||||
|
(custom_dir / "SKILL.md").write_text("# My Custom Skill\n")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.remove(manifest.id, keep_config=False)
|
||||||
|
|
||||||
|
# Custom skill should still exist
|
||||||
|
assert (custom_dir / "SKILL.md").exists()
|
||||||
|
assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n"
|
||||||
|
|
||||||
|
def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir):
|
||||||
|
"""Gracefully handle case where skill dirs were already deleted."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Manually delete skill dirs before calling remove
|
||||||
|
shutil.rmtree(skills_dir / "speckit-test-ext-hello")
|
||||||
|
shutil.rmtree(skills_dir / "speckit-test-ext-world")
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
result = manager.remove(manifest.id, keep_config=False)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir):
|
||||||
|
"""Removal without active skills should not attempt skill cleanup."""
|
||||||
|
manager = ExtensionManager(no_skills_project)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise even though no skills exist
|
||||||
|
result = manager.remove(manifest.id, keep_config=False)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Command File Without Frontmatter =====
|
||||||
|
|
||||||
|
class TestExtensionSkillEdgeCases:
|
||||||
|
"""Test edge cases in extension skill registration."""
|
||||||
|
|
||||||
|
def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir):
|
||||||
|
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
||||||
|
opts_file = project_dir / ".specify" / "init-options.json"
|
||||||
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
opts_file.write_text("[]")
|
||||||
|
_create_skills_dir(project_dir, ai="claude")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = manager.registry.get(manifest.id)
|
||||||
|
assert metadata["registered_skills"] == []
|
||||||
|
|
||||||
|
def test_command_without_frontmatter(self, skills_project, temp_dir):
|
||||||
|
"""Commands without YAML frontmatter should still produce valid skills."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "nofm-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "nofm-ext",
|
||||||
|
"name": "No Frontmatter Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.nofm-ext.plain",
|
||||||
|
"file": "commands/plain.md",
|
||||||
|
"description": "Plain command",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
(ext_dir / "commands" / "plain.md").write_text(
|
||||||
|
"# Plain Command\n\nBody without frontmatter.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
content = skill_file.read_text()
|
||||||
|
assert "name: speckit-nofm-ext-plain" in content
|
||||||
|
# Fallback description when no frontmatter description
|
||||||
|
assert "Extension command: speckit.nofm-ext.plain" in content
|
||||||
|
assert "Body without frontmatter." in content
|
||||||
|
|
||||||
|
def test_gemini_agent_skills(self, project_dir, temp_dir):
|
||||||
|
"""Gemini agent should use .gemini/skills/ for skill directory."""
|
||||||
|
_create_init_options(project_dir, ai="gemini", ai_skills=True)
|
||||||
|
_create_skills_dir(project_dir, ai="gemini")
|
||||||
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
skills_dir = project_dir / ".gemini" / "skills"
|
||||||
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||||
|
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
|
||||||
|
"""Installing and removing different extensions should be independent."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a")
|
||||||
|
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest_a = manager.install_from_directory(
|
||||||
|
ext_dir_a, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
manifest_b = manager.install_from_directory(
|
||||||
|
ext_dir_b, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both should have skills
|
||||||
|
assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
|
||||||
|
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
# Remove ext-a
|
||||||
|
manager.remove("ext-a", keep_config=False)
|
||||||
|
|
||||||
|
# ext-a skills gone, ext-b skills preserved
|
||||||
|
assert not (skills_dir / "speckit-ext-a-hello").exists()
|
||||||
|
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
|
||||||
|
"""Commands with invalid YAML frontmatter should still produce valid skills."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
|
||||||
|
ext_dir = temp_dir / "badfm-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "badfm-ext",
|
||||||
|
"name": "Bad Frontmatter Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Test",
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.badfm-ext.broken",
|
||||||
|
"file": "commands/broken.md",
|
||||||
|
"description": "Broken frontmatter",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
(ext_dir / "commands").mkdir()
|
||||||
|
# Malformed YAML: invalid key-value syntax
|
||||||
|
(ext_dir / "commands" / "broken.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"description: [invalid yaml\n"
|
||||||
|
" unclosed: bracket\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"# Broken Command\n"
|
||||||
|
"\n"
|
||||||
|
"This body should still be used.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
# Should not raise
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
ext_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
|
||||||
|
assert skill_file.exists()
|
||||||
|
content = skill_file.read_text()
|
||||||
|
# Fallback description since frontmatter was invalid
|
||||||
|
assert "Extension command: speckit.badfm-ext.broken" in content
|
||||||
|
assert "This body should still be used." in content
|
||||||
|
|
||||||
|
def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir):
|
||||||
|
"""Skills should be cleaned up even if init-options.json is deleted after install."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify skills exist
|
||||||
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
# Delete init-options.json to simulate user change
|
||||||
|
init_opts = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_opts.unlink()
|
||||||
|
|
||||||
|
# Remove should still clean up via fallback scan
|
||||||
|
result = manager.remove(manifest.id, keep_config=False)
|
||||||
|
assert result is True
|
||||||
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||||
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||||
|
|
||||||
|
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
|
||||||
|
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manifest = manager.install_from_directory(
|
||||||
|
extension_dir, "0.1.0", register_commands=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify skills exist
|
||||||
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
# Toggle ai_skills to false
|
||||||
|
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||||
|
|
||||||
|
# Remove should still clean up via fallback scan
|
||||||
|
result = manager.remove(manifest.id, keep_config=False)
|
||||||
|
assert result is True
|
||||||
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||||
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||||
@@ -22,6 +22,7 @@ from specify_cli.extensions import (
|
|||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionManager,
|
ExtensionManager,
|
||||||
CommandRegistrar,
|
CommandRegistrar,
|
||||||
|
HookExecutor,
|
||||||
ExtensionCatalog,
|
ExtensionCatalog,
|
||||||
ExtensionError,
|
ExtensionError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
@@ -759,6 +760,81 @@ $ARGUMENTS
|
|||||||
assert "Prüfe Konformität" in output
|
assert "Prüfe Konformität" in output
|
||||||
assert "\\u" not 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):
|
def test_register_commands_for_claude(self, extension_dir, project_dir):
|
||||||
"""Test registering commands for Claude agent."""
|
"""Test registering commands for Claude agent."""
|
||||||
# Create .claude directory
|
# Create .claude directory
|
||||||
@@ -875,11 +951,11 @@ $ARGUMENTS
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, extension_dir, project_dir)
|
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()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
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 "description: Test hello command" in content
|
||||||
assert "compatibility:" in content
|
assert "compatibility:" in content
|
||||||
assert "metadata:" in content
|
assert "metadata:" in content
|
||||||
@@ -944,7 +1020,7 @@ Agent __AGENT__
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
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()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -994,12 +1070,12 @@ Agent __AGENT__
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
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"
|
alias = skills_dir / "speckit-shortcut" / "SKILL.md"
|
||||||
|
|
||||||
assert primary.exists()
|
assert primary.exists()
|
||||||
assert alias.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()
|
assert "name: speckit-shortcut" in alias.read_text()
|
||||||
|
|
||||||
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
def test_codex_skill_registration_uses_fallback_script_variant_without_init_options(
|
||||||
@@ -1056,7 +1132,7 @@ Then {AGENT_SCRIPT}
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
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()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -1065,6 +1141,62 @@ Then {AGENT_SCRIPT}
|
|||||||
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
||||||
assert ".specify/scripts/bash/update-agent-context.sh codex" 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(
|
def test_codex_skill_registration_fallback_prefers_powershell_on_windows(
|
||||||
self, project_dir, temp_dir, monkeypatch
|
self, project_dir, temp_dir, monkeypatch
|
||||||
):
|
):
|
||||||
@@ -1121,7 +1253,7 @@ Then {AGENT_SCRIPT}
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir)
|
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()
|
assert skill_file.exists()
|
||||||
|
|
||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
@@ -3231,3 +3363,128 @@ class TestExtensionPriorityBackwardsCompatibility:
|
|||||||
assert result[0][0] == "ext-with-priority"
|
assert result[0][0] == "ext-with-priority"
|
||||||
assert result[1][0] == "legacy-ext"
|
assert result[1][0] == "legacy-ext"
|
||||||
assert result[2][0] == "ext-low-priority"
|
assert result[2][0] == "ext-low-priority"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookInvocationRendering:
|
||||||
|
"""Test hook invocation formatting for different agent modes."""
|
||||||
|
|
||||||
|
def test_kimi_hooks_render_skill_invocation(self, project_dir):
|
||||||
|
"""Kimi projects should render /skill:speckit-* invocations."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
message = hook_executor.format_hook_message(
|
||||||
|
"before_plan",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "speckit.plan",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Executing: `/skill:speckit-plan`" in message
|
||||||
|
assert "EXECUTE_COMMAND: speckit.plan" in message
|
||||||
|
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
||||||
|
|
||||||
|
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
||||||
|
"""Codex projects with --ai-skills should render $speckit-* invocations."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
execution = hook_executor.execute_hook(
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "speckit.tasks",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert execution["command"] == "speckit.tasks"
|
||||||
|
assert execution["invocation"] == "$speckit-tasks"
|
||||||
|
|
||||||
|
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
||||||
|
"""Custom hook commands should keep slash invocation style."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
message = hook_executor.format_hook_message(
|
||||||
|
"before_tasks",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "pre_tasks_test",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Executing: `/pre_tasks_test`" in message
|
||||||
|
assert "EXECUTE_COMMAND: pre_tasks_test" in message
|
||||||
|
assert "EXECUTE_COMMAND_INVOCATION: /pre_tasks_test" in message
|
||||||
|
|
||||||
|
def test_extension_command_uses_hyphenated_skill_invocation(self, project_dir):
|
||||||
|
"""Multi-segment extension command ids should map to hyphenated skills."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
message = hook_executor.format_hook_message(
|
||||||
|
"after_tasks",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "speckit.test.hello",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Executing: `/skill:speckit-test-hello`" in message
|
||||||
|
assert "EXECUTE_COMMAND: speckit.test.hello" in message
|
||||||
|
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-test-hello" in message
|
||||||
|
|
||||||
|
def test_hook_executor_caches_init_options_lookup(self, project_dir, monkeypatch):
|
||||||
|
"""Init options should be loaded once per executor instance."""
|
||||||
|
calls = {"count": 0}
|
||||||
|
|
||||||
|
def fake_load_init_options(_project_root):
|
||||||
|
calls["count"] += 1
|
||||||
|
return {"ai": "kimi", "ai_skills": False}
|
||||||
|
|
||||||
|
monkeypatch.setattr("specify_cli.load_init_options", fake_load_init_options)
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
assert hook_executor._render_hook_invocation("speckit.plan") == "/skill:speckit-plan"
|
||||||
|
assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
|
||||||
|
assert calls["count"] == 1
|
||||||
|
|
||||||
|
def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
|
||||||
|
"""Hook messages should still render actionable command placeholders."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "kimi", "ai_skills": False}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
message = hook_executor.format_hook_message(
|
||||||
|
"after_tasks",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": None,
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Executing: `/<missing command>`" in message
|
||||||
|
assert "EXECUTE_COMMAND: <missing command>" in message
|
||||||
|
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" in message
|
||||||
|
|||||||
@@ -1170,8 +1170,12 @@ class TestPresetCatalog:
|
|||||||
assert not catalog.cache_file.exists()
|
assert not catalog.cache_file.exists()
|
||||||
assert not catalog.cache_metadata_file.exists()
|
assert not catalog.cache_metadata_file.exists()
|
||||||
|
|
||||||
def test_search_with_cached_data(self, project_dir):
|
def test_search_with_cached_data(self, project_dir, monkeypatch):
|
||||||
"""Test search with cached catalog data."""
|
"""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 = PresetCatalog(project_dir)
|
||||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -1200,6 +1204,9 @@ class TestPresetCatalog:
|
|||||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
"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
|
# Search by query
|
||||||
results = catalog.search(query="agile")
|
results = catalog.search(query="agile")
|
||||||
assert len(results) == 1
|
assert len(results) == 1
|
||||||
@@ -1935,10 +1942,10 @@ class TestInitOptions:
|
|||||||
class TestPresetSkills:
|
class TestPresetSkills:
|
||||||
"""Tests for preset skill registration and unregistration."""
|
"""Tests for preset skill registration and unregistration."""
|
||||||
|
|
||||||
def _write_init_options(self, project_dir, ai="claude", ai_skills=True):
|
def _write_init_options(self, project_dir, ai="claude", ai_skills=True, script="sh"):
|
||||||
from specify_cli import save_init_options
|
from specify_cli import save_init_options
|
||||||
|
|
||||||
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills})
|
save_init_options(project_dir, {"ai": ai, "ai_skills": ai_skills, "script": script})
|
||||||
|
|
||||||
def _create_skill(self, skills_dir, skill_name, body="original body"):
|
def _create_skill(self, skills_dir, skill_name, body="original body"):
|
||||||
skill_dir = skills_dir / skill_name
|
skill_dir = skills_dir / skill_name
|
||||||
@@ -1988,6 +1995,26 @@ class TestPresetSkills:
|
|||||||
content = skill_file.read_text()
|
content = skill_file.read_text()
|
||||||
assert "untouched" in content, "Skill should not be modified when ai_skills=False"
|
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):
|
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."""
|
"""When no init-options.json exists, preset install should not touch skills."""
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
@@ -2033,6 +2060,52 @@ class TestPresetSkills:
|
|||||||
assert "preset:self-test" not in content, "Preset content should be gone"
|
assert "preset:self-test" not in content, "Preset content should be gone"
|
||||||
assert "templates/commands/specify.md" in content, "Should reference core template"
|
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):
|
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."""
|
"""Skills should not be created when no existing skill dir is found."""
|
||||||
self._write_init_options(project_dir, ai="claude")
|
self._write_init_options(project_dir, ai="claude")
|
||||||
@@ -2047,6 +2120,304 @@ class TestPresetSkills:
|
|||||||
metadata = manager.registry.get("self-test")
|
metadata = manager.registry.get("self-test")
|
||||||
assert metadata.get("registered_skills", []) == []
|
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:
|
class TestPresetSetPriority:
|
||||||
"""Test preset set-priority CLI command."""
|
"""Test preset set-priority CLI command."""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import pytest
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
|
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"
|
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +148,24 @@ class TestSequentialBranch:
|
|||||||
branch = line.split(":", 1)[1].strip()
|
branch = line.split(":", 1)[1].strip()
|
||||||
assert branch == "003-next-feat", f"expected 003-next-feat, got: {branch}"
|
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 ───────────────────────────────────────────────
|
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user